Code Review
A pull request to order changes the checkout so that it confirms the seat hold with one SQL statement instead of an HTTP call to inventory β "it is faster". The code compiles, the tests pass, and the reviewer, at 18:40 on a Friday, approves. Three weeks later, during an on-sale, inventory changes its hold table and checkout breaks. The rule that was violated β one schema per service β was written in a wiki page nobody opened. A reviewer that had read that page, every time, on every PR, would have caught it.
That is the promise of AI code review: a reviewer that reads every diff in minutes, never gets tired, and can be told the team's rules. This page is about turning the promise into a tool people actually trust.
Why code review, and what it is for
Modern code review β a lightweight, tool-based review of each change before merging β is near-universal in industry. Studies at Microsoft and Google found that its main outcomes are not only finding defects but knowledge transfer, shared code ownership and consistency; at Google the median review latency was under a day, with most changes small12. Two consequences for AI review:
- An AI reviewer can take over part of the defect-finding and consistency work, but not the knowledge transfer: a human must still read the code to learn it.
- Review latency matters. The first human look often waits hours; an AI review in two minutes gives the author something to fix before the human arrives, so the human review is shorter.
What AI review is good and bad at
| Good at (high precision when given context) | Bad at (low precision or blind) |
|---|---|
| Local bugs visible in the diff: null handling, off-by-one, wrong comparison, resource leaks | Bugs that need runtime knowledge: performance under load, race conditions across services |
| Security patterns: SQL by concatenation, secrets in code, missing authorization check, unsafe deserialization | Whether the feature is the right feature: product intent is not in the diff |
| Violations of written conventions and ADRs | Conventions nobody wrote down |
| Missing tests for risky logic; tests that assert nothing | Judging whether a test asserts the correct expected value (the oracle problem) |
| Explaining unfamiliar code to the human reviewer | Large refactors spanning hundreds of files (context limits, attention dilution) |
| Consistency: error formats, status codes, logging, naming | Architecture trade-offs that require weighing business context |
Two systematic weaknesses deserve names. Gullibility: the model reads text written by the author β PR description, code comments β and can be steered by it ("this was approved by security"); see prompt injection. Plausible noise: a model asked to review will find something; without explicit permission to return nothing, it pads the review with style remarks and restatements.
Anatomy of an AI reviewer
sequenceDiagram
autonumber
participant GH as Git host
participant R as Reviewer job
participant Ctx as Context builder
participant GW as LLM gateway
participant Post as Poster
GH->>R: PR opened / new push
R->>Ctx: diff + PR metadata
Ctx->>Ctx: number lines, filter files, budget tokens
Ctx->>Ctx: add conventions, ADRs, owners, related code
Ctx->>GW: system (stable, cached) + user (untrusted data)
GW-->>R: JSON findings (schema-validated)
R->>R: filter: changed lines, severity, confidence, dedup, cap
R->>Post: findings to post
Post->>GH: one review with inline comments
Post->>GH: metrics: tokens, US$, kept vs dropped The model is one box of eight. Most of the engineering β and most of the quality β is in the context builder and the filter.
Context engineering
A model can only review against what it is given. Context engineering is deciding what goes into the limited, priced context window, and in which order.
| Context | Why | Cost / risk | BoxOffice |
|---|---|---|---|
| The diff, with line numbers | The subject of the review; numbered lines let the model cite exact lines | Grows with PR size; untrusted | git diff base...head, each new line as L21 + β¦ |
| Repository conventions | Turns generic advice into your rules | Must be short and current; read on every run | CONVENTIONS.md: schema per service, timeouts, money as cents |
| Architecture decisions (ADRs) | The why behind rules; lets the model flag decisions being reversed | Long; summarise or retrieve the relevant ones | 0001-schema-per-service.md, 0004-outbox-for-events.md |
| Surrounding code | The full function or class around a hunk; the callers of a changed method | Token-expensive; retrieval needed in big repos | The whole HoldService.java when a hunk changes expire() |
| Ownership and service map | Which service owns which data and API | Small, high value | "inventory owns inventory.*; order owns orders.*" |
| History | Earlier review comments, related incidents | Can bias towards old issues | "Incident 2026-08: hold expiry off by 2 min" |
| PR title and description | Intent of the change | Untrusted β injection channel | Wrapped in <untrusted_pr_description> |
Three practical rules:
- Order for caching. Put stable content first (instructions, conventions, ADRs) and per-run content last (diff). Prompt caching is a prefix match: if the stable part is byte-identical, repeated reviews read it at a fraction of the price (LLM gateway).
- Budget, do not truncate. A 5,000-line PR does not fit β and should not be reviewed as one blob. Keep whole files by priority (production code before tests before config), list the skipped files in the review, or split: one call per file or per service (map), then one call to merge and deduplicate (reduce). Cutting a diff in the middle produces confident nonsense about code the model never saw.
- Write conventions for humans first. If a rule is unclear to a new engineer it is unclear to the model. A rule that needs a paragraph of nuance is usually better as a deterministic check (below).
Think first: retrieval for a monorepo
BoxOffice becomes a monorepo of 40 services and 2 M lines. A PR changes the signature of InventoryClient.confirmHold. Which extra context would most improve the review, and how do you find it without sending the whole repository?
The call sites of the changed method and the contract of the API it wraps. Find them deterministically β a code index (language server, ctags, a code-search service) or a simple git grep confirmHold β and add the 5β10 most relevant snippets. Embedding-based retrieval over the repository (RAG) helps for fuzzy questions ("where else do we compute seat prices?"), but for "who calls this method" a symbol index is exact and cheaper.
Structured output, posted as review comments
Free text is for humans; the pipeline needs data. Ask for JSON that validates against a schema β with Claude, structured outputs constrain the response to the schema; with other providers, JSON mode plus validation and one repair attempt:
{
"summary": "Adds direct SQL access from order to the inventory schema.",
"findings": [
{
"file": "services/order/src/main/java/boxoffice/order/OrderService.java",
"line": 21,
"severity": "blocker",
"category": "architecture",
"confidence": "high",
"title": "order writes to the inventory schema",
"explanation": "CONVENTIONS.md: a service reads and writes only its own schema. This UPDATE couples order to inventory's table layout and bypasses inventory's locking, so a hold can be confirmed after it expired.",
"suggestion": "Call InventoryClient.confirmHold(holdId) and handle 409 Conflict."
}
]
}
| Field | Used for |
|---|---|
file, line | Inline placement; validated against the diff (comments on lines outside the diff are rejected by the Git host) |
severity (nit β¦ blocker) | Thresholds and ordering; gate decisions |
category | Per-category precision; routing (security findings to the security champion) |
confidence | Noise control; calibration over time |
title | Deduplication fingerprint; the line people scan |
explanation, suggestion | The comment body; a concrete fix makes a comment actionable |
On GitHub, all findings of one run go into one review (POST /repos/{owner}/{repo}/pulls/{number}/reviews with a comments array of path, line, side), so the author gets one notification, not twelve3. A review event of COMMENT keeps the bot advisory; the code β not the model β chooses that event.
Noise control
Every comment spends a developer's attention. A reviewer is judged by its worst comments: after a few useless ones, people stop reading β including the useful ones. The controls, in the order they act:
| Control | What it removes |
|---|---|
| Triggers and path filters | Drafts, docs-only PRs, generated files, lockfiles, vendored code |
| Prompt: "an empty list is a good review; no praise; no restating" | Padding |
| Only changed lines | Comments on code the author did not touch |
| Minimum severity | Style nits a formatter should handle |
| Minimum confidence | Speculation |
| Deduplication by fingerprint across pushes | The same comment reposted after every push |
| Cap per PR | Floods on big PRs β keep the most severe |
| One review per push + one sticky summary | Notification storms |
The thresholds trade precision (the fraction of posted comments that are useful) against recall (the fraction of real problems found). The table uses an illustrative labelled sample of 100 PRs β the model emitted 60 high-, 90 medium- and 150 low-confidence findings, of which 48, 45 and 30 were real problems, out of 150 real problems in total:
| Post findings with confidence β₯ | Comments per PR | Precision | Recall |
|---|---|---|---|
| high | 0.6 | 80% | 32% |
| medium | 1.5 | 62% | 62% |
| low | 3.0 | 41% | 82% |
Posting everything finds the most problems and is ignored by everybody (3 comments per PR, 4 out of 10 useful). Posting only high confidence is trusted and misses two thirds of the problems. Most teams start at high + medium for severities β₯ minor, measure, and move per category: secrets and SQL injection can stay at medium; style never gets posted.
In the BoxOffice code: the reviewer's filter (lab 13)
Hands-on 13 β The reviewer builds the anatomy above in one script. The PR title, description and diff go to the model wrapped as untrusted data; the model answers JSON against a schema; and the code β not the model β keeps only findings on added lines, at severity β₯ minor, not low confidence, at most 6 per PR, the most severe first. A fingerprint of each finding stops it from being posted again on the next push, and an error of the model ends the job with exit code 0: fail open, because an advisory step must never block merges. A test run with the local model posted one finding, the SQL injection planted in EventSearch.java; with Claude, one review of the lab's golden set cost about US$ 0.012.
def review(diff: str, title: str, body: str, root: Path = Path(".")):
"""Review a diff: (summary, kept findings, dropped [(finding, why)], usage, skipped files)."""
files = diffutil.split_files(diff)
files, skipped = diffutil.budget(files, MAX_DIFF_LINES)
if not files:
return "No reviewable changes.", [], [], Usage(model()), skipped
numbered = "\n\n".join(diffutil.numbered(p, lines) for p, lines in files.items())
user = (f"<untrusted_pr_title>\n{title}\n</untrusted_pr_title>\n"
f"<untrusted_pr_description>\n{body[:4000]}\n</untrusted_pr_description>\n"
f"<untrusted_diff>\n{numbered}\n</untrusted_diff>")
data, usage = complete_json(load_context(root), user, SCHEMA, effort="medium")
kept, dropped = [], []
added = {p: diffutil.added_lines(lines) for p, lines in files.items()}
for f in data["findings"]:
if f["line"] not in added.get(f["file"], set()):
dropped.append((f, "not an added line"))
elif SEVERITIES.index(f["severity"]) < SEVERITIES.index(MIN_SEVERITY):
dropped.append((f, f"below {MIN_SEVERITY}"))
elif f["confidence"] == "low":
dropped.append((f, "low confidence"))
else:
f["fp"] = fingerprint(f)
kept.append(f)
kept.sort(key=lambda f: (-SEVERITIES.index(f["severity"]), f["confidence"] != "high"))
for f in kept[MAX_COMMENTS:]:
dropped.append((f, f"over the limit of {MAX_COMMENTS} comments"))
return data["summary"], kept[:MAX_COMMENTS], dropped, usage, skipped
try:
summary, kept, dropped, usage, skipped = review(diff, title, body)
except LLMError as err:
step_summary(f"### AI review skipped\n\n{err}")
return 0 # fail open: an AI outage must not block merges
Advisory or gate?
| Advisory | Gate (required check) | |
|---|---|---|
| Effect | Comments; humans decide | Merge blocked until the finding is fixed or overridden |
| Cost of a false positive | A minute of reading | A blocked PR, an override, frustration β and pressure to disable the bot |
| Cost of a false negative | Same as today | False sense of safety if humans relax |
| Needs | Nothing special | Measured high precision in that category; an override path; fail-closed reliability |
| Good for | Everything, at first | Narrow, high-precision categories: hardcoded secrets, SQL by concatenation, forbidden cross-schema access |
A safe rollout: shadow (findings logged, not posted; compare with what humans found) β advisory for volunteer teams β gate per category once precision is proven. Even as a gate, an AI check is a required status check β never an approval. Approval stays human, enforced by branch protection.
Measuring the reviewer
You cannot tune what you do not measure, and "the team likes it" is not a measurement.
| Metric | How | Notes |
|---|---|---|
| Precision | Accepted Γ· (accepted + dismissed) comments; reactions or a resolve-with-reason button | Needs people to rate; sample if necessary |
| Recall | Found Γ· planted, on a golden set of PRs with known problems | Run on every prompt or model change, in CI |
| Escaped defects | Bugs found later in code the bot reviewed | Slow, but the metric the business cares about |
| Noise | Comments per PR; % of PRs with zero comments | Zero is often the right answer |
| Cost and latency | US$ and minutes per review | From the job's token usage |
| Behavioral | Time to first review, review rounds, time to merge | Needs a baseline period |
An LLM can also judge comments ("was this comment correct?"), which is cheaper than human labels β but LLM-as-judge has biases (it tends to agree with confident text, and with itself). Use it to triage, and calibrate it against a human-labelled sample.
Architecture conformance
The opening story β order writing to the inventory schema β is an architecture rule. Such rules are the most valuable thing an automated reviewer can check, because humans forget them and violations are expensive later. But many of them do not need a model at all.
| Rule | Deterministic check (preferred) | Where AI adds value |
|---|---|---|
"order must not access the inventory schema" | SQL lint: grep for inventory. in services/order/**; separate DB users per service with grants (the database refuses) | Explaining why, suggesting the API call instead |
| "Controllers must not call repositories directly" | ArchUnit test in the build | β |
| "Every outbound HTTP client has timeouts" | Custom lint, or a shared client library that requires them | Spotting a new hand-rolled client that bypasses the library |
| "Events go through the outbox" | ArchUnit: no KafkaTemplate usage outside the outbox relay | Recognising a disguised direct publish |
| "Prefer idempotent operations for payment" | Hard to express statically | Good fit: fuzzy, semantic, context-dependent |
@AnalyzeClasses(packages = "boxoffice.order")
class ArchitectureTest {
@ArchTest
static final ArchRule no_direct_kafka_outside_outbox =
noClasses().that().resideOutsideOfPackage("..outbox..")
.should().dependOnClassesThat().haveSimpleName("KafkaTemplate")
.because("ADR-0004: events are published through the transactional outbox");
}
The best design combines both: deterministic rules block, because they are exact; the AI reviewer explains and catches what the rules cannot express, and its recurring findings become candidates for new deterministic rules. Defense in depth again β and the database user of order without grants on inventory.* is the last layer that does not depend on any reviewer.
BoxOffice: the reviewer catches the shortcut
The UPDATE inventory.hold in OrderService β the finding of the JSON above β is case 02-cross-schema of the reviewer's golden set in hands-on 13. With CONVENTIONS.md in its context, the reviewer found it in the runs the lab shows, with the local model and with Claude; but a later run of the small local model missed it. A reviewer is a probability, so the team adds the deterministic check: a separate PostgreSQL role per service, so the same mistake fails in the integration tests even if every reviewer misses it.
In the interview
When asked to design an AI code reviewer, say early that precision beats recall for anything that talks to humans, and show how you will measure it (golden set for recall, accepted/rated for precision). Describe the context (diff + conventions + ADRs + related code by symbol search), structured findings, the filter (changed lines, severity, confidence, dedup, cap) and the rollout (shadow β advisory β gate per category). Mention that architecture rules should be deterministic checks first, with AI as the explainer.
Best practices
- Start in shadow mode β log the findings for two weeks and compare them with the human reviews before posting anything.
- Version the reviewer's context with the code β
CONVENTIONS.mdand the ADRs live in the repository and change by pull request; the reviewer of lab 13 loads them as its stable, cacheable prefix. - Collect a reaction on every comment β a π/π per finding gives precision per category at no cost; every comment of lab 13 asks for one.
- Cap the comments per PR and post the most severe first β lab 13 posts at most 6 (
AI_REVIEW_MAX_COMMENTS). - Deduplicate findings across pushes β the same problem must not be posted again on every push; lab 13 keeps a fingerprint of each finding in the comment.
Common pitfalls
- No permission to stay silent: the model invents work. Say explicitly that an empty review is good.
- Reviewing the whole repository instead of the change: expensive and off-topic.
- Treating the bot's silence as approval: humans relax, escaped defects rise.
- Gating before measuring: one week of false blocks and the team disables the bot.
- Rules only in the prompt: a rule that matters must also exist as a test, a lint or a database grant.
How it connects
flowchart LR
PR["pull request event"] --> CTX["context: diff, conventions,<br/>ADRs, symbol search"]
CTX --> RET["retrieval<br/>β AI systems RAG & Memory"]
CTX --> LLM["model call<br/>β LLM gateway, prefix cache"]
LLM --> OUT["structured findings<br/>(JSON schema)"]
OUT --> NOISE["noise control: changed lines,<br/>confidence, dedup, cap"]
NOISE --> GATE{"advisory<br/>or gate?"}
GATE --> PREC["precision + golden set<br/>β AI systems safety & evals"]
ARCH["architecture rules:<br/>deterministic checks first"] --> OUT
PR --> INJ["PR text is untrusted<br/>β Security & governance"] Palette components: orchestrator (the reviewer job around the model) Β· llmgw (routing, quotas, cached prefix of conventions and ADRs) Β· memory (the context assembled for each diff) Β· safety (untrusted PR text, validated output) Β· metrics (precision and comments per PR). Scenarios: none specific. Continues in: AI systems β RAG & Memory, AI systems β LLM Gateway, AI systems β safety & evals, Security & governance, Hands-on 13 β the reviewer.
-
BACCHELLI, A.; BIRD, C. Expectations, outcomes, and challenges of modern code review. ICSE 2013.Β β©
-
SADOWSKI, C. et al. Modern code review: a case study at Google. ICSE-SEIP 2018.Β β©
-
GitHub Docs. REST API endpoints for pull request reviews.Β β©