Signals
The total risk score is the weighted sum of nine independent signals. Each signal returns a value between 0 (no risk) and 100 (maximum risk), multiplied by its weight (an integer out of 100).
If a signal's tool is unavailable (e.g., lizard is not installed), the signal returns 0 and logs a warning — the remaining signals still contribute normally.
Files changed
Default weight: 8 | Source: GitHub REST API
Counts the number of files touched by the PR (additions + deletions per file) and the total lines changed.
| File count | Score |
|---|---|
| 1 – 10 | 0 – 30 (linear) |
| 11 – 50 | 30 – 70 (linear) |
| 51+ | 70 – 100 (capped at 100) |
The score reflects how many distinct files the reviewer must context-switch across, regardless of line count. A 1,000-line change in a single file scores lower than a 10-line change spread across 60 files.
Cyclomatic complexity delta
Default weight: 17 | Source: lizard
Runs lizard --csv over changed source files (.py, .js, .ts, .java, .go, and others) and computes the average cyclomatic complexity number (CCN) across all analysed functions.
| Average CCN | Score |
|---|---|
| ≤ 5 | 0 – 30 (linear) |
| 6 – 10 | 30 – 70 (linear) |
| > 10 | 70 – 100 (capped at 100) |
Tool availability
lizard must be installed in the runner before this step runs:
lizard is not found, this signal scores 0.
Test coverage gap
Default weight: 15 | Source: lcov.info or coverage.xml
Reads the coverage report generated by your test suite and computes 1 − line_coverage_rate. High coverage means low risk.
| Line coverage | Score |
|---|---|
| 100% | 0 |
| 80% | 20 |
| 60% | 40 |
| 40% | 60 |
| 20% | 80 |
| 0% | 100 |
The action looks for reports at these locations (in order):
<workspace>/lcov.info<workspace>/coverage.xml
If neither file exists, the signal defaults to 50 (neutral) and logs a notice.
Generating a coverage report
Migration files
Default weight: 12 | Source: filesystem glob
Scans the workspace for database migration files matching:
Node modules, .git, and dist/ are excluded.
| Migration files found | Score |
|---|---|
| 0 | 0 |
| 1 | 50 |
| 2 | 65 |
| 3 | 80 |
| 4+ | 85 – 100 (capped at 100) |
Any migration file signals schema changes, which carry deployment and rollback risk regardless of the lines changed.
Dead code
Default weight: 8 | Source: ts-prune or vulture
Counts unused exports (TypeScript) or dead symbols (Python) in the workspace. The action tries ts-prune first, then falls back to vulture.
| Unused symbols | Score |
|---|---|
| 0 | 0 |
| 1 – 5 | 10 – 50 (×10 per symbol) |
| 6 – 20 | 50 – 80 (+2 per symbol) |
| 21+ | 80 – 100 (capped at 100) |
Tool availability
Install the relevant tool before this step:
If neither tool is found, this signal scores 0.
Secret leak
Default weight: 12 | Source: gitleaks
Scans the PR diff for hardcoded secrets, API keys, tokens, and other credentials using gitleaks. When a secret is detected the signal immediately returns the maximum per-signal score and the action sets an override band of CRITICAL — regardless of the weighted total.
| Secrets found | Score |
|---|---|
| 0 | 0 |
| ≥ 1 | 15 (full weight) + CRITICAL override |
CRITICAL override
A detected secret sets the overall PR band to CRITICAL and blocks merge regardless of the block_merge threshold. The Slack/Jira/Linear notifications are also triggered at any minScore.
Tool availability
gitleaks must be available on the runner PATH:
gitleaks is not found, this signal scores 0 and logs a warning.
Bundle size delta
Default weight: 4 | Source: size-limit
Runs npx size-limit --json and checks whether any configured size budgets are exceeded. Only active when a size-limit key is present in package.json.
| Budget violations | Score |
|---|---|
| 0 | 0 |
| 1 | 2 |
| 2 | 4 |
| 3+ | 5 (capped) |
If no size-limit configuration is found, the signal returns 0 and is skipped silently.
API breaking changes
Default weight: 4 | Source: openapi-diff / TypeScript compiler
Detects backward-incompatible changes in two ways:
- OpenAPI spec diff — if an
openapi.yaml,openapi.yml, oropenapi.jsonfile exists (including underdocs/), runsopenapi-diffto compare the base branch version against the PR head. - TypeScript declaration errors — if changed
.d.tsfiles are present and atsconfig.jsonexists, runstsc --noEmiton those files and counts type errors.
| Breaking changes | Score |
|---|---|
| 0 | 0 |
| 1 | 3 |
| 2 | 5 (capped) |
If neither an OpenAPI spec nor .d.ts files are found, the signal returns 0 and is skipped silently.
SonarQube quality gate
Default weight: 15 | Source: SonarQube REST API (/api/qualitygates/project_status)
Polls the SonarQube quality gate result for the PR branch. If the sonarqube block is absent from .github/pr-risk-scorer.yml, this signal returns 0 silently and its weight still counts toward the total.
| Gate status | Score |
|---|---|
OK |
0 |
WARN |
8 |
ERROR |
15 |
| Unavailable / timed out | 0 |
Failed conditions (e.g., new_coverage < 80%, code_smells > 10) are listed in the PR comment under the SonarQube row.
Setup
Add a sonarqube block to .github/pr-risk-scorer.yml:
sonarqube:
enabled: true
host_url: https://sonarqube.yourorg.com
token_secret: SONAR_TOKEN # name of a GitHub Actions secret
project_key: your-project-key
wait_for_analysis: true # poll until SonarQube finishes the PR scan
timeout_seconds: 120
Store your SonarQube token as a GitHub Actions secret and pass it to the action:
- uses: fasterapiweb/pr-risk-scorer@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
wait_for_analysis
When true (the default), the signal polls every 5 seconds until the quality gate status is no longer NONE (analysis still running). If the timeout_seconds limit is reached before analysis completes, the signal returns 0 and notes the timeout in the PR comment.
Skipping the signal
Set enabled: false or omit the sonarqube block entirely to exclude this signal from scoring. Its weight (15 by default) is still counted in the denominator; to redistribute it, override all ten weights so they sum to 100.
Score aggregation
The final score is a normalized weighted sum:
Weights are integers (default sum to 100). They are normalized at runtime, so custom configs that don't sum to exactly 100 still produce a valid 0–100 result — but the config validator enforces that they do sum to 100.
The result is clamped to [0, 100]. A CRITICAL override from the secret leak signal bypasses this calculation and immediately sets the band to CRITICAL.