@@ -5,7 +5,8 @@ name: Issue state
55# - task:draft becomes task:ready when approved, specified, and unblocked;
66# - a reporter's reply moves bug:needs-info back to bug:triage;
77# - a feature whose last sub-issue closed gets a completion-conditions note;
8- # - bug:needs-info with no activity for two weeks closes as not planned.
8+ # - bug:needs-info with no activity for two weeks closes as not planned;
9+ # - a PR from a <kind>/<n> branch must close issue <n> and no other.
910# Actions taken with the workflow token do not trigger this workflow again,
1011# so every job removes the label it replaces itself.
1112
1415 types : [labeled, edited, closed]
1516 issue_comment :
1617 types : [created]
18+ pull_request :
19+ types : [opened, edited, synchronize]
1720 schedule :
1821 - cron : " 17 6 * * *"
1922 workflow_dispatch :
2023
2124permissions : {}
2225
2326concurrency :
24- group : issue-state-${{ github.event.issue.number || 'schedule' }}
27+ group : issue-state-${{ github.event.issue.number || github.event.pull_request.number || 'schedule' }}
2528 cancel-in-progress : false
2629
2730env :
@@ -195,3 +198,30 @@ jobs:
195198 });
196199 core.info(`#${issue.number}: closed as not planned (stale bug:needs-info)`);
197200 }
201+
202+ branch-matches-issue :
203+ name : Branch name closes its issue
204+ if : github.event_name == 'pull_request'
205+ runs-on : ubuntu-24.04
206+ timeout-minutes : 5
207+ steps :
208+ - uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
209+ with :
210+ script : |
211+ const pr = context.payload.pull_request;
212+ const m = pr.head.ref.match(/^(bug|feature|task)\/(\d+)$/);
213+ if (!m) {
214+ core.info(`${pr.head.ref} is not a <kind>/<n> branch; nothing to check`);
215+ return;
216+ }
217+ const expected = Number(m[2]);
218+ const closes = [...(pr.body ?? "").matchAll(/\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)/gi)]
219+ .map((x) => Number(x[1]));
220+ if (!closes.includes(expected)) {
221+ core.setFailed(`Branch ${pr.head.ref} must close #${expected}: add "Closes #${expected}" to the PR body.`);
222+ return;
223+ }
224+ const others = closes.filter((n) => n !== expected);
225+ if (others.length) {
226+ core.setFailed(`Branch ${pr.head.ref} closes #${expected} but also ${others.map((n) => `#${n}`).join(", ")}; one branch closes one issue.`);
227+ }
0 commit comments