Skip to content

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:

- run: pip install lizard
If 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):

  1. <workspace>/lcov.info
  2. <workspace>/coverage.xml

If neither file exists, the signal defaults to 50 (neutral) and logs a notice.

Generating a coverage report

pytest --cov=src --cov-report=xml   # → coverage.xml
pytest --cov=src --cov-report=lcov  # → lcov.info
jest --coverage --coverageReporters=lcov  # → coverage/lcov.info
go test ./... -coverprofile=lcov.info

Migration files

Default weight: 12 | Source: filesystem glob

Scans the workspace for database migration files matching:

**/migrations/**/*.{sql,py,rb,ts}

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:

# TypeScript
- run: npm install -g ts-prune

# Python
- run: pip install vulture

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:

- uses: gitleaks/gitleaks-action@v2
If 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.

Setup

Add a budget to package.json:

"size-limit": [
  { "path": "dist/index.js", "limit": "50 KB" }
]


API breaking changes

Default weight: 4 | Source: openapi-diff / TypeScript compiler

Detects backward-incompatible changes in two ways:

  1. OpenAPI spec diff — if an openapi.yaml, openapi.yml, or openapi.json file exists (including under docs/), runs openapi-diff to compare the base branch version against the PR head.
  2. TypeScript declaration errors — if changed .d.ts files are present and a tsconfig.json exists, runs tsc --noEmit on 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.

Tool availability

Install openapi-diff if you use OpenAPI specs:

- run: npm install -g openapi-diff


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:

total = round( Σ(signal_score × weight) / Σ(weight) )

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.