diff --git a/.github/workflows/link-issues-to-prs.yml b/.github/workflows/link-issues-to-prs.yml new file mode 100644 index 000000000..26f55715e --- /dev/null +++ b/.github/workflows/link-issues-to-prs.yml @@ -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}`); + } + }