AI code review
A FlareDispatch port of Cloudflare's multi-agent code reviewer — up to seven domain-specific agents review every PR, findings deduplicated into one consolidated review. Runs on every push for zero GHA minutes.
Recipe: AI code review on every PR
A FlareDispatch port of Cloudflare’s multi-agent code reviewer — blog.cloudflare.com/ai-code-review. The blog’s system reviews merge requests with up to seven domain-specific agents, deduplicates their findings, and posts one consolidated review. This recipe implements the same shape as a FlareDispatch run.
Why Webhook mode
AI review should fire on every PR push, on every repo, without anyone editing .github/workflows/ and without burning GHA minutes. That is exactly Webhook mode: the FlareDispatch GitHub App webhook fires the run directly. The recipe is therefore a single DSL file — pr-review.run.ts — dropped into your repo’s runs/. No workflow file.
How the blog’s design maps onto the run
| Blog concept | In pr-review.run.ts |
|---|---|
| Triggered on merge-request open / update | triggers on pull_request actions opened, synchronize, ready_for_review |
| Noise filtering — lockfiles, minified, generated | prepare-diff step → review-agent diff --exclude lockfiles,minified,generated, written as a directory of per-file patches |
| Risk tiers — trivial / lite / full | classify-risk step → Match on the tier selects the agent set (1 / 4 / 7) and the coordinator model |
| Cheaper model on trivial diffs | the Match arm returns sonnet for trivial/lite, opus for full |
| Workers + KV control plane — model routing without redeploy | resolve-model step → config.get("pr-review.model.<tier-model>") overrides the default (03-dsl § config) |
| Up to seven domain-specific agents, each tightly scoped | FULL_AGENTS; the tier’s subset is fanned out in the review step with concurrency |
| Shared context to cut token duplication | the per-file patch directory written once by prepare-diff, read by every agent |
| Per-task timeouts (5 min, 10 min for code quality) | timeoutSec per sandbox.exec inside the review fan-out |
| Re-reviews track previous findings | load-prior step → io.priorExecution loads the last execution’s findings for this PR; coordinate --previous resolves fixed threads (03-dsl § io) |
| Coordinator dedups + filters into one verdict | coordinate step, --json output |
| Single consolidated review | the FlareDispatch check-run summary — one per execution, replaced on each push |
| Inline comments on specific lines | each Finding in the run output → a check-run annotation (04-gha-integration § Inline findings) |
| Bias toward approval unless critical findings | encoded in the review-agent coordinate rubric; surfaced as the verdict output |
| Prompt caching, circuit breakers, model failover | inside the bundled review-agent CLI / model client — not the DSL |
Flow
flowchart LR
PR[PR push] -->|App webhook| DSP[Dispatcher]
DSP --> CO[checkout]
CO --> PD[prepare-diff<br/>strip noise]
PD --> RT[classify-risk]
RT --> PLAN{tier?}
PLAN -->|trivial / lite / full| FAN[review<br/>tier's agent set, parallel]
FAN --> CRD[coordinate<br/>dedup + verdict]
CRD --> CHK[check-run<br/>summary + inline annotations]Framework surface this recipe relies on
The run is deliberately thin — it orchestrates, it does not contain model logic. Three pieces of the framework carry the weight:
config(03-dsl) — a KV-backed control plane. The coordinator model is resolved at run time, so an operator can repoint it at a fallback in seconds when a provider degrades — no redeploy. This is the seam for the blog’s “Workers + KV control plane.”io.priorExecution(03-dsl) — reads the last execution’s recorded output for the same(repo, PR)family. That is how re-reviews stay incremental: the coordinator sees what it concluded on the previous push.- Check-run annotations (04-gha-integration) — the run returns a
findingsarray; the Dispatcher posts each as an inline annotation on the PR’s Files-changed tab. The GitHub-native equivalent of GitLab’s per-line DiffNotes, with no separate review thread to manage.
The review agent
Each step shells out to a review-agent CLI baked into the container image (flare-dispatch-review). That mirrors the blog’s approach of spawning a coding-agent child process — the agent, its model client, prompt caching, the per-model circuit breaker, and provider failover all live inside that CLI, not in the DSL. The run only orchestrates: check out, slice the diff, tier it, fan out, coordinate, return findings.
Swapping the model or the agent framework is a change to the image (or a config key), not to this run.
Install
- Deploy FlareDispatch and install the GitHub App — specs/05-byoc.md.
- Copy
pr-review.run.tsinto your repo’sruns/directory. - Push. The Dispatcher auto-discovers the run; the next PR gets a
flare-dispatch/pr-reviewcheck, with inline annotations on the Files-changed tab.
Opt a PR out with the skip-ai-review label; force review on a draft with request-ai-review.
Source
This recipe ships both triggers — a typed Effect-TS run
(*.run.ts) and a GitHub Actions workflow
(ci.yml). Use whichever fits;
Webhook mode is the recommended default.
// Recipe: AI code review on every PR
//
// A FlareDispatch port of Cloudflare's multi-agent code reviewer —
// https://blog.cloudflare.com/ai-code-review/ — see ./README.md for how the
// blog's design maps onto this run.
//
// Mode: Webhook mode — fires on every pull_request push, zero GHA minutes,
// no workflow file. Drop this file into your repo's `runs/`. An
// Action-mode alternative (./ci.yml) dispatches the same run from a
// GitHub Actions workflow, for repos that cannot install the App.
// DSL: see specs/03-dsl.md (uses `config` + `io.priorExecution`); inline
// findings are posted as check-run annotations — specs/04-gha-integration.md.
// The checkout rides on the `workspace` primitive — 03-dsl § Primitives.
import { Effect, Schema, Match, Option } from "effect";
import { defineRun, step, sandbox, config, io } from "@flare-dispatch/core";
import { workspace } from "@flare-dispatch/core/primitives";
// Local helper — true if the PR carries the given label. The webhook
// payload's pull_request.labels is an array of { name }.
const hasLabel = (
payload: { pull_request: { labels: ReadonlyArray<{ name: string }> } },
name: string,
): boolean => payload.pull_request.labels.some((l) => l.name === name);
// The domain-scoped reviewers, one per concern (blog: "up to seven
// domain-specific agents"). The risk tier selects which subset actually runs
// — a trivial diff never pays for all seven.
const FULL_AGENTS = [
"security",
"performance",
"code-quality",
"documentation",
"release-management",
"compliance",
"agents-md",
] as const;
const LITE_AGENTS = ["security", "code-quality", "performance", "documentation"] as const;
const TRIVIAL_AGENTS = ["code-quality"] as const;
// Each Finding maps 1:1 onto a GitHub check-run annotation — the Dispatcher
// posts these inline on the PR's Files-changed tab, anchored to the exact
// lines (see specs/04-gha-integration.md § Inline findings — annotations).
const Finding = Schema.Struct({
path: Schema.String,
startLine: Schema.Number,
endLine: Schema.Number,
level: Schema.Literal("notice", "warning", "failure"),
title: Schema.String,
message: Schema.String,
});
// The run's output. `findings` becomes the annotation set; the rest renders
// in the check-run summary. Persisted as execution metadata, so the next
// push's re-review can read it back via `io.priorExecution`.
const ReviewOutput = Schema.Struct({
verdict: Schema.Literal("approve", "comment", "request-changes"),
tier: Schema.Literal("trivial", "lite", "full"),
critical: Schema.Number,
warnings: Schema.Number,
suggestions: Schema.Number,
findings: Schema.Array(Finding),
});
export const prReview = defineRun({
name: "pr-review",
version: "2.0.0",
image: "registry.cloudflare.com/openhackersclub/flare-dispatch-review:latest",
triggers: [
{
event: "pull_request",
actions: ["opened", "synchronize", "ready_for_review"],
idempotencyKey: ({ payload }) =>
`pr-review:${payload.repository.full_name}:${payload.pull_request.number}:${payload.pull_request.head.sha}`,
// skip drafts (unless explicitly requested), bots, and opt-outs
gate: ({ payload }) =>
(!payload.pull_request.draft || hasLabel(payload, "request-ai-review")) &&
!hasLabel(payload, "skip-ai-review") &&
!payload.pull_request.user.login.endsWith("[bot]"),
inputs: ({ payload }) => ({
repo: payload.repository.full_name,
sha: payload.pull_request.head.sha,
baseSha: payload.pull_request.base.sha,
pr: payload.pull_request.number,
installationId: payload.installation.id,
}),
},
],
inputs: Schema.Struct({
repo: Schema.String,
sha: Schema.String,
baseSha: Schema.String,
pr: Schema.Number,
// Optional: Webhook mode maps it from `payload.installation.id` above;
// Action mode (./ci.yml) omits it — the Dispatcher resolves the
// installation from the repo→installation KV map when it opens the
// check-run. The run body itself never reads it.
installationId: Schema.optional(Schema.Number),
}),
outputs: ReviewOutput,
// 25-min overall ceiling (blog: "overall (25 min)"); fan-out concurrency is
// bounded by the largest tier's agent count.
limits: { maxDurationSec: 1500, maxConcurrency: FULL_AGENTS.length },
run: (input) =>
Effect.gen(function* () {
// 1. Check out the PR head. The `review-agent` CLI is baked into the
// run's image, so the checkout needs no dependency install — just a
// container + clone, which is `workspace` with `install` off.
const { container, dir: repoDir } = yield* step("checkout", () =>
workspace({ repo: input.repo, sha: input.sha }),
);
// 2. Build the reviewable diff into DIFF — a *directory* of per-file
// patches (blog: `diff_directory`), with lockfiles, minified assets,
// and generated code stripped so agents never burn tokens on noise.
// Each agent later reads only the slices its domain touches.
const DIFF = "/tmp/diff";
yield* step("prepare-diff", () =>
sandbox.exec({
cwd: repoDir,
container,
command: [
"review-agent", "diff",
"--base", input.baseSha,
"--exclude", "lockfiles,minified,generated",
"--out", DIFF,
],
}),
);
// 3. Risk tier from diff size + touched paths.
const tierResult = yield* step("classify-risk", () =>
sandbox.exec({
cwd: repoDir,
container,
command: ["review-agent", "risk-tier", "--diff", DIFF],
}),
);
// 4. The tier IS the plan: which agents run, and which coordinator model.
// A trivial diff runs one generalist on a cheap model; a full review
// runs all seven and coordinates on the top-tier model (blog: "risk
// tiers prevent expensive model calls on trivial changes"). The
// `orElse` arm is a deliberate fail-safe — an unrecognized tier string
// escalates to the most thorough review, never the cheapest.
const plan = Match.value(tierResult.stdout.trim()).pipe(
Match.when("trivial", () => ({
tier: "trivial" as const, agents: TRIVIAL_AGENTS, model: "sonnet" as const,
})),
Match.when("lite", () => ({
tier: "lite" as const, agents: LITE_AGENTS, model: "sonnet" as const,
})),
Match.when("full", () => ({
tier: "full" as const, agents: FULL_AGENTS, model: "opus" as const,
})),
Match.orElse(() => ({
tier: "full" as const, agents: FULL_AGENTS, model: "opus" as const,
})),
);
// 5. Resolve the coordinator model through the control plane. `config`
// is KV-backed: an operator can repoint `opus` at a fallback model in
// seconds — no redeploy — when a provider degrades (blog: Workers+KV
// control plane). The plan's model is the default when no override.
const coordinatorModel = yield* step("resolve-model", () =>
config.get(`pr-review.model.${plan.model}`).pipe(
Effect.map((override) => override ?? plan.model),
),
);
// 6. Load the previous execution's findings for this same PR. On a
// re-review (a new push) the coordinator uses them to auto-resolve
// fixed threads and re-surface unfixed ones (blog: "re-reviews track
// previous findings"). Option.none() on the first push.
const prior = yield* step("load-prior", () =>
io.priorExecution({
family: `pr-review:${input.repo}:${input.pr}`,
outputSchema: ReviewOutput,
}),
);
// 7. Fan out one tightly-scoped agent per domain, in parallel — only the
// agents this tier calls for. The shared per-file patches under DIFF
// keep token use down across the concurrent reviewers; each writes
// findings to /tmp/findings. Code-quality gets a longer leash (blog:
// per-task timeouts — 5 min, 10 min for code quality).
yield* step("review", () =>
Effect.forEach(
plan.agents,
(agent) =>
sandbox.exec({
cwd: repoDir,
container,
command: [
"review-agent", "run", agent,
"--diff", DIFF,
"--tier", plan.tier,
"--out", "/tmp/findings",
],
timeoutSec: agent === "code-quality" ? 600 : 300,
}),
{ concurrency: plan.agents.length },
),
);
// 8. Seed the coordinator with the prior findings, if any. The findings
// set is small in practice (the blog reports ~1.2 findings per
// review), so passing it as a CLI arg stays well under ARG_MAX.
yield* Option.match(prior, {
onNone: () => Effect.void,
onSome: (p) =>
step("seed-prior", () =>
sandbox.exec({
cwd: repoDir,
container,
command: [
"review-agent", "seed-previous",
"--out", "/tmp/previous.json",
"--json", JSON.stringify(p.output.findings),
],
}),
),
});
// 9. Coordinator dedups + filters the agents' findings into one verdict
// on the resolved model (blog: "coordinator deduplicates and filters
// findings"; "bias toward approval unless critical issues found").
const review = yield* step("coordinate", () =>
sandbox.exec({
cwd: repoDir,
container,
command: [
"review-agent", "coordinate",
"--in", "/tmp/findings",
"--model", coordinatorModel,
...(Option.isSome(prior) ? ["--previous", "/tmp/previous.json"] : []),
"--json",
],
}),
);
// `coordinate --json` emits { verdict, critical, warnings, suggestions,
// findings }. The check-run summary FlareDispatch posts IS the single
// consolidated review; each finding additionally lands as an inline
// check-run annotation. `tier` is stitched in from the plan in step 4.
const coordinated = JSON.parse(review.stdout) as Omit<
Schema.Schema.Type<typeof ReviewOutput>,
"tier"
>;
return { ...coordinated, tier: plan.tier };
}),
}); # Recipe: AI code review — Action-mode alternative
#
# Use case: same `pr-review` run as ./pr-review.run.ts, but dispatched from a
# GitHub Actions workflow instead of the FlareDispatch GitHub App webhook.
#
# Webhook mode (the `triggers` block in ./pr-review.run.ts) is preferred — it
# burns zero GHA minutes and the run's own `gate` handles draft / bot /
# opt-out filtering. Use this Action-mode file when the App is not installed,
# or you want the review dispatch to sit alongside other PR jobs.
#
# Mode: Action mode, fire-and-forget. See specs/04-gha-integration.md.
# Run: pr-review — defined in ./pr-review.run.ts.
name: ai-code-review
on:
pull_request:
types: [opened, synchronize, ready_for_review]
jobs:
ai-code-review:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: pr-review
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.event.pull_request.head.sha }}",
"baseSha": "${{ github.event.pull_request.base.sha }}",
"pr": ${{ github.event.pull_request.number }}
}
# `installationId` is intentionally absent from `inputs`: the Dispatcher
# resolves the installation from the repo→installation KV map when it opens
# the check-run (specs/04-gha-integration.md § Check-runs callback), so the
# Action need not — and cannot reliably — supply it from the GHA context.
#
# Findings post back as inline check-run annotations on the PR's Files-changed
# tab — require the check-run `flare-dispatch/pr-review` in branch protection.