Skip to content

Commit 89b455d

Browse files
committed
feat: sync linked issue labels to pull requests
- Refactor script to reduce complexity (Codacy fixes) - Further simplify functions to meet complexity limits - Fix DRY_RUN logic in compute workflow (CodeRabbit) - Add core.setOutput() calls (CodeRabbit) - Fix download-artifact with run-id and github-token (CodeRabbit) - Add actions: read permission (CodeRabbit) - Add changelog entries Fixes #1716 Signed-off-by: cheese-cakee <farzanaman99@gmail.com>
1 parent d2f17ed commit 89b455d

4 files changed

Lines changed: 254 additions & 1 deletion

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
const CLOSING_REFERENCE_REGEX =
2+
/\b(?:fix(?:es|ed)?|close(?:s|d)?|resolve(?:s|d)?)\s*:?\s*((?:#\d+)(?:\s*(?:,|and)\s*#\d+)*)/gi;
3+
4+
function resolveExecutionContext(context) {
5+
const isDryRun = /^true$/i.test(process.env.DRY_RUN || "");
6+
const prNumber = Number(process.env.PR_NUMBER) || context.payload.pull_request?.number;
7+
return { prNumber, isDryRun, owner: context.repo.owner, repo: context.repo.repo };
8+
}
9+
10+
function validatePrNumber(prNumber) {
11+
if (!prNumber) {
12+
throw new Error("PR number could not be determined.");
13+
}
14+
}
15+
16+
function isBotAuthor(login = "") {
17+
return /\[bot\]$/i.test(login) || /dependabot/i.test(login);
18+
}
19+
20+
function parseIssueNumbers(prBody) {
21+
const numbers = new Set();
22+
let match;
23+
while ((match = CLOSING_REFERENCE_REGEX.exec(prBody)) !== null) {
24+
const text = match[1] || "";
25+
for (const n of text.matchAll(/#(\d+)/g)) {
26+
numbers.add(Number(n[1]));
27+
}
28+
}
29+
return [...numbers];
30+
}
31+
32+
function extractLabels(labelData) {
33+
const result = [];
34+
for (const item of labelData) {
35+
const name = typeof item === "string" ? item : item?.name;
36+
if (name?.trim()) {
37+
result.push(name.trim());
38+
}
39+
}
40+
return result;
41+
}
42+
43+
async function fetchPrData(github, context, prNumber) {
44+
if (context.payload.pull_request) {
45+
return context.payload.pull_request;
46+
}
47+
const { data } = await github.rest.pulls.get({
48+
owner: context.repo.owner,
49+
repo: context.repo.repo,
50+
pull_number: prNumber,
51+
});
52+
return data;
53+
}
54+
55+
function checkSkipConditions(prAuthor, linkedIssues) {
56+
if (isBotAuthor(prAuthor)) {
57+
return { skip: true, reason: `Skipping bot-authored PR from ${prAuthor}.` };
58+
}
59+
if (!linkedIssues.length) {
60+
return { skip: true, reason: "No linked issue references found in PR body." };
61+
}
62+
return { skip: false };
63+
}
64+
65+
async function fetchIssueLabels(github, owner, repo, issueNumber) {
66+
try {
67+
const { data } = await github.rest.issues.get({ owner, repo, issue_number: issueNumber });
68+
if (data.pull_request) {
69+
console.log(`[sync] Skipping #${issueNumber}: is a PR reference.`);
70+
return [];
71+
}
72+
const labels = extractLabels(data.labels || []);
73+
console.log(`[sync] Issue #${issueNumber} labels: ${labels.length ? labels.join(", ") : "(none)"}`);
74+
return labels;
75+
} catch (err) {
76+
if (err?.status === 404) {
77+
console.log(`[sync] Issue #${issueNumber} not found. Skipping.`);
78+
return [];
79+
}
80+
throw err;
81+
}
82+
}
83+
84+
function computeDelta(existingLabels, issueLabels) {
85+
const existing = new Set(existingLabels);
86+
return issueLabels.filter((l) => !existing.has(l));
87+
}
88+
89+
function logResults(prNum, toAdd, existing) {
90+
console.log(`[sync] Processing PR #${prNum}.`);
91+
console.log(`[sync] Existing: ${existing.size ? [...existing].join(", ") : "(none)"}`);
92+
console.log(`[sync] To add: ${toAdd.length ? toAdd.join(", ") : "(none)"}`);
93+
}
94+
95+
async function syncLabels({ github, context }) {
96+
const { prNumber, isDryRun, owner, repo } = resolveExecutionContext(context);
97+
validatePrNumber(prNumber);
98+
99+
console.log(`[sync] Processing PR #${prNumber} in ${owner}/${repo} (dry_run=${isDryRun}).`);
100+
101+
const prData = await fetchPrData(github, context, prNumber);
102+
const linkedIssues = parseIssueNumbers(prData?.body || "");
103+
104+
const skip = checkSkipConditions(prData?.user?.login || "", linkedIssues);
105+
if (skip.skip) {
106+
console.log(`[sync] ${skip.reason}`);
107+
return { labels: [] };
108+
}
109+
110+
console.log(`[sync] Linked issues: ${linkedIssues.map((n) => `#${n}`).join(", ")}`);
111+
112+
const allLabels = [];
113+
for (const num of linkedIssues) {
114+
const labels = await fetchIssueLabels(github, owner, repo, num);
115+
allLabels.push(...labels);
116+
}
117+
118+
if (!allLabels.length) {
119+
console.log("[sync] No labels on linked issues.");
120+
return { labels: [] };
121+
}
122+
123+
const existing = extractLabels(prData?.labels || []);
124+
const toAdd = computeDelta(existing, allLabels);
125+
logResults(prNumber, toAdd, new Set(existing));
126+
127+
if (!toAdd.length) {
128+
console.log("[sync] PR already has all labels.");
129+
return { labels: [] };
130+
}
131+
132+
if (isDryRun) {
133+
console.log(`[sync] DRY_RUN: would add ${toAdd.join(", ")}`);
134+
return { labels: toAdd };
135+
}
136+
137+
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: toAdd });
138+
console.log(`[sync] Added labels: ${toAdd.join(", ")}`);
139+
140+
return { labels: toAdd };
141+
}
142+
143+
module.exports = syncLabels;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Add Linked Issue Labels to PR
2+
3+
on:
4+
workflow_run:
5+
workflows: ["Compute Linked Issue Labels"]
6+
types: [completed]
7+
branches: [main]
8+
9+
permissions:
10+
actions: read
11+
pull-requests: write
12+
issues: write
13+
14+
jobs:
15+
add-labels:
16+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Download labels artifact
20+
uses: actions/download-artifact@ea165f8d65b6e75b540449e92b4886f436390c0e # v4.6.2
21+
with:
22+
name: pr-labels-${{ github.event.workflow_run.pull_requests[0].number }}
23+
path: .
24+
run-id: ${{ github.event.workflow_run.id }}
25+
github-token: ${{ secrets.GITHUB_TOKEN }}
26+
27+
- name: Read labels
28+
id: read
29+
run: |
30+
labels=$(cat labels.json)
31+
echo "labels=$labels" >> $GITHUB_OUTPUT
32+
33+
- name: Add labels to PR
34+
uses: dblock/github-actions-ecosystem-add-labels@4e5ec2f3a319f5c22b8e6f45d6438fd1b3c9317a # v1.0.0
35+
with:
36+
labels: ${{ fromJson(steps.read.outputs.labels) }}
37+
pr_number: ${{ github.event.workflow_run.pull_requests[0].number }}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: Compute Linked Issue Labels
2+
3+
on:
4+
pull_request_target:
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:
13+
description: "Dry run (log only, do not apply labels)"
14+
required: false
15+
type: boolean
16+
default: true
17+
18+
permissions:
19+
pull-requests: read
20+
issues: read
21+
contents: read
22+
23+
jobs:
24+
compute-labels:
25+
runs-on: ubuntu-latest
26+
steps:
27+
- name: Harden the runner
28+
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
29+
with:
30+
egress-policy: audit
31+
32+
- name: Checkout repository
33+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
34+
with:
35+
ref: main
36+
37+
- name: Compute linked issue labels
38+
id: compute
39+
env:
40+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41+
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
42+
DRY_RUN: 'true'
43+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
44+
with:
45+
result-encoding: json
46+
script: |
47+
const script = require('./.github/scripts/sync-issue-labels.js');
48+
const core = await import('@actions/core');
49+
const result = await script({ github, context, core: core.default });
50+
const labels = result?.labels || [];
51+
const hasLabels = labels.length > 0;
52+
core.default.setOutput('has_labels', String(hasLabels));
53+
core.default.setOutput('labels', JSON.stringify(labels));
54+
core.default.setOutput('pr_number', String(process.env.PR_NUMBER || ''));
55+
core.default.setOutput('dry_run', String(process.env.DRY_RUN || 'false'));
56+
console.log('Computed labels:', JSON.stringify({ hasLabels, labels, pr_number: process.env.PR_NUMBER, dry_run: process.env.DRY_RUN }));
57+
return { has_labels: hasLabels, labels: labels, pr_number: process.env.PR_NUMBER, dry_run: process.env.DRY_RUN };
58+
59+
- name: Upload labels as artifact
60+
if: steps.compute.outputs.has_labels == 'true'
61+
run: |
62+
echo '${{ steps.compute.outputs.labels }}' > labels.json
63+
shell: bash
64+
65+
- name: Upload labels artifact
66+
if: steps.compute.outputs.has_labels == 'true'
67+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f436390c0e # v4.6.2
68+
with:
69+
name: pr-labels-${{ github.event.pull_request.number || github.event.inputs.pr_number }}
70+
path: labels.json
71+
retention-days: 1

CHANGELOG.md

Lines changed: 3 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).
@@ -13,6 +13,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
1313
- Added CodeRabbit review instructions and path mapping for the schedule module (`src/hiero_sdk_python/schedule/`) in `.coderabbit.yaml` (#1698)
1414
- Added advanced code review prompts for the `src/hiero_sdk_python/file` module in `.coderabbit.yaml` to guide reviewers in verifying proper `FileAppendTransaction` chunking constraints and nuances in memo handling for `FileUpdateTransaction` according to Hiero SDK best practices. (#1697)
1515
- Added CodeRabbit review instructions for consensus module `src/hiero_sdk_python/consensus/` with critical focus on protobuf alignment `.coderabbit.yaml`.
16+
- Added workflow and bot script to automatically sync labels from linked issues to pull requests. (#1716)
1617

1718

1819
### Fixed
@@ -57,6 +58,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
5758
- Added workflow documentation guide (`docs/github/04_workflow_documentation.md`) with best practices for documenting GitHub workflows and automation scripts (#1745)
5859
- Updated CodeRabbit workflow and script review instructions to nudge higher-quality patterns without imposing rigid rules (`#1799`)
5960
- Added hiero-sdk-js to the next issue recommendation bot (`#1847`)
61+
- Added workflow and bot script to automatically sync labels from linked issues to pull requests. (#1716)
6062

6163
## [0.2.0] - 2026-11-02
6264

0 commit comments

Comments
 (0)