Skip to content

Commit ef05dd4

Browse files
cheese-cakeerbarker-devCopilot
authored
feat: sync linked issue labels to pull requests (#2015)
Signed-off-by: cheese-cakee <farzanaman99@gmail.com> Co-authored-by: Roger Barker <roger.barker@swirldslabs.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 65253a8 commit ef05dd4

3 files changed

Lines changed: 337 additions & 1 deletion

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
name: Add Linked Issue Labels to PR
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
upstream_run_id:
7+
description: "Upstream compute workflow run ID"
8+
required: true
9+
type: string
10+
pr_number:
11+
description: "Pull request number"
12+
required: true
13+
type: string
14+
dry_run:
15+
description: "Dry run flag"
16+
required: false
17+
type: string
18+
default: "true"
19+
is_fork_pr:
20+
description: "Fork PR flag"
21+
required: false
22+
type: string
23+
default: "false"
24+
defaults:
25+
run:
26+
shell: bash
27+
permissions:
28+
actions: read
29+
issues: write
30+
31+
jobs:
32+
add-labels:
33+
concurrency:
34+
group: sync-issue-labels-pr-${{ github.event.inputs.pr_number }}
35+
cancel-in-progress: true
36+
runs-on: ubuntu-latest
37+
steps:
38+
- name: Harden the runner
39+
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
40+
with:
41+
egress-policy: audit
42+
43+
- name: Download labels artifact
44+
id: download
45+
continue-on-error: true
46+
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
47+
with:
48+
name: pr-labels-${{ github.event.inputs.pr_number }}
49+
path: artifacts
50+
run-id: ${{ github.event.inputs.upstream_run_id }}
51+
github-token: ${{ secrets.GITHUB_TOKEN }}
52+
53+
- name: Read labels payload
54+
id: read
55+
env:
56+
INPUT_PR_NUMBER: ${{ github.event.inputs.pr_number }}
57+
INPUT_IS_FORK_PR: ${{ github.event.inputs.is_fork_pr }}
58+
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
59+
run: |
60+
labels_file="artifacts/labels.json"
61+
if [ ! -f "$labels_file" ]; then
62+
echo "::error::Labels artifact not found. Cross-workflow handoff is broken."
63+
echo "labels=[]" >> "$GITHUB_OUTPUT"
64+
echo "labels_count=0" >> "$GITHUB_OUTPUT"
65+
echo "labels_multiline=" >> "$GITHUB_OUTPUT"
66+
echo "pr_number=$INPUT_PR_NUMBER" >> "$GITHUB_OUTPUT"
67+
echo "is_fork_pr=$INPUT_IS_FORK_PR" >> "$GITHUB_OUTPUT"
68+
echo "dry_run=$INPUT_DRY_RUN" >> "$GITHUB_OUTPUT"
69+
echo "source_event=workflow_dispatch" >> "$GITHUB_OUTPUT"
70+
exit 1
71+
fi
72+
labels=$(jq -c '.labels // []' "$labels_file")
73+
pr_number=$(jq -r '.pr_number // 0' "$labels_file")
74+
is_fork_pr=$(jq -r '.is_fork_pr // false' "$labels_file")
75+
dry_run=$(jq -r '.dry_run // "true"' "$labels_file")
76+
source_event=$(jq -r '.source_event // ""' "$labels_file")
77+
labels_multiline=$(jq -r '.labels // [] | .[]' "$labels_file")
78+
labels_count=$(echo "$labels" | jq 'length')
79+
echo "labels=$labels" >> "$GITHUB_OUTPUT"
80+
echo "labels_count=$labels_count" >> "$GITHUB_OUTPUT"
81+
{
82+
echo "labels_multiline<<EOF"
83+
echo "$labels_multiline"
84+
echo "EOF"
85+
} >> "$GITHUB_OUTPUT"
86+
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
87+
echo "is_fork_pr=$is_fork_pr" >> "$GITHUB_OUTPUT"
88+
echo "dry_run=$dry_run" >> "$GITHUB_OUTPUT"
89+
echo "source_event=$source_event" >> "$GITHUB_OUTPUT"
90+
91+
- name: Validate labels payload
92+
id: validate
93+
run: |
94+
if [ "$PR_NUMBER" = "0" ] || [ "$(echo "$LABELS" | jq -r '. | length')" = "0" ]; then
95+
echo "Invalid payload: pr_number=$PR_NUMBER or labels empty. Skipping label addition."
96+
echo "valid_payload=false" >> "$GITHUB_OUTPUT"
97+
else
98+
echo "valid_payload=true" >> "$GITHUB_OUTPUT"
99+
fi
100+
env:
101+
PR_NUMBER: ${{ steps.read.outputs.pr_number }}
102+
LABELS: ${{ steps.read.outputs.labels }}
103+
104+
- name: Determine if labels should be applied
105+
id: should_apply
106+
run: |
107+
if [ "${{ steps.read.outputs.is_fork_pr }}" = "true" ]; then
108+
echo "apply=false" >> "$GITHUB_OUTPUT"
109+
echo "reason=fork PR" >> "$GITHUB_OUTPUT"
110+
elif [ "${{ steps.validate.outputs.valid_payload }}" != "true" ]; then
111+
echo "apply=false" >> "$GITHUB_OUTPUT"
112+
echo "reason=invalid payload" >> "$GITHUB_OUTPUT"
113+
elif [ "${{ steps.read.outputs.source_event }}" = "workflow_dispatch" ] && [ "${{ steps.read.outputs.dry_run }}" = "true" ]; then
114+
echo "apply=false" >> "$GITHUB_OUTPUT"
115+
echo "reason=dry run" >> "$GITHUB_OUTPUT"
116+
else
117+
echo "apply=true" >> "$GITHUB_OUTPUT"
118+
echo "reason=" >> "$GITHUB_OUTPUT"
119+
fi
120+
121+
- name: Add labels to PR
122+
if: ${{ steps.should_apply.outputs.apply == 'true' }}
123+
uses: actions-ecosystem/action-add-labels@1a9c3715c0037e96b97bb38cb4c4b56a1f1d4871 # main
124+
with:
125+
github_token: ${{ secrets.GITHUB_TOKEN }}
126+
labels: ${{ steps.read.outputs.labels_multiline }}
127+
number: ${{ steps.read.outputs.pr_number }}
128+
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
name: Compute Linked Issue Labels
2+
3+
on:
4+
pull_request:
5+
types: [opened, edited, reopened, synchronize, ready_for_review]
6+
workflow_dispatch:
7+
inputs:
8+
pr_number:
9+
description: "PR number to sync labels for"
10+
required: true
11+
type: number
12+
dry-run-enabled:
13+
description: "Dry run (log only, do not apply labels)"
14+
required: false
15+
type: boolean
16+
default: true
17+
18+
permissions:
19+
actions: write
20+
pull-requests: read
21+
issues: read
22+
contents: read
23+
24+
jobs:
25+
compute-labels:
26+
concurrency:
27+
group: sync-issue-labels-compute-pr-${{ github.event.pull_request.number || github.event.inputs.pr_number }}
28+
cancel-in-progress: true
29+
runs-on: ubuntu-latest
30+
outputs:
31+
pr_number: ${{ steps.compute.outputs.pr_number }}
32+
dry_run: ${{ steps.compute.outputs.dry_run }}
33+
is_fork_pr: ${{ steps.compute.outputs.is_fork_pr }}
34+
steps:
35+
- name: Harden the runner
36+
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
37+
with:
38+
egress-policy: audit
39+
40+
- name: Compute linked issue labels
41+
id: compute
42+
env:
43+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
44+
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
45+
DRY_RUN: 'true'
46+
REQUESTED_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && format('{0}', github.event.inputs['dry-run-enabled']) || 'true' }}
47+
IS_FORK_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork || 'false' }}
48+
MAX_LINKED_ISSUES: '20'
49+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
50+
with:
51+
result-encoding: json
52+
script: |
53+
const MAX_LINKED_ISSUES = Number(process.env.MAX_LINKED_ISSUES || "20");
54+
55+
function extractLabels(labelData) {
56+
const result = [];
57+
for (const item of labelData) {
58+
const name = typeof item === "string" ? item : item && item.name;
59+
if (name && name.trim()) result.push(name.trim());
60+
}
61+
return result;
62+
}
63+
64+
function extractLinkedIssueNumbers(prBody, owner, repo) {
65+
const numbers = new Set();
66+
const closingRefRegex = /(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+(?:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+))?#(\d+)\b/gi;
67+
const lines = String(prBody || "").split(/\r?\n/);
68+
for (const line of lines) {
69+
let m;
70+
while ((m = closingRefRegex.exec(line)) !== null) {
71+
const refOwner = (m[1] || "").toLowerCase();
72+
const refRepo = (m[2] || "").toLowerCase();
73+
if (refOwner && refRepo && (refOwner !== owner.toLowerCase() || refRepo !== repo.toLowerCase())) continue;
74+
numbers.add(Number(m[3]));
75+
}
76+
}
77+
const all = Array.from(numbers);
78+
if (all.length > MAX_LINKED_ISSUES) {
79+
console.log(`[sync] Limiting linked issue refs from ${all.length} to ${MAX_LINKED_ISSUES}.`);
80+
}
81+
return all.slice(0, MAX_LINKED_ISSUES);
82+
}
83+
84+
const prNumber = Number(process.env.PR_NUMBER);
85+
if (!prNumber) {
86+
core.setOutput('has_labels', 'false');
87+
core.setOutput('labels', '[]');
88+
core.setOutput('pr_number', '');
89+
core.setOutput('dry_run', 'true');
90+
core.setOutput('is_fork_pr', String(process.env.IS_FORK_PR || 'false'));
91+
core.setOutput('source_event', context.eventName);
92+
return;
93+
}
94+
95+
const { data: prData } = await github.rest.pulls.get({
96+
owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber
97+
});
98+
99+
const prAuthor = (prData.user && prData.user.login) || "";
100+
if (/\[bot\]$/i.test(prAuthor) || /dependabot/i.test(prAuthor)) {
101+
console.log(`[sync] Skipping bot-authored PR from ${prAuthor}.`);
102+
core.setOutput('has_labels', 'false');
103+
core.setOutput('labels', '[]');
104+
core.setOutput('pr_number', String(prNumber));
105+
core.setOutput('dry_run', 'true');
106+
core.setOutput('is_fork_pr', String(process.env.IS_FORK_PR || 'false'));
107+
core.setOutput('source_event', context.eventName);
108+
return;
109+
}
110+
111+
const linkedIssues = extractLinkedIssueNumbers(prData.body || "", context.repo.owner, context.repo.repo);
112+
if (!linkedIssues.length) {
113+
console.log("[sync] No linked issue references found in PR body.");
114+
core.setOutput('has_labels', 'false');
115+
core.setOutput('labels', '[]');
116+
core.setOutput('pr_number', String(prNumber));
117+
core.setOutput('dry_run', 'true');
118+
core.setOutput('is_fork_pr', String(process.env.IS_FORK_PR || 'false'));
119+
core.setOutput('source_event', context.eventName);
120+
return;
121+
}
122+
123+
console.log(`[sync] Linked issues: ${linkedIssues.map(n => '#' + n).join(', ')}`);
124+
125+
const allLabels = [];
126+
for (const num of linkedIssues) {
127+
try {
128+
const { data } = await github.rest.issues.get({
129+
owner: context.repo.owner, repo: context.repo.repo, issue_number: num
130+
});
131+
if (data.pull_request) { console.log(`[sync] Skipping #${num}: is a PR reference.`); continue; }
132+
const labels = extractLabels(data.labels || []);
133+
console.log(`[sync] Issue #${num} labels: ${labels.length ? labels.join(', ') : '(none)'}`);
134+
allLabels.push(...labels);
135+
} catch (err) {
136+
if (err && err.status === 404) { console.log(`[sync] Issue #${num} not found. Skipping.`); continue; }
137+
throw err;
138+
}
139+
}
140+
141+
const existing = extractLabels(prData.labels || []);
142+
const existingSet = new Set(existing);
143+
const deduped = Array.from(new Set(allLabels));
144+
const toAdd = deduped.filter(l => !existingSet.has(l));
145+
146+
console.log(`[sync] Existing: ${existing.length ? existing.join(', ') : '(none)'}`);
147+
console.log(`[sync] To add: ${toAdd.length ? toAdd.join(', ') : '(none)'}`);
148+
149+
const labels = toAdd;
150+
const hasLabels = labels.length > 0;
151+
core.setOutput('has_labels', String(hasLabels));
152+
core.setOutput('labels', JSON.stringify(labels));
153+
core.setOutput('pr_number', String(prNumber));
154+
core.setOutput('dry_run', String(process.env.REQUESTED_DRY_RUN || 'true'));
155+
core.setOutput('is_fork_pr', String(process.env.IS_FORK_PR || 'false'));
156+
core.setOutput('source_event', context.eventName);
157+
return { has_labels: hasLabels, labels, pr_number: String(prNumber), dry_run: process.env.REQUESTED_DRY_RUN, is_fork_pr: process.env.IS_FORK_PR, source_event: context.eventName };
158+
159+
- name: Write labels artifact payload
160+
env:
161+
LABELS_JSON: ${{ steps.compute.outputs.labels }}
162+
PR_NUMBER: ${{ steps.compute.outputs.pr_number }}
163+
IS_FORK_PR: ${{ steps.compute.outputs.is_fork_pr }}
164+
DRY_RUN: ${{ steps.compute.outputs.dry_run }}
165+
SOURCE_EVENT: ${{ steps.compute.outputs.source_event }}
166+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
167+
with:
168+
script: |
169+
const fs = require('fs');
170+
const parsed = JSON.parse(process.env.LABELS_JSON || '[]');
171+
const payload = {
172+
pr_number: Number(process.env.PR_NUMBER || 0),
173+
labels: Array.isArray(parsed) ? parsed : [],
174+
is_fork_pr: /^true$/i.test(process.env.IS_FORK_PR || ''),
175+
dry_run: /^true$/i.test(process.env.DRY_RUN || ''),
176+
source_event: process.env.SOURCE_EVENT || '',
177+
};
178+
fs.writeFileSync('labels.json', JSON.stringify(payload));
179+
console.log(`Wrote labels artifact payload for PR #${payload.pr_number}: ${payload.labels.length} labels`);
180+
181+
- name: Upload labels artifact
182+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
183+
with:
184+
name: pr-labels-${{ steps.compute.outputs.pr_number }}
185+
path: labels.json
186+
retention-days: 1
187+
188+
dispatch-add:
189+
needs: compute-labels
190+
if: ${{ needs.compute-labels.outputs.is_fork_pr != 'true' }}
191+
runs-on: ubuntu-latest
192+
steps:
193+
- name: Trigger add workflow
194+
uses: step-security/workflow-dispatch@acca1a315af3bf7f33dd116d3cb405cb83f5cbdc # v1.2.8
195+
with:
196+
workflow: .github/workflows/sync-issue-labels-add.yml
197+
repo: ${{ github.repository }}
198+
ref: main
199+
token: ${{ secrets.GH_ACCESS_TOKEN }}
200+
inputs: >-
201+
{
202+
"upstream_run_id":"${{ github.run_id }}",
203+
"pr_number":"${{ needs.compute-labels.outputs.pr_number }}",
204+
"dry_run":"${{ needs.compute-labels.outputs.dry_run }}",
205+
"is_fork_pr":"${{ needs.compute-labels.outputs.is_fork_pr }}"
206+
}
207+

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Changelog
1+
# Changelog
22

33
All notable changes to this project will be documented in this file.
44
This project adheres to [Semantic Versioning](https://semver.org).
@@ -23,6 +23,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
2323
- chore: update GitHub Actions runners from ubuntu-latest to hl-sdk-py-lin-md (#2021)
2424
- Refactored the Advanced Issue Template to V2 with stricter prerequisites and a focus on architectural design (#2016).
2525
- Refactored the Advanced Issue Template to ensure PR-level quality checklists do not block maintainers during issue creation (#2036)
26+
- Add automated label sync workflow to propagate labels from linked issues to pull requests (#1716)
2627

2728
## [0.2.3] - 2026-03-26
2829

0 commit comments

Comments
 (0)