Skip to content

Commit 8d9d2f9

Browse files
jacalataclaude
andauthored
ci: shield issues with an open PR from the stale bot (#1864)
* 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) <noreply@anthropic.com> * 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. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6820191 commit 8d9d2f9

1 file changed

Lines changed: 104 additions & 0 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
name: Mark linked issues in-progress
2+
3+
# When an open PR references an issue with Closes/Fixes/Resolves, apply the
4+
# 'in-progress' label to that issue so the stale bot leaves it alone (the
5+
# stale workflow already exempts 'in-progress'). Remove the label when the
6+
# PR closes without merging, so a genuinely abandoned PR does not keep its
7+
# referenced issues shielded forever.
8+
#
9+
# actions/stale looks at issue-level events only; a PR that references an
10+
# issue does not reset the issue's stale timer or move it off the stale
11+
# label. This workflow bridges that gap.
12+
#
13+
# Security note: the script only reads pr.body, extracts decimal issue
14+
# numbers via a fixed regex, and passes those numbers to the REST API.
15+
# Body content is never expanded into a run: command or a shell.
16+
17+
on:
18+
pull_request_target:
19+
types: [opened, edited, reopened, synchronize, ready_for_review, closed]
20+
21+
permissions:
22+
issues: write
23+
pull-requests: read
24+
25+
jobs:
26+
link:
27+
runs-on: ubuntu-latest
28+
steps:
29+
- uses: actions/github-script@v7
30+
with:
31+
script: |
32+
const pr = context.payload.pull_request;
33+
const body = pr.body || '';
34+
const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi;
35+
// Cross-repo references (owner/repo#123) are intentionally skipped;
36+
// this workflow only labels issues in the current repo.
37+
const MAX_REFS = 50; // cap runaway PR bodies from forks (pull_request_target).
38+
const extract = (text) => [...new Set(
39+
[...(text || '').matchAll(re)].map(m => Number(m[1]))
40+
)].slice(0, MAX_REFS);
41+
42+
const current = extract(body);
43+
44+
// On `edited`, compute which references were REMOVED so their
45+
// labels come off. Without this, a PR that once said "Closes #42"
46+
// and no longer does would leave #42 shielded indefinitely.
47+
let removed = [];
48+
if (context.payload.action === 'edited') {
49+
const prevBody = context.payload.changes?.body?.from;
50+
if (prevBody !== undefined) {
51+
const previous = extract(prevBody);
52+
const now = new Set(current);
53+
removed = previous.filter(n => !now.has(n));
54+
}
55+
}
56+
57+
// Apply the label while the PR is open. Remove it when the PR
58+
// closes without merging (merged PRs also close, but the issue
59+
// will be auto-closed by GitHub once the merge lands, so the
60+
// in-progress label on it is harmless). Also remove on `edited`
61+
// when a reference was deleted from the body.
62+
const shouldLabel = pr.state === 'open';
63+
const closeUnlabel = pr.state === 'closed' && !pr.merged ? current : [];
64+
const toUnlabel = [...new Set([...removed, ...closeUnlabel])];
65+
66+
if (current.length === 0 && toUnlabel.length === 0) {
67+
core.info('No Closes/Fixes/Resolves references to process; nothing to do.');
68+
return;
69+
}
70+
71+
const removeLabelSafe = async (n) => {
72+
await github.rest.issues.removeLabel({
73+
...context.repo,
74+
issue_number: n,
75+
name: 'in-progress',
76+
}).catch(err => {
77+
// 404 just means the label was not present; not an error.
78+
if (err.status !== 404) throw err;
79+
});
80+
core.info(`#${n}: removed in-progress`);
81+
};
82+
83+
if (shouldLabel) {
84+
for (const n of current) {
85+
try {
86+
await github.rest.issues.addLabels({
87+
...context.repo,
88+
issue_number: n,
89+
labels: ['in-progress'],
90+
});
91+
core.info(`#${n}: added in-progress`);
92+
} catch (err) {
93+
core.warning(`#${n}: ${err.message}`);
94+
}
95+
}
96+
}
97+
98+
for (const n of toUnlabel) {
99+
try {
100+
await removeLabelSafe(n);
101+
} catch (err) {
102+
core.warning(`#${n}: ${err.message}`);
103+
}
104+
}

0 commit comments

Comments
 (0)