From 8c3c5a54a943d1fb105187fd79ae3b88d952f2d2 Mon Sep 17 00:00:00 2001 From: strawgate Date: Sun, 17 May 2026 14:43:17 -0500 Subject: [PATCH 1/7] ci: require external PRs to link a tracked issue --- .github/workflows/require-issue-link.yml | 386 +++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 .github/workflows/require-issue-link.yml diff --git a/.github/workflows/require-issue-link.yml b/.github/workflows/require-issue-link.yml new file mode 100644 index 000000000..663c0cd43 --- /dev/null +++ b/.github/workflows/require-issue-link.yml @@ -0,0 +1,386 @@ +# Require external PRs to reference an issue with an auto-close keyword +# (e.g. "Fixes #123"). On failure the PR is labeled "missing-issue-link", +# commented on, and closed. CONTRIBUTING.md requires every PR to address a +# tracked issue; this enforces that for outside contributors. +# +# Adapted from langchain-ai/langchain's require_issue_link.yml. Differences: +# - Self-contained: "external" is derived from the PR author's +# author_association, not a label applied by a separate labeler +# workflow, so this can also run on `opened`. +# - No assignee requirement. fastmcp's CONTRIBUTING.md states that +# referencing an issue "isn't a permission step" — only the link is +# enforced, not issue assignment. +# +# Maintainer override: reopen the PR, or remove the "missing-issue-link" +# label — either applies "bypass-issue-check" and reopens. + +name: Require Issue Link + +on: + pull_request_target: + # SECURITY: this is a pull_request_target workflow. It runs with repo + # write scope against the BASE repo. NEVER check out or execute code + # from the PR head here — it would let a PR run arbitrary code with + # these permissions. This workflow only reads the PR payload via the + # GitHub API; it never checks anything out. + types: [opened, edited, reopened, labeled, unlabeled] + +# Set to 'false' for a dry run: the check still runs and logs its verdict +# but will NOT label, comment, close, or fail PRs. Flip to 'true' to enforce. +env: + ENFORCE_ISSUE_LINK: "true" + +permissions: + contents: read + +concurrency: + group: require-issue-link-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + check-issue-link: + # Skip drafts, bots, maintainers (write+ access shows up as OWNER / + # MEMBER / COLLABORATOR), and PRs already bypassed or trusted. Only the + # primary actions plus the maintainer-override action (removing the + # missing-issue-link label) get past this gate. + if: >- + github.event.pull_request.draft == false && + github.actor != 'dependabot[bot]' && + !endsWith(github.actor, '[bot]') && + !contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) && + !contains(github.event.pull_request.labels.*.name, 'trusted-contributor') && + !contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') && + ( + (github.event.action != 'labeled' && github.event.action != 'unlabeled') || + (github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link') + ) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + pull-requests: write + + steps: + - name: Check for issue link + id: check-link + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const action = context.payload.action; + + // ── Ensure a label exists, then add it to the PR ─────────────── + async function ensureAndAddLabel(labelName, color) { + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ owner, repo, name: labelName, color }); + } catch (createErr) { + // 422 = created by a concurrent run between GET and POST. + if (createErr.status !== 422) throw createErr; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [labelName], + }); + } + + // ── Does the event sender have write+ access on this repo? ───── + // Uses the collaborator-permission endpoint, not org membership: + // GITHUB_TOKEN is an app installation token and is never an org + // member, so the org endpoint always 403s. + async function senderIsMaintainer() { + const sender = context.payload.sender?.login; + if (!sender) { + throw new Error('Event has no sender — cannot check permissions'); + } + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username: sender, + }); + const perm = data.permission; + if (['admin', 'maintain', 'write'].includes(perm)) { + console.log(`${sender} has ${perm} permission — treating as maintainer`); + return { isMaintainer: true, login: sender }; + } + console.log(`${sender} has ${perm} permission — not a maintainer`); + return { isMaintainer: false, login: sender }; + } catch (e) { + if (e.status === 404) { + console.log(`Cannot resolve permissions for ${sender} — treating as non-maintainer`); + return { isMaintainer: false, login: sender }; + } + throw new Error( + `Permission check failed for ${sender} (HTTP ${e.status ?? 'unknown'}): ${e.message}`, + ); + } + } + + const MARKER = ''; + + // ── Minimize a stale enforcement comment, if any (best-effort) ── + async function minimizeStaleComment() { + try { + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const stale = comments.find(c => c.body && c.body.includes(MARKER)); + if (stale) { + await github.graphql(` + mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { + minimizedComment { isMinimized } + } + } + `, { id: stale.node_id }); + console.log(`Minimized stale enforcement comment ${stale.id}`); + } + } catch (e) { + core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); + } + } + + // ── Maintainer bypass: clear enforcement state and reopen ────── + async function applyMaintainerBypass(reason) { + console.log(reason); + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: 'missing-issue-link', + }); + } catch (e) { + if (e.status !== 404) throw e; + } + if (context.payload.pull_request.state === 'closed') { + try { + await github.rest.pulls.update({ + owner, repo, pull_number: prNumber, state: 'open', + }); + console.log(`Reopened PR #${prNumber}`); + } catch (e) { + core.warning( + `Could not reopen PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` + + `Bypass label was applied — reopen manually if needed.`, + ); + } + } + await ensureAndAddLabel('bypass-issue-check', '0e8a16'); + await minimizeStaleComment(); + core.setOutput('has-link', 'true'); + } + + // ── Maintainer override: removed "missing-issue-link" label ──── + if (action === 'unlabeled') { + const { isMaintainer, login } = await senderIsMaintainer(); + if (isMaintainer) { + await applyMaintainerBypass( + `Maintainer ${login} removed missing-issue-link from PR #${prNumber} — bypassing`, + ); + return; + } + // Non-maintainer stripped the label — re-add it and let the + // downstream steps re-enforce. addLabels fires a "labeled" + // event, but the job gate ignores labeled events, so there is + // no re-trigger loop. + console.log(`Non-maintainer ${login} removed missing-issue-link — re-adding`); + try { + await ensureAndAddLabel('missing-issue-link', 'b76e79'); + } catch (e) { + core.warning( + `Failed to re-add missing-issue-link (HTTP ${e.status ?? 'unknown'}): ${e.message}`, + ); + } + core.setOutput('has-link', 'false'); + return; + } + + // ── Maintainer override: reopened a PR we had closed ─────────── + const prLabels = context.payload.pull_request.labels.map(l => l.name); + if (action === 'reopened' && prLabels.includes('missing-issue-link')) { + const { isMaintainer, login } = await senderIsMaintainer(); + if (isMaintainer) { + await applyMaintainerBypass( + `Maintainer ${login} reopened PR #${prNumber} — bypassing`, + ); + return; + } + console.log(`Non-maintainer ${login} reopened PR — proceeding with check`); + } + + // ── Race guard: re-read live labels ──────────────────────────── + const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({ + owner, repo, issue_number: prNumber, + }); + const liveNames = liveLabels.map(l => l.name); + if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) { + console.log('PR carries trusted-contributor or bypass-issue-check — skipping'); + core.setOutput('has-link', 'true'); + return; + } + + // ── The actual check: an auto-close keyword + issue number ───── + const body = context.payload.pull_request.body || ''; + const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)/gi; + const matches = [...body.matchAll(pattern)]; + + if (matches.length === 0) { + console.log('No issue link found in PR body'); + core.setOutput('has-link', 'false'); + return; + } + + const issues = [...new Set(matches.map(m => `#${m[1]}`))].join(', '); + console.log(`Found issue link(s): ${issues}`); + core.setOutput('has-link', 'true'); + + - name: Add missing-issue-link label + if: >- + env.ENFORCE_ISSUE_LINK == 'true' && + steps.check-link.outputs.has-link != 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const labelName = 'missing-issue-link'; + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ + owner, repo, name: labelName, color: 'b76e79', + }); + } catch (createErr) { + if (createErr.status !== 422) throw createErr; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [labelName], + }); + + - name: Clear missing-issue-link and reopen + if: >- + env.ENFORCE_ISSUE_LINK == 'true' && + steps.check-link.outputs.has-link == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: 'missing-issue-link', + }); + } catch (e) { + if (e.status !== 404) throw e; + } + + // Reopen only if this workflow had closed the PR (payload labels + // still reflect pre-removal state). + const labels = context.payload.pull_request.labels.map(l => l.name); + if (context.payload.pull_request.state === 'closed' && labels.includes('missing-issue-link')) { + await github.rest.pulls.update({ + owner, repo, pull_number: prNumber, state: 'open', + }); + console.log(`Reopened PR #${prNumber}`); + } + + const MARKER = ''; + try { + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const stale = comments.find(c => c.body && c.body.includes(MARKER)); + if (stale) { + await github.graphql(` + mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { + minimizedComment { isMinimized } + } + } + `, { id: stale.node_id }); + console.log(`Minimized stale enforcement comment ${stale.id}`); + } + } catch (e) { + core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); + } + + - name: Comment, close, and fail + if: >- + env.ENFORCE_ISSUE_LINK == 'true' && + steps.check-link.outputs.has-link != 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const MARKER = ''; + + const body = [ + MARKER, + '**This PR has been automatically closed** because its description does not reference a tracked issue.', + '', + 'Per [CONTRIBUTING.md](https://github.com/' + owner + '/' + repo + '/blob/main/CONTRIBUTING.md), every PR should address a tracked issue. To proceed:', + '', + '1. Find or [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new/choose) describing the change.', + '2. Add `Fixes #`, `Closes #`, or `Resolves #` to the PR description.', + '3. The PR reopens automatically once the description links an issue.', + '', + '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*', + ].join('\n'); + + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const existing = comments.find(c => c.body && c.body.includes(MARKER)); + if (!existing) { + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, body, + }); + console.log('Posted requirement comment'); + } else if (existing.body !== body) { + await github.rest.issues.updateComment({ + owner, repo, comment_id: existing.id, body, + }); + console.log('Updated requirement comment'); + } else { + console.log('Requirement comment already present — skipping'); + } + + if (context.payload.pull_request.state === 'open') { + await github.rest.pulls.update({ + owner, repo, pull_number: prNumber, state: 'closed', + }); + console.log(`Closed PR #${prNumber}`); + } + + // Cancel this PR's other in-progress / queued runs — no point + // burning CI on a PR we just closed. + const headSha = context.payload.pull_request.head.sha; + for (const status of ['in_progress', 'queued']) { + const runs = await github.paginate( + github.rest.actions.listWorkflowRunsForRepo, + { owner, repo, head_sha: headSha, status, per_page: 100 }, + ); + for (const run of runs) { + if (run.id === context.runId) continue; + try { + await github.rest.actions.cancelWorkflowRun({ + owner, repo, run_id: run.id, + }); + console.log(`Cancelled ${status} run ${run.id} (${run.name})`); + } catch (err) { + console.log(`Could not cancel run ${run.id}: ${err.message}`); + } + } + } + + core.setFailed( + 'PR must reference a tracked issue using an auto-close keyword (e.g. "Fixes #123").', + ); From df80403592524aec8e984cdb8ea463112aa933b3 Mon Sep 17 00:00:00 2001 From: strawgate Date: Sun, 17 May 2026 14:50:06 -0500 Subject: [PATCH 2/7] ci: classify external authors by repo permission, not author_association --- .github/workflows/require-issue-link.yml | 108 ++++++++++++++++------- 1 file changed, 75 insertions(+), 33 deletions(-) diff --git a/.github/workflows/require-issue-link.yml b/.github/workflows/require-issue-link.yml index 663c0cd43..a851f923c 100644 --- a/.github/workflows/require-issue-link.yml +++ b/.github/workflows/require-issue-link.yml @@ -4,9 +4,15 @@ # tracked issue; this enforces that for outside contributors. # # Adapted from langchain-ai/langchain's require_issue_link.yml. Differences: -# - Self-contained: "external" is derived from the PR author's -# author_association, not a label applied by a separate labeler -# workflow, so this can also run on `opened`. +# - Self-contained: it does NOT depend on a separate labeler workflow +# applying an "external" label first, so it can run on `opened`. +# - "External" is determined authoritatively, in-script, from the PR +# author's repo collaborator permission level — NOT from the event +# payload's author_association. author_association reports MEMBER only +# for *public* org members; a maintainer whose org membership is +# private appears as CONTRIBUTOR/NONE, so gating on it would wrongly +# enforce against private-member maintainers. getCollaboratorPermission +# reflects effective write access regardless of membership visibility. # - No assignee requirement. fastmcp's CONTRIBUTING.md states that # referencing an issue "isn't a permission step" — only the link is # enforced, not issue assignment. @@ -39,15 +45,19 @@ concurrency: jobs: check-issue-link: - # Skip drafts, bots, maintainers (write+ access shows up as OWNER / - # MEMBER / COLLABORATOR), and PRs already bypassed or trusted. Only the - # primary actions plus the maintainer-override action (removing the - # missing-issue-link label) get past this gate. + # Cheap pre-filters only. Maintainer detection is deliberately NOT done + # here: the job-level `if` can't call the API, and author_association is + # unreliable for private org members (see file header). The job runs, + # then the script resolves the author's real permission level and + # exits early for maintainers. The extra cost is a couple of API calls + # on maintainer PRs — acceptable for a correct, single source of truth. + # + # Gate: skip drafts, bots, and already-bypassed/trusted PRs. Allow the + # primary actions plus the one maintainer-override action we care about + # (removing the missing-issue-link label). if: >- github.event.pull_request.draft == false && - github.actor != 'dependabot[bot]' && !endsWith(github.actor, '[bot]') && - !contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) && !contains(github.event.pull_request.labels.*.name, 'trusted-contributor') && !contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') && ( @@ -88,35 +98,54 @@ jobs: }); } - // ── Does the event sender have write+ access on this repo? ───── - // Uses the collaborator-permission endpoint, not org membership: - // GITHUB_TOKEN is an app installation token and is never an org - // member, so the org endpoint always 403s. + // ── Does `username` have write+ access on this repo? ─────────── + // Authoritative maintainer check. Uses the collaborator- + // permission endpoint rather than org membership or the event + // payload's author_association: + // - GITHUB_TOKEN is an app installation token and is never an + // org member, so the org-membership endpoint always 403s. + // - author_association reports MEMBER only for *public* org + // members; a private-member maintainer shows as + // CONTRIBUTOR/NONE. Permission level is membership-visibility + // independent and reflects effective access. + // A 404 (not a collaborator at all) → not a maintainer. Other + // errors (rate limit, 5xx) must throw: silently treating them + // as "not a maintainer" could wrongly enforce against — and + // close — a legitimate maintainer's PR. + async function hasWriteAccess(username) { + if (!username) { + throw new Error('No username supplied — cannot check permissions'); + } + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username, + }); + const perm = data.permission; + const isMaintainer = ['admin', 'maintain', 'write'].includes(perm); + console.log( + `${username} has ${perm} permission — ` + + `${isMaintainer ? 'treating as maintainer' : 'not a maintainer'}`, + ); + return isMaintainer; + } catch (e) { + if (e.status === 404) { + console.log(`${username} is not a collaborator — not a maintainer`); + return false; + } + throw new Error( + `Permission check failed for ${username} (HTTP ${e.status ?? 'unknown'}): ${e.message}`, + ); + } + } + + // Thin wrapper for the maintainer-override paths, which key off + // the event sender (who reopened the PR / removed the label). async function senderIsMaintainer() { const sender = context.payload.sender?.login; if (!sender) { throw new Error('Event has no sender — cannot check permissions'); } - try { - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner, repo, username: sender, - }); - const perm = data.permission; - if (['admin', 'maintain', 'write'].includes(perm)) { - console.log(`${sender} has ${perm} permission — treating as maintainer`); - return { isMaintainer: true, login: sender }; - } - console.log(`${sender} has ${perm} permission — not a maintainer`); - return { isMaintainer: false, login: sender }; - } catch (e) { - if (e.status === 404) { - console.log(`Cannot resolve permissions for ${sender} — treating as non-maintainer`); - return { isMaintainer: false, login: sender }; - } - throw new Error( - `Permission check failed for ${sender} (HTTP ${e.status ?? 'unknown'}): ${e.message}`, - ); - } + return { isMaintainer: await hasWriteAccess(sender), login: sender }; } const MARKER = ''; @@ -172,6 +201,19 @@ jobs: core.setOutput('has-link', 'true'); } + // ── Maintainer-authored PRs are exempt entirely ──────────────── + // Authoritative check (see hasWriteAccess). This is why the + // job-level `if` does NOT gate on author_association: a + // private-member maintainer would slip past that and get + // enforced against. Resolving real permission here is the single + // source of truth. + const prAuthor = context.payload.pull_request.user.login; + if (await hasWriteAccess(prAuthor)) { + console.log(`PR author ${prAuthor} has write access — exempt from issue-link enforcement`); + core.setOutput('has-link', 'true'); + return; + } + // ── Maintainer override: removed "missing-issue-link" label ──── if (action === 'unlabeled') { const { isMaintainer, login } = await senderIsMaintainer(); From 03e062126a34bfc3d39176876bf23921c1cb6901 Mon Sep 17 00:00:00 2001 From: strawgate Date: Sun, 17 May 2026 15:00:56 -0500 Subject: [PATCH 3/7] ci: consolidate require-issue-link into one step; read-only dry run; drop actions:write --- .github/workflows/require-issue-link.yml | 443 +++++++++-------------- 1 file changed, 167 insertions(+), 276 deletions(-) diff --git a/.github/workflows/require-issue-link.yml b/.github/workflows/require-issue-link.yml index a851f923c..cb4bc4243 100644 --- a/.github/workflows/require-issue-link.yml +++ b/.github/workflows/require-issue-link.yml @@ -1,5 +1,5 @@ # Require external PRs to reference an issue with an auto-close keyword -# (e.g. "Fixes #123"). On failure the PR is labeled "missing-issue-link", +# (e.g. "Fixes #123"). When missing, the PR is labeled "missing-issue-link", # commented on, and closed. CONTRIBUTING.md requires every PR to address a # tracked issue; this enforces that for outside contributors. # @@ -16,23 +16,25 @@ # - No assignee requirement. fastmcp's CONTRIBUTING.md states that # referencing an issue "isn't a permission step" — only the link is # enforced, not issue assignment. +# - Single github-script step (the upstream version is split across four, +# forcing the label/comment/reopen helpers to be duplicated per scope). # # Maintainer override: reopen the PR, or remove the "missing-issue-link" -# label — either applies "bypass-issue-check" and reopens. +# label — either applies a sticky "bypass-issue-check" label and reopens. name: Require Issue Link on: pull_request_target: - # SECURITY: this is a pull_request_target workflow. It runs with repo - # write scope against the BASE repo. NEVER check out or execute code - # from the PR head here — it would let a PR run arbitrary code with - # these permissions. This workflow only reads the PR payload via the - # GitHub API; it never checks anything out. + # SECURITY: pull_request_target runs with repo write scope against the + # BASE repo. NEVER check out or execute PR-head code here — it would run + # with these permissions. This workflow only reads the PR payload and + # calls the API; it never checks anything out. types: [opened, edited, reopened, labeled, unlabeled] -# Set to 'false' for a dry run: the check still runs and logs its verdict -# but will NOT label, comment, close, or fail PRs. Flip to 'true' to enforce. +# Dry run: when 'false' the check still runs and logs its verdict but makes +# NO mutations at all (no label, comment, close, reopen, or failure). Flip +# to 'true' to enforce. env: ENFORCE_ISSUE_LINK: "true" @@ -48,9 +50,8 @@ jobs: # Cheap pre-filters only. Maintainer detection is deliberately NOT done # here: the job-level `if` can't call the API, and author_association is # unreliable for private org members (see file header). The job runs, - # then the script resolves the author's real permission level and - # exits early for maintainers. The extra cost is a couple of API calls - # on maintainer PRs — acceptable for a correct, single source of truth. + # then the script resolves the author's real permission and exits early + # for maintainers. # # Gate: skip drafts, bots, and already-bypassed/trusted PRs. Allow the # primary actions plus the one maintainer-override action we care about @@ -67,66 +68,53 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: - actions: write pull-requests: write steps: - - name: Check for issue link - id: check-link + - name: Enforce issue link uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { owner, repo } = context.repo; - const prNumber = context.payload.pull_request.number; + const pr = context.payload.pull_request; + const prNumber = pr.number; const action = context.payload.action; + const enforce = process.env.ENFORCE_ISSUE_LINK === 'true'; + const LABEL = 'missing-issue-link'; + const MARKER = ''; - // ── Ensure a label exists, then add it to the PR ─────────────── - async function ensureAndAddLabel(labelName, color) { - try { - await github.rest.issues.getLabel({ owner, repo, name: labelName }); - } catch (e) { - if (e.status !== 404) throw e; - try { - await github.rest.issues.createLabel({ owner, repo, name: labelName, color }); - } catch (createErr) { - // 422 = created by a concurrent run between GET and POST. - if (createErr.status !== 422) throw createErr; - } + // Dry-run guard: every mutating call goes through this so that + // ENFORCE_ISSUE_LINK=false means strictly read-only. + async function mutate(description, fn) { + if (!enforce) { + console.log(`[dry-run] would ${description}`); + return; } - await github.rest.issues.addLabels({ - owner, repo, issue_number: prNumber, labels: [labelName], - }); + await fn(); } - // ── Does `username` have write+ access on this repo? ─────────── - // Authoritative maintainer check. Uses the collaborator- - // permission endpoint rather than org membership or the event - // payload's author_association: - // - GITHUB_TOKEN is an app installation token and is never an - // org member, so the org-membership endpoint always 403s. + // Authoritative maintainer check. Uses collaborator permission, + // not org membership or author_association: + // - GITHUB_TOKEN is an app token and is never an org member, + // so the org-membership endpoint always 403s. // - author_association reports MEMBER only for *public* org // members; a private-member maintainer shows as - // CONTRIBUTOR/NONE. Permission level is membership-visibility + // CONTRIBUTOR/NONE. Permission level is visibility- // independent and reflects effective access. - // A 404 (not a collaborator at all) → not a maintainer. Other - // errors (rate limit, 5xx) must throw: silently treating them - // as "not a maintainer" could wrongly enforce against — and - // close — a legitimate maintainer's PR. + // 404 (not a collaborator) → not a maintainer. Other errors + // (rate limit, 5xx) MUST throw: silently treating them as + // "not a maintainer" could wrongly close a maintainer's PR. + // A throw aborts the script before any close/label call, so the + // job fails red and the PR is left untouched — the safe direction. async function hasWriteAccess(username) { - if (!username) { - throw new Error('No username supplied — cannot check permissions'); - } + if (!username) throw new Error('No username — cannot check permissions'); try { const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username, }); - const perm = data.permission; - const isMaintainer = ['admin', 'maintain', 'write'].includes(perm); - console.log( - `${username} has ${perm} permission — ` + - `${isMaintainer ? 'treating as maintainer' : 'not a maintainer'}`, - ); - return isMaintainer; + const ok = ['admin', 'maintain', 'write'].includes(data.permission); + console.log(`${username}: ${data.permission} — ${ok ? 'maintainer' : 'not a maintainer'}`); + return ok; } catch (e) { if (e.status === 404) { console.log(`${username} is not a collaborator — not a maintainer`); @@ -138,19 +126,25 @@ jobs: } } - // Thin wrapper for the maintainer-override paths, which key off - // the event sender (who reopened the PR / removed the label). - async function senderIsMaintainer() { - const sender = context.payload.sender?.login; - if (!sender) { - throw new Error('Event has no sender — cannot check permissions'); - } - return { isMaintainer: await hasWriteAccess(sender), login: sender }; + async function addLabel() { + await mutate(`label PR #${prNumber} "${LABEL}"`, async () => { + try { + await github.rest.issues.getLabel({ owner, repo, name: LABEL }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'b76e79' }); + } catch (createErr) { + // 422 = created by a concurrent run between GET and POST. + if (createErr.status !== 422) throw createErr; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [LABEL], + }); + }); } - const MARKER = ''; - - // ── Minimize a stale enforcement comment, if any (best-effort) ── async function minimizeStaleComment() { try { const comments = await github.paginate( @@ -158,98 +152,97 @@ jobs: { owner, repo, issue_number: prNumber, per_page: 100 }, ); const stale = comments.find(c => c.body && c.body.includes(MARKER)); - if (stale) { - await github.graphql(` - mutation($id: ID!) { - minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { - minimizedComment { isMinimized } - } + if (!stale) return; + await mutate(`minimize stale comment ${stale.id}`, () => github.graphql(` + mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { + minimizedComment { isMinimized } } - `, { id: stale.node_id }); - console.log(`Minimized stale enforcement comment ${stale.id}`); - } + } + `, { id: stale.node_id })); } catch (e) { core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); } } - // ── Maintainer bypass: clear enforcement state and reopen ────── - async function applyMaintainerBypass(reason) { - console.log(reason); - try { - await github.rest.issues.removeLabel({ - owner, repo, issue_number: prNumber, name: 'missing-issue-link', - }); - } catch (e) { - if (e.status !== 404) throw e; - } - if (context.payload.pull_request.state === 'closed') { + // Shared "this PR passes" cleanup: drop the label, reopen the PR + // only if THIS workflow had closed it (payload labels still show + // pre-removal state), and retire any stale enforcement comment. + async function clearEnforcement() { + await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => { try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: LABEL, + }); + } catch (e) { + if (e.status !== 404) throw e; + } + }); + const hadLabel = pr.labels.map(l => l.name).includes(LABEL); + if (pr.state === 'closed' && hadLabel) { + await mutate(`reopen PR #${prNumber}`, async () => { await github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'open', }); - console.log(`Reopened PR #${prNumber}`); - } catch (e) { - core.warning( - `Could not reopen PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` + - `Bypass label was applied — reopen manually if needed.`, - ); - } + }); } - await ensureAndAddLabel('bypass-issue-check', '0e8a16'); await minimizeStaleComment(); - core.setOutput('has-link', 'true'); + } + + async function applyBypass(reason) { + console.log(reason); + await clearEnforcement(); + await mutate(`add sticky "bypass-issue-check" to PR #${prNumber}`, async () => { + try { + await github.rest.issues.getLabel({ owner, repo, name: 'bypass-issue-check' }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ + owner, repo, name: 'bypass-issue-check', color: '0e8a16', + }); + } catch (createErr) { + if (createErr.status !== 422) throw createErr; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: ['bypass-issue-check'], + }); + }); } // ── Maintainer-authored PRs are exempt entirely ──────────────── - // Authoritative check (see hasWriteAccess). This is why the - // job-level `if` does NOT gate on author_association: a - // private-member maintainer would slip past that and get - // enforced against. Resolving real permission here is the single - // source of truth. - const prAuthor = context.payload.pull_request.user.login; - if (await hasWriteAccess(prAuthor)) { - console.log(`PR author ${prAuthor} has write access — exempt from issue-link enforcement`); - core.setOutput('has-link', 'true'); + if (await hasWriteAccess(pr.user.login)) { + console.log(`PR author ${pr.user.login} has write access — exempt`); + await clearEnforcement(); return; } - // ── Maintainer override: removed "missing-issue-link" label ──── + const sender = context.payload.sender?.login; + + // ── Maintainer override: removed the "missing-issue-link" label ─ if (action === 'unlabeled') { - const { isMaintainer, login } = await senderIsMaintainer(); - if (isMaintainer) { - await applyMaintainerBypass( - `Maintainer ${login} removed missing-issue-link from PR #${prNumber} — bypassing`, - ); + if (await hasWriteAccess(sender)) { + await applyBypass(`Maintainer ${sender} removed ${LABEL} from PR #${prNumber} — bypassing`); return; } - // Non-maintainer stripped the label — re-add it and let the - // downstream steps re-enforce. addLabels fires a "labeled" - // event, but the job gate ignores labeled events, so there is - // no re-trigger loop. - console.log(`Non-maintainer ${login} removed missing-issue-link — re-adding`); - try { - await ensureAndAddLabel('missing-issue-link', 'b76e79'); - } catch (e) { - core.warning( - `Failed to re-add missing-issue-link (HTTP ${e.status ?? 'unknown'}): ${e.message}`, - ); - } - core.setOutput('has-link', 'false'); + // Only triage/admin can manage labels, so a non-write actor + // reaching here is rare (triage role). Re-add and fall through + // to enforcement. addLabels fires a "labeled" event, which the + // job gate ignores — no re-trigger loop. + console.log(`Non-maintainer ${sender} removed ${LABEL} — re-enforcing`); + await closeForMissingLink(); return; } // ── Maintainer override: reopened a PR we had closed ─────────── - const prLabels = context.payload.pull_request.labels.map(l => l.name); - if (action === 'reopened' && prLabels.includes('missing-issue-link')) { - const { isMaintainer, login } = await senderIsMaintainer(); - if (isMaintainer) { - await applyMaintainerBypass( - `Maintainer ${login} reopened PR #${prNumber} — bypassing`, - ); - return; - } - console.log(`Non-maintainer ${login} reopened PR — proceeding with check`); + if ( + action === 'reopened' && + pr.labels.map(l => l.name).includes(LABEL) && + (await hasWriteAccess(sender)) + ) { + await applyBypass(`Maintainer ${sender} reopened PR #${prNumber} — bypassing`); + return; } // ── Race guard: re-read live labels ──────────────────────────── @@ -258,171 +251,69 @@ jobs: }); const liveNames = liveLabels.map(l => l.name); if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) { - console.log('PR carries trusted-contributor or bypass-issue-check — skipping'); - core.setOutput('has-link', 'true'); + console.log('PR carries trusted-contributor or bypass-issue-check — clearing any prior enforcement'); + await clearEnforcement(); return; } // ── The actual check: an auto-close keyword + issue number ───── - const body = context.payload.pull_request.body || ''; + const body = pr.body || ''; const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)/gi; const matches = [...body.matchAll(pattern)]; - if (matches.length === 0) { - console.log('No issue link found in PR body'); - core.setOutput('has-link', 'false'); + if (matches.length > 0) { + const issues = [...new Set(matches.map(m => `#${m[1]}`))].join(', '); + console.log(`Found issue link(s): ${issues} — clearing any prior enforcement`); + await clearEnforcement(); return; } - const issues = [...new Set(matches.map(m => `#${m[1]}`))].join(', '); - console.log(`Found issue link(s): ${issues}`); - core.setOutput('has-link', 'true'); + console.log('No issue link found in PR body'); + await closeForMissingLink(); - - name: Add missing-issue-link label - if: >- - env.ENFORCE_ISSUE_LINK == 'true' && - steps.check-link.outputs.has-link != 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const { owner, repo } = context.repo; - const prNumber = context.payload.pull_request.number; - const labelName = 'missing-issue-link'; - try { - await github.rest.issues.getLabel({ owner, repo, name: labelName }); - } catch (e) { - if (e.status !== 404) throw e; - try { - await github.rest.issues.createLabel({ - owner, repo, name: labelName, color: 'b76e79', - }); - } catch (createErr) { - if (createErr.status !== 422) throw createErr; - } - } - await github.rest.issues.addLabels({ - owner, repo, issue_number: prNumber, labels: [labelName], - }); + // ── Label, comment, close, and fail ──────────────────────────── + async function closeForMissingLink() { + await addLabel(); - - name: Clear missing-issue-link and reopen - if: >- - env.ENFORCE_ISSUE_LINK == 'true' && - steps.check-link.outputs.has-link == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const { owner, repo } = context.repo; - const prNumber = context.payload.pull_request.number; - try { - await github.rest.issues.removeLabel({ - owner, repo, issue_number: prNumber, name: 'missing-issue-link', - }); - } catch (e) { - if (e.status !== 404) throw e; - } + const commentBody = [ + MARKER, + '**This PR has been automatically closed** because its description does not reference a tracked issue.', + '', + `Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), every PR should address a tracked issue. To proceed:`, + '', + `1. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change.`, + '2. Add `Fixes #`, `Closes #`, or `Resolves #` to the PR description.', + '3. The PR reopens automatically once the description links an issue.', + '', + `*Maintainers: reopen this PR or remove the \`${LABEL}\` label to bypass this check.*`, + ].join('\n'); - // Reopen only if this workflow had closed the PR (payload labels - // still reflect pre-removal state). - const labels = context.payload.pull_request.labels.map(l => l.name); - if (context.payload.pull_request.state === 'closed' && labels.includes('missing-issue-link')) { - await github.rest.pulls.update({ - owner, repo, pull_number: prNumber, state: 'open', - }); - console.log(`Reopened PR #${prNumber}`); - } - - const MARKER = ''; - try { const comments = await github.paginate( github.rest.issues.listComments, { owner, repo, issue_number: prNumber, per_page: 100 }, ); - const stale = comments.find(c => c.body && c.body.includes(MARKER)); - if (stale) { - await github.graphql(` - mutation($id: ID!) { - minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { - minimizedComment { isMinimized } - } - } - `, { id: stale.node_id }); - console.log(`Minimized stale enforcement comment ${stale.id}`); + const existing = comments.find(c => c.body && c.body.includes(MARKER)); + if (!existing) { + await mutate(`comment on PR #${prNumber}`, () => github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, body: commentBody, + })); + } else if (existing.body !== commentBody) { + await mutate(`update comment ${existing.id}`, () => github.rest.issues.updateComment({ + owner, repo, comment_id: existing.id, body: commentBody, + })); + } else { + console.log('Requirement comment already present — skipping'); } - } catch (e) { - core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); - } - - name: Comment, close, and fail - if: >- - env.ENFORCE_ISSUE_LINK == 'true' && - steps.check-link.outputs.has-link != 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const { owner, repo } = context.repo; - const prNumber = context.payload.pull_request.number; - const MARKER = ''; + if (pr.state === 'open') { + await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({ + owner, repo, pull_number: prNumber, state: 'closed', + })); + } - const body = [ - MARKER, - '**This PR has been automatically closed** because its description does not reference a tracked issue.', - '', - 'Per [CONTRIBUTING.md](https://github.com/' + owner + '/' + repo + '/blob/main/CONTRIBUTING.md), every PR should address a tracked issue. To proceed:', - '', - '1. Find or [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new/choose) describing the change.', - '2. Add `Fixes #`, `Closes #`, or `Resolves #` to the PR description.', - '3. The PR reopens automatically once the description links an issue.', - '', - '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*', - ].join('\n'); - - const comments = await github.paginate( - github.rest.issues.listComments, - { owner, repo, issue_number: prNumber, per_page: 100 }, - ); - const existing = comments.find(c => c.body && c.body.includes(MARKER)); - if (!existing) { - await github.rest.issues.createComment({ - owner, repo, issue_number: prNumber, body, - }); - console.log('Posted requirement comment'); - } else if (existing.body !== body) { - await github.rest.issues.updateComment({ - owner, repo, comment_id: existing.id, body, - }); - console.log('Updated requirement comment'); - } else { - console.log('Requirement comment already present — skipping'); - } - - if (context.payload.pull_request.state === 'open') { - await github.rest.pulls.update({ - owner, repo, pull_number: prNumber, state: 'closed', - }); - console.log(`Closed PR #${prNumber}`); - } - - // Cancel this PR's other in-progress / queued runs — no point - // burning CI on a PR we just closed. - const headSha = context.payload.pull_request.head.sha; - for (const status of ['in_progress', 'queued']) { - const runs = await github.paginate( - github.rest.actions.listWorkflowRunsForRepo, - { owner, repo, head_sha: headSha, status, per_page: 100 }, - ); - for (const run of runs) { - if (run.id === context.runId) continue; - try { - await github.rest.actions.cancelWorkflowRun({ - owner, repo, run_id: run.id, - }); - console.log(`Cancelled ${status} run ${run.id} (${run.name})`); - } catch (err) { - console.log(`Could not cancel run ${run.id}: ${err.message}`); - } + if (enforce) { + core.setFailed( + 'PR must reference a tracked issue using an auto-close keyword (e.g. "Fixes #123").', + ); } } - - core.setFailed( - 'PR must reference a tracked issue using an auto-close keyword (e.g. "Fixes #123").', - ); From a082415ce79cf8dc995f124e932815b679408051 Mon Sep 17 00:00:00 2001 From: strawgate Date: Sun, 17 May 2026 15:10:35 -0500 Subject: [PATCH 4/7] ci: also require the linked issue be assigned to the PR author; update CONTRIBUTING --- .github/workflows/require-issue-link.yml | 111 ++++++++++++++++++----- CONTRIBUTING.md | 4 +- 2 files changed, 88 insertions(+), 27 deletions(-) diff --git a/.github/workflows/require-issue-link.yml b/.github/workflows/require-issue-link.yml index cb4bc4243..68faa1ab2 100644 --- a/.github/workflows/require-issue-link.yml +++ b/.github/workflows/require-issue-link.yml @@ -1,7 +1,8 @@ # Require external PRs to reference an issue with an auto-close keyword -# (e.g. "Fixes #123"). When missing, the PR is labeled "missing-issue-link", -# commented on, and closed. CONTRIBUTING.md requires every PR to address a -# tracked issue; this enforces that for outside contributors. +# (e.g. "Fixes #123") AND have the PR author assigned to that issue. +# Otherwise the PR is labeled "missing-issue-link", commented on, and +# closed. CONTRIBUTING.md requires external contributors to be assigned to +# an issue before opening a PR; this enforces that. # # Adapted from langchain-ai/langchain's require_issue_link.yml. Differences: # - Self-contained: it does NOT depend on a separate labeler workflow @@ -13,9 +14,6 @@ # private appears as CONTRIBUTOR/NONE, so gating on it would wrongly # enforce against private-member maintainers. getCollaboratorPermission # reflects effective write access regardless of membership visibility. -# - No assignee requirement. fastmcp's CONTRIBUTING.md states that -# referencing an issue "isn't a permission step" — only the link is -# enforced, not issue assignment. # - Single github-script step (the upstream version is split across four, # forcing the label/comment/reopen helpers to be duplicated per scope). # @@ -227,12 +225,10 @@ jobs: return; } // Only triage/admin can manage labels, so a non-write actor - // reaching here is rare (triage role). Re-add and fall through - // to enforcement. addLabels fires a "labeled" event, which the - // job gate ignores — no re-trigger loop. - console.log(`Non-maintainer ${sender} removed ${LABEL} — re-enforcing`); - await closeForMissingLink(); - return; + // reaching here is rare (triage role). Fall through to the + // normal check, which recomputes link + assignment and + // re-enforces with the correct message if still failing. + console.log(`Non-maintainer ${sender} removed ${LABEL} — re-checking`); } // ── Maintainer override: reopened a PR we had closed ─────────── @@ -261,29 +257,92 @@ jobs: const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)/gi; const matches = [...body.matchAll(pattern)]; - if (matches.length > 0) { - const issues = [...new Set(matches.map(m => `#${m[1]}`))].join(', '); - console.log(`Found issue link(s): ${issues} — clearing any prior enforcement`); - await clearEnforcement(); + if (matches.length === 0) { + console.log('No issue link found in PR body'); + await enforceFailure('no-link'); return; } - console.log('No issue link found in PR body'); - await closeForMissingLink(); + // The author must be assigned to at least one linked issue. + // CONTRIBUTING.md requires external contributors to be assigned + // before opening a PR (so maintainers can deconflict / steer + // approach first). + const MAX_ISSUES = 5; + const allNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))]; + const numbers = allNumbers.slice(0, MAX_ISSUES); + if (allNumbers.length > MAX_ISSUES) { + core.warning(`PR references ${allNumbers.length} issues — checking only the first ${MAX_ISSUES}`); + } + + const prAuthor = pr.user.login.toLowerCase(); + let sawRealIssue = false; + let assignedToAny = false; + for (const num of numbers) { + let issue; + try { + ({ data: issue } = await github.rest.issues.get({ + owner, repo, issue_number: num, + })); + } catch (e) { + if (e.status === 404) { + console.log(`#${num} does not exist — ignoring`); + continue; + } + // Same safe-direction rule as hasWriteAccess: a transient + // error must not be read as "not assigned" and close the PR. + throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? 'unknown'}): ${e.message}`); + } + sawRealIssue = true; + const assignees = (issue.assignees || []).map(a => a.login.toLowerCase()); + if (assignees.includes(prAuthor)) { + console.log(`PR author ${pr.user.login} is assigned to #${num}`); + assignedToAny = true; + break; + } + console.log(`PR author ${pr.user.login} is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`); + } + + if (!sawRealIssue) { + console.log('Referenced issue(s) do not exist'); + await enforceFailure('no-link'); + return; + } + if (!assignedToAny) { + await enforceFailure('not-assigned'); + return; + } + + console.log('Linked and assigned — clearing any prior enforcement'); + await clearEnforcement(); // ── Label, comment, close, and fail ──────────────────────────── - async function closeForMissingLink() { + // `kind`: 'no-link' (no valid issue reference) or 'not-assigned' + // (referenced an issue, but the author isn't assigned to it). + async function enforceFailure(kind) { await addLabel(); + const intro = kind === 'no-link' + ? '**This PR has been automatically closed** because its description does not reference a tracked issue.' + : '**This PR has been automatically closed** because you are not assigned to the issue it references.'; + const steps = kind === 'no-link' + ? [ + `1. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change.`, + '2. Comment on the issue to ask a maintainer to assign it to you.', + '3. Add `Fixes #`, `Closes #`, or `Resolves #` to the PR description.', + '4. Once you are assigned and the link is present, the PR reopens automatically.', + ] + : [ + '1. Comment on the linked issue to ask a maintainer to assign it to you.', + '2. Once a maintainer assigns you, the PR reopens automatically.', + ]; + const commentBody = [ MARKER, - '**This PR has been automatically closed** because its description does not reference a tracked issue.', + intro, '', - `Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), every PR should address a tracked issue. To proceed:`, + `Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), an external PR must reference an issue that is assigned to its author. To proceed:`, '', - `1. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change.`, - '2. Add `Fixes #`, `Closes #`, or `Resolves #` to the PR description.', - '3. The PR reopens automatically once the description links an issue.', + ...steps, '', `*Maintainers: reopen this PR or remove the \`${LABEL}\` label to bypass this check.*`, ].join('\n'); @@ -313,7 +372,9 @@ jobs: if (enforce) { core.setFailed( - 'PR must reference a tracked issue using an auto-close keyword (e.g. "Fixes #123").', + kind === 'no-link' + ? 'PR must reference a tracked issue using an auto-close keyword (e.g. "Fixes #123").' + : 'PR author must be assigned to the referenced issue.', ); } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f861c50a..44b035746 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ We encourage you to use LLMs to help identify bugs, write MREs, and prepare cont ## When to open a pull request -An open issue is not an invitation to submit a PR. Issues track problems; whether and how to solve them is a separate decision. If you want to work on something, propose your approach in the issue first — especially for anything beyond a trivial fix. +An open issue is not an invitation to submit a PR. Issues track problems; whether and how to solve them is a separate decision. If you want to work on something, propose your approach in the issue first and ask a maintainer to assign it to you — especially for anything beyond a trivial fix. External PRs that reference an issue not assigned to their author are closed automatically (see [PR guidelines](#pr-guidelines)). **Bug fixes** — PRs are welcome for simple, well-scoped bug fixes where the problem and solution are both straightforward. "The function raises `TypeError` when passed `None` because of a missing guard" is a good candidate. If the fix requires design decisions or touches multiple subsystems, open an issue with a design proposal instead. @@ -34,7 +34,7 @@ An open issue is not an invitation to submit a PR. Issues track problems; whethe If you do open a PR: -- **Reference an issue.** Every PR should address a tracked issue. If there isn't one, open an issue first. This isn't a permission step — you don't need to wait for a response. But the issue gives us context on the problem, and if a maintainer is already working on it, we can let you know before you invest time in code. +- **Reference an issue you're assigned to.** Every PR must reference a tracked issue using an auto-close keyword (`Fixes #123`, `Closes #123`, or `Resolves #123`), and the referenced issue must be assigned to you. If there isn't an issue, open one; then comment to ask a maintainer to assign it to you. This lets us deconflict effort and steer the approach before you invest time in code. External PRs that don't meet both conditions are automatically labeled `missing-issue-link` and closed; they reopen automatically once the link is present and you're assigned. (Maintainers can bypass the check by reopening the PR or removing the label.) - **Keep it focused.** One logical change per PR. Don't bundle unrelated fixes or refactors. - **Match existing patterns.** Follow the code style, type annotation conventions, and test patterns you see in the codebase. Run `uv run prek run --all-files` before submitting. - **Write tests.** Bug fixes should include a test that fails without the fix. Enhancements should include tests for the new behavior. From 090e8afe49274497b316c78995ed1af38a959793 Mon Sep 17 00:00:00 2001 From: strawgate Date: Sun, 17 May 2026 15:20:14 -0500 Subject: [PATCH 5/7] ci: trigger on ready_for_review, reopen on label-removal bypass; trim CONTRIBUTING --- .github/workflows/require-issue-link.yml | 24 +++++++++++++++++------- CONTRIBUTING.md | 2 +- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/workflows/require-issue-link.yml b/.github/workflows/require-issue-link.yml index 68faa1ab2..1ae5aa9f5 100644 --- a/.github/workflows/require-issue-link.yml +++ b/.github/workflows/require-issue-link.yml @@ -28,7 +28,10 @@ on: # BASE repo. NEVER check out or execute PR-head code here — it would run # with these permissions. This workflow only reads the PR payload and # calls the API; it never checks anything out. - types: [opened, edited, reopened, labeled, unlabeled] + # ready_for_review matters because the job skips drafts: without it a + # draft opened with no issue link would never be checked when it later + # becomes reviewable. + types: [opened, edited, reopened, ready_for_review, labeled, unlabeled] # Dry run: when 'false' the check still runs and logs its verdict but makes # NO mutations at all (no label, comment, close, reopen, or failure). Flip @@ -163,10 +166,17 @@ jobs: } } - // Shared "this PR passes" cleanup: drop the label, reopen the PR - // only if THIS workflow had closed it (payload labels still show - // pre-removal state), and retire any stale enforcement comment. - async function clearEnforcement() { + // Shared "this PR passes" cleanup: drop the label, reopen, and + // retire any stale enforcement comment. + // + // For the normal pass paths we only reopen if THIS workflow had + // closed the PR — inferred from the label still being on the + // payload. The maintainer-override paths pass forceReopen: the + // `unlabeled` event payload no longer carries the just-removed + // label, so the heuristic can't see it; without forcing, the + // advertised "remove the label to bypass" gesture would leave + // the PR closed. + async function clearEnforcement(forceReopen = false) { await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => { try { await github.rest.issues.removeLabel({ @@ -177,7 +187,7 @@ jobs: } }); const hadLabel = pr.labels.map(l => l.name).includes(LABEL); - if (pr.state === 'closed' && hadLabel) { + if (pr.state === 'closed' && (forceReopen || hadLabel)) { await mutate(`reopen PR #${prNumber}`, async () => { await github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'open', @@ -189,7 +199,7 @@ jobs: async function applyBypass(reason) { console.log(reason); - await clearEnforcement(); + await clearEnforcement(true); await mutate(`add sticky "bypass-issue-check" to PR #${prNumber}`, async () => { try { await github.rest.issues.getLabel({ owner, repo, name: 'bypass-issue-check' }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44b035746..974d9d6e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ An open issue is not an invitation to submit a PR. Issues track problems; whethe If you do open a PR: -- **Reference an issue you're assigned to.** Every PR must reference a tracked issue using an auto-close keyword (`Fixes #123`, `Closes #123`, or `Resolves #123`), and the referenced issue must be assigned to you. If there isn't an issue, open one; then comment to ask a maintainer to assign it to you. This lets us deconflict effort and steer the approach before you invest time in code. External PRs that don't meet both conditions are automatically labeled `missing-issue-link` and closed; they reopen automatically once the link is present and you're assigned. (Maintainers can bypass the check by reopening the PR or removing the label.) +- **Reference an issue you're assigned to.** Every PR must reference a tracked issue using an auto-close keyword (`Fixes #123`, `Closes #123`, or `Resolves #123`), and the referenced issue must be assigned to you. If there isn't an issue, open one; then comment to ask a maintainer to assign it to you. This lets us deconflict effort and steer the approach before you invest time in code. External PRs that don't meet both conditions are automatically labeled `missing-issue-link` and closed; they reopen automatically once the link is present and you're assigned. - **Keep it focused.** One logical change per PR. Don't bundle unrelated fixes or refactors. - **Match existing patterns.** Follow the code style, type annotation conventions, and test patterns you see in the codebase. Run `uv run prek run --all-files` before submitting. - **Write tests.** Bug fixes should include a test that fails without the fix. Enhancements should include tests for the new behavior. From cb559240685dc4bc718a0b206f73058e6d2d53a8 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 20 May 2026 08:40:07 -0400 Subject: [PATCH 6/7] ci: reopen issue-linked PRs on assignment --- .github/workflows/require-issue-link.yml | 183 ++++++++++++++++++++++- 1 file changed, 177 insertions(+), 6 deletions(-) diff --git a/.github/workflows/require-issue-link.yml b/.github/workflows/require-issue-link.yml index 1ae5aa9f5..0e7d6e68b 100644 --- a/.github/workflows/require-issue-link.yml +++ b/.github/workflows/require-issue-link.yml @@ -14,8 +14,11 @@ # private appears as CONTRIBUTOR/NONE, so gating on it would wrongly # enforce against private-member maintainers. getCollaboratorPermission # reflects effective write access regardless of membership visibility. -# - Single github-script step (the upstream version is split across four, -# forcing the label/comment/reopen helpers to be duplicated per scope). +# - The enforcement path is a single github-script step (the upstream +# version is split across four, forcing the label/comment/reopen helpers +# to be duplicated per scope). +# - Issue assignment events are handled in this same workflow so assigning +# the linked issue reopens previously closed PRs automatically. # # Maintainer override: reopen the PR, or remove the "missing-issue-link" # label — either applies a sticky "bypass-issue-check" label and reopens. @@ -32,6 +35,10 @@ on: # draft opened with no issue link would never be checked when it later # becomes reviewable. types: [opened, edited, reopened, ready_for_review, labeled, unlabeled] + issues: + # Assignment is what makes a previously closed "not assigned" PR compliant, + # so it needs a separate event path that finds and reopens matching PRs. + types: [assigned] # Dry run: when 'false' the check still runs and logs its verdict but makes # NO mutations at all (no label, comment, close, reopen, or failure). Flip @@ -42,10 +49,6 @@ env: permissions: contents: read -concurrency: - group: require-issue-link-${{ github.event.pull_request.number }} - cancel-in-progress: false - jobs: check-issue-link: # Cheap pre-filters only. Maintainer detection is deliberately NOT done @@ -68,7 +71,11 @@ jobs: ) runs-on: ubuntu-latest timeout-minutes: 10 + concurrency: + group: require-issue-link-${{ github.event.pull_request.number }} + cancel-in-progress: false permissions: + issues: write pull-requests: write steps: @@ -388,3 +395,167 @@ jobs: ); } } + + reopen-on-assignment: + if: github.event_name == 'issues' && github.event.action == 'assigned' && !github.event.issue.pull_request + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: reopen-on-assignment-${{ github.event.issue.number }}-${{ github.event.assignee.login }} + cancel-in-progress: false + permissions: + actions: write + issues: write + pull-requests: write + + steps: + - name: Reopen linked PRs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const issueNumber = context.payload.issue.number; + const assignee = context.payload.assignee.login; + const enforce = process.env.ENFORCE_ISSUE_LINK === 'true'; + const LABEL = 'missing-issue-link'; + const MARKER = ''; + const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)/gi; + + async function mutate(description, fn) { + if (!enforce) { + console.log(`[dry-run] would ${description}`); + return; + } + await fn(); + } + + console.log(`Issue #${issueNumber} assigned to ${assignee} — searching for closed PRs to reopen`); + + const q = [ + 'is:pr', + 'is:closed', + `author:${assignee}`, + `label:${LABEL}`, + `repo:${owner}/${repo}`, + ].join(' '); + + let search; + try { + ({ data: search } = await github.rest.search.issuesAndPullRequests({ + q, + per_page: 30, + })); + } catch (e) { + throw new Error( + `Failed to search closed PRs for ${assignee} after assigning #${issueNumber} ` + + `(HTTP ${e.status ?? 'unknown'}): ${e.message}`, + ); + } + + if (search.total_count === 0) { + console.log('No matching closed PRs found'); + return; + } + + console.log(`Found ${search.total_count} candidate PR(s)`); + + for (const item of search.items) { + const prNumber = item.number; + + let issue; + try { + ({ data: issue } = await github.rest.issues.get({ + owner, repo, issue_number: prNumber, + })); + } catch (e) { + throw new Error(`Cannot fetch PR #${prNumber} issue data (HTTP ${e.status ?? 'unknown'}): ${e.message}`); + } + + const labels = (issue.labels || []).map(label => label.name); + if (labels.includes('bypass-issue-check')) { + console.log(`PR #${prNumber} already has bypass-issue-check — skipping`); + continue; + } + + const body = issue.body || ''; + const referencedIssues = [...body.matchAll(pattern)].map(match => parseInt(match[1], 10)); + if (!referencedIssues.includes(issueNumber)) { + console.log(`PR #${prNumber} does not reference #${issueNumber} — skipping`); + continue; + } + + try { + await mutate(`reopen PR #${prNumber}`, () => github.rest.pulls.update({ + owner, repo, pull_number: prNumber, state: 'open', + })); + } catch (e) { + if (e.status === 422) { + core.warning(`Cannot reopen PR #${prNumber}: the head branch was likely deleted`); + await mutate(`comment on unreopenable PR #${prNumber}`, () => github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: + `You have been assigned to #${issueNumber}, but this PR could not be ` + + 'reopened because the head branch has been deleted. Please open a new PR ' + + 'referencing the issue.', + })); + continue; + } + throw e; + } + + await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => { + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: LABEL, + }); + } catch (e) { + if (e.status !== 404) throw e; + } + }); + + try { + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const stale = comments.find(comment => comment.body && comment.body.includes(MARKER)); + if (stale) { + await mutate(`minimize stale comment ${stale.id}`, () => github.graphql(` + mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { + minimizedComment { isMinimized } + } + } + `, { id: stale.node_id })); + } + } catch (e) { + core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); + } + + try { + const { data: pr } = await github.rest.pulls.get({ + owner, repo, pull_number: prNumber, + }); + const { data: runs } = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: 'require-issue-link.yml', + head_sha: pr.head.sha, + status: 'failure', + per_page: 1, + }); + if (runs.workflow_runs.length === 0) { + console.log(`No failed require-issue-link runs found for PR #${prNumber}`); + continue; + } + await mutate(`re-run failed require-issue-link run for PR #${prNumber}`, () => + github.rest.actions.reRunWorkflowFailedJobs({ + owner, repo, run_id: runs.workflow_runs[0].id, + }), + ); + } catch (e) { + core.warning(`Could not re-run require-issue-link for PR #${prNumber}: ${e.message}`); + } + } From a4488c5c2cdb4686b59b5e6bd80528c45a8d7bae Mon Sep 17 00:00:00 2001 From: strawgate Date: Wed, 20 May 2026 08:48:31 -0500 Subject: [PATCH 7/7] ci: split reopen-on-issue-assignment into its own workflow file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.7 --- .../workflows/reopen-on-issue-assignment.yml | 286 ++++++++++++++++++ .github/workflows/require-issue-link.yml | 176 +---------- 2 files changed, 292 insertions(+), 170 deletions(-) create mode 100644 .github/workflows/reopen-on-issue-assignment.yml diff --git a/.github/workflows/reopen-on-issue-assignment.yml b/.github/workflows/reopen-on-issue-assignment.yml new file mode 100644 index 000000000..54fe8ed4e --- /dev/null +++ b/.github/workflows/reopen-on-issue-assignment.yml @@ -0,0 +1,286 @@ +# When the linked issue is assigned to a PR author, reopen any closed PR +# that this workflow's sibling, `require-issue-link.yml`, closed for the +# `not-assigned` failure mode. This is the happy-path continuation of the +# enforcement workflow: a contributor referenced a real issue but wasn't +# assigned to it; the natural maintainer response is to assign them, and +# the PR should resume without requiring a separate PR-side action. +# +# Out of scope: PRs closed for the `no-link` failure mode (no auto-close +# keyword in body). No signal connects an arbitrary issue assignment to +# such a PR. Those require a PR edit to retrigger. +# +# Coupling with `require-issue-link.yml` — keep in sync if changed there: +# • the `missing-issue-link` label name (LABEL constant) +# • the `bypass-issue-check` label name +# • the `` comment marker (MARKER constant) +# • the auto-close keyword regex +# +# SECURITY: `issues` events run with the BASE repo token. This workflow +# never checks out or executes PR-head code; it only reads the event +# payload and calls the API. + +name: Reopen on Issue Assignment + +on: + issues: + types: [assigned] + +# Dry run: when "false" the check still runs and logs its decisions but +# makes NO mutations. Mirror of the sibling workflow's flag so the pair +# can be flipped together. +env: + ENFORCE_ISSUE_LINK: "true" + +permissions: + contents: read + +concurrency: + # Serialize per (issue, assignee): rapid re-assignments don't race. + # Different issues / different assignees run in parallel. + group: reopen-on-issue-assignment-${{ github.event.issue.number }}-${{ github.event.assignee.login }} + cancel-in-progress: false + +jobs: + reopen: + # Skip when the assignment is on a PR (in the GitHub API a PR is an + # issue; the payload has `pull_request` set when the issue is in fact + # a PR — we only care about real issue assignments). Skip bots. + if: >- + github.event.assignee != null && + !github.event.issue.pull_request && + !endsWith(github.event.assignee.login, '[bot]') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + # pull-requests: write — reopen, remove label, post the + # deleted-branch comment, minimize the stale enforcement comment + # (all PR-as-issue API endpoints; require pull-requests scope). + # issues: read — GraphQL issue.timelineItems on the assigned issue. + # actions: write — re-run the original failed enforce workflow so + # the PR's red check run flips to green, instead of leaving the + # old run red alongside the new event-driven green check run. + pull-requests: write + issues: read + actions: write + + steps: + - name: Reopen PRs newly compliant after issue assignment + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const issueNumber = context.payload.issue.number; + const assignee = context.payload.assignee.login; + const enforce = process.env.ENFORCE_ISSUE_LINK === 'true'; + + // ── Contracts shared with require-issue-link.yml ────────────── + const LABEL = 'missing-issue-link'; + const BYPASS_LABEL = 'bypass-issue-check'; + const MARKER = ''; + // Identical regex to the enforce path: kept byte-for-byte the + // same so the two paths can't disagree about what counts as + // an auto-close link. + const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)/gi; + + async function mutate(description, fn) { + if (!enforce) { + console.log(`[dry-run] would ${description}`); + return; + } + await fn(); + } + + console.log( + `Issue #${issueNumber} assigned to ${assignee} — ` + + `searching for closed PRs to reopen` + ); + + // ── Discover candidates via the issue's timeline ───────────── + // CROSS_REFERENCED_EVENT + body regex re-check is preferred + // over the Search API: no indexing lag (Search can take ~30s + // to reflect a freshly-closed PR), and the response gives + // state + body + labels + author in a single round trip. + const query = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + timelineItems(itemTypes: [CROSS_REFERENCED_EVENT], first: 50) { + nodes { + ... on CrossReferencedEvent { + source { + __typename + ... on PullRequest { + number + state + body + author { login } + labels(first: 30) { nodes { name } } + } + } + } + } + } + } + } + } + `; + + let timeline; + try { + const result = await github.graphql(query, { + owner, repo, number: issueNumber, + }); + timeline = result?.repository?.issue?.timelineItems?.nodes ?? []; + } catch (e) { + throw new Error( + `Cannot fetch timeline for #${issueNumber} ` + + `(HTTP ${e.status ?? 'unknown'}): ${e.message}` + ); + } + + // Dedupe + filter to closed (not merged) PRs the sibling + // workflow closed for *this specific* issue, authored by the + // newly-assigned user, and not already bypassed. + const assigneeLower = assignee.toLowerCase(); + const seen = new Set(); + const candidates = []; + for (const node of timeline) { + const src = node?.source; + if (!src || src.__typename !== 'PullRequest') continue; + // state is OPEN | CLOSED | MERGED — only act on CLOSED + // (never resurrect a merged PR). + if (src.state !== 'CLOSED') continue; + if (seen.has(src.number)) continue; + seen.add(src.number); + + const labels = (src.labels?.nodes ?? []).map(l => l.name); + if (!labels.includes(LABEL)) continue; + if (labels.includes(BYPASS_LABEL)) { + console.log(`PR #${src.number} already bypassed — skipping`); + continue; + } + + const authorLogin = src.author?.login?.toLowerCase(); + if (!authorLogin || authorLogin !== assigneeLower) continue; + + // Re-apply the enforce path's regex against the PR body to + // confirm an auto-close link to *this* issue (rather than a + // cross-reference from a comment or commit mention). + const referenced = [...(src.body ?? '').matchAll(pattern)] + .map(m => parseInt(m[1], 10)); + if (!referenced.includes(issueNumber)) continue; + + candidates.push(src.number); + } + + if (candidates.length === 0) { + console.log('No matching closed PRs found'); + return; + } + + console.log(`Reopening PR(s): ${candidates.join(', ')}`); + + for (const prNumber of candidates) { + // ── Reopen the PR (handle deleted head branch) ────────────── + let reopened = false; + try { + await mutate(`reopen PR #${prNumber}`, () => + github.rest.pulls.update({ + owner, repo, pull_number: prNumber, state: 'open', + }), + ); + reopened = true; + } catch (e) { + if (e.status === 422) { + core.warning( + `Cannot reopen PR #${prNumber}: ` + + `the head branch was likely deleted` + ); + await mutate(`comment on unreopenable PR #${prNumber}`, () => + github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, + body: + `You have been assigned to #${issueNumber}, but ` + + `this PR could not be reopened because the head ` + + `branch has been deleted. Please open a new PR ` + + `referencing the issue.`, + }), + ); + continue; + } + throw e; + } + + // ── Remove the enforcement label ──────────────────────────── + await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => { + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: LABEL, + }); + } catch (e) { + if (e.status !== 404) throw e; + } + }); + + // ── Minimize the stale enforcement comment ────────────────── + // listComments paginated: a long-running PR can accumulate + // many comments and per_page caps at 100. + try { + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const stale = comments.find( + c => c.body && c.body.includes(MARKER), + ); + if (stale) { + await mutate(`minimize stale comment ${stale.id}`, () => + github.graphql(` + mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { + minimizedComment { isMinimized } + } + } + `, { id: stale.node_id }), + ); + } + } catch (e) { + core.warning( + `Could not minimize stale comment on PR #${prNumber}: ${e.message}` + ); + } + + // ── Re-run the failed enforce run so the red check turns ─── + // green on this same head SHA, instead of leaving the + // original failure visible alongside the new event-driven + // run that the reopen itself triggers. + if (!reopened) continue; + try { + const { data: pr } = await github.rest.pulls.get({ + owner, repo, pull_number: prNumber, + }); + const { data: runs } = await github.rest.actions.listWorkflowRuns({ + owner, repo, + workflow_id: 'require-issue-link.yml', + head_sha: pr.head.sha, + status: 'failure', + per_page: 1, + }); + if (runs.workflow_runs.length === 0) { + console.log( + `No failed require-issue-link runs found for PR #${prNumber}` + ); + continue; + } + await mutate( + `re-run failed require-issue-link run for PR #${prNumber}`, + () => github.rest.actions.reRunWorkflowFailedJobs({ + owner, repo, run_id: runs.workflow_runs[0].id, + }), + ); + } catch (e) { + core.warning( + `Could not re-run require-issue-link for PR #${prNumber}: ${e.message}` + ); + } + } diff --git a/.github/workflows/require-issue-link.yml b/.github/workflows/require-issue-link.yml index 0e7d6e68b..23ccb418f 100644 --- a/.github/workflows/require-issue-link.yml +++ b/.github/workflows/require-issue-link.yml @@ -17,8 +17,12 @@ # - The enforcement path is a single github-script step (the upstream # version is split across four, forcing the label/comment/reopen helpers # to be duplicated per scope). -# - Issue assignment events are handled in this same workflow so assigning -# the linked issue reopens previously closed PRs automatically. +# - The issue-assignment side (reopening previously closed PRs once the +# linked issue is assigned to the author) lives in the sibling workflow +# `reopen-on-issue-assignment.yml` to keep this file focused on +# enforcement. The two files share the `missing-issue-link` / +# `bypass-issue-check` label names, the `` +# comment marker, and the auto-close keyword regex — keep them in sync. # # Maintainer override: reopen the PR, or remove the "missing-issue-link" # label — either applies a sticky "bypass-issue-check" label and reopens. @@ -35,10 +39,6 @@ on: # draft opened with no issue link would never be checked when it later # becomes reviewable. types: [opened, edited, reopened, ready_for_review, labeled, unlabeled] - issues: - # Assignment is what makes a previously closed "not assigned" PR compliant, - # so it needs a separate event path that finds and reopens matching PRs. - types: [assigned] # Dry run: when 'false' the check still runs and logs its verdict but makes # NO mutations at all (no label, comment, close, reopen, or failure). Flip @@ -395,167 +395,3 @@ jobs: ); } } - - reopen-on-assignment: - if: github.event_name == 'issues' && github.event.action == 'assigned' && !github.event.issue.pull_request - runs-on: ubuntu-latest - timeout-minutes: 10 - concurrency: - group: reopen-on-assignment-${{ github.event.issue.number }}-${{ github.event.assignee.login }} - cancel-in-progress: false - permissions: - actions: write - issues: write - pull-requests: write - - steps: - - name: Reopen linked PRs - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const { owner, repo } = context.repo; - const issueNumber = context.payload.issue.number; - const assignee = context.payload.assignee.login; - const enforce = process.env.ENFORCE_ISSUE_LINK === 'true'; - const LABEL = 'missing-issue-link'; - const MARKER = ''; - const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)/gi; - - async function mutate(description, fn) { - if (!enforce) { - console.log(`[dry-run] would ${description}`); - return; - } - await fn(); - } - - console.log(`Issue #${issueNumber} assigned to ${assignee} — searching for closed PRs to reopen`); - - const q = [ - 'is:pr', - 'is:closed', - `author:${assignee}`, - `label:${LABEL}`, - `repo:${owner}/${repo}`, - ].join(' '); - - let search; - try { - ({ data: search } = await github.rest.search.issuesAndPullRequests({ - q, - per_page: 30, - })); - } catch (e) { - throw new Error( - `Failed to search closed PRs for ${assignee} after assigning #${issueNumber} ` + - `(HTTP ${e.status ?? 'unknown'}): ${e.message}`, - ); - } - - if (search.total_count === 0) { - console.log('No matching closed PRs found'); - return; - } - - console.log(`Found ${search.total_count} candidate PR(s)`); - - for (const item of search.items) { - const prNumber = item.number; - - let issue; - try { - ({ data: issue } = await github.rest.issues.get({ - owner, repo, issue_number: prNumber, - })); - } catch (e) { - throw new Error(`Cannot fetch PR #${prNumber} issue data (HTTP ${e.status ?? 'unknown'}): ${e.message}`); - } - - const labels = (issue.labels || []).map(label => label.name); - if (labels.includes('bypass-issue-check')) { - console.log(`PR #${prNumber} already has bypass-issue-check — skipping`); - continue; - } - - const body = issue.body || ''; - const referencedIssues = [...body.matchAll(pattern)].map(match => parseInt(match[1], 10)); - if (!referencedIssues.includes(issueNumber)) { - console.log(`PR #${prNumber} does not reference #${issueNumber} — skipping`); - continue; - } - - try { - await mutate(`reopen PR #${prNumber}`, () => github.rest.pulls.update({ - owner, repo, pull_number: prNumber, state: 'open', - })); - } catch (e) { - if (e.status === 422) { - core.warning(`Cannot reopen PR #${prNumber}: the head branch was likely deleted`); - await mutate(`comment on unreopenable PR #${prNumber}`, () => github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body: - `You have been assigned to #${issueNumber}, but this PR could not be ` + - 'reopened because the head branch has been deleted. Please open a new PR ' + - 'referencing the issue.', - })); - continue; - } - throw e; - } - - await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => { - try { - await github.rest.issues.removeLabel({ - owner, repo, issue_number: prNumber, name: LABEL, - }); - } catch (e) { - if (e.status !== 404) throw e; - } - }); - - try { - const comments = await github.paginate( - github.rest.issues.listComments, - { owner, repo, issue_number: prNumber, per_page: 100 }, - ); - const stale = comments.find(comment => comment.body && comment.body.includes(MARKER)); - if (stale) { - await mutate(`minimize stale comment ${stale.id}`, () => github.graphql(` - mutation($id: ID!) { - minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { - minimizedComment { isMinimized } - } - } - `, { id: stale.node_id })); - } - } catch (e) { - core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); - } - - try { - const { data: pr } = await github.rest.pulls.get({ - owner, repo, pull_number: prNumber, - }); - const { data: runs } = await github.rest.actions.listWorkflowRuns({ - owner, - repo, - workflow_id: 'require-issue-link.yml', - head_sha: pr.head.sha, - status: 'failure', - per_page: 1, - }); - if (runs.workflow_runs.length === 0) { - console.log(`No failed require-issue-link runs found for PR #${prNumber}`); - continue; - } - await mutate(`re-run failed require-issue-link run for PR #${prNumber}`, () => - github.rest.actions.reRunWorkflowFailedJobs({ - owner, repo, run_id: runs.workflow_runs[0].id, - }), - ); - } catch (e) { - core.warning(`Could not re-run require-issue-link for PR #${prNumber}: ${e.message}`); - } - }