// Pull request checks for .github/workflows/pr-checks.yml (CONTRIBUTING.md, "Sending a change"): // 1. duplicate: fails when a lower-numbered open PR already closes the same issue; // 2. defaults: the template line `Changes a default: no | yes - , old behaviour kept behind // preference ` must be present and well formed; `yes` earns the `changes-default` label; // 3. picture: a PR adding to the showcase or docs/screenshots shows a picture in its description. // The parsing is pure and tested in scripts/pr-checks/pr-checks.test.js; the functions that talk to // GitHub take the github-script `github` client and `context`, so the tests can pass fakes. 'use strict'; const SKIP_LABEL = 'not-a-duplicate'; const DEFAULT_LABEL = 'changes-default'; // PRs opened before this predate the template line; they pass the defaults check with a notice. const DEFAULTS_ENFORCED_FROM = Date.parse('2026-10-25T00:00:00Z'); // PRs opened before this predate the picture rule; they pass the picture check with a notice. const PICTURE_ENFORCED_FROM = Date.parse('2026-11-02T00:00:00Z'); const PICTURE_HELP = 'Show it in the description: drag a picture or video into the PR text so it can be seen without ' + 'opening the changed files, and say in plain words what it is (CONTRIBUTING.md, "Sending a change").'; const BOT_LOGINS = new Set(['dependabot[bot]', 'github-actions[bot]', 'dependabot-preview[bot]']); // Template comments and code blocks hold examples, not claims. function stripNoise(body, { inlineCode = true } = {}) { const text = String(body || '') .replace(//g, '') .replace(/```[\s\S]*?```/g, '') .replace(/~~~[\s\S]*?~~~/g, ''); return inlineCode ? text.replace(/`[^`\n]*`/g, '') : text; } // Issue numbers after a closing keyword: Fixes #1, closes: #2, Resolved owner/repo#3. // A list ("Fixes #1, #2") counts every number; a URL to another repo does not. function parseClosing(body, repoFullName) { const text = stripNoise(body); const found = new Set(); const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b:?[ \t]+((?:(?:[\w.-]+\/[\w.-]+)?#\d+(?:[ \t]*(?:,|and|&)[ \t]*)?)+)/gi; for (const m of text.matchAll(re)) { for (const ref of m[1].matchAll(/(?:([\w.-]+\/[\w.-]+))?#(\d+)/g)) { if (ref[1] && repoFullName && ref[1].toLowerCase() !== repoFullName.toLowerCase()) continue; found.add(Number(ref[2])); } } return [...found].sort((a, b) => a - b); } // -> { status: 'missing' | 'malformed' | 'no' | 'yes', preference?, detail? } function parseDefaults(body) { const m = stripNoise(body, { inlineCode: false }).replace(/\*\*/g, '').match(/^[ \t]*(?:[-*][ \t]+)?Changes a default:[ \t]*(.*?)[ \t]*$/im); if (!m) return { status: 'missing' }; const value = m[1].replace(/\*\*/g, '').trim(); if (/^no\.?$/i.test(value)) return { status: 'no' }; const yes = value.match(/^yes\b[ \t]*(?:—|–|->|→|-{1,2}|:)[ \t]*(.+)$/i); if (yes) { const pref = yes[1].match(/^(.*?)[ ,;]*\bold (?:behaviou?r|value) kept behind (?:preference|pref|setting)[ \t]+`?([\w.:/-]+)`?\.?$/i); if (pref && pref[1].trim() && !/^<.*>$/.test(pref[2])) { return { status: 'yes', detail: pref[1].trim(), preference: pref[2] }; } } return { status: 'malformed', detail: value }; } const DEFAULTS_HELP = 'Add one line to the description: `Changes a default: no`, or ' + '`Changes a default: yes — , old behaviour kept behind preference `.'; function isBot(user) { return !!user && (user.type === 'Bot' || BOT_LOGINS.has(user.login) || /\[bot\]$/.test(user.login || '')); } const DOC_FILE = /(^docs\/|\.md$|^LICENSE|^\.github\/ISSUE_TEMPLATE\/|^\.github\/pull_request_template\.md$)/; function isDocsOnly(files) { return files.length > 0 && files.every((f) => DOC_FILE.test(f)); } // Open PRs with a lower number than `number` that close one of `issues`, by someone else. // `pulls` is a list of REST pull objects. function findDuplicates({ number, author, issues, pulls, repoFullName }) { const out = []; for (const p of pulls) { if (p.number >= number || p.state !== 'open') continue; if (p.user && p.user.login === author) continue; // stacked work by the same author if ((p.labels || []).some((l) => l.name === SKIP_LABEL)) continue; const shared = parseClosing(p.body, repoFullName).filter((n) => issues.includes(n)); if (shared.length) out.push({ number: p.number, title: p.title, url: p.html_url, draft: !!p.draft, issues: shared }); } return out.sort((a, b) => a.number - b.number); } function hasLabel(pull, name) { return (pull.labels || []).some((l) => l.name === name); } async function checkDuplicate({ github, context, core }) { const pull = context.payload.pull_request; const repoFullName = `${context.repo.owner}/${context.repo.repo}`; if (isBot(pull.user)) return { skipped: 'bot' }; if (hasLabel(pull, SKIP_LABEL)) return { skipped: SKIP_LABEL }; const issues = parseClosing(pull.body, repoFullName); if (!issues.length) return { skipped: 'no closing keyword' }; const pulls = await github.paginate(github.rest.pulls.list, { ...context.repo, state: 'open', per_page: 100, }); const dups = findDuplicates({ number: pull.number, author: pull.user.login, issues, pulls, repoFullName }); if (!dups.length) return { dups }; const lines = dups.map( (d) => ` #${d.number}${d.draft ? ' (draft)' : ''} ${d.title}\n ${d.url} (closes ${d.issues.map((n) => '#' + n).join(', ')})`, ); core.setFailed( `A lower-numbered open pull request already closes the same issue, and the lowest number wins:\n${lines.join('\n')}\n` + `Close this one or help on that one. If this is not a duplicate (a different fix that happens to name the same issue), ` + `change "Fixes" to "Refs" or ask a maintainer to add the \`${SKIP_LABEL}\` label.`, ); return { dups }; } // A picture or video someone can see in the description: not one in a comment, a code block or the // template's own text. function hasPicture(body) { const text = stripNoise(body); return /!\[[^\]]*\]\([^)\s]+[^)]*\)/.test(text) || /<(?:img|video)\b[^>]*\bsrc=/i.test(text) || /https:\/\/github\.com\/user-attachments\/assets\/\S+/.test(text); } // Files that are about something to look at, whatever the rest of the PR does. function needsPicture(files) { return files.some((f) => f === 'docs/showcase.md' || f.startsWith('docs/screenshots/')); } async function checkPicture({ github, context, core }) { const pull = context.payload.pull_request; if (isBot(pull.user)) return { skipped: 'bot' }; if (!(Date.parse(pull.created_at) >= PICTURE_ENFORCED_FROM)) return { skipped: 'opened before enforcement' }; const files = (await github.paginate(github.rest.pulls.listFiles, { ...context.repo, pull_number: pull.number, per_page: 100, })).map((f) => f.filename); if (!needsPicture(files)) return { skipped: 'nothing to show' }; const shown = hasPicture(pull.body); if (!shown) core.setFailed(`This PR adds something to look at, but the description shows no picture. ${PICTURE_HELP}`); return { shown }; } async function checkDefaults({ github, context, core }) { const pull = context.payload.pull_request; if (isBot(pull.user)) return { skipped: 'bot' }; if (!(Date.parse(pull.created_at) >= DEFAULTS_ENFORCED_FROM)) { core.notice('The "Changes a default:" line is only enforced on pull requests opened on or after 2026-10-25; this one predates it. Please add the line when you next push.'); return { skipped: 'opened before enforcement' }; } const files = (await github.paginate(github.rest.pulls.listFiles, { ...context.repo, pull_number: pull.number, per_page: 100, })).map((f) => f.filename); if (isDocsOnly(files)) return { skipped: 'docs only' }; const result = parseDefaults(pull.body); if (result.status === 'missing') { core.setFailed(`The description has no "Changes a default:" line. ${DEFAULTS_HELP}`); } else if (result.status === 'malformed') { core.setFailed(`Could not read "Changes a default: ${result.detail}". ${DEFAULTS_HELP}`); } return result; } // Separate job (it needs write access, which fork PRs don't get): best effort. async function syncDefaultLabel({ github, context, core }) { const pull = context.payload.pull_request; const { status } = parseDefaults(pull.body); const has = hasLabel(pull, DEFAULT_LABEL); const args = { ...context.repo, issue_number: pull.number }; try { if (status === 'yes' && !has) { await github.rest.issues.addLabels({ ...args, labels: [DEFAULT_LABEL] }); } else if (status !== 'yes' && has) { await github.rest.issues.removeLabel({ ...args, name: DEFAULT_LABEL }); } } catch (e) { // A fork PR's token is read-only: say so instead of failing the check. if (e.status !== 403) throw e; core.notice(`Could not ${status === 'yes' ? 'add' : 'remove'} the ${DEFAULT_LABEL} label (read-only token); a maintainer sets it.`); } core.info(`Defaults line: ${status}`); } module.exports = { DEFAULTS_ENFORCED_FROM, SKIP_LABEL, DEFAULT_LABEL, stripNoise, parseClosing, parseDefaults, isBot, isDocsOnly, findDuplicates, checkDuplicate, checkDefaults, syncDefaultLabel, PICTURE_ENFORCED_FROM, hasPicture, needsPicture, checkPicture, };