Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/link-issues-to-prs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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}`);
}
}
Loading