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.
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.
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.
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:
[{ title, severity, file?, line?, detail? }] }, so the
gate branches on a typed result.
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.
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.