diff --git a/.github/scripts/issue-gate.js b/.github/scripts/issue-gate.js new file mode 100644 index 00000000..dca13f34 --- /dev/null +++ b/.github/scripts/issue-gate.js @@ -0,0 +1,247 @@ +'use strict'; + +/** + * Issue gate — shared logic for `.github/workflows/issue-gate.yml` (immediate + * feedback on pull request events) and `.github/workflows/pr-sweeper.yml` + * (deferred re-check, close, and stale-draft cleanup). + * + * Both workflows `require` this file through actions/github-script, so it must + * stay dependency-free: neither job runs an install step. + * + * See CONTRIBUTING.md for the policy this enforces. + */ + +const REQUIRED_LABEL = 'maintainer-approved'; +const GATE_LABEL = 'needs-approved-issue'; +const EXEMPT_LABEL = 'gate-exempt'; +const MARKER = ''; +const DISCORD = 'http://discord.gg/honcho'; + +// Hours a labelled pull request has before the sweeper closes it. Measured from +// the notice comment, so the clock starts when the author was actually told — +// not when the pull request was opened. +const GRACE_HOURS = 72; + +// Days without activity before a draft from outside the org is closed. +const DRAFT_STALE_DAYS = 30; + +const hasLabel = (pr, name) => (pr.labels || []).some((l) => l.name === name); + +// Write access to the repository. CONTRIBUTOR is deliberately absent: GitHub uses +// it for "has previously committed to the repository", which describes every +// returning outside contributor, not a maintainer. Do not add it. +const WRITE_ACCESS = ['OWNER', 'MEMBER', 'COLLABORATOR']; + +const CLOSING_ISSUES = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 20) { + nodes { + number + state + labels(first: 50) { nodes { name } } + } + } + } + } + } +`; + +/** + * Decide whether a pull request clears the gate. + * + * Reads GitHub's own resolved issue links rather than parsing the body, so both + * `Fixes #123` and the sidebar "Development" link count. A bare `#123` mention + * deliberately does not — that is a reference, not a claim to close. + * + * @returns {Promise<{passed: boolean, skipped?: string, issue?: number, reason?: string}>} + */ +async function checkGate({ github, owner, repo, pr }) { + if (pr.state !== 'open') return { passed: true, skipped: 'pull request is not open' }; + if (pr.draft) return { passed: true, skipped: 'pull request is a draft' }; + if (pr.user && pr.user.type === 'Bot') return { passed: true, skipped: 'author is a bot' }; + if (WRITE_ACCESS.includes(pr.author_association)) { + return { passed: true, skipped: `author_association is ${pr.author_association}` }; + } + if (hasLabel(pr, EXEMPT_LABEL)) { + return { passed: true, skipped: `carries the ${EXEMPT_LABEL} label` }; + } + + const data = await github.graphql(CLOSING_ISSUES, { owner, repo, number: pr.number }); + const issues = data.repository.pullRequest.closingIssuesReferences.nodes; + + if (issues.length === 0) { + return { passed: false, reason: 'This pull request is not linked to an issue.' }; + } + + const approved = issues.find( + (i) => i.state === 'OPEN' && i.labels.nodes.some((l) => l.name === REQUIRED_LABEL), + ); + if (approved) return { passed: true, issue: approved.number }; + + const detail = issues + .map((i) => `#${i.number} (${i.state === 'CLOSED' ? 'closed' : 'not approved'})`) + .join(', '); + return { + passed: false, + reason: + `The linked ${issues.length === 1 ? 'issue is' : 'issues are'} not open with the ` + + `\`${REQUIRED_LABEL}\` label: ${detail}.`, + }; +} + +function noticeBody({ owner, repo, reason }) { + return [ + MARKER, + 'Thanks for the contribution. This pull request does not clear our issue gate yet.', + '', + `**${reason}**`, + '', + `Every pull request to Honcho needs to be linked to an open issue carrying the \`${REQUIRED_LABEL}\` label. We do this so the review queue only holds work we have already agreed should be built — it means nobody spends time on a change we cannot merge.`, + '', + 'To get this moving:', + '', + `1. Find or open an issue describing the change. [Approved issues are here](https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3A${REQUIRED_LABEL}).`, + `2. Make the case for it in [Discord](${DISCORD}) — maintainers are most active there, and it is by far the fastest route to a decision.`, + `3. Once the issue has the label, link it: put \`Fixes #\` in this pull request's description, or use **Development** in the sidebar.`, + '', + `**This will close automatically in ${GRACE_HOURS} hours if it is still unlinked.** Nothing is lost if that happens — link the issue, reopen, and it goes into the review queue.`, + '', + `See [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md) for the full process. If you think this is wrong, say so here and a maintainer will take a look.`, + ].join('\n'); +} + +/** Every gate notice on a pull request, oldest first. */ +async function findNotices({ github, owner, repo, number }) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: number, per_page: 100, + }); + return comments.filter((c) => (c.body || '').includes(MARKER)); +} + +/** + * Drop the gate label and delete the notice. + * + * Deleting matters: `runGate` posts a notice only when none exists, and the + * sweeper measures grace from the notice timestamp. A notice left behind after + * the gate clears would make a later re-block look weeks old and be closed with + * no warning. + */ +async function clearGate({ github, owner, repo, pr }) { + if (hasLabel(pr, GATE_LABEL)) { + await github.rest.issues + .removeLabel({ owner, repo, issue_number: pr.number, name: GATE_LABEL }) + .catch(() => {}); + } + for (const notice of await findNotices({ github, owner, repo, number: pr.number })) { + await github.rest.issues + .deleteComment({ owner, repo, comment_id: notice.id }) + .catch(() => {}); + } +} + +/** + * Entry point for `.github/workflows/issue-gate.yml`. + * Labels and explains. Never closes — that is the sweeper's job. + */ +async function runGate({ github, core, context }) { + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + const result = await checkGate({ github, owner, repo, pr }); + + if (result.passed) { + core.info( + result.skipped ? `Skipping gate: ${result.skipped}` : `Gate passed via #${result.issue}`, + ); + await clearGate({ github, owner, repo, pr }); + return; + } + + core.warning(`Gate failed: ${result.reason}`); + await github.rest.issues.addLabels({ + owner, repo, issue_number: pr.number, labels: [GATE_LABEL], + }); + + const notices = await findNotices({ github, owner, repo, number: pr.number }); + if (notices.length > 0) return; + + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, + body: noticeBody({ owner, repo, reason: result.reason }), + }); +} + +/** Entry point for `.github/workflows/pr-sweeper.yml`. */ +async function runSweep({ github, core, context, dryRun }) { + const { owner, repo } = context.repo; + + const act = async (what, fn) => { + core.info(dryRun ? `[dry run] ${what}` : what); + if (!dryRun) await fn(); + }; + + const close = (pr, body) => async () => { + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' }); + }; + + const prs = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', per_page: 100, + }); + core.info(`${prs.length} open pull requests${dryRun ? ' (dry run)' : ''}`); + + // Re-check everything wearing the gate label. Never close blind: a pull request + // linked through the sidebar fires no webhook, so the gate workflow cannot have + // noticed it — this pass is the only thing that will. + for (const pr of prs.filter((p) => hasLabel(p, GATE_LABEL))) { + const result = await checkGate({ github, owner, repo, pr }); + + if (result.passed) { + const why = result.skipped || `via #${result.issue}`; + await act(`#${pr.number}: gate now clear (${why})`, async () => { + await clearGate({ github, owner, repo, pr }); + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, + body: 'The issue link is in place — this pull request has cleared the gate and is waiting on review.', + }); + }); + continue; + } + + const [notice] = await findNotices({ github, owner, repo, number: pr.number }); + if (!notice) { + core.info(`#${pr.number}: labelled but never notified — leaving it for the gate workflow`); + continue; + } + + const hours = (Date.now() - Date.parse(notice.created_at)) / 3_600_000; + if (hours < GRACE_HOURS) { + core.info(`#${pr.number}: ${Math.round(GRACE_HOURS - hours)}h of grace left`); + continue; + } + + await act(`#${pr.number}: closing — notified ${Math.round(hours)}h ago, still failing`, close(pr, + `Closing this: ${GRACE_HOURS} hours have passed and the gate is still not clear. This is not a judgement on the code. Link an approved issue and reopen — it goes straight into the review queue.`, + )); + } + + // Stale drafts. The gate skips drafts entirely, so they never carry the label; + // this pass keys off inactivity and re-applies the exemptions itself. + for (const pr of prs.filter((p) => p.draft)) { + if (WRITE_ACCESS.includes(pr.author_association)) continue; + if (hasLabel(pr, EXEMPT_LABEL)) continue; + + const days = (Date.now() - Date.parse(pr.updated_at)) / 86_400_000; + if (days < DRAFT_STALE_DAYS) continue; + + await act(`#${pr.number}: closing stale draft — ${Math.round(days)}d without activity`, close(pr, + `Closing this draft after ${DRAFT_STALE_DAYS} days without activity, to keep the pull request list readable. Reopen whenever you pick it back up — nothing here is lost.`, + )); + } +} + +module.exports = { + checkGate, runGate, runSweep, noticeBody, + REQUIRED_LABEL, GATE_LABEL, EXEMPT_LABEL, MARKER, GRACE_HOURS, DRAFT_STALE_DAYS, +}; diff --git a/.github/scripts/issue-gate.test.js b/.github/scripts/issue-gate.test.js new file mode 100644 index 00000000..af05a09d --- /dev/null +++ b/.github/scripts/issue-gate.test.js @@ -0,0 +1,72 @@ +'use strict'; + +// Self-check for the gate decision logic. No framework, no install: +// node .github/scripts/issue-gate.test.js +// Covers checkGate() only — the side-effecting halves (runGate/runSweep) are +// exercised against the real API via `pr-sweeper.yml`'s dry_run dispatch. + +const assert = require('node:assert'); +const { checkGate, REQUIRED_LABEL, EXEMPT_LABEL } = require('./issue-gate.js'); + +const pull = (over = {}) => ({ + number: 1, state: 'open', draft: false, + user: { type: 'User' }, author_association: 'NONE', labels: [], + ...over, +}); + +// `linked` is the list of issues GitHub resolves as closing references. +const stub = (linked) => ({ + graphql: async () => ({ + repository: { pullRequest: { closingIssuesReferences: { + nodes: linked.map((i) => ({ + number: i.number, state: i.state || 'OPEN', + labels: { nodes: (i.labels || []).map((name) => ({ name })) }, + })), + } } }, + }), +}); + +const run = (linked, over) => + checkGate({ github: stub(linked), owner: 'o', repo: 'r', pr: pull(over) }); + +const cases = [ + ['no linked issue fails', () => run([]), (r) => r.passed === false], + ['linked but unapproved fails', () => run([{ number: 7 }]), (r) => r.passed === false], + ['linked and approved passes', + () => run([{ number: 7, labels: [REQUIRED_LABEL] }]), + (r) => r.passed === true && r.issue === 7], + ['approved but closed fails', + () => run([{ number: 7, state: 'CLOSED', labels: [REQUIRED_LABEL] }]), + (r) => r.passed === false], + ['picks the approved one out of several', + () => run([{ number: 7 }, { number: 8, labels: [REQUIRED_LABEL] }]), + (r) => r.passed === true && r.issue === 8], + + // Exemptions. + ['maintainer skips', () => run([], { author_association: 'MEMBER' }), (r) => r.passed === true], + ['collaborator skips', () => run([], { author_association: 'COLLABORATOR' }), (r) => r.passed === true], + ['bot skips', () => run([], { user: { type: 'Bot' } }), (r) => r.passed === true], + ['draft skips', () => run([], { draft: true }), (r) => r.passed === true], + [`${EXEMPT_LABEL} skips`, () => run([], { labels: [{ name: EXEMPT_LABEL }] }), (r) => r.passed === true], + + // Regression guard: GitHub hands CONTRIBUTOR to anyone who has previously + // committed, i.e. every returning outside contributor. It must stay gated. + ['CONTRIBUTOR is still gated', + () => run([], { author_association: 'CONTRIBUTOR' }), + (r) => r.passed === false], +]; + +(async () => { + let failed = 0; + for (const [name, thunk, ok] of cases) { + const result = await thunk(); + if (ok(result)) { + console.log(` ok ${name}`); + } else { + failed++; + console.log(` FAIL ${name} -> ${JSON.stringify(result)}`); + } + } + assert.strictEqual(failed, 0, `${failed} case(s) failed`); + console.log(`\n${cases.length} passed`); +})(); diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index a1b07e5c..74df40c7 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -1,20 +1,27 @@ name: Issue Gate -# Closes pull requests that are not linked to an issue carrying the -# `maintainer-approved` label. See CONTRIBUTING.md for the policy. +# Labels pull requests that are not linked to an issue carrying the +# `maintainer-approved` label, and comments explaining how to fix it. # -# `pull_request_target` is required so the job has write access on PRs from -# forks. This workflow must therefore NEVER check out or execute code from the -# pull request — it only calls the GitHub API. +# This workflow never closes anything. `pr-sweeper.yml` re-checks later and closes +# only after the grace period — that gives contributors time to link an issue, and +# gives maintainers time to wave through a one-line fix. It is also the only thing +# that can notice a sidebar issue link, which fires no webhook of its own. # -# Not triggered on `synchronize`: re-running the gate on every push to an -# in-flight PR would be noise. Drafts are ignored until marked ready. +# `pull_request_target` is required so the job has write access on pull requests +# from forks. It must therefore NEVER run code from the pull request. The checkout +# below is safe because on `pull_request_target` actions/checkout defaults to the +# BASE ref, which is repo-trusted code. Never point it at `pr.head.sha`. +# +# Not triggered on `synchronize`: re-running on every push would be noise. +# Drafts are ignored until marked ready. on: pull_request_target: types: [opened, edited, reopened, ready_for_review] permissions: + contents: read issues: write pull-requests: write @@ -22,99 +29,9 @@ jobs: gate: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 - uses: actions/github-script@v7 with: script: | - const pr = context.payload.pull_request; - const { owner, repo } = context.repo; - const GATE_LABEL = 'needs-approved-issue'; - const EXEMPT_LABEL = 'gate-exempt'; - const REQUIRED_LABEL = 'maintainer-approved'; - const MARKER = ''; - - const skip = (why) => core.info(`Skipping gate: ${why}`); - - if (pr.state !== 'open') return skip('pull request is not open'); - if (pr.draft) return skip('pull request is a draft'); - if (pr.user.type === 'Bot') return skip('author is a bot'); - if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association)) { - return skip(`author_association is ${pr.author_association}`); - } - if ((pr.labels || []).some((l) => l.name === EXEMPT_LABEL)) { - return skip(`pull request carries the ${EXEMPT_LABEL} label`); - } - - // Collect candidate issue numbers from the PR body. HTML comments are - // stripped first so the commented-out `Fixes #XXX` hint in the template - // never counts. Any `#123` is treated as a candidate, not just the - // closing keywords — being generous here only risks letting a PR - // through, while being strict risks closing a legitimate one. - const body = (pr.body || '').replace(//g, ''); - const numbers = new Set(); - for (const m of body.matchAll(/#(\d+)\b/g)) numbers.add(Number(m[1])); - const urlPattern = new RegExp(`github\\.com/${owner}/${repo}/issues/(\\d+)`, 'gi'); - for (const m of body.matchAll(urlPattern)) numbers.add(Number(m[1])); - - let approved = null; - const seen = []; - for (const n of [...numbers].slice(0, 10)) { - let issue; - try { - ({ data: issue } = await github.rest.issues.get({ owner, repo, issue_number: n })); - } catch (e) { - if (e.status === 404) { seen.push(`#${n} (not found)`); continue; } - throw e; - } - if (issue.pull_request) { seen.push(`#${n} (is a pull request)`); continue; } - if (issue.labels.some((l) => (l.name || l) === REQUIRED_LABEL)) { approved = n; break; } - seen.push(`#${n} (not approved)`); - } - - if (approved) { - core.info(`Gate passed via #${approved}`); - if ((pr.labels || []).some((l) => l.name === GATE_LABEL)) { - await github.rest.issues.removeLabel({ - owner, repo, issue_number: pr.number, name: GATE_LABEL, - }).catch(() => {}); - } - return; - } - - const reason = numbers.size === 0 - ? 'This pull request does not reference an issue in its description.' - : `The referenced ${seen.length === 1 ? 'issue does' : 'issues do'} not have the \`${REQUIRED_LABEL}\` label: ${seen.join(', ')}.`; - - core.warning(`Gate failed: ${reason}`); - - await github.rest.issues.addLabels({ - owner, repo, issue_number: pr.number, labels: [GATE_LABEL], - }); - - const comments = await github.paginate(github.rest.issues.listComments, { - owner, repo, issue_number: pr.number, per_page: 100, - }); - if (!comments.some((c) => (c.body || '').includes(MARKER))) { - await github.rest.issues.createComment({ - owner, repo, issue_number: pr.number, - body: [ - MARKER, - 'Thanks for the contribution. Closing this for now, because it does not clear our issue gate.', - '', - `**${reason}**`, - '', - `Every pull request to Honcho needs to be linked to an issue carrying the \`${REQUIRED_LABEL}\` label. We do this so the review queue only holds work we have already agreed should be built — it means nobody spends time on a change we cannot merge.`, - '', - 'To get this moving:', - '', - `1. Find or open an issue describing the change. [Approved issues are here](https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3A${REQUIRED_LABEL}).`, - '2. Make the case for it in [Discord](http://discord.gg/honcho) — maintainers are most active there, and it is by far the fastest route to a decision.', - `3. Once the issue has the \`${REQUIRED_LABEL}\` label, add \`Fixes #\` to this pull request's description and reopen it.`, - '', - `See [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md) for the full process. If you think this was closed in error, comment here and a maintainer will take a look.`, - ].join('\n'), - }); - } - - await github.rest.pulls.update({ - owner, repo, pull_number: pr.number, state: 'closed', - }); + const gate = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-gate.js`); + await gate.runGate({ github, core, context }); diff --git a/.github/workflows/pr-sweeper.yml b/.github/workflows/pr-sweeper.yml new file mode 100644 index 00000000..d5bde42c --- /dev/null +++ b/.github/workflows/pr-sweeper.yml @@ -0,0 +1,41 @@ +name: PR Sweeper + +# Deferred half of the issue gate. Every six hours: +# +# 1. Re-check every pull request carrying `needs-approved-issue`. Clear the ones +# that now link an approved issue; close the ones still failing 72h after they +# were told. The re-check is the point — linking an issue through the sidebar +# fires no webhook, so `issue-gate.yml` never sees it. +# 2. Close drafts from outside the org after 30 days without activity. +# +# Runs on `schedule`, so it never touches pull request code and needs none of the +# `pull_request_target` precautions. Dispatch manually with dry_run to see what it +# would do before it does it. + +on: + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Log intended actions without closing anything' + type: boolean + default: true + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + sweep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/github-script@v7 + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} + with: + script: | + const gate = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-gate.js`); + await gate.runSweep({ github, core, context, dryRun: process.env.DRY_RUN === 'true' }); diff --git a/.github/workflows/staticanalysis.yml b/.github/workflows/staticanalysis.yml index 18bb37fd..9cad8b10 100644 --- a/.github/workflows/staticanalysis.yml +++ b/.github/workflows/staticanalysis.yml @@ -26,3 +26,11 @@ jobs: run: uv sync --all-extras --dev - name: run basedpyright run: uv run basedpyright + + issue-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # The gate runs from `pull_request_target`, where a crash is invisible until + # a contributor's PR is silently ungated. Check it here instead. + - run: node .github/scripts/issue-gate.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23f8f1f6..7b131f70 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,10 +26,12 @@ The rules below exist so that the work you do has somewhere to land — not to k **Every pull request needs an issue, and that issue needs the `maintainer-approved` label.** -A pull request that is not linked to an issue, or that is linked to an issue without the -label, will be closed without review. This is automated. We do this because an unreviewable -backlog helps nobody: a PR against an unapproved issue is work you did that we may not be -able to merge, no matter how good it is. +A pull request that is not linked to an approved issue gets labelled +`needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one +before it is closed automatically. Reopening costs nothing once the link is in place. This +is automated. We do this because an unreviewable backlog helps nobody: a PR against an +unapproved issue is work you did that we may not be able to merge, no matter how good it +is. So, in order: @@ -47,7 +49,8 @@ So, in order: issue tracker, and a five-minute conversation about what you want to build usually resolves whether it fits before either side spends real time on it. -4. **Then open the PR**, with `Fixes #123` in the body. +4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or + **Development → link an issue** in the sidebar. Both work. Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the @@ -317,8 +320,9 @@ If you have the choice, fork from your personal account. a screenshot, the failing case before and after. This is the section that most determines how fast your PR gets reviewed. Do not add sections to the template. -Include `Fixes #123` so the issue link is machine-readable — the automated gate reads the PR -body. +Link the issue so the gate can see it: `Fixes #123` in the description, or the +**Development** section of the sidebar. The gate reads GitHub's own resolved issue links, so +either route works — but a bare `#123` mention is only a reference and does not count. ### Review diff --git a/docs/v3/contributing/guidelines.mdx b/docs/v3/contributing/guidelines.mdx index a219cbc3..b98bd5c9 100644 --- a/docs/v3/contributing/guidelines.mdx +++ b/docs/v3/contributing/guidelines.mdx @@ -15,10 +15,12 @@ The rules below exist so that the work you do has somewhere to land — not to k **Every pull request needs an issue, and that issue needs the `maintainer-approved` label.** -A pull request that is not linked to an issue, or that is linked to an issue without the -label, will be closed without review. This is automated. We do this because an unreviewable -backlog helps nobody: a PR against an unapproved issue is work you did that we may not be -able to merge, no matter how good it is. +A pull request that is not linked to an approved issue gets labelled +`needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one +before it is closed automatically. Reopening costs nothing once the link is in place. This +is automated. We do this because an unreviewable backlog helps nobody: a PR against an +unapproved issue is work you did that we may not be able to merge, no matter how good it +is. So, in order: @@ -36,7 +38,8 @@ So, in order: issue tracker, and a five-minute conversation about what you want to build usually resolves whether it fits before either side spends real time on it. -4. **Then open the PR**, with `Fixes #123` in the body. +4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or + **Development → link an issue** in the sidebar. Both work. Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the @@ -306,8 +309,9 @@ If you have the choice, fork from your personal account. a screenshot, the failing case before and after. This is the section that most determines how fast your PR gets reviewed. Do not add sections to the template. -Include `Fixes #123` so the issue link is machine-readable — the automated gate reads the PR -body. +Link the issue so the gate can see it: `Fixes #123` in the description, or the +**Development** section of the sidebar. The gate reads GitHub's own resolved issue links, so +either route works — but a bare `#123` mention is only a reference and does not count. ### Review