-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add reusable PR risk classifier #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| #!/usr/bin/env node | ||
| // Risk classifier for PR auto-merge gating. | ||
| // | ||
| // Reads .github/risk-paths.yml from cwd (the caller repo's checkout), reads | ||
| // the list of changed files from stdin (one path per line), and prints the | ||
| // highest-priority risk class to stdout. | ||
| // | ||
| // Priority (high → low): blocked > sensitive > standard > safe_test > safe_deps > safe_config > trivial | ||
| // `standard` is the implicit fallback for any file that doesn't match a | ||
| // known class — this is intentional: unknown paths default to the most | ||
| // strict non-blocking class so the auto-merge gate stays safe. | ||
| // | ||
| // CLI: | ||
| // echo "<file paths, one per line>" | node classify.mjs | ||
| // | ||
| // Exit codes: | ||
| // 0 — printed a class name on stdout | ||
| // 1 — fatal error (missing rules file, parse error, etc.) | ||
| // | ||
| // Used by topcoder1/ci-workflows/.github/workflows/pr-classify.yml. | ||
|
|
||
| import { readFileSync } from 'node:fs'; | ||
| import { parse } from 'yaml'; | ||
| import { minimatch } from 'minimatch'; | ||
|
|
||
| const RULES_PATH = '.github/risk-paths.yml'; | ||
| const PRIORITY = [ | ||
| 'blocked', | ||
| 'sensitive', | ||
| 'standard', | ||
| 'safe_test', | ||
| 'safe_deps', | ||
| 'safe_config', | ||
| 'trivial' | ||
| ]; | ||
| // Classes we test patterns against. `standard` is not in this list — it's | ||
| // the fallback for any file that matches NO pattern in any class. | ||
| const PATTERN_CLASSES = ['blocked', 'sensitive', 'safe_test', 'safe_deps', 'safe_config', 'trivial']; | ||
|
|
||
| function fail(msg) { | ||
| process.stderr.write(`classify.mjs: ${msg}\n`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| let rules; | ||
| try { | ||
| rules = parse(readFileSync(RULES_PATH, 'utf8')); | ||
| } catch (e) { | ||
| fail(`failed to read ${RULES_PATH}: ${e.message}`); | ||
| } | ||
|
|
||
| const changedFiles = readFileSync(0, 'utf8') | ||
| .split('\n') | ||
| .map((s) => s.trim()) | ||
| .filter(Boolean); | ||
|
|
||
| if (changedFiles.length === 0) { | ||
| // No changed files = nothing to classify. Default to standard so the | ||
| // caller doesn't choke on an empty PR (shouldn't happen on real PRs). | ||
| process.stdout.write('standard\n'); | ||
| process.exit(0); | ||
| } | ||
|
|
||
| function classify(file) { | ||
| for (const cls of PATTERN_CLASSES) { | ||
| const patterns = rules[cls] || []; | ||
| for (const p of patterns) { | ||
| if (minimatch(file, p, { dot: true, matchBase: false })) return cls; | ||
| } | ||
| } | ||
| return 'standard'; | ||
| } | ||
|
|
||
| const classes = new Set(changedFiles.map(classify)); | ||
| const winner = PRIORITY.find((c) => classes.has(c)) || 'standard'; | ||
| process.stdout.write(winner + '\n'); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| name: PR Risk Classifier (reusable) | ||
|
|
||
| # Reads .github/risk-paths.yml from the caller repo's checkout, classifies | ||
| # the PR's changed files, and exposes the highest-priority class as both a | ||
| # PR label and a job output for downstream workflows to gate on. | ||
| # | ||
| # Priority (high → low): | ||
| # blocked > sensitive > standard > safe_test > safe_deps > safe_config > trivial | ||
| # | ||
| # Caller wires this in via `uses:` and consumes `outputs.risk_class`. | ||
| # See impl plan §4 for the design rationale. | ||
|
|
||
| on: | ||
| workflow_call: | ||
| outputs: | ||
| risk_class: | ||
| description: "Highest risk class for this PR (blocked|sensitive|standard|safe_test|safe_deps|safe_config|trivial)" | ||
| value: ${{ jobs.classify.outputs.class }} | ||
|
|
||
| jobs: | ||
| classify: | ||
| name: Classify PR Risk | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| pull-requests: write | ||
| outputs: | ||
| class: ${{ steps.compute.outputs.class }} | ||
| steps: | ||
| - name: Checkout caller repo | ||
| uses: actions/checkout@v5 | ||
|
|
||
| - name: Verify risk-paths.yml exists | ||
| run: | | ||
| if [ ! -f .github/risk-paths.yml ]; then | ||
| echo "::error::.github/risk-paths.yml is missing — every repo using pr-classify.yml MUST have one. See: https://github.com/topcoder1/ci-workflows/blob/main/README.md#pr-classifier" | ||
| exit 1 | ||
| fi | ||
|
|
||
| - name: Setup Node | ||
| uses: actions/setup-node@v5 | ||
| with: | ||
| node-version: '22' | ||
|
|
||
| - name: Fetch classifier script from this reusable's repo | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| run: | | ||
| # The caller checked out THEIR repo. We need our classify.mjs script, | ||
| # which lives in topcoder1/ci-workflows. Pull it via the GitHub API | ||
| # so we don't pollute the caller's working tree. | ||
| mkdir -p .github/scripts | ||
| gh api repos/topcoder1/ci-workflows/contents/.github/scripts/classify.mjs \ | ||
| --jq '.content' | base64 -d > .github/scripts/classify.mjs | ||
|
|
||
| - name: Install classifier deps | ||
| run: npm install --no-save yaml@2 minimatch@10 | ||
|
|
||
| - name: Compute highest-priority class | ||
| id: compute | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| PR: ${{ github.event.pull_request.number }} | ||
| run: | | ||
| set -euo pipefail | ||
| changed=$(gh pr diff "$PR" --name-only) | ||
| class=$(echo "$changed" | node .github/scripts/classify.mjs) | ||
| echo "class=$class" >> "$GITHUB_OUTPUT" | ||
| echo "Computed risk class: $class" | ||
| echo "Changed files:" | ||
| echo "$changed" | sed 's/^/ /' | ||
|
|
||
| - name: Set risk label on PR | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| PR: ${{ github.event.pull_request.number }} | ||
| CLASS: ${{ steps.compute.outputs.class }} | ||
| run: | | ||
| # Remove any existing risk:* labels, add the current one. | ||
| existing=$(gh pr view "$PR" --json labels --jq '.labels[].name' | grep '^risk:' || true) | ||
| for L in $existing; do | ||
| if [ "$L" != "risk:$CLASS" ]; then | ||
| gh pr edit "$PR" --remove-label "$L" 2>/dev/null || true | ||
| fi | ||
| done | ||
| # Create the label if it doesn't exist (idempotent — gh ignores conflict). | ||
| gh label create "risk:$CLASS" --color "ededed" --description "Risk class: $CLASS" 2>/dev/null || true | ||
| gh pr edit "$PR" --add-label "risk:$CLASS" | ||
|
|
||
| - name: Block merge for `blocked` class | ||
| if: steps.compute.outputs.class == 'blocked' | ||
| run: | | ||
| echo "::error::This PR touches blocked paths (Dockerfile, compose, workflows, secrets, classifier itself). Auto-merge is refused; manual merge by a maintainer is required." | ||
| exit 1 |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug:
rulesis not guarded againstnullyaml.parse()returnsnullfor an empty file or one that contains only comments. Thetry/catchabove catches I/O and parse errors, but a valid-but-empty YAML file silently producesnull. The first call torules[cls]inclassify()(line 66) then throws an uncaughtTypeError: Cannot read properties of null (reading 'blocked'), which exits with code 1 and a stack trace instead of the cleanfail()message.The "Verify risk-paths.yml exists" workflow step only checks for file presence, so this path is reachable.