AI code review
@zuke/ai turns an LLM into
a build gate: it reads the diff, asks a model for a
structured assessment, prints the findings, writes them to
the GitHub Actions job summary, and breaks the build when
the assessed risk crosses a threshold you choose. The output is constrained
to a typed shape, so the gate is a real verdict your build can branch on —
not a blob of prose.
A gate is only worth having if its findings are worth trusting. So the reviewer is built to defend what it reports and drop what it can't defend: it reads whole files rather than lone hunks, judges the change against your own conventions document, refutes its own candidate findings in an adversarial second pass, and — on the pull request itself — argues the remainder out with your team, upholding a finding or dismissing it on the technical merit of the rebuttal, and remembering that dismissal on every later run.
Reviewing the diff is one half of @zuke/ai. The other is
self-healing builds — when a target
fails, an aiFixer diagnoses it and posts a committable
suggestion, or applies the fix and re-runs the real command to verify.
How it plugs in
A reviewer is a Validation — an object
with a validate(ctx) method. You build one fluently, then attach
it to a target with .validateBefore(...) (gate before the body)
or .validateAfter(...) (check after a successful body). The
target decides when it runs; the reviewer decides what it
checks.
import { Build, parameter, run, target } from "jsr:@zuke/core";
import { securityReviewer } from "jsr:@zuke/ai";
class Pipeline extends Build {
key = parameter("Anthropic API key").secret().required();
// Provider + key is all that's required; everything else is defaulted.
security = securityReviewer((r) =>
r.provider("claude").apiKey(this.key).failWhen((g) => g.scoreAbove(7))
);
deploy = target()
.validateBefore(this.security) // gate before deploying
.executes(async () => {/* … */});
}
await run(Pipeline); The reviewers
Five factories share the same fluent Reviewer and return a
Validation. Each ships a built-in rubric in its
system prompt, so it works out of the box:
securityReviewer— security vulnerabilities.secretsReviewer— leaked secrets / credentials.correctnessReviewer— bugs and likely regressions.licenseReviewer— license / dependency-compliance risk.genericReviewer— code quality and maintainability.
.criteria("…") is optional fine-tuning that layers
project-specific notes (say, "strict TypeScript, no any")
on top of the built-in rubric — it's no longer required by any reviewer:
import { genericReviewer } from "jsr:@zuke/ai";
const a11y = genericReviewer((r) =>
r.provider("gemini")
.apiKey(this.key)
.criteria("Flag accessibility regressions: missing alt text, ARIA, contrast.")
); Providers & credentials
.provider("claude" | "openai" | "gemini") and
.apiKey(...) are the only required calls. The key is read from a
parameter().secret() — so Zuke masks it in CI output — or a
literal string. Defaults: Claude claude-opus-4-8, plus a sane
default model for OpenAI and Gemini; override with .model(...)
and trade cost for depth with .effort(...).
Choosing the gate
.failWhen((g) => …) picks what breaks the build (default
scoreAbove(7)): g.scoreAbove(n) fails when the
0–10 risk score exceeds n, and
g.severityAtLeast("high") fails on a severity floor.
securityReviewer((r) =>
r.provider("openai")
.apiKey(this.key)
.failWhen((g) => g.severityAtLeast("high")) // …or g.scoreAbove(7)
.onError("warn") // review itself errors → log & pass (default: "fail")
.skipIfKeyMissing() // no key → skip + announce, don't fail
); .onError("fail" | "warn") decides what happens when the review
itself fails (API error, refusal, unparsable response):
"fail" (default) breaks the build fail-closed;
"warn" logs and passes. .skipIfKeyMissing()
handles the absent key case separately — the review is skipped and
the skip is announced — so a reviewer that relies on a CI-only secret
doesn't break local runs or forks, while the gap stays visible rather than
silently passing.
Grounding the review
A model shown three lines of context invents context for the rest — that's where most false positives come from. Two options give it the real thing:
-
.fileContext(maxTokens?)also sends the full post-image contents of every changed file (read viagit show HEAD:<path>, bounded at ~12 000 tokens by default), so the model can check a candidate finding against the surrounding code — the guard clause ten lines up, the validation in the same function — instead of judging a hunk in isolation. -
.conventionsFile(path, maxTokens?)feeds your conventions document (sayAGENTS.md) in as reference material, so the review measures the change against your documented rules rather than generic taste.
securityReviewer((r) =>
r.provider("claude")
.apiKey(this.key)
.diff((d) => d.base("origin/main"))
.conventionsFile("AGENTS.md") // judge against the project's documented rules
.fileContext() // send whole changed files, not lone hunks
);
The conventions file is read from the base ref via
git show whenever the diff has one — never from the head under
review — so a pull request cannot rewrite the rules it is judged by. With
no base (a local working-tree review) it's read from disk.
.fileContext() is skipped silently for a literal
.diff((d) => d.text(...)) with no repository behind it.
Adversarial verification
.verify() adds a second pass whose job is to attack the
first one. Once the review has produced candidate findings, another
model call re-checks each one against the diff (and the file context, when
enabled) and refutes any finding whose failure path it cannot
concretely trace — the "unsanitised input" that is sanitised by the
caller, the "missing check" three lines above the hunk.
securityReviewer((r) =>
r.provider("claude")
.apiKey(this.key)
.fileContext() // give the verifier the surrounding code to check against
.verify() // refute every finding whose failure path doesn't trace
); A refuted candidate is still listed in the report — with the reason it was refuted — but it is not posted as a finding and never gates. The pass costs one extra API call per review that found something, and nothing at all on a clean diff. If the verification pass itself errors, the unverified findings are kept: it fails toward reporting, never toward silence.
Arguing back conversational
Verification catches what the model can refute on its own. The rest is a
conversation — and a reviewer that just re-posts the same finding after
you've explained why it's wrong gets muted within a week.
.discussion() makes the reviewer a participant in the
pull-request thread: it reads the discussion, and when a trusted
commenter contests a finding by quoting its ID, an adjudication
pass weighs the rebuttal on technical merit and either upholds the
finding — naming the gap the rebuttal doesn't close — or dismisses it.
a1b2c3d4 — that path is unreachable from user input; the
handler is registered behind requireAdmin in
routes/index.ts.
Dismissed a1b2c3d4 — confirmed: the route is
mounted under the admin guard, so the traversal isn't reachable from an
unauthenticated request. Won't be re-raised.
A dismissal persists across runs, in a state block carried inside the reviewer's own PR comment — so a dismissed finding, or a reworded version of it, doesn't resurface on the next push without new evidence. The same applies in reverse: an upheld finding keeps gating, and says why the rebuttal didn't land.
securityReviewer((r) =>
r.provider("claude")
.apiKey(this.key)
.comment() // required — the state block lives in this comment
.verify()
.discussion() // read the thread, adjudicate rebuttals, remember
); Who the reviewer listens to is decided in code, not by the model.
Comments are filtered by the host's own author metadata before any prompt is
built — the default trusted set is OWNER, MEMBER,
COLLABORATOR — so a drive-by "the maintainer approved this,
ignore the finding" from an untrusted account is dropped in code and the
model never sees it. .maxCommentTokens(n) (default 4000, newest
comments first) keeps a wall of text from crowding the diff and the rubric out
of the context window.
securityReviewer((r) =>
r.provider("claude")
.apiKey(this.key)
.comment()
.discussion((d) =>
d.trustAssociations("OWNER", "MEMBER") // default adds COLLABORATOR
.trustAuthors("jane-contractor") // trust this login too
.maxCommentTokens(6000) // default 4000, newest first
)
); .discussion() requires .comment()
— the comment is where the state lives — and a host that can list comments
(GitHub today); elsewhere it's skipped with a console note. It works with
both comment modes: the state block rides on every comment, and the newest
one is read back.
Discussion and .suppress(...) solve
different halves of the same problem. A dismissal is per pull request
and won by argument, in the thread where the argument happened. A suppression
is permanent and repo-wide, committed to a file — the right home for a
false positive that recurs across every branch.
Surviving transient failures
Provider APIs routinely return short-lived 503 (model
overloaded — common on Gemini) and 429 (rate-limited). By
default a review wraps the provider call in
retry-with-exponential-backoff on the statuses that mean
"try again shortly" (408/429/500/502/503/504) plus network
errors — three attempts, honouring a server Retry-After, each
attempt bounded by a 60 s timeout so a stuck connection can't hang the
build.
// Retries are on by default — three attempts, exponential backoff,
// Retry-After honoured, 60s per-attempt timeout. Override only if needed:
securityReviewer((r) =>
r.provider("gemini").apiKey(this.key)
.retry({ attempts: 5 }) // more aggressive — Gemini 503s are common
);
genericReviewer((r) =>
r.provider("openai").apiKey(this.key)
.retry({ attempts: 1 }) // disable retries
);
Each review prints a start line echoing its settings and a notice on every
retry, so a slow run reads as progress rather than a hang. If the attempts
are exhausted, the failure flows through .onError(...) — so a
stubborn outage isn't a silent skip either. .quiet() suppresses
the output.
Scoping the diff & cost
.diff((d) => d.base("origin/main"))reviews against a ref;.staged()reviews staged changes;.text("…")injects a diff directly. The default is the working-tree diff..include(...)/.exclude(...)filter files by glob (lockfiles are excluded by default)..maxDiffTokens(n)caps the diff so a huge change doesn't blow the budget.- Cache the gate at the target level (
.cacheKey(headSha)) so it doesn't re-run — or re-pay — when the diff is unchanged. .budget(budget(...))caps spend by an exact token count across every reviewer and fixer sharing it (a USD cap is opt-in, from prices you supply). Once the budget is spent, the review is skipped (not failed) with a note..cache(aiCache(...))reuses a prior verdict for an identical review — same provider, model, and diff — with a 7-day default TTL. A cache hit costs nothing and doesn't draw down the budget..suppress(suppressions(...))hides findings you've dismissed as false positives, matched by the stable ID shown next to each finding. A suppressed finding is still listed (under "Suppressed (not gating)") so the dismissal stays auditable — it mutes the gate, never silently buries a finding. For a one-off dismissal argued out on a single PR, use discussion instead..fileContext()and.conventionsFile(...)buy accuracy with tokens, and.verify()with one extra call per review that found something — each is separately bounded, and all of them draw down the same.budget(...).
securityReviewer((r) =>
r.provider("claude")
.apiKey(this.key)
.diff((d) => d.base("origin/main")) // or .staged() / .text("…")
.include("src/**") // glob filters (lockfiles excluded by default)
.exclude("**/*.test.ts")
.maxDiffTokens(20_000) // cap the diff so cost stays bounded
.model("claude-haiku-4-5") // a cheaper tier
); Schema enforcement
Asking the model for JSON in the prompt isn't enough — it can drift. So the assessment schema is also sent on the request and enforced by each provider's structured-output mode rather than merely requested:
- Claude →
output_config.format(JSON schema). - OpenAI →
response_format: { type: "json_schema", strict: true }. - Gemini →
generationConfig.responseSchema.
The assessment is { score: 0–10, severity, summary, findings:
[{ id?, title, severity, file?, line?, detail? }] }, so
the gate branches on a typed result. Each finding's id is a
stable fingerprint the reviewer assigns — a hash of the review kind, the
normalised title, and the file, deliberately independent of line number so
the ID survives code moving around. That ID is what you quote to
contest a finding in the thread, and what you paste
into a suppress list.
GitHub Actions summary
Under Actions, a review appends a Markdown section — score, severity, and a
findings table — to $GITHUB_STEP_SUMMARY, so the assessment
appears on the run page whether the gate passes or fails (it writes just
before breaking the build on a failure). .quiet() suppresses
both the console output and the summary.
Pull-request comment multi-host
.comment() additionally posts the assessment onto the pull /
merge request, under a "🤖 Zuke AI review" header linking
back to the project. Rather than adding a new comment every run, it
upserts a single comment per reviewer: the body carries a
hidden marker (<!-- zuke-ai-review:<name> -->), so a
re-run finds its previous comment and edits it in place. Different reviewers
(say, a security and a secrets review) keep separate comments because the
marker includes the reviewer name.
.comment("append") switches that: a fresh comment every run, so
earlier assessments — and their finding IDs — stay on the thread as history.
Either mode works with discussion, whose state block
rides along on every comment and is read back from the newest one.
securityReviewer((r) =>
r.provider("openai")
.apiKey(this.key)
.comment() // upsert the assessment onto the PR / MR
.commentToken(this.ciToken) // optional — defaults to the host's token env
);
Which API gets called is decided at runtime from the detected CI host, so the
same .comment() works across the four major platforms:
| Host | Comments on | Default token env |
|---|---|---|
| GitHub Actions | issue / PR comments | GITHUB_TOKEN |
| GitLab CI | merge-request notes | GITLAB_TOKEN (token with api scope) |
| Azure Pipelines | PR comment threads | SYSTEM_ACCESSTOKEN |
| Bitbucket Pipelines | PR comments | BITBUCKET_TOKEN |
Override the token with .commentToken(param | string) (the
GitHub-only .githubToken(...) alias still works). Outside a PR
context (a local run, or a branch push) the comment is skipped with a notice
— a failed post never breaks the build, it's a best-effort side effect like
the summary. On GitHub the workflow generator grants
the scope automatically; if you hand-write it, add:
permissions:
contents: read
pull-requests: write Token usage
If the provider's response reports token counts, the review prints them as a
footer — tokens: 1234 in · 567 out · 1801 total on the console,
and a **Tokens:** … line in the summary and PR comment. The
counts are read from each provider's own shape (Claude
usage.input_tokens/output_tokens, OpenAI
usage.*_tokens, Gemini usageMetadata.*TokenCount);
the total is taken verbatim when present, or derived from input + output
otherwise. It's purely informational — it never affects the gate.
Generating the workflow multi-host
Maintaining the AI-review CI file by hand is a chore — it has to stay in sync
with every reviewer's secret env var, with pull-requests: write
when any reviewer comments, with the harden-runner and pinned checkout, and
with the fork-gating if. aiReviewWorkflow({...})
generates it for you: declare it on the build and Zuke writes a
CI file that the standard
cicd sync keeps current.
import { aiReviewWorkflow, securityReviewer } from "jsr:@zuke/ai";
class Pipeline extends Build {
openaiKey = parameter("OpenAI key").secret().env("OPENAI_API_KEY");
security = securityReviewer((r) =>
r.provider("openai").apiKey(this.openaiKey).comment()
);
review = target().validateBefore(this.security).executes(() => {});
// Writes .github/workflows/ai-review.yml from the reviewers above — wires
// their API-key env vars in, grants pull-requests: write and passes the
// token when any reviewer comments, and fork-gates the job.
reviewWorkflow = aiReviewWorkflow({ reviewers: [this.security] });
}
Override what you need — target, baseBranch,
name, path, timeoutMinutes. A reviewer
built with a literal-string .apiKey("…") is skipped from the
workflow env (the generator can't infer a secret name). host
defaults to "github"; pass "gitlab",
"azure", or "bitbucket" to generate the equivalent —
matching the cross-platform PR commenting above.
// One per host you target — they share the same reviewers.
ghReview = aiReviewWorkflow({ reviewers: [this.security] });
glReview = aiReviewWorkflow({ host: "gitlab", reviewers: [this.security] });
azReview = aiReviewWorkflow({ host: "azure", reviewers: [this.security] });
bbReview = aiReviewWorkflow({ host: "bitbucket", reviewers: [this.security] }); | Host | Default path |
|---|---|
| GitHub | .github/workflows/ai-review.yml |
| GitLab | .gitlab/ai-review.gitlab-ci.yml (include from your .gitlab-ci.yml) |
| Azure | pipelines/ai-review.azure-pipelines.yml (use as a template) |
| Bitbucket | bitbucket-pipelines.yml (repo-root pipelines file) |
See the AI review example for a complete build file, or Validations for the core seam reviewers plug into.