Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE/generic.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ about: Submit changes to the project for review and inclusion

This PR fixes #

## PR Category

<!--- CI ENFORCED: You MUST check at least ONE category below or the CI will fail. -->
<!--- Check all categories that apply to this pull request. -->

- [ ] Bug Fix — Fixes a bug or incorrect behavior
- [ ] Feature — Adds new functionality
- [ ] Performance — Improves performance (load time, memory, rendering, etc.)
- [ ] Tests — Adds or updates test coverage
- [ ] Documentation — Updates to docs, comments, or README

## Changes Made

<!--- Provide a summary of the changes made in this pull request. -->
Expand Down
169 changes: 169 additions & 0 deletions .github/workflows/pr-category-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: PR Category Check

# Ensures every PR has at least one category checkbox checked.
# Automatically applies matching GitHub labels to the PR.
# Creates labels with custom colors if they don't exist yet.
# Uses pull_request_target so it works for fork PRs too (runs in base repo context).
# This is safe because we only read the PR body from the event payload — no fork code is checked out or executed.

on:
pull_request_target:
types: [opened, edited, synchronize, reopened]

permissions:
pull-requests: write
contents: read

jobs:
check-pr-category:
name: Validate PR Category & Auto-Label
runs-on: ubuntu-latest
steps:
- name: Check categories and apply labels
uses: actions/github-script@v7
with:
script: |
const prBody = context.payload.pull_request.body || '';
const prNumber = context.payload.pull_request.number;

// Category checkboxes mapped to their GitHub label names, colors, and descriptions
const categories = [
{
label: 'bug fix',
color: 'D73A4A',
description: 'Fixes a bug or incorrect behavior',
displayName: 'Bug Fix',
pattern: /-\s*\[x\]\s*Bug Fix/i,
},
{
label: 'feature',
color: '9333EA',
description: 'Adds new functionality',
displayName: 'Feature',
pattern: /-\s*\[x\]\s*Feature/i,
},
{
label: 'performance',
color: 'F97316',
description: 'Improves performance (load time, memory, rendering)',
displayName: 'Performance',
pattern: /-\s*\[x\]\s*Performance/i,
},
{
label: 'tests',
color: '3B82F6',
description: 'Adds or updates test coverage',
displayName: 'Tests',
pattern: /-\s*\[x\]\s*Tests/i,
},
{
label: 'documentation',
color: '10B981',
description: 'Updates to docs, comments, or README',
displayName: 'Documentation',
pattern: /-\s*\[x\]\s*Documentation/i,
},
];

const checkedCategories = categories.filter(cat => cat.pattern.test(prBody));
const uncheckedCategories = categories.filter(cat => !cat.pattern.test(prBody));

// --- Step 1: Fail CI if no category is selected ---
if (checkedCategories.length === 0) {
const message = [
'## PR Category Required',
'',
'This pull request does not have any **PR Category** selected.',
'Please edit your PR description and check **at least one** category checkbox:',
'',
'| Category | Description |',
'|----------|-------------|',
'| Bug Fix | Fixes a bug or incorrect behavior |',
'| Feature | Adds new functionality |',
'| Performance | Improves performance |',
'| Tests | Adds or updates test coverage |',
'| Documentation | Updates to docs, comments, or README |',
'',
'Example: Change `- [ ] Bug Fix` to `- [x] Bug Fix`',
'',
'> **Tip:** You can select multiple categories if your PR spans several areas.',
].join('\n');

core.setFailed(message);
return;
}

// --- Step 2: Ensure labels exist with proper colors ---
const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const existingLabelNames = existingLabels.map(l => l.name);

for (const cat of categories) {
if (!existingLabelNames.includes(cat.label)) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: cat.label,
color: cat.color,
description: cat.description,
});
core.info(`Created label: "${cat.label}" with color #${cat.color}`);
} catch (error) {
core.warning(`Could not create label "${cat.label}". Error: ${error.message}`);
}
}
}

// --- Step 3: Auto-apply labels for checked categories ---
const labelsToAdd = checkedCategories.map(cat => cat.label);
const labelsToRemove = uncheckedCategories.map(cat => cat.label);

// Get current labels on the PR
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const currentLabelNames = currentLabels.map(l => l.name);

// Add labels that are checked but not yet on the PR
for (const label of labelsToAdd) {
if (!currentLabelNames.includes(label)) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: [label],
});
core.info(`Added label: "${label}"`);
} catch (error) {
core.warning(`Could not add label "${label}". Error: ${error.message}`);
}
}
}

// Remove labels that are unchecked but still on the PR
for (const label of labelsToRemove) {
if (currentLabelNames.includes(label)) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: label,
});
core.info(`Removed label: "${label}"`);
} catch (error) {
core.warning(`Could not remove label "${label}". Error: ${error.message}`);
}
}
}

const selected = checkedCategories.map(c => c.displayName).join(', ');
core.info(`PR categories selected: ${selected}`);
core.info(`Labels synced: ${labelsToAdd.join(', ')}`);
Loading