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:

  • Claudeoutput_config.format (JSON schema).
  • OpenAIresponse_format: { type: "json_schema", strict: true }.
  • GeminigenerationConfig.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:

HostComments onDefault token env
GitHub Actionsissue / PR commentsGITHUB_TOKEN
GitLab CImerge-request notesGITLAB_TOKEN (token with api scope)
Azure PipelinesPR comment threadsSYSTEM_ACCESSTOKEN
Bitbucket PipelinesPR commentsBITBUCKET_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] });
HostDefault path
GitHub.github/workflows/ai-review.yml
GitLab.gitlab/ai-review.gitlab-ci.yml (include from your .gitlab-ci.yml)
Azurepipelines/ai-review.azure-pipelines.yml (use as a template)
Bitbucketbitbucket-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.