Skip to content

Commit 9264396

Browse files
committed
feat: sync linked issue labels to pull requests
- Refactor script to reduce complexity (Codacy fixes) - 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 9264396

4 files changed

Lines changed: 303 additions & 1 deletion

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Resolves PR number from environment or context, and determines if dry-run mode is enabled.
2+
function resolveExecutionContext(context) {
3+
const isDryRun = /^true$/i.test(process.env.DRY_RUN || "");
4+
const prNumber = Number(process.env.PR_NUMBER) || context.payload.pull_request?.number;
5+
return { prNumber, isDryRun, owner: context.repo.owner, repo: context.repo.repo };
6+
}
7+
8+
// Validates that PR number is available.
9+
function validatePrNumber(prNumber) {
10+
if (!prNumber) {
11+
throw new Error("PR number could not be determined.");
12+
}
13+
}
14+
15+
// Checks if the given login belongs to a bot account.
16+
function isBotLogin(login = "") {
17+
return /\[bot\]$/i.test(login) || /dependabot/i.test(login);
18+
}
19+
20+
// Extracts linked issue numbers from PR body.
21+
function extractLinkedIssueNumbers(prBody = "") {
22+
const closingReferenceRegex =
23+
/\b(?:fix(?:es|ed)?|close(?:s|d)?|resolve(?:s|d)?)\s*:?\s*((?:#\d+)(?:\s*(?:,|and)\s*#\d+)*)/gi;
24+
const numbers = new Set();
25+
let referenceMatch;
26+
27+
while ((referenceMatch = closingReferenceRegex.exec(prBody)) !== null) {
28+
const referencesText = referenceMatch[1] || "";
29+
const issueMatches = referencesText.matchAll(/#(\d+)/g);
30+
31+
for (const issueMatch of issueMatches) {
32+
numbers.add(Number(issueMatch[1]));
33+
}
34+
}
35+
36+
return [...numbers];
37+
}
38+
39+
// Normalizes label objects/strings to trimmed label names.
40+
function normalizeLabelNames(labels = []) {
41+
const names = [];
42+
for (const label of labels) {
43+
if (typeof label === "string" && label.trim()) {
44+
names.push(label.trim());
45+
continue;
46+
}
47+
48+
if (label && typeof label.name === "string" && label.name.trim()) {
49+
names.push(label.name.trim());
50+
}
51+
}
52+
return names;
53+
}
54+
55+
// Fetches PR data from payload or API.
56+
async function getPullRequestData({ github, context, prNumber }) {
57+
if (context.payload.pull_request) {
58+
return context.payload.pull_request;
59+
}
60+
61+
const response = await github.rest.pulls.get({
62+
owner: context.repo.owner,
63+
repo: context.repo.repo,
64+
pull_number: prNumber,
65+
});
66+
67+
return response.data;
68+
}
69+
70+
// Determines if PR should be skipped.
71+
function shouldSkipPR(prData, linkedIssueNumbers) {
72+
const prAuthor = prData?.user?.login || "";
73+
74+
if (isBotLogin(prAuthor)) {
75+
return { skip: true, reason: `Skipping bot-authored PR from ${prAuthor}.` };
76+
}
77+
78+
if (!linkedIssueNumbers.length) {
79+
return { skip: true, reason: "No linked issue references found in PR body." };
80+
}
81+
82+
return { skip: false };
83+
}
84+
85+
// Collects labels from linked issues.
86+
async function collectLabelsFromLinkedIssues({ github, owner, repo, linkedIssueNumbers }) {
87+
const labelsFromIssues = new Set();
88+
89+
for (const issueNumber of linkedIssueNumbers) {
90+
try {
91+
const issueResponse = await github.rest.issues.get({
92+
owner,
93+
repo,
94+
issue_number: issueNumber,
95+
});
96+
97+
if (issueResponse?.data?.pull_request) {
98+
console.log(`[sync-issue-labels] Skipping #${issueNumber}: reference points to a pull request.`);
99+
continue;
100+
}
101+
102+
const issueLabelNames = normalizeLabelNames(issueResponse?.data?.labels || []);
103+
console.log(
104+
`[sync-issue-labels] Issue #${issueNumber} labels: ${
105+
issueLabelNames.length ? issueLabelNames.join(", ") : "(none)"
106+
}`
107+
);
108+
109+
for (const label of issueLabelNames) {
110+
labelsFromIssues.add(label);
111+
}
112+
} catch (error) {
113+
if (error?.status === 404) {
114+
console.log(`[sync-issue-labels] Linked issue #${issueNumber} not found. Skipping.`);
115+
continue;
116+
}
117+
118+
throw error;
119+
}
120+
}
121+
122+
return labelsFromIssues;
123+
}
124+
125+
// Computes labels to add (missing from PR but present in issues).
126+
function computeLabelsToAdd(prData, labelsFromIssues) {
127+
const prLabelNames = new Set(normalizeLabelNames(prData?.labels || []));
128+
const labelsToAdd = [...labelsFromIssues].filter((label) => !prLabelNames.has(label));
129+
return { labelsToAdd, prLabelNames };
130+
}
131+
132+
// Logs label operations for debugging.
133+
function logLabelOperation(operation, prNumber, labels, prLabelNames) {
134+
console.log(`[sync-issue-labels] ${operation} PR #${prNumber}.`);
135+
console.log(`[sync-issue-labels] Existing labels: ${prLabelNames.size ? [...prLabelNames].join(", ") : "(none)"}`);
136+
console.log(`[sync-issue-labels] Labels to add: ${labels.length ? labels.join(", ") : "(none)"}`);
137+
}
138+
139+
// Main orchestrator function.
140+
async function syncLabels({ github, context }) {
141+
const { prNumber, isDryRun, owner, repo } = resolveExecutionContext(context);
142+
143+
validatePrNumber(prNumber);
144+
145+
console.log(`[sync-issue-labels] Processing PR #${prNumber} in ${owner}/${repo} (dry_run=${isDryRun}).`);
146+
147+
const prData = await getPullRequestData({ github, context, prNumber });
148+
149+
const linkedIssueNumbers = extractLinkedIssueNumbers(prData?.body || "");
150+
const skipResult = shouldSkipPR(prData, linkedIssueNumbers);
151+
152+
if (skipResult.skip) {
153+
console.log(`[sync-issue-labels] ${skipResult.reason}`);
154+
return { labels: [] };
155+
}
156+
157+
console.log(`[sync-issue-labels] Linked issues: ${linkedIssueNumbers.map((n) => `#${n}`).join(", ")}`);
158+
159+
const labelsFromIssues = await collectLabelsFromLinkedIssues({ github, owner, repo, linkedIssueNumbers });
160+
161+
if (!labelsFromIssues.size) {
162+
console.log("[sync-issue-labels] No labels on linked issues. Nothing to sync.");
163+
return { labels: [] };
164+
}
165+
166+
const { labelsToAdd, prLabelNames } = computeLabelsToAdd(prData, labelsFromIssues);
167+
168+
logLabelOperation("Processing", prNumber, labelsToAdd, prLabelNames);
169+
170+
if (!labelsToAdd.length) {
171+
console.log("[sync-issue-labels] PR already has all labels from linked issues.");
172+
return { labels: [] };
173+
}
174+
175+
if (isDryRun) {
176+
console.log(`[sync-issue-labels] DRY_RUN enabled; would add: ${labelsToAdd.join(", ")}`);
177+
return { labels: labelsToAdd };
178+
}
179+
180+
await github.rest.issues.addLabels({
181+
owner,
182+
repo,
183+
issue_number: prNumber,
184+
labels: labelsToAdd,
185+
});
186+
187+
console.log(`[sync-issue-labels] Added labels: ${labelsToAdd.join(", ")}`);
188+
189+
return { labels: labelsToAdd };
190+
}
191+
192+
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)