feat(ci): defer issue-gate closes to a scheduled sweeper

Addresses review feedback on #1041.

The gate now reads GitHub's resolved closing references
(closingIssuesReferences) instead of regex-parsing the pull request body,
so an issue linked through the sidebar Development panel counts, and a
bare `#123` mention no longer does.

It also no longer closes on the pull request event. It labels and
explains; pr-sweeper.yml re-checks every six hours and closes only what is
still failing 72 hours after the notice. That re-check is load-bearing:
linking an issue via the sidebar fires no webhook, so an event-only gate
could never observe a contributor complying that way. The sweeper also
closes drafts from outside the org after 30 days.

The shared check lives in .github/scripts/issue-gate.js so both workflows
run identical logic, with a dependency-free self-check wired into static
analysis. Its one regression guard: author_association CONTRIBUTOR stays
gated, since GitHub assigns it to anyone who has previously committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vineeth Voruganti 2026-08-24 15:56:53 -04:00
parent cf7877f96f
commit 5a4e82d7df
7 changed files with 407 additions and 114 deletions

247
.github/scripts/issue-gate.js vendored Normal file
View File

@ -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 = '<!-- issue-gate -->';
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 #<number>\` 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,
};

72
.github/scripts/issue-gate.test.js vendored Normal file
View File

@ -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`);
})();

View File

@ -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 = '<!-- issue-gate -->';
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(/<!--[\s\S]*?-->/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 #<number>\` 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 });

41
.github/workflows/pr-sweeper.yml vendored Normal file
View File

@ -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' });

View File

@ -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

View File

@ -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

View File

@ -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