mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 20:02:16 +00:00
15e7615ce3
A pull request now starts by saying what it is in plain words, and shows anything there is to look at in its description. A file in the diff doesn't count. The new Picture check fails a PR that adds to the showcase or docs/screenshots without showing a picture. It isn't a required check. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
9.1 KiB
JavaScript
187 lines
9.1 KiB
JavaScript
// 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 - <what>, old behaviour kept behind
|
||
// preference <name>` 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(/<!--[\s\S]*?-->/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 — <what>, old behaviour kept behind preference <name>`.';
|
||
|
||
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,
|
||
};
|