From 052fe499fb311f9835cda8b11c198ea7de617cf1 Mon Sep 17 00:00:00 2001 From: Brandon Geraci Date: Thu, 21 May 2026 18:12:04 -0500 Subject: [PATCH 1/2] chore: enforce PR-issue link via GH Actions --- .github/workflows/require-linked-issue.yml | 147 +++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 .github/workflows/require-linked-issue.yml diff --git a/.github/workflows/require-linked-issue.yml b/.github/workflows/require-linked-issue.yml new file mode 100644 index 0000000..32f07fe --- /dev/null +++ b/.github/workflows/require-linked-issue.yml @@ -0,0 +1,147 @@ +name: Require Linked Issue + +on: + pull_request: + types: [opened, edited, synchronize, reopened, labeled, unlabeled] + +permissions: + pull-requests: write + issues: read + contents: read + +jobs: + require-linked-issue: + name: Require linked issue in PR body + runs-on: ubuntu-latest + steps: + - name: Check PR body for linked issue + uses: actions/github-script@v7 + with: + script: | + const BOT_MARKER = ''; + const BYPASS_LABEL = 'no-issue-required'; + + const pr = context.payload.pull_request; + if (!pr) { + core.setFailed('This workflow must run on pull_request events.'); + return; + } + + const labels = (pr.labels || []).map(l => (l && l.name) ? l.name : ''); + if (labels.includes(BYPASS_LABEL)) { + core.info(`Bypass label "${BYPASS_LABEL}" present on PR #${pr.number}; skipping check.`); + return; + } + + const body = pr.body || ''; + + // Match Closes/Fixes/Resolves (all tense variants) followed by #N. + // Allows optional owner/repo prefix to be tolerant, but only same-repo refs satisfy the requirement. + const linkRegex = /\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*:?\s*(?:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+))?#(\d+)/gi; + + const owner = context.repo.owner; + const repo = context.repo.repo; + + const candidates = []; + let m; + while ((m = linkRegex.exec(body)) !== null) { + const refOwner = m[2] || owner; + const refRepo = m[3] || repo; + const number = parseInt(m[4], 10); + candidates.push({ owner: refOwner, repo: refRepo, number, keyword: m[1] }); + } + + core.info(`Found ${candidates.length} candidate issue link(s) in PR body.`); + + // Only same-repo references satisfy the policy. + const sameRepoCandidates = candidates.filter( + c => c.owner.toLowerCase() === owner.toLowerCase() && c.repo.toLowerCase() === repo.toLowerCase() + ); + + let validLink = null; + for (const c of sameRepoCandidates) { + try { + const { data: issue } = await github.rest.issues.get({ + owner: c.owner, + repo: c.repo, + issue_number: c.number, + }); + // Skip if the "issue" is actually this same PR (self-reference). + if (issue.pull_request && issue.number === pr.number) { + continue; + } + validLink = { number: c.number, title: issue.title }; + break; + } catch (err) { + core.warning(`Could not verify #${c.number}: ${err.message}`); + } + } + + // Look for an existing bot comment to keep things idempotent. + const { data: existingComments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: pr.number, + per_page: 100, + }); + const priorBotComment = existingComments.find( + c => c.user && c.user.type === 'Bot' && c.body && c.body.includes(BOT_MARKER) + ); + + if (validLink) { + core.info(`Found valid linked issue #${validLink.number}: ${validLink.title}`); + if (priorBotComment) { + core.info(`Deleting stale bot comment ${priorBotComment.id}.`); + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: priorBotComment.id, + }); + } + return; + } + + const templatePath = '.github/PULL_REQUEST_TEMPLATE.md'; + const templateUrl = `https://github.com/${owner}/${repo}/blob/main/${templatePath}`; + + const commentBody = [ + BOT_MARKER, + '### Missing linked issue', + '', + 'This PR does not reference a tracking issue in its body. Every PR must link to an issue in this repository so we can trace work back to a planned change.', + '', + '**How to fix**', + '', + `1. Edit the PR description and add a line like \`Closes #123\`, \`Fixes #123\`, or \`Resolves #123\` referring to an open issue in \`${owner}/${repo}\`.`, + '2. Save the description. This check will re-run automatically.', + '', + '**Accepted keywords** (case-insensitive, any tense): `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`.', + '', + `**Policy reference**: see the [PR template](${templateUrl}).`, + '', + `**Maintainer bypass**: apply the \`${BYPASS_LABEL}\` label to this PR to skip the check (use sparingly, e.g. for trivial typo fixes or release-tag chores).`, + ].join('\n'); + + if (priorBotComment) { + if (priorBotComment.body !== commentBody) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: priorBotComment.id, + body: commentBody, + }); + core.info(`Updated existing bot comment ${priorBotComment.id}.`); + } else { + core.info('Existing bot comment is already up to date.'); + } + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: commentBody, + }); + core.info('Posted missing-link bot comment.'); + } + + core.setFailed('PR body must reference a tracking issue (e.g. "Closes #123"). See the bot comment for details.'); From c136c43c24717cb1afbb379ffce534e93033c5a5 Mon Sep 17 00:00:00 2001 From: Brandon Geraci Date: Thu, 21 May 2026 18:20:34 -0500 Subject: [PATCH 2/2] chore: address review feedback on issue-link enforcement workflow --- .github/workflows/require-linked-issue.yml | 57 +++++++++++++++++----- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/.github/workflows/require-linked-issue.yml b/.github/workflows/require-linked-issue.yml index 32f07fe..3779df0 100644 --- a/.github/workflows/require-linked-issue.yml +++ b/.github/workflows/require-linked-issue.yml @@ -1,7 +1,9 @@ name: Require Linked Issue +# Use pull_request_target so the workflow has a writable GITHUB_TOKEN on fork PRs. +# Safe: workflow does not check out PR code; reads pr.body via event payload only. on: - pull_request: + pull_request_target: types: [opened, edited, synchronize, reopened, labeled, unlabeled] permissions: @@ -9,13 +11,17 @@ permissions: issues: read contents: read +concurrency: + group: require-linked-issue-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: require-linked-issue: name: Require linked issue in PR body runs-on: ubuntu-latest steps: - name: Check PR body for linked issue - uses: actions/github-script@v7 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | const BOT_MARKER = ''; @@ -33,7 +39,13 @@ jobs: return; } - const body = pr.body || ''; + // Strip HTML comments, fenced code blocks, and inline code spans from the PR body + // before scanning for link keywords. This prevents contributors from "passing" the + // check by leaving a Closes #N reference inside a comment or code block. + const sanitizedBody = (pr.body || '') + .replace(//g, '') + .replace(/```[\s\S]*?```/g, '') + .replace(/`[^`\n]*`/g, ''); // Match Closes/Fixes/Resolves (all tense variants) followed by #N. // Allows optional owner/repo prefix to be tolerant, but only same-repo refs satisfy the requirement. @@ -44,7 +56,7 @@ jobs: const candidates = []; let m; - while ((m = linkRegex.exec(body)) !== null) { + while ((m = linkRegex.exec(sanitizedBody)) !== null) { const refOwner = m[2] || owner; const refRepo = m[3] || repo; const number = parseInt(m[4], 10); @@ -59,6 +71,7 @@ jobs: ); let validLink = null; + const notFoundRefs = []; for (const c of sameRepoCandidates) { try { const { data: issue } = await github.rest.issues.get({ @@ -73,18 +86,23 @@ jobs: validLink = { number: c.number, title: issue.title }; break; } catch (err) { - core.warning(`Could not verify #${c.number}: ${err.message}`); + if (err.status === 404) { + notFoundRefs.push({ owner: c.owner, repo: c.repo, number: c.number }); + core.warning(`Referenced issue #${c.number} not found in ${c.owner}/${c.repo}.`); + } else { + core.warning(`Could not verify #${c.number}: ${err.message}`); + } } } - // Look for an existing bot comment to keep things idempotent. - const { data: existingComments } = await github.rest.issues.listComments({ + // Paginate so the bot's prior comment is found even on long-discussion PRs. + const allComments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100, }); - const priorBotComment = existingComments.find( + const priorBotComment = allComments.find( c => c.user && c.user.type === 'Bot' && c.body && c.body.includes(BOT_MARKER) ); @@ -104,11 +122,23 @@ jobs: const templatePath = '.github/PULL_REQUEST_TEMPLATE.md'; const templateUrl = `https://github.com/${owner}/${repo}/blob/main/${templatePath}`; + // If we got specific 404s, surface them so the contributor can correct the number directly. + let headline; + let detail; + if (notFoundRefs.length > 0) { + const ref = notFoundRefs[0]; + headline = `Referenced issue #${ref.number} does not exist in ${ref.owner}/${ref.repo}`; + detail = `The PR body references issue #${ref.number} in \`${ref.owner}/${ref.repo}\`, but that issue could not be found. Check the number and edit the PR body to point at a real, open issue.`; + } else { + headline = 'Missing linked issue'; + detail = 'This PR does not reference a tracking issue in its body. Every PR must link to an issue in this repository so we can trace work back to a planned change.'; + } + const commentBody = [ BOT_MARKER, - '### Missing linked issue', + `### ${headline}`, '', - 'This PR does not reference a tracking issue in its body. Every PR must link to an issue in this repository so we can trace work back to a planned change.', + detail, '', '**How to fix**', '', @@ -144,4 +174,9 @@ jobs: core.info('Posted missing-link bot comment.'); } - core.setFailed('PR body must reference a tracking issue (e.g. "Closes #123"). See the bot comment for details.'); + if (notFoundRefs.length > 0) { + const ref = notFoundRefs[0]; + core.setFailed(`Referenced issue #${ref.number} does not exist in ${ref.owner}/${ref.repo}. Check the number and edit the PR body.`); + } else { + core.setFailed('PR body must reference a tracking issue (e.g. "Closes #123"). See the bot comment for details.'); + }