Skip to content

Commit ec939ea

Browse files
committed
feat: add manual PR labeling workflow with auto-categorization based on title, branch, and commits
1 parent fa7da73 commit ec939ea

1 file changed

Lines changed: 282 additions & 0 deletions

File tree

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
name: Manual PR Labeler
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
pr_number:
7+
description: 'Specific PR number to label (leave blank to scan all open PRs)'
8+
required: false
9+
type: string
10+
dry_run:
11+
description: 'Dry run (only print proposed labels without applying them)'
12+
required: false
13+
type: boolean
14+
default: false
15+
16+
permissions:
17+
pull-requests: write
18+
issues: write
19+
contents: read
20+
21+
jobs:
22+
label-pr:
23+
name: Categorize and Label PRs
24+
runs-on: ubuntu-latest
25+
26+
steps:
27+
- name: Process Labeling
28+
uses: actions/github-script@v7
29+
with:
30+
github-token: ${{ secrets.GITHUB_TOKEN }}
31+
script: |
32+
const owner = context.repo.owner;
33+
const repo = context.repo.repo;
34+
35+
const prNumberInput = '${{ github.event.inputs.pr_number }}'.trim();
36+
const dryRun = ${{ github.event.inputs.dry_run || 'false' }};
37+
38+
// Define the 10 label types, colors, and descriptions
39+
const labelSpecs = {
40+
'type:bug': { color: 'ef4444', description: 'Something isn\'t working as expected' },
41+
'type:feature': { color: '10b981', description: 'New features, additions, or enhancements' },
42+
'type:docs': { color: '3b82f6', description: 'Documentation changes, wikis, or README updates' },
43+
'type:testing': { color: '8b5cf6', description: 'Adding, updating, or fixing tests' },
44+
'type:security': { color: 'f59e0b', description: 'Security fixes, dependency updates, or hardening' },
45+
'type:performance': { color: '6366f1', description: 'Code changes that improve performance/speed' },
46+
'type:design': { color: 'ec4899', description: 'UI designs, styling, SVG icons, and themes' },
47+
'type:refactor': { color: '14b8a6', description: 'Code changes that neither fix a bug nor add a feature' },
48+
'type:devops': { color: '64748b', description: 'CI/CD pipelines, workflows, dev scripts, and config' },
49+
'type:accessibility': { color: 'f97316', description: 'Accessibility (a11y) improvements and screen reader fixes' }
50+
};
51+
52+
// 1. Pre-create or verify all label specifications exist in the repo
53+
console.log('🔄 Verifying label definitions exist in repository...');
54+
for (const [name, spec] of Object.entries(labelSpecs)) {
55+
try {
56+
await github.rest.issues.getLabel({ owner, repo, name });
57+
console.log(` ✅ Label "${name}" already exists.`);
58+
} catch (err) {
59+
if (err.status === 404) {
60+
if (dryRun) {
61+
console.log(` [DRY RUN] Would create label "${name}" with color #${spec.color}`);
62+
} else {
63+
console.log(` ➕ Creating label "${name}"...`);
64+
await github.rest.issues.createLabel({
65+
owner,
66+
repo,
67+
name,
68+
color: spec.color,
69+
description: spec.description
70+
});
71+
console.log(` ✨ Created label "${name}" successfully!`);
72+
}
73+
} else {
74+
console.error(` ❌ Error checking/creating label "${name}":`, err.message);
75+
}
76+
}
77+
}
78+
79+
// 2. Determine which PRs to process
80+
let prs = [];
81+
if (prNumberInput) {
82+
const num = parseInt(prNumberInput, 10);
83+
if (isNaN(num)) {
84+
core.setFailed(`Invalid PR number specified: "${prNumberInput}"`);
85+
return;
86+
}
87+
console.log(`🔍 Fetching details for specific PR #${num}...`);
88+
try {
89+
const { data } = await github.rest.pulls.get({ owner, repo, pull_number: num });
90+
prs.push(data);
91+
} catch (err) {
92+
core.setFailed(`Failed to fetch PR #${num}: ${err.message}`);
93+
return;
94+
}
95+
} else {
96+
console.log('🔍 Fetching all open PRs in repository...');
97+
try {
98+
const { data } = await github.rest.pulls.list({
99+
owner,
100+
repo,
101+
state: 'open',
102+
per_page: 100
103+
});
104+
prs = data;
105+
console.log(` Found ${prs.length} open PR(s) to analyze.`);
106+
} catch (err) {
107+
core.setFailed(`Failed to fetch open PRs: ${err.message}`);
108+
return;
109+
}
110+
}
111+
112+
if (prs.length === 0) {
113+
console.log('ℹ️ No PRs to process.');
114+
return;
115+
}
116+
117+
// Define mapping from prefixes to the PR label keys
118+
const prefixMapping = {
119+
'fix': 'type:bug',
120+
'bug': 'type:bug',
121+
'bugfix': 'type:bug',
122+
'hotfix': 'type:bug',
123+
'feat': 'type:feature',
124+
'feature': 'type:feature',
125+
'docs': 'type:docs',
126+
'doc': 'type:docs',
127+
'test': 'type:testing',
128+
'testing': 'type:testing',
129+
'spec': 'type:testing',
130+
'security': 'type:security',
131+
'sec': 'type:security',
132+
'perf': 'type:performance',
133+
'performance': 'type:performance',
134+
'style': 'type:design',
135+
'design': 'type:design',
136+
'theme': 'type:design',
137+
'css': 'type:design',
138+
'ui': 'type:design',
139+
'refactor': 'type:refactor',
140+
'cleanup': 'type:refactor',
141+
'ci': 'type:devops',
142+
'build': 'type:devops',
143+
'chore': 'type:devops',
144+
'devops': 'type:devops',
145+
'workflow': 'type:devops',
146+
'a11y': 'type:accessibility',
147+
'accessibility': 'type:accessibility'
148+
};
149+
150+
// Helper to get type from text using conventional commit/prefix matching
151+
function determineTypeFromText(text) {
152+
if (!text) return null;
153+
154+
// 1. Try matching conventional commit prefix like "feat(scope):" or "fix:"
155+
const conventionalRegex = /^\s*([a-zA-Z0-9_-]+)(?:\(.*\))?!?\s*:/i;
156+
const match = text.match(conventionalRegex);
157+
if (match) {
158+
const prefix = match[1].toLowerCase();
159+
if (prefixMapping[prefix]) {
160+
return prefixMapping[prefix];
161+
}
162+
}
163+
164+
// 2. Fallback to generic keyword matching at start of text or branch parts
165+
const cleanText = text.toLowerCase().trim();
166+
for (const [key, label] of Object.entries(prefixMapping)) {
167+
if (cleanText.startsWith(key + '/') || cleanText.startsWith(key + '-') || cleanText === key) {
168+
return label;
169+
}
170+
}
171+
172+
return null;
173+
}
174+
175+
// Loop through each PR and perform categorization
176+
for (const pr of prs) {
177+
const prNum = pr.number;
178+
const prTitle = pr.title;
179+
const branchName = pr.head.ref;
180+
const currentLabels = pr.labels.map(l => l.name);
181+
182+
console.log(`\n--------------------------------------------------`);
183+
console.log(`📂 Analyzing PR #${prNum}: "${prTitle}"`);
184+
console.log(` Branch: ${branchName}`);
185+
console.log(` Current Labels: ${currentLabels.join(', ') || 'None'}`);
186+
187+
let matchedLabel = null;
188+
let source = '';
189+
190+
// Step 1: Check title
191+
matchedLabel = determineTypeFromText(prTitle);
192+
if (matchedLabel) {
193+
source = 'PR Title (Conventional Commits)';
194+
}
195+
196+
// Step 2: Check branch name
197+
if (!matchedLabel) {
198+
matchedLabel = determineTypeFromText(branchName);
199+
if (matchedLabel) {
200+
source = `PR Branch Name ("${branchName}")`;
201+
}
202+
}
203+
204+
// Step 3: Check commits if still not determined
205+
if (!matchedLabel) {
206+
console.log(` 🔍 No direct match from Title or Branch. Fetching commits for PR #${prNum}...`);
207+
try {
208+
const { data: commits } = await github.rest.pulls.listCommits({
209+
owner,
210+
repo,
211+
pull_number: prNum,
212+
per_page: 50
213+
});
214+
215+
// Check commits from newest to oldest
216+
for (let i = commits.length - 1; i >= 0; i--) {
217+
const commitMsg = commits[i].commit.message;
218+
const commitLabel = determineTypeFromText(commitMsg);
219+
if (commitLabel) {
220+
matchedLabel = commitLabel;
221+
source = `Commit Message ("${commitMsg.split('\n')[0]}")`;
222+
break;
223+
}
224+
}
225+
} catch (err) {
226+
console.error(` ⚠️ Failed to fetch commits:`, err.message);
227+
}
228+
}
229+
230+
// Output finding
231+
if (matchedLabel) {
232+
console.log(` 🎯 Categorized as **${matchedLabel}** (Detected via: ${source})`);
233+
234+
// If it already has the exact label, skip
235+
if (currentLabels.includes(matchedLabel)) {
236+
console.log(` ⏭️ PR already has the correct label applied. Skipping.`);
237+
continue;
238+
}
239+
240+
// Check if it already has ANY type:* label
241+
const existingTypeLabels = currentLabels.filter(l => l.startsWith('type:'));
242+
if (existingTypeLabels.length > 0) {
243+
console.log(` ⚠️ PR already has other type label(s) applied: ${existingTypeLabels.join(', ')}`);
244+
// Remove other type:* labels
245+
for (const labelToRemove of existingTypeLabels) {
246+
if (dryRun) {
247+
console.log(` [DRY RUN] Would remove label "${labelToRemove}"`);
248+
} else {
249+
console.log(` ➖ Removing old label "${labelToRemove}"...`);
250+
try {
251+
await github.rest.issues.removeLabel({
252+
owner,
253+
repo,
254+
issue_number: prNum,
255+
name: labelToRemove
256+
});
257+
} catch (err) {
258+
console.error(` ⚠️ Failed to remove label:`, err.message);
259+
}
260+
}
261+
}
262+
}
263+
264+
// Apply the new label
265+
if (dryRun) {
266+
console.log(` [DRY RUN] Would add label "${matchedLabel}" to PR #${prNum}`);
267+
} else {
268+
console.log(` ➕ Adding label "${matchedLabel}" to PR #${prNum}...`);
269+
await github.rest.issues.addLabels({
270+
owner,
271+
repo,
272+
issue_number: prNum,
273+
labels: [matchedLabel]
274+
});
275+
console.log(` 🎉 Label "${matchedLabel}" successfully applied!`);
276+
}
277+
} else {
278+
console.log(` ❓ Could not automatically determine the type for PR #${prNum}.`);
279+
console.log(` 💡 Tip: Format the PR Title using Conventional Commits (e.g., "feat: login page") or name your branch appropriately (e.g., "bugfix/button-color").`);
280+
}
281+
}
282+
console.log(`\n🏁 Finished processing PR labeling.`);

0 commit comments

Comments
 (0)