Skip to content

Commit 9c9732c

Browse files
authored
ci: add PR category enforcement with auto-labeling (sugarlabs#6062)
Add a CI workflow that enforces PR categorization and auto-applies labels. - Add PR Category section to PR template with 5 categories - Add GitHub Actions workflow that validates at least one category is checked - Auto-creates labels with custom colors if they don't exist - Auto-applies/removes labels based on checked/unchecked categories - Uses pull_request_target for fork PR compatibility
1 parent 0967857 commit 9c9732c

File tree

2 files changed

+180
-0
lines changed

2 files changed

+180
-0
lines changed

.github/PULL_REQUEST_TEMPLATE/generic.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ about: Submit changes to the project for review and inclusion
1515

1616
This PR fixes #
1717

18+
## PR Category
19+
20+
<!--- CI ENFORCED: You MUST check at least ONE category below or the CI will fail. -->
21+
<!--- Check all categories that apply to this pull request. -->
22+
23+
- [ ] Bug Fix — Fixes a bug or incorrect behavior
24+
- [ ] Feature — Adds new functionality
25+
- [ ] Performance — Improves performance (load time, memory, rendering, etc.)
26+
- [ ] Tests — Adds or updates test coverage
27+
- [ ] Documentation — Updates to docs, comments, or README
28+
1829
## Changes Made
1930

2031
<!--- Provide a summary of the changes made in this pull request. -->
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
name: PR Category Check
2+
3+
# Ensures every PR has at least one category checkbox checked.
4+
# Automatically applies matching GitHub labels to the PR.
5+
# Creates labels with custom colors if they don't exist yet.
6+
# Uses pull_request_target so it works for fork PRs too (runs in base repo context).
7+
# This is safe because we only read the PR body from the event payload — no fork code is checked out or executed.
8+
9+
on:
10+
pull_request_target:
11+
types: [opened, edited, synchronize, reopened]
12+
13+
permissions:
14+
pull-requests: write
15+
contents: read
16+
17+
jobs:
18+
check-pr-category:
19+
name: Validate PR Category & Auto-Label
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Check categories and apply labels
23+
uses: actions/github-script@v7
24+
with:
25+
script: |
26+
const prBody = context.payload.pull_request.body || '';
27+
const prNumber = context.payload.pull_request.number;
28+
29+
// Category checkboxes mapped to their GitHub label names, colors, and descriptions
30+
const categories = [
31+
{
32+
label: 'bug fix',
33+
color: 'D73A4A',
34+
description: 'Fixes a bug or incorrect behavior',
35+
displayName: 'Bug Fix',
36+
pattern: /-\s*\[x\]\s*Bug Fix/i,
37+
},
38+
{
39+
label: 'feature',
40+
color: '9333EA',
41+
description: 'Adds new functionality',
42+
displayName: 'Feature',
43+
pattern: /-\s*\[x\]\s*Feature/i,
44+
},
45+
{
46+
label: 'performance',
47+
color: 'F97316',
48+
description: 'Improves performance (load time, memory, rendering)',
49+
displayName: 'Performance',
50+
pattern: /-\s*\[x\]\s*Performance/i,
51+
},
52+
{
53+
label: 'tests',
54+
color: '3B82F6',
55+
description: 'Adds or updates test coverage',
56+
displayName: 'Tests',
57+
pattern: /-\s*\[x\]\s*Tests/i,
58+
},
59+
{
60+
label: 'documentation',
61+
color: '10B981',
62+
description: 'Updates to docs, comments, or README',
63+
displayName: 'Documentation',
64+
pattern: /-\s*\[x\]\s*Documentation/i,
65+
},
66+
];
67+
68+
const checkedCategories = categories.filter(cat => cat.pattern.test(prBody));
69+
const uncheckedCategories = categories.filter(cat => !cat.pattern.test(prBody));
70+
71+
// --- Step 1: Fail CI if no category is selected ---
72+
if (checkedCategories.length === 0) {
73+
const message = [
74+
'## PR Category Required',
75+
'',
76+
'This pull request does not have any **PR Category** selected.',
77+
'Please edit your PR description and check **at least one** category checkbox:',
78+
'',
79+
'| Category | Description |',
80+
'|----------|-------------|',
81+
'| Bug Fix | Fixes a bug or incorrect behavior |',
82+
'| Feature | Adds new functionality |',
83+
'| Performance | Improves performance |',
84+
'| Tests | Adds or updates test coverage |',
85+
'| Documentation | Updates to docs, comments, or README |',
86+
'',
87+
'Example: Change `- [ ] Bug Fix` to `- [x] Bug Fix`',
88+
'',
89+
'> **Tip:** You can select multiple categories if your PR spans several areas.',
90+
].join('\n');
91+
92+
core.setFailed(message);
93+
return;
94+
}
95+
96+
// --- Step 2: Ensure labels exist with proper colors ---
97+
const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({
98+
owner: context.repo.owner,
99+
repo: context.repo.repo,
100+
per_page: 100,
101+
});
102+
const existingLabelNames = existingLabels.map(l => l.name);
103+
104+
for (const cat of categories) {
105+
if (!existingLabelNames.includes(cat.label)) {
106+
try {
107+
await github.rest.issues.createLabel({
108+
owner: context.repo.owner,
109+
repo: context.repo.repo,
110+
name: cat.label,
111+
color: cat.color,
112+
description: cat.description,
113+
});
114+
core.info(`Created label: "${cat.label}" with color #${cat.color}`);
115+
} catch (error) {
116+
core.warning(`Could not create label "${cat.label}". Error: ${error.message}`);
117+
}
118+
}
119+
}
120+
121+
// --- Step 3: Auto-apply labels for checked categories ---
122+
const labelsToAdd = checkedCategories.map(cat => cat.label);
123+
const labelsToRemove = uncheckedCategories.map(cat => cat.label);
124+
125+
// Get current labels on the PR
126+
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
127+
owner: context.repo.owner,
128+
repo: context.repo.repo,
129+
issue_number: prNumber,
130+
});
131+
const currentLabelNames = currentLabels.map(l => l.name);
132+
133+
// Add labels that are checked but not yet on the PR
134+
for (const label of labelsToAdd) {
135+
if (!currentLabelNames.includes(label)) {
136+
try {
137+
await github.rest.issues.addLabels({
138+
owner: context.repo.owner,
139+
repo: context.repo.repo,
140+
issue_number: prNumber,
141+
labels: [label],
142+
});
143+
core.info(`Added label: "${label}"`);
144+
} catch (error) {
145+
core.warning(`Could not add label "${label}". Error: ${error.message}`);
146+
}
147+
}
148+
}
149+
150+
// Remove labels that are unchecked but still on the PR
151+
for (const label of labelsToRemove) {
152+
if (currentLabelNames.includes(label)) {
153+
try {
154+
await github.rest.issues.removeLabel({
155+
owner: context.repo.owner,
156+
repo: context.repo.repo,
157+
issue_number: prNumber,
158+
name: label,
159+
});
160+
core.info(`Removed label: "${label}"`);
161+
} catch (error) {
162+
core.warning(`Could not remove label "${label}". Error: ${error.message}`);
163+
}
164+
}
165+
}
166+
167+
const selected = checkedCategories.map(c => c.displayName).join(', ');
168+
core.info(`PR categories selected: ${selected}`);
169+
core.info(`Labels synced: ${labelsToAdd.join(', ')}`);

0 commit comments

Comments
 (0)