From 74edb67a9da518b294e3ca6af6de9d5ce3b8c125 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Sun, 16 Aug 2026 13:54:18 -0700 Subject: [PATCH 1/2] ci: shield issues with an open PR from the stale bot `actions/stale` only considers issue-level events when it decides whether to mark or close an issue. A PR that references an issue via `Closes #NNN` / `Fixes #NNN` does not reset the issue's stale timer or remove the `stale` label, so an issue can be auto-closed by the bot even while a PR that closes it is in review. Add a companion workflow that reacts to PR opens/edits and stamps the `in-progress` label on every referenced issue. The existing stale workflow already exempts `in-progress`. When a PR closes without being merged, the label is removed so a genuinely abandoned effort does not keep its referenced issues shielded forever. Merged PRs auto-close the referenced issues via GitHub's usual behavior, so leaving the label on them is harmless (they're closed). Uses `pull_request_target` for permissions on external-contributor PRs. The script only reads `pr.body`, extracts decimal issue numbers with a fixed regex, and passes those numbers to the REST API -- body content never reaches a `run:` step or a shell, so there is no command-injection surface even though the trigger runs with write permissions. Companion to #1841. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/link-issues-to-prs.yml | 72 ++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .github/workflows/link-issues-to-prs.yml diff --git a/.github/workflows/link-issues-to-prs.yml b/.github/workflows/link-issues-to-prs.yml new file mode 100644 index 000000000..4011cb306 --- /dev/null +++ b/.github/workflows/link-issues-to-prs.yml @@ -0,0 +1,72 @@ +name: Mark linked issues in-progress + +# When an open PR references an issue with Closes/Fixes/Resolves, apply the +# 'in-progress' label to that issue so the stale bot leaves it alone (the +# stale workflow already exempts 'in-progress'). Remove the label when the +# PR closes without merging, so a genuinely abandoned PR does not keep its +# referenced issues shielded forever. +# +# actions/stale looks at issue-level events only; a PR that references an +# issue does not reset the issue's stale timer or move it off the stale +# label. This workflow bridges that gap. +# +# Security note: the script only reads pr.body, extracts decimal issue +# numbers via a fixed regex, and passes those numbers to the REST API. +# Body content is never expanded into a run: command or a shell. + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize, ready_for_review, closed] + +permissions: + issues: write + pull-requests: read + +jobs: + link: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const body = pr.body || ''; + const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi; + const nums = [...new Set([...body.matchAll(re)].map(m => Number(m[1])))]; + if (nums.length === 0) { + core.info('No Closes/Fixes/Resolves references found; nothing to do.'); + return; + } + + // Apply the label while the PR is open. Remove it when the PR + // closes without merging (merged PRs also close, but the issue + // will be auto-closed by GitHub once the merge lands, so the + // in-progress label on it is harmless). + const shouldLabel = pr.state === 'open'; + const shouldUnlabel = pr.state === 'closed' && !pr.merged; + + for (const n of nums) { + try { + if (shouldLabel) { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: n, + labels: ['in-progress'], + }); + core.info(`#${n}: added in-progress`); + } else if (shouldUnlabel) { + await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: n, + name: 'in-progress', + }).catch(err => { + // 404 just means the label was not present; not an error. + if (err.status !== 404) throw err; + }); + core.info(`#${n}: removed in-progress (PR closed unmerged)`); + } + } catch (err) { + // Do not fail the whole run on one bad reference; log and continue. + core.warning(`#${n}: ${err.message}`); + } + } From c70abac2f91277ce96399ceef41a58a6b0f60467 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 17 Aug 2026 21:45:49 -0700 Subject: [PATCH 2/2] ci: unlabel on edited body, cap ref count for pull_request_target safety Two review findings: - On `edited` events the script now diffs `payload.changes.body.from` against the new body and removes 'in-progress' from any issue whose Closes/Fixes/Resolves reference was deleted. Previously, editing a PR to drop `Closes #42` left #42 shielded from the stale bot indefinitely. - Cap the number of references processed per event at 50 (`MAX_REFS`). `pull_request_target` runs on PRs from forks, so an accidental or malicious PR body with thousands of matches would burn the repo's REST budget on labeling calls. Also adds a one-line comment noting that cross-repo refs (owner/repo#N) are intentionally out of scope; this workflow only labels issues in the current repo. --- .github/workflows/link-issues-to-prs.yml | 72 +++++++++++++++++------- 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/.github/workflows/link-issues-to-prs.yml b/.github/workflows/link-issues-to-prs.yml index 4011cb306..26f55715e 100644 --- a/.github/workflows/link-issues-to-prs.yml +++ b/.github/workflows/link-issues-to-prs.yml @@ -32,41 +32,73 @@ jobs: const pr = context.payload.pull_request; const body = pr.body || ''; const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi; - const nums = [...new Set([...body.matchAll(re)].map(m => Number(m[1])))]; - if (nums.length === 0) { - core.info('No Closes/Fixes/Resolves references found; nothing to do.'); - return; + // Cross-repo references (owner/repo#123) are intentionally skipped; + // this workflow only labels issues in the current repo. + const MAX_REFS = 50; // cap runaway PR bodies from forks (pull_request_target). + const extract = (text) => [...new Set( + [...(text || '').matchAll(re)].map(m => Number(m[1])) + )].slice(0, MAX_REFS); + + const current = extract(body); + + // On `edited`, compute which references were REMOVED so their + // labels come off. Without this, a PR that once said "Closes #42" + // and no longer does would leave #42 shielded indefinitely. + let removed = []; + if (context.payload.action === 'edited') { + const prevBody = context.payload.changes?.body?.from; + if (prevBody !== undefined) { + const previous = extract(prevBody); + const now = new Set(current); + removed = previous.filter(n => !now.has(n)); + } } // Apply the label while the PR is open. Remove it when the PR // closes without merging (merged PRs also close, but the issue // will be auto-closed by GitHub once the merge lands, so the - // in-progress label on it is harmless). + // in-progress label on it is harmless). Also remove on `edited` + // when a reference was deleted from the body. const shouldLabel = pr.state === 'open'; - const shouldUnlabel = pr.state === 'closed' && !pr.merged; + const closeUnlabel = pr.state === 'closed' && !pr.merged ? current : []; + const toUnlabel = [...new Set([...removed, ...closeUnlabel])]; - for (const n of nums) { - try { - if (shouldLabel) { + if (current.length === 0 && toUnlabel.length === 0) { + core.info('No Closes/Fixes/Resolves references to process; nothing to do.'); + return; + } + + const removeLabelSafe = async (n) => { + await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: n, + name: 'in-progress', + }).catch(err => { + // 404 just means the label was not present; not an error. + if (err.status !== 404) throw err; + }); + core.info(`#${n}: removed in-progress`); + }; + + if (shouldLabel) { + for (const n of current) { + try { await github.rest.issues.addLabels({ ...context.repo, issue_number: n, labels: ['in-progress'], }); core.info(`#${n}: added in-progress`); - } else if (shouldUnlabel) { - await github.rest.issues.removeLabel({ - ...context.repo, - issue_number: n, - name: 'in-progress', - }).catch(err => { - // 404 just means the label was not present; not an error. - if (err.status !== 404) throw err; - }); - core.info(`#${n}: removed in-progress (PR closed unmerged)`); + } catch (err) { + core.warning(`#${n}: ${err.message}`); } + } + } + + for (const n of toUnlabel) { + try { + await removeLabelSafe(n); } catch (err) { - // Do not fail the whole run on one bad reference; log and continue. core.warning(`#${n}: ${err.message}`); } }