A GitHub App that reviews pull requests automatically — cuts the noise so maintainers only look at PRs worth looking at.
On every PR open / push / reopen (drafts skipped), it runs 18 deterministic checks, optionally runs a tool-using LLM review agent, and posts a single Markdown comment (edited in place on later events, never spammed) — led by a one-line verdict and tagged with specific facet labels for queue triage.
A pull_request webhook (opened / synchronize / reopened, non-draft)
kicks off a background task that:
- Loads the repo's
.github/prwarden.ymlconfig (falls back to defaults). - Fetches the PR's files, commits, repo tree, and CODEOWNERS, then runs
gitleaksover the diff additions. - Runs the deterministic checks.
- Optionally runs the LLM agent (allowlisted repos only, under a daily cost ceiling).
- Creates or edits one PR comment, swaps the label, and records the run in the
database for
/statsand cost accounting.
The agent is purely additive — if it's disabled, over budget, times out, or crashes, the deterministic checks still post.
The comment opens with a single verdict headline that reads both layers and leads with the most serious signal, so the bot's most valuable finding is the first thing a maintainer sees — even when every deterministic check passes:
| Verdict | When |
|---|---|
| 🔴 High concern | a HIGH check failed, or the agent found the diff doesn't match the stated intent |
| 🟠 Worth a look | a high-priority (risk × centrality) attention spot, or a MEDIUM+ check failed |
| the agent didn't finish, or is low-confidence (never softens a 🔴/🟠) | |
| 🟡 Minor flags | advisory-only nits, nothing blocking |
| 🟢 Looks low-risk / No automated flags | agent ran and agrees, or checks-only |
It's a triage read, never a ruling — it never tells the maintainer to merge.
PRwarden does not apply a generic clean / needs-attention status label.
The verdict carries the overall read; the PR list carries only specific facet
labels a maintainer routes on (older status labels are stripped automatically):
| Label | Applied when |
|---|---|
prwarden:blocker |
a HIGH-severity check failed (secret_leak, critical_path) |
prwarden:security |
a security-kind check failed |
prwarden:ai-authored |
ai_branch or ai_commit_footer tripped |
prwarden:intent-mismatch |
intent_scope flagged a linked issue the diff misses, or the agent found diff ≠ stated intent |
Facets are additive and independent — a PR off an AI-named branch with no real
flags gets ai-authored and nothing else.
PR metadata (checks/no_diff.py)
title_quality— title is descriptive, not a placeholderdescription— description meets a minimum length (min_chars, default 20)pr_size— under file/line limits (file_limit25,line_limit500)branch_naming— branch uses an allowed prefix (feat/,fix/, …)self_merge— author isn't the sole reviewer/approverai_branch— flags AI-tool-named branches (claude-,codex-,cursor-,copilot-,devin-)ai_commit_footer— flagsCo-Authored-By: Claude(and similar) footersboilerplate_description— flags untouched PR-template boilerplatedescription_vs_diff— flags a thin description on a large diff
Diff smell (checks/diff_aware.py)
trivial_metadata_diff— flags trivial metadata-only churnno_tests— source changed without any test file (supportsexempt_paths)test_assertions_weakened— flags weakened/removed test assertionsempty_function_bodies— flagspass/return None/// TODOstubsdependency_only_churn— flags lock-file-only diffs
Impact & security (checks/impact.py)
shared_code— touches conventionally-shared dirs (utils,lib,core, …)critical_path— touches repo-declared high-risk globs (auth/**,payments/**,migrations/**, …); off until globs are configuredcodeowners— changed files have a required CODEOWNERS reviewersecret_leak—gitleaksscan of the diff additions surfaced a secret
Intent & scope (checks/intent.py)
intent_scope— the always-on, no-LLM counterpart to the agent'sintent_matches_diff. When the PR links an issue via a closing keyword (Closes #123) and that issue names a real, distinctive module of the repo the diff never touches — "issue says auth, diff touches billing" — it flags scope drift and applies theintent-mismatchfacet. Setrequire_linked_issue: trueto also flag PRs that link no issue at all.
Drop a .github/prwarden.yml to tune thresholds or disable individual checks.
No config file (or invalid YAML) = sensible defaults; unknown keys are ignored.
# Example .github/prwarden.yml
checks:
pr_size:
file_limit: 100
branch_naming:
enabled: false
no_tests:
exempt_paths: ["docs/**", "scripts/one_off/**"]
critical_path:
globs: ["auth/**", "payments/**", "migrations/**", ".github/workflows/**"]
intent_scope:
require_linked_issue: true # also flag PRs that link no issue
ignore_modules: ["src", "lib"] # dir names too generic to be a signalA tool-using agent (Claude Sonnet by default) that produces a structured
assessment of the PR. It is off by default and gated three ways: the repo
must be in the AGENT_REVIEW_REPOS allowlist, an Anthropic API key must be set,
and the day's recorded spend must be under DAILY_COST_LIMIT_USD. A wall-clock
timeout (default 90s) guards the webhook handler.
The loop (agent/loop.py) owns iteration and budget — the model only decides
"what next." It bounds runs to 12 tool calls / 15 iterations / 80k tokens, runs
the model's tool calls in parallel, terminates via an explicit structured done
tool, and force-finalizes on budget exhaustion so a run always returns something.
Tool errors come back as tool_results rather than crashing the loop.
Tools available to the agent: get_pr_diff, get_file, get_issue,
find_references, search_code, git_blame, get_repo_conventions,
get_author_history, check_security_patterns (Semgrep), and done.
The done assessment (rendered as an "Agent Review" section in the PR comment)
carries: a summary, files touched, whether the diff matches the stated intent
(with a mismatch reason), a risk-ranked attention map (the 2–3 spots to look
first, each with a one-line "why"), open questions, and a confidence score.
POST /webhook— GitHub webhook receiver (HMAC signature verified)GET /stats— totals, failures-by-check, and recent runs. Closed by default: returns403unlessSTATS_BEARER_TOKENis set (then requiresAuthorization: Bearer …), orSTATS_PUBLIC=trueexposes it without auth
Requires Python 3.12+ and a Postgres database. gitleaks and semgrep should
be on PATH for the secret-leak check and the agent's security tool; both
degrade gracefully if missing.
python3.12 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env # fill in GitHub App creds (and optional agent creds)
alembic upgrade head
uvicorn pr_warden.main:app --reloadOr via Docker: docker compose up.
Tests: pytest -q.
.github/workflows/ci.yml runs the suite on every PR and push. On PRs it also
runs diff-cover — not total
coverage, just "are the lines this PR changed exercised by any test?" — and
fails under 80%. That's the one signal most correlated with "will this break in
prod," and it's the deterministic answer to the coverage question the bot's
no_tests check only approximates. Reproduce locally:
pytest --cov=src/pr_warden --cov-report=xml
diff-cover coverage.xml --compare-branch=origin/main --fail-under=80| Variable | Purpose | Default |
|---|---|---|
GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY_PATH / GITHUB_WEBHOOK_SECRET |
GitHub App auth | — |
DATABASE_URL |
Postgres (async) DSN | local dev DSN |
ANTHROPIC_API_KEY |
Enables the review agent | unset |
AGENT_REVIEW_REPOS |
Comma-separated owner/name allowlist for the agent |
empty (agent off) |
AGENT_MODEL / AGENT_TIMEOUT_S |
Agent model and wall-clock cap | claude-sonnet-4-6 / 90s |
DAILY_COST_LIMIT_USD |
Daily agent spend ceiling | 5.00 |
SEMGREP_CONFIGS / SEMGREP_EXCLUDE_RULES |
Override the security ruleset | curated defaults |
STATS_BEARER_TOKEN |
Bearer token for /stats |
unset (/stats closed → 403) |
STATS_PUBLIC |
Expose /stats with no auth |
false |
- High signal, low noise. A reviewer that posts noise is worse than no
reviewer. Default to comment-only; never
request_changes, never block a merge. - One comment, edited. Never thread, never reply, never react. A per-PR lock serializes create-or-update so concurrent events don't double-post.
- Cheap by default. Deterministic checks run first and always. The LLM runs only for allowlisted repos, gated by a daily cost ceiling.
- Additive, never fatal. The agent layer can fail in any way and the base pipeline still posts.
- Maintainer always owns the decision. The bot suggests; humans decide.
Released under the MIT License.
