Skip to content

Commit 1593e8e

Browse files
committed
feat: sync linked issue labels to pull requests
- Adds compute workflow to read labels from linked issues - Adds add workflow triggered by workflow_run to write labels - Uses actions-ecosystem-add-labels for writing - Implements single-responsibility functions for maintainability Signed-off-by: cheese-cakee <farzanaman99@gmail.com>
1 parent 61a6f38 commit 1593e8e

4 files changed

Lines changed: 317 additions & 0 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Checks if the given login belongs to a bot account (ends with [bot] or contains "dependabot").
2+
function isBotLogin(login = "") {
3+
return /\[bot\]$/i.test(login) || /dependabot/i.test(login);
4+
}
5+
6+
// Extracts issue numbers from PR body closing keywords (Fixes #123, Closes #456, Resolves #789, etc.).
7+
function extractLinkedIssueNumbers(prBody = "") {
8+
const closingReferenceRegex =
9+
/\b(?:fix(?:es|ed)?|close(?:s|d)?|resolve(?:s|d)?)\s*:?\s*((?:#\d+)(?:\s*(?:,|and)\s*#\d+)*)/gi;
10+
const numbers = new Set();
11+
let referenceMatch;
12+
13+
while ((referenceMatch = closingReferenceRegex.exec(prBody)) !== null) {
14+
const referencesText = referenceMatch[1] || "";
15+
const issueMatches = referencesText.matchAll(/#(\d+)/g);
16+
17+
for (const issueMatch of issueMatches) {
18+
numbers.add(Number(issueMatch[1]));
19+
}
20+
}
21+
22+
return [...numbers];
23+
}
24+
25+
// Normalizes label objects/strings to an array of trimmed label names.
26+
function normalizeLabelNames(labels = []) {
27+
const names = [];
28+
for (const label of labels) {
29+
if (typeof label === "string" && label.trim()) {
30+
names.push(label.trim());
31+
continue;
32+
}
33+
34+
if (label && typeof label.name === "string" && label.name.trim()) {
35+
names.push(label.name.trim());
36+
}
37+
}
38+
return names;
39+
}
40+
41+
// Fetches PR data from the payload or GitHub API if not present in payload.
42+
async function getPullRequestData({ github, context, prNumber }) {
43+
if (context.payload.pull_request) {
44+
return context.payload.pull_request;
45+
}
46+
47+
const response = await github.rest.pulls.get({
48+
owner: context.repo.owner,
49+
repo: context.repo.repo,
50+
pull_number: prNumber,
51+
});
52+
53+
return response.data;
54+
}
55+
56+
// Resolves PR number from environment or context, and determines if dry-run mode is enabled.
57+
function resolveExecutionContext(context) {
58+
const isDryRun = /^true$/i.test(process.env.DRY_RUN || "");
59+
const prNumber = Number(process.env.PR_NUMBER) || context.payload.pull_request?.number;
60+
return { prNumber, isDryRun, owner: context.repo.owner, repo: context.repo.repo };
61+
}
62+
63+
// Determines if the PR should be skipped (e.g., bot-authored PRs, no linked issues).
64+
function shouldSkipPR(prData, linkedIssueNumbers) {
65+
const prAuthor = prData?.user?.login || "";
66+
67+
if (isBotLogin(prAuthor)) {
68+
return { skip: true, reason: `Skipping bot-authored PR from ${prAuthor}.` };
69+
}
70+
71+
if (!linkedIssueNumbers.length) {
72+
return { skip: true, reason: "No linked issue references found in PR body." };
73+
}
74+
75+
return { skip: false };
76+
}
77+
78+
// Collects labels from all linked issues, handling 404s and PR references.
79+
async function collectLabelsFromLinkedIssues({ github, owner, repo, linkedIssueNumbers }) {
80+
const labelsFromIssues = new Set();
81+
82+
for (const issueNumber of linkedIssueNumbers) {
83+
try {
84+
const issueResponse = await github.rest.issues.get({
85+
owner,
86+
repo,
87+
issue_number: issueNumber,
88+
});
89+
90+
if (issueResponse?.data?.pull_request) {
91+
console.log(`[sync-issue-labels] Skipping #${issueNumber}: reference points to a pull request.`);
92+
continue;
93+
}
94+
95+
const issueLabelNames = normalizeLabelNames(issueResponse?.data?.labels || []);
96+
console.log(
97+
`[sync-issue-labels] Issue #${issueNumber} labels: ${
98+
issueLabelNames.length ? issueLabelNames.join(", ") : "(none)"
99+
}`
100+
);
101+
102+
for (const label of issueLabelNames) {
103+
labelsFromIssues.add(label);
104+
}
105+
} catch (error) {
106+
if (error?.status === 404) {
107+
console.log(`[sync-issue-labels] Linked issue #${issueNumber} not found. Skipping.`);
108+
continue;
109+
}
110+
111+
throw error;
112+
}
113+
}
114+
115+
return labelsFromIssues;
116+
}
117+
118+
// Computes which labels should be added to the PR (missing from PR but present in issues).
119+
// Returns both labelsToAdd and prLabelNames for logging purposes.
120+
function computeLabelsToAdd(prData, labelsFromIssues) {
121+
const prLabelNames = new Set(normalizeLabelNames(prData?.labels || []));
122+
const labelsToAdd = [...labelsFromIssues].filter((label) => !prLabelNames.has(label));
123+
return { labelsToAdd, prLabelNames };
124+
}
125+
126+
// Adds labels to the pull request via GitHub API.
127+
async function addLabelsToPullRequest({ github, owner, repo, prNumber, labelsToAdd }) {
128+
await github.rest.issues.addLabels({
129+
owner,
130+
repo,
131+
issue_number: prNumber,
132+
labels: labelsToAdd,
133+
});
134+
135+
console.log(`[sync-issue-labels] Added labels to PR #${prNumber}: ${labelsToAdd.join(", ")}`);
136+
}
137+
138+
// Main entry point: syncs labels from linked issues to the PR.
139+
module.exports = async ({ github, context, core }) => {
140+
const { prNumber, isDryRun, owner, repo } = resolveExecutionContext(context);
141+
142+
if (!prNumber) {
143+
throw new Error("PR number could not be determined.");
144+
}
145+
146+
console.log(
147+
`[sync-issue-labels] Processing PR #${prNumber} in ${owner}/${repo} (dry_run=${isDryRun}).`
148+
);
149+
150+
let prData;
151+
try {
152+
prData = await getPullRequestData({ github, context, prNumber });
153+
} catch (error) {
154+
throw new Error(`[sync-issue-labels] Failed to fetch PR #${prNumber}: ${error?.message || error}`);
155+
}
156+
157+
const linkedIssueNumbers = extractLinkedIssueNumbers(prData?.body || "");
158+
const skipResult = shouldSkipPR(prData, linkedIssueNumbers);
159+
160+
if (skipResult.skip) {
161+
console.log(`[sync-issue-labels] ${skipResult.reason}`);
162+
return { labels: [] };
163+
}
164+
165+
console.log(
166+
`[sync-issue-labels] Linked issues detected: ${linkedIssueNumbers.map((n) => `#${n}`).join(", ")}`
167+
);
168+
169+
const labelsFromIssues = await collectLabelsFromLinkedIssues({
170+
github,
171+
owner,
172+
repo,
173+
linkedIssueNumbers,
174+
});
175+
176+
if (!labelsFromIssues.size) {
177+
console.log("[sync-issue-labels] No labels found on linked issues. Nothing to sync.");
178+
return { labels: [] };
179+
}
180+
181+
const { labelsToAdd, prLabelNames } = computeLabelsToAdd(prData, labelsFromIssues);
182+
183+
console.log(
184+
`[sync-issue-labels] Existing PR labels: ${
185+
prLabelNames.size ? [...prLabelNames].join(", ") : "(none)"
186+
}`
187+
);
188+
console.log(
189+
`[sync-issue-labels] Labels to add: ${labelsToAdd.length ? labelsToAdd.join(", ") : "(none)"}`
190+
);
191+
192+
if (!labelsToAdd.length) {
193+
console.log("[sync-issue-labels] PR already contains all labels from linked issues.");
194+
return { labels: [] };
195+
}
196+
197+
if (isDryRun) {
198+
console.log(`[sync-issue-labels] DRY_RUN enabled; would add labels: ${labelsToAdd.join(", ")}`);
199+
return { labels: labelsToAdd };
200+
}
201+
202+
try {
203+
await addLabelsToPullRequest({ github, owner, repo, prNumber, labelsToAdd });
204+
} catch (error) {
205+
throw new Error(
206+
`[sync-issue-labels] Failed to add labels to PR #${prNumber}: ${error?.message || error}`
207+
);
208+
}
209+
210+
return { labels: labelsToAdd };
211+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
pull-requests: write
11+
issues: write
12+
13+
jobs:
14+
add-labels:
15+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Download labels artifact
19+
uses: actions/download-artifact@ea165f8d65b6e75b540449e92b4886f436390c0e # v4.6.2
20+
with:
21+
name: pr-labels-${{ github.event.workflow_run.pull_requests[0].number }}
22+
path: .
23+
24+
- name: Read labels
25+
id: read
26+
run: |
27+
labels=$(cat labels.json)
28+
echo "labels=$labels" >> $GITHUB_OUTPUT
29+
30+
- name: Add labels to PR
31+
uses: dblock/github-actions-ecosystem-add-labels@4e5ec2f3a319f5c22b8e6f45d6438fd1b3c9317a # v1.0.0
32+
with:
33+
labels: ${{ fromJson(steps.read.outputs.labels) }}
34+
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: ${{ github.event.inputs.dry_run || 'false' }}
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 result = await script({ github, context });
49+
const labels = result?.labels || [];
50+
const hasLabels = labels.length > 0;
51+
console.log('Computed labels:', JSON.stringify({ hasLabels, labels, pr_number: process.env.PR_NUMBER, dry_run: process.env.DRY_RUN }));
52+
return {
53+
has_labels: hasLabels,
54+
labels: labels,
55+
pr_number: process.env.PR_NUMBER,
56+
dry_run: process.env.DRY_RUN
57+
};
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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
1212
- Added CodeRabbit review instructions for the transaction module in `.coderabbit.yaml` (#1696)
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)
15+
- Added workflow and bot script to automatically sync labels from linked issues to pull requests. (#1716)
1516

1617
### Fixed
1718

0 commit comments

Comments
 (0)