DEV Community

Emery Lin
Emery Lin

Posted on

An AI Review Gate That Doesn't Flake: Hooks, Fixtures, and a Green-to-Merge Path

An AI review agent in CI is only worth the trouble if it fails loudly, flakes rarely, and never blocks a merge on vibes. This article walks through a pipeline design that does all three: a hook that fires on every pull request, a fixture suite that tests the reviewer before it reviews, and a severity-based gate with a visible escape hatch. The cost model matters too. With free model access and a free server option, the per-PR price of this gate is zero — and that changes when you can afford to run it.

Why most AI review gates annoy everyone

Three failure modes kill AI review gates in practice.

First, cost. A paid model call on every PR adds up fast, so teams run the reviewer only on big PRs — exactly when it's least useful. Second, infrastructure. A dedicated runner that sits idle between PRs is a bill you can see. Third, flakiness. The same diff gets a pass on Monday and a block on Tuesday, and nobody trusts the gate anymore.

This design addresses all three. The agent runs in a short-lived CI job on the free server option. The model calls use free model access. And a fixture suite keeps the output honest.

The pieces

MonkeyCode is an open-source AI coding agent you can run from the command line. In this pipeline it plays one role: turn a diff into a structured review report. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The pipeline has four parts: a hook, a wrapper, a gate, and a fixture suite. You can steal all four and swap the agent for any CLI tool that emits JSON.

Step 1: The hook

The hook is a GitHub Actions workflow. It fires on opened and synchronize so the gate re-runs when you push. It also fires on labeled and unlabeled — that's the override mechanism.

name: ai-review-gate

on:
  pull_request:
    types: [opened, synchronize, labeled, unlabeled]

permissions:
  contents: read
  pull-requests: write

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Run the reviewer
        id: review
        run: |
          ./scripts/review_agent.sh \
            --base "${{ github.event.pull_request.base.sha }}" \
            --head "${{ github.event.pull_request.head.sha }}" \
            --out /tmp/review.json

      - name: Upload the report
        uses: actions/upload-artifact@v4
        with:
          name: review-report
          path: /tmp/review.json

      - name: Gate on severity
        env:
          HAS_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'override:ai-review') }}
        run: |
          if [ "$HAS_OVERRIDE" = "true" ]; then
            echo "override:ai-review present — passing with override logged"
            exit 0
          fi
          python scripts/gate.py /tmp/review.json
Enter fullscreen mode Exit fullscreen mode

Two details matter. The fetch-depth: 0 checkout gives the wrapper the full history it needs to diff two SHAs. And the gate step reads the override label from the event payload, not from the repo, so a maintainer can unblock a PR without touching the branch.

Step 2: The wrapper

The wrapper keeps the agent call in one place. It takes two SHAs, produces a diff, and hands it to the agent. The CLI shape below is a placeholder; check your installed agent version for the exact flags.

#!/usr/bin/env bash
set -euo pipefail

BASE=""; HEAD=""; OUT=""; DIFF=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --base) BASE="$2"; shift 2 ;;
    --head) HEAD="$2"; shift 2 ;;
    --diff) DIFF="$2"; shift 2 ;;
    --out) OUT="$2"; shift 2 ;;
    *) echo "unknown argument: $1" >&2; exit 2 ;;
  esac
done

if [[ -n "$DIFF" ]]; then
  DIFF_FILE="$DIFF"
else
  DIFF_FILE=$(mktemp)
  git diff --unified=3 "$BASE" "$HEAD" > "$DIFF_FILE"
fi

monkeycode review \
  --diff "$DIFF_FILE" \
  --repo "$PWD" \
  --format json \
  --out "$OUT"
Enter fullscreen mode Exit fullscreen mode

Keep the wrapper thin. The fixture suite will call it hundreds of times.

Step 3: The fixtures

Here's the part most people skip. Before you let an AI gate block real PRs, you need to know what it does on diffs you already understand. Create a fixtures/ directory with four kinds of cases:

  • bug.diff — a real, injected bug (off-by-one, missing null check)
  • security.diff — an injection or a leaked secret
  • clean.diff — a clean refactor with no findings
  • noise.diff — style nits only

A runner executes the reviewer against each fixture and compares the result with an expected JSON file.

#!/usr/bin/env bash
set -euo pipefail

PASS=0; FAIL=0

for fixture in fixtures/*.diff; do
  name=$(basename "$fixture" .diff)
  expected="fixtures/${name}.expected.json"

  ./scripts/review_agent.sh --diff "$fixture" --out "/tmp/${name}.json"

  if python scripts/compare_findings.py \
      --actual "/tmp/${name}.json" \
      --expected "$expected"; then
    PASS=$((PASS + 1))
  else
    FAIL=$((FAIL + 1))
    echo "FAIL: $name"
  fi
done

echo "fixtures passed: $PASS, failed: $FAIL"
[ "$FAIL" -eq 0 ]
Enter fullscreen mode Exit fullscreen mode

compare_findings.py compares the severity and location of each finding against the expected JSON — strict on critical, lenient on nits. Three numbers come out of the suite: recall (does it find the injected bug?), false positives (does it stay quiet on clean?), and stability (does it give the same answer three times in a row?). If any of the three is bad, the gate is not ready for real PRs.

Step 4: Flake control

Flakes are what kill trust in AI gates. Five rules keep them down:

  1. Pin the model version in the agent config. Unpinned models drift, and the gate changes behavior without a code change.
  2. Set temperature to 0 if the model supports it. Review output should be reproducible.
  3. Retry only on transport or parse errors, never on content disagreement. A model that says "clean" twice and "bug" once is a flake you want to see, not retry away.
  4. Give the agent an explicit abstain path. A low-confidence "maybe" should never block a merge.
  5. Run every prompt or config change through the fixture suite before it reaches the gate.

Step 5: The gate

The gate is a small script that reads the report and exits 0 or 1. GitHub turns that into a required status check.

#!/usr/bin/env python3
import json
import sys

report = json.load(open(sys.argv[1]))
blocking = [f for f in report.get("findings", [])
            if f.get("severity") == "critical"]

for finding in blocking:
    print(f"BLOCK: {finding.get('file')}{finding.get('message')}")

sys.exit(1 if blocking else 0)
Enter fullscreen mode Exit fullscreen mode

The decision table is the contract between the agent and the team:

Severity Example Gate behavior
critical SQL injection, auth bypass, secret in diff Block merge; human review required
warning null deref, resource leak, race Comment on the PR; no block
nit naming, formatting Ignored
abstain confidence below threshold Pass; logged for audit

Step 6: The green-to-merge path

Here's the full loop:

  1. You push a PR. The hook fires.
  2. The reviewer runs in a short-lived job on the free server option. A few minutes per PR is the right shape for this; it's not a production runner.
  3. The report is uploaded as an artifact. Every decision is auditable later.
  4. The gate exits 0 or 1. No critical findings means the check is green.
  5. A critical finding turns the check red. A maintainer can add the override:ai-review label, which re-triggers the workflow and lets the gate pass — but the override is visible in the log and the label history, not silent.

The override is the part that keeps the gate honest. An AI reviewer that cannot be overridden becomes a process problem. One that logs its overrides stays a tool.

What this costs

The reason this pattern is worth building now is the cost curve. The job runs on the free server option, and the model calls use free model access. As of this writing, the free tier includes 10 million tokens, which covers a lot of small diffs; check the project repo for current terms, because free tiers change.

Compare that with the alternative: a paid model on a dedicated runner, used only on PRs that feel risky. The free tier flips the default. You can afford to review every PR, and the fixture suite keeps the reviewer cheap to trust.

Limitations and who should skip this

The reviewer sees a diff, not the whole codebase. It will miss cross-file issues that a human with context would catch. Free model access usually means shared or rate-limited resources, so don't build a gate that needs a 99.9% SLA. And the fixture suite is a maintenance cost: if the fixtures rot, the gate lies.

Skip this pattern if you're under a compliance regime that requires human-only review, or if your PRs are so large that the diff doesn't fit the model's context. If your team won't maintain the fixtures, the gate will quietly become noise — which is worse than no gate at all.

Start with four diffs

The fixture directory is the real deliverable here. Start with four diffs, wire the hook, and let the gate earn trust before you make it required. If you want to try the pattern, the MonkeyCode repo has the agent and the free tier — the scripts above are a good starting point.

Top comments (0)