From 5e8709d86fb975e1891513616df9918ef684a2c4 Mon Sep 17 00:00:00 2001 From: Ashutosh Singh Date: Wed, 4 Mar 2026 10:44:06 +0530 Subject: [PATCH] ci: add PR category enforcement with auto-labeling 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 --- .github/PULL_REQUEST_TEMPLATE/generic.md | 11 ++ .github/workflows/pr-category-check.yml | 169 +++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 .github/workflows/pr-category-check.yml diff --git a/.github/PULL_REQUEST_TEMPLATE/generic.md b/.github/PULL_REQUEST_TEMPLATE/generic.md index 32c54642cf..01b6ed836b 100644 --- a/.github/PULL_REQUEST_TEMPLATE/generic.md +++ b/.github/PULL_REQUEST_TEMPLATE/generic.md @@ -15,6 +15,17 @@ about: Submit changes to the project for review and inclusion This PR fixes # +## PR Category + + + + +- [ ] 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 diff --git a/.github/workflows/pr-category-check.yml b/.github/workflows/pr-category-check.yml new file mode 100644 index 0000000000..a6ee8dac44 --- /dev/null +++ b/.github/workflows/pr-category-check.yml @@ -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(', ')}`);