Skip to content

ci: shield issues with an open PR from the stale bot #1

ci: shield issues with an open PR from the stale bot

ci: shield issues with an open PR from the stale bot #1

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;
// 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). Also remove on `edited`
// when a reference was deleted from the body.
const shouldLabel = pr.state === 'open';
const closeUnlabel = pr.state === 'closed' && !pr.merged ? current : [];
const toUnlabel = [...new Set([...removed, ...closeUnlabel])];
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`);
} catch (err) {
core.warning(`#${n}: ${err.message}`);
}
}
}
for (const n of toUnlabel) {
try {
await removeLabelSafe(n);
} catch (err) {
core.warning(`#${n}: ${err.message}`);
}
}