AI Code Review as a CI Merge Gate: What Should Block

Photo by User:ArnoldReinhold via Wikimedia Commons (CC BY-SA 3.0)
Only for a narrow set of findings. A class may block when it is objectively decidable, cheap for the author to verify from the comment alone, and measured to be rarely wrong, which in practice covers things like a secret in the diff, a new route with no authorisation check, and a migration dropping a column the running release still reads. Everything else, including naming and structure, should comment without turning the check red.
Shadow mode means running the reviewer over pull requests that have already merged, with nothing posted and nobody notified. The diffs are real and the outcome is known, so you can count how many findings per class a human would have agreed with before the gate is allowed to block anything. Keep the harness afterwards and re-run it monthly, because model versions and codebases both move.
Collect the shadow-mode findings, strip the class label and the merge outcome, shuffle the rows, and have a human answer one question per row: would you have asked for a change here. That gives you precision per class, and the blindness matters because an adjudicator who knows the pull request shipped without incident marks almost everything as noise. Below about twenty adjudicated findings in a class, report insufficient sample rather than a rate.
Usually because it was allowed to block on findings that require taste. Simplification and naming suggestions are the highest-volume output of most AI reviewers and the least enforceable, so blocking on them teaches everyone that the bot's verdict is negotiable. Once the comment is collapsed by habit, the genuinely useful findings buried in it go unread too, which is why a muted gate is worse than no gate.
Yes, and this is the failure mode to test for. GitHub's status checks reference states that a job which is skipped reports its status as Success and will not prevent a pull request from merging even when it is a required check. Any condition on the job, such as a paths filter or a fork exclusion, can therefore turn the gate into a green tick, so open a pull request that deliberately trips the condition and confirm the merge is still held.

Photo by User:ArnoldReinhold via Wikimedia Commons (CC BY-SA 3.0)
Key Takeaway
An AI code review merge gate should block only findings that are objectively decidable, cheap to verify and rarely wrong: a secret in a diff, a new route with no authorisation check, a migration dropping a column the running release still reads. Everything requiring taste stays advisory until shadow mode has measured it.
The reviewer went in on a Thursday. By the middle of the next week it had commented on every open pull request in the repository, including a two-line copy change. The comments were not wrong, exactly: a variable name, a helper that could have been a map, a comment that no longer matched the line under it. And near the bottom of one of those reviews, past where anybody was still reading, a note that a migration in the diff dropped a column the deployed release still selected.
That is the failure worth writing about: not a reviewer that misses things, but one right often enough to install and noisy enough to mute. Wiring it into CI takes an afternoon. Deciding what it may block, and proving that decision with numbers before it blocks anything, is the work, and it is the part I got wrong first. What follows is the split I use now, the measurement that earns a class the right to block, and the rule that takes it away.
Adoption and trust are moving in opposite directions, and a merge gate has to be designed for the gap. The 2025 Stack Overflow Developer Survey reports that 84% of respondents are using or planning to use AI tools in their development process, up from 76% the year before, and that 51% of professional developers use AI tools daily. The same survey reports that more developers actively distrust the accuracy of AI tools, 46%, than trust it, 33%, with only 3% highly trusting the output. Usage rose; trust in accuracy hit an all-time low.
That is why a gate needs evidence rather than enthusiasm. A team that doubts a tool's accuracy will not argue with it in public; it will learn where the collapse arrow is. In the first week people read the comments, by the second they scrolled past them to the diff, and by the third somebody asked whether the check was still running at all, which is the honest measure of a muted gate: nobody knows. A muted gate is worse than no gate, because the team now believes something is checking, and human reviewers skim harder when they think a machine looked first.
A class of finding may block a merge only when all three of these hold at once. Two out of three is an advisory comment, and that is not a consolation prize.
Three classes clear that bar, and all three are boring. A migration that drops or renames a column the deployed release still reads, decidable by grepping the deployed tag for the column name and expensive enough in production to deserve a red cross. A new HTTP route with no authorisation check, the risk OWASP Top 10:2021 ranks first, present in the 94% of applications tested for some form of Broken Access Control, and whose prevention rule is one sentence a machine can apply: except for public resources, deny by default. And a live credential in the added lines, which is barely an AI finding at all.
The other half of the rule is easier. Naming, file layout, whether a function could be simpler, whether an abstraction arrived too early: these are the findings an AI reviewer produces most fluently and has the least standing to enforce. Human reviewers solved this with vocabulary rather than tooling. Google's engineering practices tell reviewers to prefix a minor comment with Nit, meaning technically you should do it but it will not hugely impact things, to use Optional or Consider for an idea that is not strictly required, and FYI for something the author is not expected to act on in this change. The label stops the author reading every comment as a requirement. An AI reviewer shipped without that vocabulary is one whose every remark reads as mandatory.
| Finding class | What decides it | Gate |
|---|---|---|
| Live credential in the added lines | A pattern match on the diff range, no model involved | Block |
| New route with no authorisation check | Route diff cross-checked against the guard list | Block |
| Migration drops a column the release reads | Grep the deployed tag for the column name | Block |
| New branch with no test covering it | Coverage delta on changed lines, then judgement | Advisory |
| Naming, structure, could be simpler | Nothing. It is a preference with a good argument | Never |
The last row is the one that cost me. Simplification suggestions were the reviewer's highest-volume output, they were frequently reasonable, and letting them block for a single sprint is what taught everybody to ignore the bot for good. That is the shape of the mistake: a gate is muted by its most defensible-sounding class, not its most obviously wrong one.

The split has to live in the pipeline, not the prompt. One job that comments on everything and fails on some of it puts the gate's behaviour inside a model's output, where you cannot change it without a redeploy and cannot audit it at all. Two jobs, one required and one not, moves the policy into YAML, where it is reviewable, diffable and revertible like any other change.
# .github/workflows/ai-review.yml
# Two jobs on purpose. Only the first is a required status check; the second
# can never turn the merge button red, whatever it thinks of your naming.
name: AI review
on: pull_request
jobs:
blocking:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # need real history to find the merge base
# A secret in a diff is a pattern match, not an opinion. Do not pay a
# model to do a job a regex does deterministically and for free.
- name: Secrets in the added lines
run: |
BASE=$(git merge-base "origin/$GITHUB_BASE_REF" HEAD)
gitleaks git --log-opts="$BASE..HEAD" --redact --exit-code 1 .
# Only the classes that earned the right to block. --fail-on=verified
# means the model proposes and a checker confirms: the reviewer must
# name the route, column or file, and the checker must find it there.
- name: Reviewer, blocking classes only
run: node scripts/ai-review.mjs
--classes=secret,missing-authz,unsafe-migration
--fail-on=verified
advisory:
runs-on: ubuntu-latest
continue-on-error: true # this job's conclusion is ignored on purpose
steps:
- uses: actions/checkout@v7
- name: Reviewer, everything else
run: node scripts/ai-review.mjs --classes=all --advisory --label=nitThe blocking job runs the deterministic checks first, then the reviewer restricted to the classes that earned their place, with every model finding held behind a verifier: the reviewer names the route, the column or the file, and something that is not the model has to find it there. Secrets are the clearest case for keeping the model out entirely, because gitleaks scans a git log range directly, exits 1 on a hit by default, and redacts the secret from its own output. Only the blocking job goes into the branch protection rule; the advisory job carries continue-on-error, so its verdict is recorded and never enforced.
The dangerous failure in a required check is not a false red, it is a false green. GitHub's status checks reference states that a job which is skipped reports its status as Success, and will not prevent a pull request from merging even when it is a required check. Any condition on your blocking job, a paths filter, a draft guard, a fork exclusion, quietly turns the gate into a tick. Open a pull request that deliberately trips the condition and confirm the merge is still held.
Before the reviewer may block anything, run it where it can do no damage: over pull requests that already merged. The diffs are real, the outcomes are known, and nothing it says reaches an author. A couple of hundred merged pull requests gives a per-class count worth arguing about, and it costs a laptop and one evening rather than anybody's goodwill.
# Shadow mode: replay already-merged pull requests through the reviewer.
# Nothing is posted, nobody is notified. The output is a file you can count.
gh pr list --state merged --limit 200 --json number --jq '.[].number' \
> /tmp/merged-prs.txt
while read -r pr; do
gh pr diff "$pr" --patch > "/tmp/pr-$pr.patch"
# Same binary, same prompt, same config as the CI job will use. Change one
# of the three and the rate you measured stops describing what you shipped.
node scripts/ai-review.mjs --patch "/tmp/pr-$pr.patch" --json \
| jq -c --arg pr "$pr" '.findings[] | [$pr, .class, .file, .line, .message]' \
>> /tmp/shadow-findings.jsonl
done < /tmp/merged-prs.txt
# What humans flagged on the same diffs, for the other half of the picture:
# a class the reviewer never raises is not precise, it is silent.
while read -r pr; do
gh api "repos/OWNER/REPO/pulls/$pr/comments" --paginate \
--jq '.[] | [.path, .line, .user.login, .body]' \
>> /tmp/human-comments.jsonl
done < /tmp/merged-prs.txtThen count. Precision per class is the number the gate decision turns on: of the findings the reviewer raised in a class, how many a human would have agreed with. Recall matters too, and the human review comments on those same diffs are the cheapest proxy available, because a class the reviewer never raises is not precise, it is silent. The adjudication step is where this goes wrong, so run it blind.
// scripts/shadow-report.mjs
// One precision figure per finding class, with the sample size behind it.
const MIN_SAMPLE = 20; // policy, not a measurement: below this, no verdict
const PROMOTE_AT = 0.9; // policy: one wrong finding in ten is the most I
const DEMOTE_AT = 0.8; // will inflict on a gate, and one in five revokes it
const rows = readJsonl("/tmp/shadow-adjudicated.jsonl");
const byClass = new Map();
for (const r of rows) {
// r.verdict was set by a human who saw the finding WITHOUT its class label
// and WITHOUT being told the PR shipped fine. Adjudicate blind, or the
// number is theatre: knowing it merged makes every finding look like noise.
const c = byClass.get(r.class) ?? { agreed: 0, rejected: 0 };
if (r.verdict === "agreed") c.agreed++;
else c.rejected++;
byClass.set(r.class, c);
}
for (const [cls, c] of byClass) {
const n = c.agreed + c.rejected;
const precision = c.agreed / n;
const verdict =
n < MIN_SAMPLE
? "insufficient sample"
: precision >= PROMOTE_AT
? "eligible to block"
: precision < DEMOTE_AT
? "advisory, and losing ground"
: "advisory";
console.log(cls.padEnd(20), String(n).padStart(4), precision.toFixed(2), verdict);
}Blindness is not ceremony. An adjudicator who knows the pull request merged and shipped without incident marks almost everything as noise, because the absence of an incident feels like proof of harmlessness. Strip the class label and the outcome, shuffle the rows, and ask one question per row: would you have asked for a change here. The rate that comes out of that is the only one worth putting in a branch protection rule.
Commit these to the repository as a policy rather than deciding them under pressure while a release is blocked and three people wait. Mine are five lines.
The asymmetry is deliberate. Promotion has a cost that arrives later and spread thinly across everyone, so it should be slow and require evidence. Demotion prevents the exact failure this post is about, so it should be immediate and require nothing. In practice the demotion rule is what makes people willing to promote anything, because it turns a permanent-feeling decision into a reversible one.
Keep the shadow-mode harness after the gate goes live and re-run it monthly over the previous month of merges. Model versions change under you and your codebase changes under the model, so a class that cleared the bar in March is not necessarily clearing it in September. The re-run is also the cheapest way to notice a class has gone silent rather than clean.

After all that calibration, the reviewer's genuinely useful output turned out to be unglamorous. A summary of a large diff that lets a human choose which four files to read carefully. A test copied and left asserting nothing. A route added without the guard every one of its neighbours has. A column dropped in the same pull request that still reads it three files away. Fast, mechanical, and each one traceable to a fact sitting in the diff.
The design opinions were the part I wanted to be valuable and the part that had to be defanged. That is not a limitation of one tool or one model; it is what the trust figures predict. A team that distrusts a machine's accuracy will still accept its verdict on facts and reject its verdict on taste, and a gate built against that grain gets muted however good the model becomes. Sell the boring checks internally and the reviewer survives its first bad week.
One rule to carry: a class may block only when it is objectively decidable, cheap to verify and measured to be rarely wrong, and it stays advisory until all three are true at once. Build the shadow-mode harness before the gate rather than after the argument, write the demotion rule down while nothing is on fire, and accept that a good AI reviewer's best work looks more like a checklist than a critique.
Sources & further reading