Skip to content

Commit 1d85e30

Browse files
EMaherCopilot
andauthored
feat: label issues close:fixed when a merged PR closes them (#213)
* feat: label issues close:fixed when a merged PR closes them Add the "Label Fixed Issues on Merge" workflow. On pull_request_target closed, when the PR was merged, it resolves the issues closed via linking keywords (Closes/Fixes/Resolves) using GraphQL closingIssuesReferences and applies the existing close:fixed label to each, keeping the close reason visible in issue triage. Uses pull_request_target so fork PRs still get issues:write; no PR head code is checked out or executed. Skips issues that already carry the label and ensures the label exists with its canonical color/description. Also documents the auto-labeling in CONTRIBUTING.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor: rely on sync workflow as sole source for close:fixed label Remove the defensive label-creation block and the FIXED_LABEL_COLOR/ FIXED_LABEL_DESCRIPTION env vars. issue-labels-sync.yml already defines and creates close:fixed and is the single source of truth for its color and description, so duplicating them here only created a maintenance coupling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs: revert CONTRIBUTING.md note; defer until all workflows merged Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat: fail workflow when close:fixed label is missing Fail fast via core.setFailed if the label does not exist, rather than letting addLabels silently create a default-colored variant. Signals the label taxonomy is out of sync and the Sync Labels workflow needs to run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor: move label existence check into its own preceding step Split the close:fixed existence check out of the apply step into a dedicated 'Verify close:fixed label exists' step that runs first, so the run fails before any labeling work when the label is missing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent ee26bd6 commit 1d85e30

1 file changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
name: Label Fixed Issues on Merge
2+
3+
# Trigger: a pull request is closed.
4+
# When the PR was actually MERGED, every issue it closed via a linking keyword
5+
# (Closes #N / Fixes #N / Resolves #N) is labeled `close:fixed` so the close
6+
# reason is visible at a glance and consistent with the manual triage taxonomy.
7+
#
8+
# ─── WHY pull_request_target ────────────────────────────────────────────────
9+
# `pull_request` grants a read-only GITHUB_TOKEN to PRs opened from forks, which
10+
# would make `issues: write` unavailable and silently skip fork contributions.
11+
# `pull_request_target` runs with the base repository's trusted workflow file and
12+
# a read/write token. This workflow NEVER checks out or executes PR head code —
13+
# it only calls the GitHub API via github-script — so there is no untrusted-code
14+
# execution risk from using pull_request_target.
15+
# ────────────────────────────────────────────────────────────────────────────
16+
17+
on:
18+
pull_request_target:
19+
types: [closed]
20+
21+
permissions:
22+
issues: write
23+
contents: read
24+
25+
# The close:fixed label is defined and created by issue-labels-sync.yml
26+
# (CLOSE_LABELS) — that workflow is the single source of truth for its
27+
# color/description. This workflow only applies the existing label.
28+
env:
29+
FIXED_LABEL: 'close:fixed'
30+
31+
jobs:
32+
label-fixed:
33+
name: Label issues closed by merge
34+
runs-on: ubuntu-latest
35+
# Only act on merged PRs — a closed-but-unmerged PR fixes nothing.
36+
if: github.event.pull_request.merged == true
37+
38+
steps:
39+
- name: Verify close:fixed label exists
40+
uses: actions/github-script@v8
41+
with:
42+
script: |
43+
const FIXED_LABEL = process.env.FIXED_LABEL;
44+
const { owner, repo } = context.repo;
45+
46+
// The close:fixed label is owned by issue-labels-sync.yml. If it's
47+
// missing, fail the run rather than letting a later addLabels call
48+
// silently create a default-colored variant — that signals the
49+
// label taxonomy is out of sync and needs the sync workflow to run.
50+
try {
51+
await github.rest.issues.getLabel({ owner, repo, name: FIXED_LABEL });
52+
core.info(`Label "${FIXED_LABEL}" exists`);
53+
} catch (err) {
54+
if (err.status === 404) {
55+
core.setFailed(
56+
`Label "${FIXED_LABEL}" does not exist. Run the "Sync Labels" ` +
57+
`workflow (issue-labels-sync.yml) to create it, then re-run.`
58+
);
59+
return;
60+
}
61+
throw err;
62+
}
63+
64+
- name: Apply close:fixed to auto-closed issues
65+
uses: actions/github-script@v8
66+
with:
67+
script: |
68+
const FIXED_LABEL = process.env.FIXED_LABEL;
69+
const { owner, repo } = context.repo;
70+
const prNumber = context.payload.pull_request.number;
71+
72+
// Resolve the issues this PR closes via linking keywords. GraphQL's
73+
// closingIssuesReferences is the authoritative source — it mirrors
74+
// exactly what GitHub itself closed on merge, so it stays correct
75+
// even if the PR body wording is unusual.
76+
const query = `
77+
query($owner: String!, $repo: String!, $number: Int!) {
78+
repository(owner: $owner, name: $repo) {
79+
pullRequest(number: $number) {
80+
closingIssuesReferences(first: 50) {
81+
nodes { number }
82+
}
83+
}
84+
}
85+
}`;
86+
87+
const result = await github.graphql(query, {
88+
owner,
89+
repo,
90+
number: prNumber
91+
});
92+
93+
const issues = result.repository.pullRequest.closingIssuesReferences.nodes || [];
94+
if (issues.length === 0) {
95+
core.info(`PR #${prNumber} closed no linked issues — nothing to label`);
96+
return;
97+
}
98+
99+
core.info(`PR #${prNumber} closed issue(s): ${issues.map(i => '#' + i.number).join(', ')}`);
100+
101+
let labeled = 0;
102+
for (const { number } of issues) {
103+
// Read live labels so we don't post a redundant label event
104+
// (which would needlessly re-trigger the enforce-unique workflow).
105+
const fresh = await github.rest.issues.get({
106+
owner,
107+
repo,
108+
issue_number: number
109+
});
110+
111+
const labels = fresh.data.labels.map(l => (typeof l === 'string' ? l : l.name));
112+
if (labels.includes(FIXED_LABEL)) {
113+
core.info(`#${number} already has ${FIXED_LABEL} — skipping`);
114+
continue;
115+
}
116+
117+
await github.rest.issues.addLabels({
118+
owner,
119+
repo,
120+
issue_number: number,
121+
labels: [FIXED_LABEL]
122+
});
123+
124+
core.info(`Labeled #${number} with ${FIXED_LABEL}`);
125+
labeled++;
126+
}
127+
128+
core.info(`Done — applied ${FIXED_LABEL} to ${labeled} issue(s)`);

0 commit comments

Comments
 (0)