Most patches do not deserve three review passes. They deserve one, and the size of that pass should come from the diff, not from how nervous the reviewer feels. This workflow computes a review tier from measurable diff properties, then spends model passes only where the tier allows it.
The artifact is a short script that reads git diff --numstat and a small rule table. It is deterministic, it costs one git call, and it runs before any prompt is sent.
Why diff shape beats reviewer intuition
Review attention is finite. Model calls cost tokens. Both get spent unevenly when the only routing signal is a gut feeling about the author.
A two-line fix in a parser and a two-line fix in auth/ are not the same review. A patch that adds a test and a patch that deletes an untested branch are not the same review either. The diff already contains enough structure to tell them apart.
The router below does not judge correctness. It only answers one narrow question: which evidence must exist before this patch is called reviewed.
Step 1 — Freeze the base before counting anything
Pin the comparison. A moving base makes the router non-reproducible.
git fetch origin --prune
git rev-parse --short origin/main
# note this SHA in the PR description
git switch -c fix/parser-empty-input
Run the router against origin/main...HEAD with three dots. That form diffs against the merge base and avoids pulling in unrelated commits from the target branch.
Step 2 — Route the patch with a deterministic script
Save this as tools/review_route.py. It uses only the standard library.
#!/usr/bin/env python3
"""Deterministic review-depth router. No model calls, no network."""
from __future__ import annotations
import argparse
import json
import re
import subprocess
PROTECTED = (
"auth/", "crypto/", "security/", "migrations/", "migration/",
".github/workflows/", "Dockerfile", "docker-compose",
)
BUILD_FILES = (
"pyproject.toml", "setup.py", "setup.cfg", "package.json",
"Cargo.toml", "go.mod", "go.sum", "requirements.txt",
)
TEST_HINT = re.compile(r"(^|/)(tests?|__tests__|spec)/|(_test\.|\.test\.|_spec\.|\.spec\.)")
DOCS_HINT = re.compile(r"\.(md|rst|adoc)$|(^|/)docs?/")
MAX_TIER1_LINES = 40
CHECKLIST = {
0: ["link check", "changelog entry present"],
1: ["test asserts the new behavior", "no public signature change"],
2: ["test fails on base and passes on head", "error path covered",
"no unrelated refactor in the diff"],
3: ["named maintainer review", "rollback or revert plan",
"explicit note on data or permission impact"],
}
def git(*args: str) -> str:
return subprocess.run(["git", *args], check=True, capture_output=True,
text=True).stdout
def is_source(path: str) -> bool:
return not DOCS_HINT.search(path) and not TEST_HINT.search(path)
def changed(base: str, head: str) -> list[tuple[str, int, int]]:
rows = []
for line in git("diff", "--numstat", f"{base}...{head}").splitlines():
added, deleted, path = line.split("\t", 2)
if added == "-":
continue # binary file, route manually
rows.append((path, int(added), int(deleted)))
return rows
def route(rows: list[tuple[str, int, int]]) -> tuple[int, list[str]]:
src = [f for f, _, _ in rows if is_source(f)]
tests = [f for f, _, _ in rows if TEST_HINT.search(f)]
total = sum(a + d for _, a, d in rows)
removed_src = sum(d for f, _, d in rows if is_source(f))
if not rows:
return 0, ["empty-diff"]
if not src and not tests:
return 0, ["docs-only"]
tier, reasons = 1, []
for path, _, _ in rows:
if any(p in path for p in PROTECTED):
reasons.append(f"protected-path:{path}")
tier = 3
if path in BUILD_FILES or path.startswith(".github/"):
reasons.append(f"build-surface:{path}")
tier = max(tier, 2)
if total > MAX_TIER1_LINES:
reasons.append(f"size:{total}>{MAX_TIER1_LINES}")
tier = max(tier, 2)
if src and not tests:
reasons.append("source-without-test")
tier = max(tier, 2)
if removed_src:
reasons.append(f"removed-source-lines:{removed_src}")
tier = max(tier, 2)
return tier, reasons or ["small-additive-source-with-tests"]
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="origin/main")
ap.add_argument("--head", default="HEAD")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
rows = changed(args.base, args.head)
tier, reasons = route(rows)
result = {"base": args.base, "head": args.head, "tier": tier,
"reasons": reasons, "files": len(rows),
"checklist": CHECKLIST[tier]}
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"tier={tier} files={len(rows)}")
for reason in reasons:
print(f" reason: {reason}")
for item in CHECKLIST[tier]:
print(f" evidence: {item}")
if __name__ == "__main__":
main()
Run it in CI as a required check. Output the JSON into the job summary so the tier travels with the PR.
python3 tools/review_route.py --base origin/main --head HEAD --json > review-tier.json
cat review-tier.json
Step 3 — Read the tier, not the line count
Example output for a patch that adds one guard clause and one test:
tier=1 files=2
reason: small-additive-source-with-tests
evidence: test asserts the new behavior
evidence: no public signature change
Example output for the same patch with no test and one deleted branch:
tier=2 files=1
reason: source-without-test
reason: removed-source-lines:6
evidence: test fails on base and passes on head
evidence: error path covered
evidence: no unrelated refactor in the diff
The rule table is short on purpose. Each rule maps to a review cost that a maintainer can defend in a comment.
| Tier | Typical shape | Model passes | Final call |
|---|---|---|---|
| 0 | docs, changelog, link fixes | 0 | maintainer skim |
| 1 | small additive source change with tests | 1 checklist pass | author plus reviewer |
| 2 | size, removals, build surface, no test | 2 passes, different lenses | reviewer reconciles |
| 3 | auth, crypto, migrations, CI workflows | 0 verdicts, 1 summary pass | named human only |
Tier 3 exists to keep models out of the decision. A summary pass is allowed. A verdict is not.
Step 4 — Spend model passes by tier
A tier 1 pass asks for assertions, not opinions. The prompt schema below forbids verdicts and forces file and line references.
Reviewing a tier-1 patch. Return exactly two sections, nothing else.
ASSERTIONS: for each changed behavior, the test assertion that proves it.
Format: <behavior> -> <test file:line> or MISSING
GAPS: error paths in the changed lines with no covering test.
Format: <file:line> or NONE
Do not summarize the diff. Do not assign a verdict. Do not restate the code.
Tier 2 sends two passes with different lenses. The first pass looks only at failure behavior. The second looks only at test adequacy. Disagreement between them is a signal to the reviewer, not an automatic block.
This is where free model access matters for the workflow. The operator of MonkeyCode describes a free allowance of roughly 10M tokens, which is enough to run tier 1 and tier 2 passes across a normal contribution series without a paid key. Two passes per tier-2 patch stay cheap when the router keeps tier 0 and tier 3 out of the loop entirely.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Step 5 — Run the router and the test subset somewhere disposable
The router needs the repo and a pinned base. Nothing else. A clean, throwaway environment keeps local worktrees out of the picture.
The same free server option is the reproducible half of this workflow. Run the router, then the failing test on base and on head, and keep the captured stdout as the evidence attached to the tier. A reviewer who reruns the job gets identical output.
Treat the server as a run environment, not as proof. It shows what executed. It does not show that the test is the right test.
Limitations
- The rules are path and line based. They cannot see semantic risk inside one line.
- Vendor directories and generated code distort the line count. Add them to an ignore list before trusting tier 2.
- Repos without tests degrade hard. Everything with source changes becomes tier 2, and the router stops discriminating.
- Binary diffs are skipped. Route them by hand.
- A tier is a routing decision, not a quality score. A reviewer may override upward at any time, and should write the reason in the PR.
Who should skip this
Maintainers of tiny repos with two files do not need a router. Projects whose review policy forbids model output should use the script for tiering only and drop steps 4. Teams without a pinned base branch will fight the script instead of gaining from it.
For everyone else, the order is fixed. Compute the tier, then decide how much review the patch has earned, then spend the passes. The cheapest review is the one that never needed to run.
If the two-pass step is the part that blocks you, start with the free model access on one tier-2 patch and see whether the checklist prompts fit your repo before wiring anything into CI.
Top comments (0)