A FlareDispatch port of Cloudflare's multi-agent code reviewer — up to seven domain-specific agents review every PR (`pr-review.agents=multi`), findings deduplicated into one consolidated review; or one generalist reviewer (`agents=single`) for a leaner pass. Backend is selectable from CONFIG_KV without redeploy (Workers AI, Anthropic-via-AI-Gateway BYOK, or Bedrock via the OIDC→STS→SigV4 BYOC trust path), and a dispatch can override the model/region/role per call for model bake-offs. The GitHub App webhook fires it directly on every push — no `.github/workflows/` file and zero GHA minutes; the `ci.yml` shown is the optional Action-mode alternative, not a requirement. An optional Schedule-mode sweep re-reviews every open PR on a cron cadence.
Fires onEvery PR push— wire it any of these ways:
Webhook recommended The GitHub App webhook fires the run directly — no .github/workflows file, no GHA minutes.
Action A ci.yml step dispatches the run — when it must interleave with other CI jobs or gate the PR.
Schedule A Cloudflare Cron Trigger fires it on a wall-clock cadence — no GitHub event, no workflow file. (the pr-review-sweep run re-reviews open PRs on a cron)
Use case
Configurable single- or multi-agent code review on every PR — plus a nightly sweep
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.
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.
triggers on pull_request actions opened, synchronize, ready_for_review
Noise filtering — lockfiles, minified, generated
prepare-diff step → plain git diff, then stripDiffNoise drops lockfile / minified / generated / vendored file sections in the Worker
Risk tiers — trivial / lite / full
classify-risk step → the engine’s pure riskTier heuristic (diff size + sensitive paths); Match on the tier selects the agent set (1 / 4 / 7)
Cheaper model on trivial diffs
the tier gates how many domain reviewers fan out; the model is the operator-pinned backend (see below)
Workers + KV control plane — model routing without redeploy
resolve-backend step → config.get("pr-review.backend") + the backend’s model / mode keys (03-dsl § config) — repoint a backend in seconds, no redeploy
Up to seven domain-specific agents, each tightly scoped
FULL_AGENTS; the tier’s subset is fanned out in the review step (Effect.forEach with concurrency), each calling reviewDomainin the Worker
Shared context to cut token duplication
the single noise-stripped, size-capped diff passed to every domain reviewer
Coordinator dedups + filters into one verdict
coordinate step → the engine’s coordinate, pure deterministic code (dedup + counts-by-severity + verdict-by-rule) — no model call. The current run is authoritative (no carry-over), so a fixed finding clears on its own
Single consolidated review
a PR review comment (github.pullReview, event: COMMENT) posted on every run — success AND failure — plus the check-run summary
the verdict rule in coordinate: any failure → request-changes; else any warning → comment; else approve
Provider-agnostic model client
@flare-dispatch/review-agent calls the model through the modelGateway capability — backed by the Cloudflare Workers AI binding (env.AI) via an AI Gateway. The binding is the auth (account-billed); no model API key
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 reviewer backend (model id + mode) is resolved at run time, so an operator can repoint it at a fallback in seconds when a model degrades — no redeploy. This is the seam for the blog’s “Workers + KV control plane.”
modelGateway (03-dsl) — the model-calling capability, backed by the Cloudflare Workers AI binding (env.AI) via an AI Gateway. The binding is the auth (account-billed), so the run carries no model API key. The engine yields the Tag; the runtime provides it ambiently, like config / sandbox.
Check-run annotations (04-gha-integration) — the run returns a findings array; 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 is performed in the Worker run body, not in a container CLI. The single container image (infra/Dockerfile.sandbox: Node + git + curl) is used only for git (checkout + git diff). The engine is @flare-dispatch/review-agent: riskTier (a pure heuristic, no model call), reviewDomain (the one model-calling surface — calls the model through the modelGateway capability, backed by the Cloudflare Workers AI binding via an AI Gateway, no API key), and coordinate (pure deterministic assembly — dedup + counts + verdict-by-rule, no model call). reviewDomain’s findings come from a Schema-validated tool call (tools mode) or a parsed strict-JSON text response (json mode).
Earlier versions shelled out to a review-agent CLI that did not exist in the deployed image, so every review silently failed. Moving the engine into the Worker removes that dependency entirely.
The engine selects a model backend from config — repoint it in seconds, no redeploy. No API key — the model is called through the modelGateway capability (the Workers AI binding is the auth):
Key (CONFIG_KV)
Meaning
pr-review.backend
workers-ai (default), anthropic, or bedrock
pr-review.prompt
(optional) REPLACE the generic per-domain reviewer system prompt
pr-review.guidelines
(optional) ADDITIVE house rules appended on top of the reviewer prompt — a suppression rubric (“what NOT to flag”), project conventions, or severity calibration. Layers onto the maintained default instead of replacing it.
pr-review.workers-ai.model
bare Workers AI catalog id (e.g. @cf/meta/llama-3.3-70b-instruct-fp8-fast, account-billed, no key) or a deepseek/-prefixed hosted reasoner (e.g. deepseek/deepseek-reasoner, BYOK via AI Gateway; routes via the AI Gateway universal endpoint with a BYOK DeepSeek key stored in the gateway; requires AI_GATEWAY_ID)
pr-review.workers-ai.mode
tools (default) or json — pin json for reasoning models (DeepSeek-class models ignore tool-calls)
pr-review.anthropic.model
anthropic/-prefixed model id, e.g. anthropic/claude-sonnet-4-6. Routes via the AI Gateway universal endpoint (BYOK Anthropic key stored in the gateway). Requires AI_GATEWAY_ID.
pr-review.anthropic.mode
tools (default) or json
pr-review.bedrock.model
bedrock/-prefixed model id, e.g. bedrock/us.anthropic.claude-opus-4-6-v1. Routes via the AI Gateway Bedrock forwarder; SigV4-signed with short-lived STS creds (BYOC trust path). Requires AI_GATEWAY_ID + CLOUDFLARE_ACCOUNT_ID + OIDC_SIGNING_JWK + OIDC_ISSUER_URL.
pr-review.bedrock.region
AWS region (default us-east-1)
pr-review.bedrock.roleArn
IAM role ARN to AssumeRoleWithWebIdentity into. Trust policy MUST pin sub: pr-review:*.
Model ids are bare @cf/... for the Workers AI catalog, anthropic/... or deepseek/... for the AI-Gateway universal endpoint (BYOK), or bedrock/... for the AI-Gateway Bedrock forwarder. An AI Gateway can front Workers AI calls by setting the AI_GATEWAY_ID var on the Worker; the same AI_GATEWAY_ID is used for the anthropic/, deepseek/ and bedrock/ routes (required, not optional).
A misconfigured backend (no model key, or for bedrock no roleArn) fails fast — the run posts a PR comment naming the exact missing key.
The bedrock backend mints short-lived AWS credentials per execution via OIDC federation — no long-lived AWS key in GHA or the Worker. The dispatcher signs an OIDC JWT, AWS STS exchanges it for short-lived creds, then the run SigV4-signs bedrock:InvokeModel and POSTs through the AI Gateway Bedrock forwarder (the gateway forwards the SigV4 Authorization header verbatim, never holds the creds).
Set the Worker secrets + vars: OIDC_SIGNING_JWK (secret — pnpm cli oidc keygen), OIDC_ISSUER_URL (secret — must equal the OIDC provider --url), AI_GATEWAY_ID (var), CLOUDFLARE_ACCOUNT_ID (var), and optionally AI_GATEWAY_AUTH_TOKEN (secret — only if Authenticated Gateway is on). The matching public JWK is auto-served at <issuer>/.well-known/jwks.json.
Point the backend at Bedrock: pr-review.backend=bedrock, pr-review.bedrock.model=bedrock/us.anthropic.claude-opus-4-6-v1, pr-review.bedrock.roleArn=<the role ARN> (and optionally pr-review.bedrock.region).
pr-review.agents=multi(default) — the tier-scaled per-domain personas above (1 / 4 / 7 reviewers by risk tier). The “multi-agent” reviewer.
pr-review.agents=single — one generalist reviewer covering every concern, through the same structured engine (same findings → annotations → verdict → consolidated comment). A leaner, cheaper pass; the historical multi-agent-review run collapsed into this mode.
The risk tier is still classified either way (it sizes the diff cap and renders in the comment); in single mode it just doesn’t scale the reviewer count.
When dispatched in Action mode (a GHA workflow POSTs the run), a single dispatch can override the CONFIG_KV defaults via the run inputs — agents, backend, modelId, region, roleArn, focusArea. Absent, the CONFIG_KV defaults apply (the Webhook path is unchanged). This is the model-bake-off / per-PR-escalation path: dispatch backend: "bedrock", a modelId under test, and a roleArn to compare a model on a real PR without touching CONFIG_KV. These inputs ride the HMAC-authenticated dispatch (operator-trusted), never the diff.
Not every model honours tool-calling. Validated against Workers AI: a tool-calling-capable model (e.g. @cf/meta/llama-3.3-70b-…) returns tool calls fine, but a reasoning model (@cf/deepseek-ai/deepseek-r1-distill-qwen-32b) returns no tool calls (it emits <think>…</think> reasoning instead).
tools — sends the report tool and reads the Schema-validated tool args. Workers AI returns the args as a parsed object (the engine also tolerates an OpenAI-style JSON string). Best when the model supports it.
json — no tools; the model is asked for a strict JSON object. The engine strips <think>…</think> blocks and markdown code fences, isolates the JSON value, JSON.parses it, and Schema-decodes against the same Finding[] schema. A parse/decode failure surfaces a StructuredOutputInvalid error (the run posts a PR comment naming it).
Auto-fallback — a tools-mode call that comes back with zero tool calls retries once in json mode, so a model that silently drops tool-calling still produces a review.
Every run posts a top-level PR review comment (event: COMMENT) via the github capability — on success (the consolidated verdict + findings) and on failure (⚠️ pr-review could not complete: <reason>). The comment carries the <!-- flare-dispatch: pr-review --> footer marker.
pr-review fires on every PR push (Webhook mode). That misses three cases: a PR opened before the App was installed, a webhook delivery GitHub dropped, and a PR whose review you want re-run on a cadence regardless of pushes. pr-review-sweep.run.ts closes them — a Schedule-mode run (04-gha-integration § Schedule mode) that fires on a Cloudflare Cron Trigger instead of a GitHub event.
flowchart LR
CRON[Cron Trigger<br/>0 3 * * *] -->|scheduled| SW[pr-review-sweep<br/>scheduling Workflow]
SW --> ENUM[enumerate<br/>github.openPullRequests]
ENUM --> FAN[fan out · staggered<br/>one child per PR]
FAN --> R1[pr-review · PR #41]
FAN --> R2[pr-review · PR #58]
FAN --> Rn[pr-review · PR #N]
The sweep contains no review logic — it reuses the pr-review run above, unchanged. It only decides what to review and when:
Concern
How the sweep handles it
A cron tick names no target
The enumerate step calls github.openPullRequests (03-dsl § github) — the App-token-backed read surface — to discover every open PR across the App’s installations.
Don’t re-review unchanged PRs
Each child is created with the semantic instanceId pr-review:{repo}:{pr}:{headSha}. CF Workflows treats a duplicate create({ id }) as a no-op, so a PR already reviewed at its current head SHA — by Webhook mode or an earlier sweep — is skipped for free. The sweep is a backstop, not a duplicate channel.
Don’t burst the API / model provider
The fan-out is staggered with step.sleepUntil (03-dsl § Deferred scheduling) — children are spread evenly across a 45-minute window. The scheduling Workflow hibernates between slots, consuming no CPU.
Skip weekends / freeze windows
The schedules[].gate receiver-side check skips the tick before any Workflow is created.
The cadence (0 3 * * *) lives in the run’s schedulesand in wrangler.jsonctriggers.crons — the latter is what Cloudflare subscribes to (05-byoc § Wrangler config). The sweep posts no check-run of its own; each child pr-review posts its own per-PR check exactly as in Webhook mode.
Add its cron to wrangler.jsonc — "triggers": { "crons": ["0 3 * * *"] } — and wrangler deploy. The expression must match the run’s schedules[].cron.
At 03:00 UTC the Dispatcher’s scheduled() handler instantiates the sweep; every open PR not already reviewed at its head SHA gets a flare-dispatch/pr-review check.
Source
The recommended FlareDispatch shape is shown first. Toggle to
Without FlareDispatch to see the full GitHub Actions
workflow a team would maintain to do the same job without it.
Webhook mode — triggered directly by the FlareDispatch GitHub App webhook — zero GHA minutes.
recipes/ai-code-review/pr-review.run.ts
// 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.//// Drop this file into your repo's `runs/`; it is identical to the deployed// `runs/pr-review.ts`. The review engine (`@flare-dispatch/review-agent`) runs// in the Worker — no `review-agent` CLI in the container image.//// --- v3: the review runs IN THE WORKER, not a container CLI ------------------//// Earlier versions shelled out to a `review-agent` CLI baked into the run's// container image. That CLI does not exist in the deployed image (every exec// exited 127), so reviews silently failed. v3 moves the review into the run// body via `@flare-dispatch/review-agent`, which calls a model through the// `modelGateway` capability — backed by the Cloudflare Workers AI binding// (`env.AI`) via an AI Gateway. The binding is the auth (Workers AI is// account-billed), so NO model API key is configured. The ONE container image// (infra/Dockerfile.sandbox: Node + git + curl) is used only for `git`// (checkout + diff); every model call happens in the Worker, against a// CONFIGURABLE backend resolved from CONFIG_KV.//// --- CONFIG the operator sets (out of band) ---------------------------------//// CONFIG_KV pr-review.agents "single" (one generalist reviewer) | "multi" (tier-scaled per-domain personas) (default "multi")// CONFIG_KV pr-review.backend "workers-ai" | "anthropic" | "bedrock" (default workers-ai). A MODEL ROUTE, not an agentic tool — `opencode`/`reasonix` are reserved for the agent tier (specs/09-agentic-review.md), not this one.// CONFIG_KV pr-review.prompt (optional) REPLACE the reviewer system prompt// CONFIG_KV pr-review.guidelines (optional) ADDITIVE house rules appended to the reviewer prompt (suppression rubric / conventions / severity calibration)// CONFIG_KV pr-review.workers-ai.model model id — a bare Workers AI catalog id (e.g. @cf/meta/llama-3.3-70b-instruct-fp8-fast, account-billed, no key) OR a `deepseek/`-prefixed hosted reasoner (e.g. deepseek/deepseek-reasoner, BYOK via AI Gateway — the real model)// CONFIG_KV pr-review.workers-ai.mode "tools" | "json" (default "tools"; pin "json" for reasoning models — DeepSeek-class models ignore tool-calls)// CONFIG_KV pr-review.<backend>.maxDiffChars (optional) override the per-backend diff cap — a positive int clamped to [1_000, 1_000_000]. Defaults: workers-ai 60_000, anthropic/bedrock 240_000. Raise it for a big-context Workers AI model (GLM / Kimi); a value above the model's context overflows invisibly.// CONFIG_KV pr-review.<backend>.maxTokens (optional) override the per-backend output-token budget — a positive int clamped to [256, 32_768]. Defaults: workers-ai 8_192, anthropic/bedrock 4_096. A ceiling (non-reasoning models unaffected); raise it if a reasoning model truncates inside <think> before the JSON answer.// CONFIG_KV pr-review.anthropic.model `anthropic/`-prefixed model id (e.g. anthropic/claude-sonnet-4-6) — BYOK via AI Gateway// CONFIG_KV pr-review.anthropic.mode "tools" | "json" (default "tools")// CONFIG_KV pr-review.bedrock.model `bedrock/`-prefixed model id (e.g. bedrock/us.anthropic.claude-opus-4-6-v1) — BYOC via AI Gateway// CONFIG_KV pr-review.bedrock.region AWS region (default us-east-1)// CONFIG_KV pr-review.bedrock.roleArn IAM role to AssumeRoleWithWebIdentity into — trust policy MUST pin `sub: pr-review:*`// CONFIG_KV pr-review.style "default" (verbose verdict-table) | "compact" (LGTM-header + 3-col emoji table)// CONFIG_KV pr-review.compact-max how many findings the `compact` layout lists inline before "…and N more" (positive int, clamp 1..100, default 7; no-op for `default`)//// No API key: the Workers AI binding is the auth. A "tools"-mode backend that// returns no tool calls auto-retries once in "json" mode, so a model that// silently drops tool-calling still produces a review. A json-mode answer that// carries no parseable JSON (prose, or a value truncated inside a reasoning// block) gets ONE blunt "JSON only" repair retry before it counts as failed.//// The per-domain fan-out is fault-ISOLATED: one reviewer whose model call fails// (unparseable output, a transient 429) is dropped to zero findings and flagged// in the engagement line — the review still ships with the domains that// succeeded. The run only goes red when EVERY reviewer fails.//// --- Per-dispatch overrides (Action mode) -----------------------------------//// A dispatch MAY override the CONFIG_KV defaults per call via the run inputs —// `agents`, `backend`, `modelId`, `region`, `roleArn`, `focusArea`. Absent, the// CONFIG_KV defaults apply, so the everyday Webhook path is unchanged. This is// the model-bake-off / per-PR-escalation path the former `multi-agent-review`// run served: dispatch `backend: "bedrock"`, a `modelId` under test, and a// `roleArn` to compare a model on a real PR without touching CONFIG_KV.//// Mode: Webhook mode (fires on every pull_request push, zero GHA minutes) AND// Action mode (a GHA workflow dispatches it with per-call overrides).// DSL: see specs/03-dsl.md (uses `config` + `github`).import { Effect, Schema, Match, Option, Either } from "effect";import { defineRun, step, sandbox, artifact, config, io, github, type ReadFileFailed, StepFailed, type Container, type WebhookPayload,} from "@flare-dispatch/core";import { awsAssumeRole, workspace } from "@flare-dispatch/core/primitives";import { type BackendUnconfigured, backendConfigKey, capDiff, composeSystemPrompt, coordinate as engineCoordinate, DEFAULT_NAMESPACE, DEFAULT_REVIEW_SYSTEM_PROMPT, type Finding, guidelinesKey, type ModelCallFailed, namespacedKeys, parseBackend, resolveBackend, reviewDomain, riskTier, ReviewOutputSchema, stripDiffNoise, type StructuredOutputInvalid, type Tier,} from "@flare-dispatch/review-agent";// Local helper — true if the PR carries the given label.const hasLabel = (payload: WebhookPayload, name: string): boolean => // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call payload.pull_request?.labels?.some((l: { name: string }) => l.name === name) ?? false;// The domain-scoped reviewers, one per concern (blog: "up to seven// domain-specific agents"). The risk tier selects which subset actually runs.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;// Agent fan-out mode. `multi` (the default) fans out to the tier-scaled// per-domain personas above — the historical behaviour. `single` runs ONE// generalist reviewer covering every concern (the collapsed `multi-agent-review`// run's single-agent shape, now through the same structured engine). The// operator picks the mode via `pr-review.agents` CONFIG_KV; a dispatch overrides// it per call via the `agents` input.const GENERAL_AGENT = ["general"] as const;const AGENT_MODES = ["single", "multi"] as const;type AgentMode = (typeof AGENT_MODES)[number];const DEFAULT_AGENT_MODE: AgentMode = "multi";/** Narrow an arbitrary config string to a known agent mode, or the default. */const parseAgentMode = (raw: string | undefined): AgentMode => AGENT_MODES.includes(raw as AgentMode) ? (raw as AgentMode) : DEFAULT_AGENT_MODE;/** Config namespace this run's backend + prompt keys live under (`pr-review.*`). */const NS = DEFAULT_NAMESPACE;// The run's output. `findings` becomes the check-run annotation set; the rest// renders in the summary. Imported from the engine package so the run's// `outputs` schema and the engine's return type are one source of truth. Each// push re-reviews the full PR diff independently — the current run is// authoritative (no carry-over from prior executions), so a fixed finding clears.const ReviewOutput = ReviewOutputSchema;/** Footer marker on every PR comment this run posts — for idempotent updates. */const COMMENT_MARKER = "<!-- flare-dispatch: pr-review -->";/** * The "view full logs" footer for a PR comment — a markdown link to this * execution's log viewer, which (since #137) also lists the reviewed * `pr-review.diff` artifact. `undefined` viewer URL (a deploy with no public * origin / no log-link key) → empty string, so the comment renders exactly as * before. `viewerUrl` is dispatcher-minted (tokened), never model-authored, so * it needs no sanitization. */const viewerFooter = (viewerUrl: string | undefined): readonly string[] => viewerUrl === undefined ? [] : ["", `📋 [View full logs & reviewed diff ↗](${viewerUrl})`];/** * Where `prepare-diff` writes the unified diff inside the container. Read back * in full via `sandbox.readFile` — `ExecResult.stdout` inlines only a 16KB * tail, which silently reviewed a sliver of any sizeable PR. */const DIFF_FILE = "/tmp/pr-review.diff";// --- Oxc grounding -----------------------------------------------------------//// Before the model fans out, run oxlint (the Oxc Rust linter — Vite/VoidZero,// now inside Cloudflare) on the PR's changed files IN THE SAME container the// diff was produced in, and prepend its findings to the reviewable text. The// model then CONFIRMS / EXPANDS deterministic, pre-computed static-analysis// results (with exact `file:line` anchors) instead of re-deriving lint-level// issues from the diff alone — cheaper tokens, fewer hallucinated findings,// citable anchors.//// Best-effort by construction: oxlint runs via `npx` (no image change), and ANY// failure — no lintable files, fetch error, read error — degrades to an empty// block, so the review proceeds ungrounded and never goes red on this step./** Where the oxlint run writes its findings inside the container. */const OXLINT_FILE = "/tmp/pr-review.oxlint.txt";/** oxlint version line fetched via `npx` — tracks the 1.x major. */const OXLINT_VERSION = "1";/** Cap the changed-file list passed to oxlint (bounds the command line). */const OXLINT_MAX_FILES = 60;/** Cap the grounding block prepended to the model context. */const OXLINT_MAX_CHARS = 8000;/** Extensions oxlint lints — the changed files worth scanning. */const LINTABLE_EXT = /\.(?:m?[jt]sx?|cjs)$/;/** * Pull the changed (added/modified) file paths from a unified diff's * `+++ b/<path>` headers, keep the oxlint-lintable ones, dedupe, and cap. * `/dev/null` targets (deletions) are dropped. */const changedLintableFiles = (diff: string): readonly string[] => { const files = new Set<string>(); for (const line of diff.split("\n")) { if (!line.startsWith("+++ ")) continue; const target = line.slice(4).trim(); if (target === "/dev/null") continue; const path = target.startsWith("b/") ? target.slice(2) : target; if (LINTABLE_EXT.test(path)) files.add(path); } return [...files].slice(0, OXLINT_MAX_FILES);};/** Wrap raw oxlint output in a labelled, capped grounding block (or "" if empty). */const groundingBlock = (raw: string): string => { const trimmed = raw.trim(); if (trimmed.length === 0) return ""; const body = trimmed.length > OXLINT_MAX_CHARS ? `${trimmed.slice(0, OXLINT_MAX_CHARS)}\n…(truncated)` : trimmed; return [ "## Static analysis — oxlint findings on the changed files", "", "Deterministic, pre-computed by the Oxc linter. Treat as authoritative:", "confirm/expand these, cite their `file:line` anchors, and do NOT re-derive", "lint-level issues the linter already reports.", "", "```", body, "```", ].join("\n");};export const prReview = defineRun({ name: "pr-review", version: "3.1.0", image: "registry.cloudflare.com/openhackersclub/flare-dispatch-review:latest", triggers: [ { event: "pull_request", actions: ["opened", "synchronize", "ready_for_review"], // MUST match Action mode's semantic instanceId — `{run}:{repo_}:{sha12}` // (dispatch.ts `semanticInstanceId`) — so a repo that BOTH installs the // App (webhook mode) and dispatches from its own CI (Action mode) // collapses to ONE review per head SHA at the `create({id})` layer // instead of paying two sandbox runs. The PR number adds nothing to the // identity: the dispatcher's dedup contract is "same {run, repo, sha} → // same execution", and the number still rides in `inputs`. idempotencyKey: ({ payload }) => `pr-review:${payload.repository.full_name.replace(/\//g, "_")}:${payload.pull_request.head.sha.slice(0, 12)}`, 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, // Webhook mode maps it from `payload.installation.id`; Action mode omits // it. The run threads it to `github.pullReview` to authenticate the comment. installationId: Schema.optional(Schema.Number), // --- Per-dispatch overrides (Action mode) — all optional. When absent the // CONFIG_KV defaults apply, so the Webhook path is unchanged. These ride // the HMAC-authenticated dispatch (operator-trusted), never the diff. --- /** Override `pr-review.agents` — one generalist reviewer vs the persona fan-out. */ agents: Schema.optional(Schema.Literal("single", "multi")), /** Override `pr-review.backend` — pin a backend for this dispatch (e.g. a bake-off). */ backend: Schema.optional(Schema.String), /** Override the resolved backend's model id (e.g. the model under test). */ modelId: Schema.optional(Schema.String), /** Override the bedrock backend's AWS region. */ region: Schema.optional(Schema.String), /** Override the bedrock backend's IAM role ARN to AssumeRoleWithWebIdentity into. */ roleArn: Schema.optional(Schema.String), /** Extra focus line appended to the reviewer system prompt for this dispatch. */ focusArea: Schema.optional(Schema.String), }), outputs: ReviewOutput, limits: { maxDurationSec: 1500, maxConcurrency: FULL_AGENTS.length }, // At most one review per PR per 30 minutes, across BOTH dispatch paths // (webhook + Action). A rapid push sequence on an active PR collapses to // the first dispatch of the window; the skipped pushes answer 202 with the // prior execution's id, so CI stays green. The LAST state of a busy PR // still gets reviewed on its next dispatch after the window — and a review // can always be forced by re-running the CI job ≥30 min later. cooldown: { seconds: 1800, scope: (input) => `pr-${input.pr}` }, run: (input) => Effect.gen(function* () { // This execution's log-viewer URL — links every PR comment (success OR // failure) back to the full logs + the reviewed `pr-review.diff` artifact. // `Option.none()` on a deploy with no public origin / log-link key, in // which case the comment renders link-less, as before. const viewerUrl = Option.getOrUndefined(yield* io.viewerUrl); // The whole review is wrapped in an error boundary that ALWAYS posts a PR // comment — success or failure. `reviewBody` produces the output; the // catch arm posts a "could not complete" comment and re-fails (as a // `StepFailed`, a member of `RunError`) so the check still goes red // honestly. The comment post itself is best-effort — a failure to post // must not mask the original cause. return yield* reviewBody(input, viewerUrl).pipe( Effect.catchAll((err) => Effect.gen(function* () { const reason = describeError(err); // Post inside a step so a CF Workflow instance retry replays from // the checkpoint instead of posting a duplicate failure comment. yield* step("post-failure-comment", () => postComment( input, [ `⚠️ **pr-review could not complete**: ${reason}`, ...viewerFooter(viewerUrl), "", COMMENT_MARKER, ].join("\n"), ).pipe( Effect.catchAll((postErr) => io.log( "warn", `pr-review: failure-comment post failed — ${describeError(postErr)}`, ), ), ), ); return yield* Effect.fail( new StepFailed({ step: "pr-review", cause: reason }), ); }), ), ); }),});// ---------------------------------------------------------------------------// The review proper.const reviewBody = (input: RunInput, viewerUrl?: string) => Effect.gen(function* () { // 1. Resolve the configurable backend (model id + output mode + diff cap) // from CONFIG_KV, with any per-dispatch input overrides layered on top — // FIRST, before paying for a container, so a misconfigured backend fails // fast → the error boundary posts a PR comment naming the missing key. No // API key — the model is called through the `modelGateway` capability // (Workers AI binding via an AI Gateway), which the runtime provides // ambiently. const resolved = yield* step("resolve-backend", () => resolveEffectiveBackend(input), ); // 2. Check out the PR head. `git` is in the image; no dependency install. const { container, dir: repoDir } = yield* step("checkout", () => workspace({ repo: input.repo, sha: input.sha }), ); // 3. Build the reviewable diff with plain `git` (no `review-agent` CLI). // A non-zero exit FAILS the step (honest red check) — see `execOrFail`. // // The diff is written to a FILE and read back with `sandbox.readFile` // — NOT taken from `ExecResult.stdout`, which inlines only a bounded // 16KB tail (the rest streams to R2). Reading stdout silently reviewed // just the last sliver of any sizeable PR: the risk tier under-counted // (a huge PR classified `lite`/`trivial`), sensitive paths escaped the // `full` escalation, and the reviewers "found nothing" because they // never saw the change. // // Noise-strip + backend-sized cap happen INSIDE the step so the value // the Workflow checkpoints is bounded by `maxDiffChars`, never the raw // multi-MB diff. One global cap sized for a frontier model would // overflow a catalog model's context invisibly (the model goes // needle-blind and "finds nothing") — hence the RESOLVED backend's cap. // // THREE-dot diff (`base...head`), never two-dot: `baseSha` is the base // branch TIP at event time (`pull_request.base.sha`), not the fork // point. A two-dot endpoint diff on any PR behind its base reviewed // `base-tip → head`, presenting everything merged to the base since // the fork as DELETIONS — reviewers flagged phantom removals of files // the PR never touched. Three-dot diffs from `merge-base(base, head)`, // matching the PR diff GitHub itself renders. const diff = yield* step("prepare-diff", () => execOrFail({ container, cwd: repoDir, command: [ "git", "diff", "--unified=3", `--output=${DIFF_FILE}`, `${input.baseSha}...${input.sha}`, ], }).pipe( Effect.andThen(sandbox.readFile({ container, path: DIFF_FILE })), Effect.map((raw) => capDiff(stripDiffNoise(raw), resolved.maxDiffChars)), ), ); // 3b. Persist the FULL (uncapped) diff as an artifact so the log viewer can // surface it and a reviewer can read exactly what was reviewed. The // checkpointed `diff` value is capped for the model's context; the file // on disk is the whole thing. Best-effort: a `git diff --output=<file>` // emits no stdout, so without this the run leaves no visible record of // the diff. A capture failure must never fail the review — swallow it. yield* step("upload-diff", () => artifact.upload({ name: "pr-review.diff", path: DIFF_FILE, container, contentType: "text/plain; charset=utf-8", }), ).pipe( Effect.catchAllCause((cause) => Effect.logWarning(`upload-diff failed (non-fatal): ${cause}`), ), ); // 4. Risk tier — a pure heuristic on diff size + touched paths (no model // call). The tier is always classified (it sizes the diff cap + renders // in the comment); the agent MODE decides whether it also scales the // persona fan-out. const tier = yield* step("classify-risk", () => riskTier({ diff })); // 4b. Agent fan-out mode — `single` (one generalist reviewer) vs `multi` // (the tier-scaled per-domain personas). Input override > CONFIG_KV > // default. The plan's agent set follows from the mode. const agentMode: AgentMode = input.agents ?? parseAgentMode( yield* step("resolve-agents", () => config.get("pr-review.agents")), ); const plan = planForMode(agentMode, tier); // 5. The reviewer system prompt — layered base → guidelines → focus, // composed here (not threaded through the engine) so `focusArea` rides // the trusted dispatch input, never the attacker-controllable diff. The // model's OUTPUT is still sanitized before it renders in the public // comment regardless. // - `pr-review.prompt` REPLACES the base reviewer instruction. // - `pr-review.guidelines` is ADDITIVE — appended on top as authoritative // house rules (a suppression rubric, project conventions, severity // calibration), so an operator can shape the review without forking // the maintained default prompt. const promptOverride = yield* step("resolve-prompt", () => config.get("pr-review.prompt"), ); const guidelines = yield* step("resolve-guidelines", () => config.get(guidelinesKey(NS)), ); const systemPrompt = composeSystemPrompt({ base: promptOverride ?? DEFAULT_REVIEW_SYSTEM_PROMPT, ...(guidelines !== undefined ? { guidelines } : {}), ...(input.focusArea !== undefined ? { focus: input.focusArea } : {}), }); // 5b. Comment style preset — operator picks the comment layout. Both // presets are hard-coded server-side (so model-authored text can't // inject layout). See `parseStyle` + `renderReviewComment`. const style = parseStyle( yield* step("resolve-style", () => config.get("pr-review.style")), ); // 5b-ii. How many findings the `compact` layout lists inline before the // "…and N more" overflow line. Operator-tunable so the leaderboard // layout can show more (or fewer) without a code change; clamps to a // sane range, defaults to 7. No-op for the `default` layout, which // uses its own MAX_RENDERED_FINDINGS. const compactMax = parseCompactMax( yield* step("resolve-compact-max", () => config.get("pr-review.compact-max")), ); // 5c. When the resolved backend is `bedrock`, mint short-lived AWS creds via // OIDC federation — the modelGateway Bedrock route SigV4-signs each call // with these. The role's trust policy SHOULD pin `sub: pr-review:*` so // a leaked HMAC alone can't assume the role; `awsAssumeRole`'s default // subject is `<run>:<execution-id>`, which matches that pattern. Other // backends skip this step entirely. const bedrockCreds = resolved.backend === "bedrock" && resolved.roleArn !== undefined && resolved.region !== undefined ? yield* step("assume-bedrock-role", () => awsAssumeRole({ roleArn: resolved.roleArn as string, region: resolved.region as string, sessionName: `pr-review-${input.sha.slice(0, 12)}`, }), ) : undefined; const awsCreds = bedrockCreds !== undefined && resolved.region !== undefined ? { accessKeyId: bedrockCreds.accessKeyId, secretAccessKey: bedrockCreds.secretAccessKey, sessionToken: bedrockCreds.sessionToken, region: resolved.region, } : undefined; // 5d. Oxc grounding — run oxlint on the PR's changed files in the checkout // container and prepend its findings to the reviewable text. Best- // effort: any failure (no lintable files, npx fetch, read) degrades to // the ungrounded diff via `catchAllCause`, so the review never goes red // on this step. `riskTier` above used the RAW diff (size heuristic), so // the grounding block doesn't perturb tier classification. const oxlintBlock = yield* step("oxlint-scan", () => Effect.gen(function* () { const files = changedLintableFiles(diff); if (files.length === 0) return ""; // Non-zero exit (oxlint found issues) is a NORMAL ExecResult, not a // failure; redirect both streams to a file read back in full (the // 16KB stdout tail would truncate a busy lint report). yield* sandbox.exec({ container, cwd: repoDir, command: `npx --yes oxlint@${OXLINT_VERSION} ${files.join(" ")} > ${OXLINT_FILE} 2>&1`, }); const out = yield* sandbox.readFile({ container, path: OXLINT_FILE }); return groundingBlock(out); }), ).pipe(Effect.catchAllCause(() => Effect.succeed(""))); const groundedDiff = oxlintBlock.length > 0 ? `${oxlintBlock}\n\n${diff}` : diff; // 6. Fan out one reviewer per domain, IN-WORKER, in parallel — only the // agents this tier calls for. Each calls the model via the `modelGateway` // capability (provided by the runtime, like `config`/`sandbox`); findings // are Schema-validated tool-call / json output. The reviewers see the // GROUNDED diff (oxlint findings prepended), not the raw diff. // // FAULT-ISOLATED fan-out: each reviewer is wrapped in `Effect.either`, so // one domain whose model call fails — unparseable output // (`StructuredOutputInvalid`), a transient 429 under the concurrent // fan-out (`ModelCallFailed`) — is dropped to zero findings and flagged // `errored` in the engagement line, rather than aborting the whole review. // The review still ships with every domain that DID succeed — resilience // is the point of the multi-agent design. ONLY when EVERY reviewer fails // do we re-raise (the typed cause), so the boundary posts an honest "could // not complete" for a systemic fault (misconfigured backend, wrong // model/mode, gateway down) instead of masking it as an empty review. const reviewed = yield* step("review", () => Effect.gen(function* () { const results = yield* Effect.forEach( plan.agents, (agent) => reviewDomain({ agent, diff: groundedDiff, tier: plan.tier, model: resolved.model, backend: resolved.backend, mode: resolved.mode, maxTokens: resolved.maxTokens, systemPrompt, ...(awsCreds !== undefined ? { aws: awsCreds } : {}), }).pipe(Effect.either), { concurrency: plan.agents.length }, ); // Log each failed domain's cause (the per-reviewer error is otherwise // swallowed by the tolerance below) for operator diagnosis in the logs. for (let i = 0; i < plan.agents.length; i++) { const r = results[i]; if (r !== undefined && Either.isLeft(r)) { yield* io.log( "warn", `pr-review: reviewer "${plan.agents[i]}" failed — ${describeError(r.left)}`, ); } } // Every reviewer failed → no partial review to salvage; re-raise the // first cause (typed) so the error boundary names it precisely. const firstLeft = results.find(Either.isLeft); if (firstLeft !== undefined && results.every(Either.isLeft)) { return yield* Effect.fail(firstLeft.left); } // Findings from the domains that succeeded; failed domains contribute // none. Per-domain counts (or an `errored` flag) render in the comment so // an all-empty review is visibly "N reviewers each reported 0", and a // degraded review is visibly "this domain errored" — never silently // indistinguishable from "found nothing". const findings: ReadonlyArray<Finding> = results.flatMap((r) => Either.isRight(r) ? r.right : [], ); const domainCounts: ReadonlyArray<DomainCount> = plan.agents.map( (agent, i) => { const r = results[i]; return r !== undefined && Either.isRight(r) ? { agent, count: r.right.length } : { agent, count: 0, errored: true }; }, ); return { findings, domainCounts }; }), // Cap the per-step retry. CF Workflows' default is `limit: 5` (up to 6 // attempts), and a step retry REPLAYS the whole fan-out. This step throws // ONLY when EVERY reviewer failed — an all-fail is systemic: a 429 storm // from the concurrent burst (`concurrency: plan.agents.length`) hitting a // low-per-model-rate-limit model, a backend down, or a wrong model/mode. // The per-domain calls are NOT individually retried on 429, so replaying // the 7-way burst 5 more times just re-throttles the same model and // re-issues every call. One retry (after the backoff, by which the // per-minute rate window may have rolled over) keeps recovery for a // genuine transient while bounding the worst case from ×6 attempts to ×2. { retries: 1 }, ); const allFindings: ReadonlyArray<Finding> = reviewed.findings; const domainCounts: ReadonlyArray<DomainCount> = reviewed.domainCounts; // 7. Coordinate — PURE deterministic assembly (dedup + counts + verdict) over // THIS run's findings. No model call; the current run is authoritative, so // a fixed finding clears. `tier` is stitched in from the plan. const coordinated = yield* step("coordinate", () => engineCoordinate({ findings: allFindings }), ); const output = { ...coordinated, tier: plan.tier }; // 8. Post the visible top-level PR review comment (the findings additionally // land as check-run annotations via the run output). Best-effort — a // comment failure must not turn a green review red. yield* step("post-comment", () => postComment( input, renderReviewComment(input, output, domainCounts, style, viewerUrl, compactMax), ).pipe( Effect.catchAll((e) => io.log("warn", `pr-review: posting PR comment failed — ${describeError(e)}`), ), ), ); return output; });// ---------------------------------------------------------------------------// Helpers.type RunInput = { readonly repo: string; readonly sha: string; readonly baseSha: string; readonly pr: number; readonly installationId?: number; // Per-dispatch overrides (Action mode) — see the `inputs` schema. readonly agents?: AgentMode; readonly backend?: string; readonly modelId?: string; readonly region?: string; readonly roleArn?: string; readonly focusArea?: string;};type Plan = { readonly tier: Tier; readonly agents: readonly string[];};/** Map the risk tier to its agent set. (The model is resolved from the backend * config, not the tier — see `resolveBackend`.) */const planForTier = (tier: Tier): Plan => Match.value(tier).pipe( Match.when("trivial", () => ({ tier: "trivial" as const, agents: TRIVIAL_AGENTS })), Match.when("lite", () => ({ tier: "lite" as const, agents: LITE_AGENTS })), Match.when("full", () => ({ tier: "full" as const, agents: FULL_AGENTS })), Match.exhaustive, );/** Map the resolved agent mode + risk tier to the reviewer plan. `single` runs * ONE generalist reviewer (the tier still renders in the comment); `multi` runs * the tier-scaled per-domain personas. */const planForMode = (mode: AgentMode, tier: Tier): Plan => mode === "single" ? { tier, agents: GENERAL_AGENT } : planForTier(tier);/** * Resolve the active backend, layering per-dispatch input overrides over * CONFIG_KV. An override-aware `getConfig` shim feeds the engine's * `resolveBackend` the input values where present — so a single dispatch can * fully specify a backend (e.g. a Bedrock model bake-off: `backend: "bedrock"`, * `modelId`, `roleArn`, `region`) with NO CONFIG_KV keys set, while an everyday * webhook review with no overrides resolves exactly as before. The shim only * shadows the keys an override targets; everything else still reads CONFIG_KV. */const resolveEffectiveBackend = (input: RunInput) => Effect.gen(function* () { // The effective backend names which `<backend>.*` keys an override targets. const backend = parseBackend( input.backend ?? (yield* config.get(backendConfigKey(NS))), ); const keys = namespacedKeys(NS)[backend]; const overrides = new Map<string, string>(); if (input.backend !== undefined) overrides.set(backendConfigKey(NS), input.backend); if (input.modelId !== undefined) overrides.set(keys.modelKey, input.modelId); if (input.region !== undefined && keys.regionKey !== undefined) overrides.set(keys.regionKey, input.region); if (input.roleArn !== undefined && keys.roleArnKey !== undefined) overrides.set(keys.roleArnKey, input.roleArn); return yield* resolveBackend( (key) => overrides.has(key) ? Effect.succeed<string | undefined>(overrides.get(key)) : config.get(key), { namespace: NS }, ); });/** * Run a container command and FAIL the Effect when it exits non-zero. The core * `sandbox.exec` deliberately surfaces a non-zero exit as a normal `ExecResult` * (a failing test is data, not an error) — but for `pr-review`'s git steps a * non-zero exit IS a real failure that must turn the check red, so we lift it * into the typed error channel here (rather than mutating the shared * `sandbox.exec`, which other runs rely on). */const execOrFail = (opts: { container: Container; cwd: string; command: readonly string[]; timeoutSec?: number;}) => sandbox.exec(opts).pipe( Effect.flatMap((result) => result.exitCode === 0 ? Effect.succeed(result) : Effect.fail( new ExecNonZero({ command: opts.command.join(" "), exitCode: result.exitCode, stderrTail: result.stderr.slice(-2000), }), ), ), );/** A container command that ran to completion but exited non-zero. */class ExecNonZero extends Schema.TaggedError<ExecNonZero>()("ExecNonZero", { command: Schema.String, exitCode: Schema.Number, stderrTail: Schema.String,}) {}/** Post a top-level PR review comment via the `github` capability. */const postComment = (input: RunInput, body: string) => github.pullReview({ repo: input.repo, pr: input.pr, sha: input.sha, body, ...(input.installationId !== undefined ? { installationId: input.installationId } : {}), });/** The tagged errors the review boundary knows how to describe precisely; * anything else (e.g. `StepFailed`, a core capability error) falls to the * `Match.orElse` arm. */type DescribableError = | BackendUnconfigured | ModelCallFailed | StructuredOutputInvalid | ExecNonZero | ReadFileFailed;/** Human-readable one-liner for any error the boundary catches. */const describeError = (err: unknown): string => Match.value(err as DescribableError).pipe( Match.tag( "BackendUnconfigured", (e) => `backend "${e.backend}" is misconfigured — set ${e.missing}`, ), Match.tag( "ModelCallFailed", (e) => `model call failed (${e.reason}): ${e.message}`, ), Match.tag( "StructuredOutputInvalid", (e) => `model returned unparseable ${e.surface} output (${e.reason}); the backend may need \`mode: "json"\` or a different model` + // Surface a short, SANITIZED excerpt of the RAW model text. It is // captured on the error but was previously dropped here, so the only // record of what the model actually returned lived in the (auth-gated) // step logs — which is exactly why an `empty` (the model's answer // silently dropped at the binding boundary) took so long to diagnose. // `sanitizeModelText` defangs it; the verdict never derives from it. (e.excerpt.trim() !== "" ? ` — model returned: "${sanitizeModelText(e.excerpt)}"` : ""), ), Match.tag( "ExecNonZero", (e) => `\`${e.command}\` exited ${e.exitCode}`, ), Match.tag( "ReadFileFailed", (e) => `reading the diff file \`${e.path}\` failed: ${e.message}`, ), Match.orElse(() => err instanceof Error ? err.message : JSON.stringify(err), ), );/** * Neutralize model-authored text before it renders in the public PR comment. * The diff is attacker-controllable on a hostile PR and feeds the model, so a * finding's `title`/`message`/`path` could carry `@mention` pings, raw HTML, or * code-fence/backtick break-outs. Collapse to one line, drop angle brackets, * defang `@` and backticks, and bound the length. (Control flow is already safe * — the verdict derives only from the schema-constrained `level`.) */const SANITIZE_MAX = 500;// U+200B zero-width space — inserted after `@` it breaks GitHub's @mention// autolink without visibly altering the text. Built from a code point so the// source stays ASCII-only.const ZWSP = String.fromCharCode(0x200b);const sanitizeModelText = (s: string): string => s .replace(/[\r\n]+/g, " ") .replace(/[<>]/g, "") .replace(/`/g, "'") .replace(/@(?=[\w-])/g, `@${ZWSP}`) .slice(0, SANITIZE_MAX);/** One domain reviewer's engagement — how many findings it reported, or * `errored: true` when its model call failed and it was skipped (count 0). */type DomainCount = { readonly agent: string; readonly count: number; readonly errored?: boolean;};/** How many findings render in the comment; the rest land as check annotations. */const MAX_RENDERED_FINDINGS = 25;/** Severity → icon + label, shared by the summary table and per-finding headings. * Labels mirror the header's count names (critical / warnings / suggestions). */const severityBadge = (level: Finding["level"]): string => Match.value(level).pipe( Match.when("failure", () => "🛑 Critical"), Match.when("warning", () => "⚠️ Warning"), Match.when("notice", () => "💡 Suggestion"), Match.exhaustive, );/** * GitHub blob URL for a finding — `https://github.com/<repo>/blob/<sha>/<path>#L<n>`. * `repo`/`sha` come from the trusted webhook input; `path` is model-authored, so * each segment is sanitized then URL-encoded (plus manual paren-encoding — * `encodeURIComponent` leaves `()` alone, and a bare `)` would terminate the * markdown link). The line fragment is dropped when the model's line numbers * are nonsense (≤ 0), leaving a plain file link. */const findingUrl = (repo: string, sha: string, f: Finding): string => { const encodedPath = sanitizeModelText(f.path) .replace(/^\/+/, "") .split("/") .map(encodeURIComponent) .join("/") .replace(/\(/g, "%28") .replace(/\)/g, "%29"); const start = Math.floor(f.startLine); const end = Math.floor(f.endLine); const fragment = start > 0 ? (end > start ? `#L${start}-L${end}` : `#L${start}`) : ""; return `https://github.com/${repo}/blob/${sha}/${encodedPath}${fragment}`;};/** `path:line` display text for a finding's location. Square brackets are * stripped on top of `sanitizeModelText` — the text renders inside `[…](url)` * link syntax, where a `]` would break out of the link. */const findingLoc = (f: Finding): string => { const path = sanitizeModelText(f.path).replace(/[[\]]/g, ""); return f.startLine === f.endLine ? `${path}:${f.startLine}` : `${path}:${f.startLine}-${f.endLine}`;};/** Sanitized text safe inside a markdown table cell — an unescaped `|` would * split the row. */const tableCell = (s: string): string => sanitizeModelText(s).replace(/\|/g, "\\|");/** Comment style preset — operator selects the layout via `pr-review.style` * CONFIG_KV. Hard-coded presets only; never an arbitrary template (model- * authored text would otherwise be able to inject layout). */const STYLES = ["default", "compact"] as const;type Style = (typeof STYLES)[number];const DEFAULT_STYLE: Style = "default";/** Narrow an arbitrary config string to a known style, or fall back to default. */const parseStyle = (raw: string | undefined): Style => STYLES.includes(raw as Style) ? (raw as Style) : DEFAULT_STYLE;/** Default for how many findings the `compact` style lists (most-critical * first) when `pr-review.compact-max` is unset. The full set is still emitted * as check-run annotations regardless of style; this only bounds how many * appear inline in the PR comment before the "…and N more" overflow line. * Mirrors typical "leaderboard" PR-review bots (7 most-critical first). */const COMPACT_MAX_LISTED_DEFAULT = 7;/** Upper clamp on the operator-configured compact cap — a comment listing * hundreds of findings inline would blow past sensible PR-comment length and * GitHub's body limit; the overflow → check-run annotations is the relief * valve. 100 is well above any real review. */const COMPACT_MAX_LISTED_CLAMP = 100;/** Parse the `pr-review.compact-max` config (how many findings the `compact` * layout lists inline). Accepts a positive integer string; clamps to * 1..{@link COMPACT_MAX_LISTED_CLAMP}; falls back to * {@link COMPACT_MAX_LISTED_DEFAULT} for unset / non-numeric / ≤0 values, so a * typo never silently drops the comment to zero rows. */const parseCompactMax = (raw: string | undefined): number => { if (raw === undefined) return COMPACT_MAX_LISTED_DEFAULT; const n = Number.parseInt(raw.trim(), 10); if (!Number.isInteger(n) || n <= 0) return COMPACT_MAX_LISTED_DEFAULT; return Math.min(n, COMPACT_MAX_LISTED_CLAMP);};/** Render the consolidated review as a markdown PR comment. `style` picks the * layout: `default` is the verbose verdict-table for power users; `compact` is * the lean leaderboard-bot format ("`## ✅ LGTM`" + 3-col emoji table). */const renderReviewComment = ( input: Pick<RunInput, "repo" | "sha">, output: Schema.Schema.Type<typeof ReviewOutput>, domainCounts: ReadonlyArray<DomainCount>, style: Style = DEFAULT_STYLE, viewerUrl?: string, compactMax: number = COMPACT_MAX_LISTED_DEFAULT,): string => Match.value(style).pipe( Match.when("compact", () => renderCompact(input, output, viewerUrl, compactMax)), Match.when("default", () => renderDefault(input, output, domainCounts, viewerUrl), ), Match.exhaustive, );/** Verbose verdict-table layout: summary table + per-finding details + per-domain * engagement line. Full reviewer transparency — the historical default. */const renderDefault = ( input: Pick<RunInput, "repo" | "sha">, output: Schema.Schema.Type<typeof ReviewOutput>, domainCounts: ReadonlyArray<DomainCount>, viewerUrl?: string,): string => { const verdictBadge = Match.value(output.verdict).pipe( Match.when("approve", () => "✅ Approve"), Match.when("comment", () => "💬 Comment"), Match.when("request-changes", () => "🛑 Request changes"), Match.exhaustive, ); const header = [ `### AI code review — ${verdictBadge}`, "", `Risk tier: \`${output.tier}\` · ${output.critical} critical · ${output.warnings} warnings · ${output.suggestions} suggestions`, "", // Engagement line: every domain that ran, with its finding count — the // counts may exceed the deduped totals above. A domain whose model call // failed (and was skipped) shows `⚠️` instead of a count, so a degraded // review is visibly partial rather than silently under-reporting. `Reviewers: ${domainCounts.map((d) => (d.errored === true ? `${d.agent} ⚠️` : `${d.agent} ${d.count}`)).join(" · ")}`, ]; const rendered = output.findings.slice(0, MAX_RENDERED_FINDINGS); const summaryTable = [ "", "| # | Severity | Change required | Location |", "| --- | --- | --- | --- |", ...rendered.map( (f, i) => `| ${i + 1} | ${severityBadge(f.level)} | ${tableCell(f.title)} | [${tableCell(findingLoc(f))}](${findingUrl(input.repo, input.sha, f)}) |`, ), ]; const details = rendered.flatMap((f, i) => [ "", `#### ${i + 1}. ${severityBadge(f.level)} — ${sanitizeModelText(f.title)}`, "", `📍 [${findingLoc(f)}](${findingUrl(input.repo, input.sha, f)})`, "", sanitizeModelText(f.message), ]); const findingsBlock = output.findings.length === 0 ? ["", "_No findings._"] : [ ...summaryTable, ...details, ...(output.findings.length > MAX_RENDERED_FINDINGS ? [ "", `_…and ${output.findings.length - MAX_RENDERED_FINDINGS} more (see check annotations)._`, ] : []), ]; return [ ...header, ...findingsBlock, ...viewerFooter(viewerUrl), "", COMMENT_MARKER, ].join("\n");};/** Compact "leaderboard-bot" layout — verdict header (`## ✅ LGTM` / * `⚠️ Minor Issues` / `🚫 Changes Requested`) + 3-col emoji table, max 7 rows. * Built to match the format teams already get from in-house reviewers like * opencode-agent so a FlareDispatch comment lands without a layout cliff. */const renderCompact = ( input: Pick<RunInput, "repo" | "sha">, output: Schema.Schema.Type<typeof ReviewOutput>, viewerUrl?: string, compactMax: number = COMPACT_MAX_LISTED_DEFAULT,): string => { const verdictHeader = Match.value(output.verdict).pipe( Match.when("approve", () => "## ✅ LGTM"), Match.when("comment", () => "## ⚠️ Minor Issues"), Match.when("request-changes", () => "## 🚫 Changes Requested"), Match.exhaustive, ); if (output.findings.length === 0) { return [ verdictHeader, "", "No issues found.", ...viewerFooter(viewerUrl), "", COMMENT_MARKER, ].join("\n"); } const compactSeverity = (level: Finding["level"]): string => Match.value(level).pipe( Match.when("failure", () => "🔴"), Match.when("warning", () => "🟡"), Match.when("notice", () => "🔵"), Match.exhaustive, ); const rendered = output.findings.slice(0, compactMax); const overflow = output.findings.length - rendered.length; return [ verdictHeader, "", "| Severity | Location | Issue |", "| --- | --- | --- |", ...rendered.map( (f) => `| ${compactSeverity(f.level)} | [${tableCell(findingLoc(f))}](${findingUrl(input.repo, input.sha, f)}) | ${tableCell(f.message)} |`, ), ...(overflow > 0 ? ["", `_…and ${overflow} more (see check annotations)._`] : []), ...viewerFooter(viewerUrl), "", COMMENT_MARKER, ].join("\n");};
recipes/ai-code-review/pr-review-sweep.run.ts
// Recipe: scheduled AI code-review sweep//// The Schedule-mode companion to pr-review.run.ts. Where `pr-review` fires on// every PR push (Webhook mode), `pr-review-sweep` fires on a wall-clock// cadence (Schedule mode): each cron tick it enumerates open PRs and fans out// one `pr-review` execution per PR that still needs one. It contains NO// review logic — it is pure orchestration. The review itself is the existing// `pr-review` run, unchanged; the sweep only decides *what* to review and// *when*.//// Mode: Schedule mode — a Cloudflare Cron Trigger drives the Dispatcher's// scheduled() handler, which instantiates this run as a durable// scheduling Workflow. See specs/04-gha-integration.md § Schedule mode// and specs/01-architecture.md § Schedule-mode dispatch.// DSL: `schedules` on defineRun (03-dsl § schedules); the `github` read// capability for enumeration (03-dsl § github); `sharded` +// step.sleepUntil to stagger the fan-out under the GitHub API rate// limit (03-dsl § Deferred scheduling).//// Why this is a *backstop*, not a duplicate channel: every child execution// keeps the semantic instanceId `pr-review:{repo}:{pr}:{headSha}`, and CF// Workflows treats a duplicate `create({ id })` as a no-op. So a PR already// reviewed at its current head SHA by Webhook mode is silently skipped here —// the sweep only spends tokens on PRs that changed since their last review,// or that Webhook mode never saw (App installed after the push, a delivery// dropped, the run added to an existing repo).import { Effect, Schema } from "effect";import { defineRun, step, github, io, spawnChildRun } from "@flare-dispatch/core";import { sharded } from "@flare-dispatch/core/primitives";// ISO calendar date (UTC) — the cron-window dedup key. One sweep per day.const isoDate = (ms: number): string => new Date(ms).toISOString().slice(0, 10);// Coarse scope produced by schedules[].inputs. A cron tick names no target,// so this is NOT a repo/PR — it is the filter the `enumerate` step applies.const SweepInput = Schema.Struct({ staleAfterHours: Schema.Number, // only PRs updated within this window includeDrafts: Schema.Boolean, firedAt: Schema.Number, // controller.scheduledTime, epoch ms});// The sweep's own output is a digest written to D1 execution metadata. The// sweep posts no check-run of its own — it is not anchored to a commit; each// child `pr-review` posts its own per-PR check-run (01-architecture// § Schedule-mode dispatch).const SweepOutput = Schema.Struct({ prsFound: Schema.Number, dispatched: Schema.Number, // child pr-review executions actually created skipped: Schema.Number, // already reviewed at head SHA — no-op create});export const prReviewSweep = defineRun({ name: "pr-review-sweep", version: "1.0.0", // Schedule mode: 03:00 UTC daily. This expression MUST also appear in // wrangler.jsonc `triggers.crons` — that array is what Cloudflare actually // subscribes to; `schedules` is how the scheduled() handler routes the // firing `controller.cron` back to this run (05-byoc § Wrangler config). schedules: [ { cron: "0 3 * * *", // Cron-window key — collapses a duplicate cron delivery before any // Workflow is touched (04-gha-integration § Receiver dedup). The // scheduling Workflow's instanceId derives from this. idempotencyKey: ({ firedAt }) => `pr-review-sweep:${isoDate(firedAt)}`, // Receiver-side gate: skip weekends, before any compute is spent. gate: ({ firedAt }) => { const day = new Date(firedAt).getUTCDay(); return day !== 0 && day !== 6; }, inputs: ({ firedAt }) => ({ staleAfterHours: 24, includeDrafts: false, firedAt, }), }, ], inputs: SweepInput, outputs: SweepOutput, // The sweep spends almost all of its wall-clock hibernating on staggered // `sleepUntil` checkpoints, not consuming CPU. The ceiling covers the // 45-min stagger window plus enumeration headroom. limits: { maxDurationSec: 5400, maxConcurrency: 100 }, run: (input) => Effect.gen(function* () { // 1. Enumerate. A cron tick names no target — discovery is the first // step. `github.openPullRequests` is the runtime-provided, // App-token-backed read surface; it spans every repo the // FlareDispatch App is installed on (03-dsl § github). const prs = yield* step("enumerate", () => github.openPullRequests({ updatedWithinHours: input.staleAfterHours, includeDrafts: input.includeDrafts, }), ); yield* step("log-scope", () => io.log("info", `sweep found ${prs.length} open PR(s)`, { firedAt: input.firedAt, }), ); // 2. Fan out one `pr-review` per PR, staggered evenly across 45 min so // the GitHub API and the model provider never see a burst. Each // child is created with the SEMANTIC instanceId — so a PR already // reviewed at its current head SHA (by Webhook mode, or an earlier // sweep) is a no-op create that this run records as `skipped`. const STAGGER_MS = 45 * 60_000; const outcomes = yield* sharded({ count: prs.length, concurrency: prs.length, // each child is just a create() + sleep body: ({ index, total }) => Effect.gen(function* () { const pr = prs[index - 1]; const offset = Math.floor((STAGGER_MS / Math.max(total, 1)) * (index - 1)); // Durable sleep — the Workflow hibernates, consuming no CPU and // surviving eviction, until this child's slot in the window. yield* step.sleepUntil( `stagger-${pr.repo}-${pr.number}`, input.firedAt + offset, ); return yield* step(`dispatch-${pr.repo}-${pr.number}`, () => spawnChildRun({ run: "pr-review", // Semantic id — identical to the key Webhook mode uses, so // the two modes dedup against each other for free. instanceId: `pr-review:${pr.repo}:${pr.number}:${pr.headSha}`, input: { repo: pr.repo, sha: pr.headSha, baseSha: pr.baseSha, pr: pr.number, installationId: pr.installationId, }, }), ); }), }); // `spawnChildRun` reports whether the create() actually started a new // instance (`created: true`) or collapsed onto an existing one // (`created: false` — already reviewed at this head SHA). const dispatched = outcomes.filter((o) => o.created).length; return { prsFound: prs.length, dispatched, skipped: prs.length - dispatched, }; }),});
recipes/ai-code-review/ci.yml
# 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-reviewon: 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.
A faithful, runnable GHA workflow — what a team actually
maintains to do this job without FlareDispatch.
recipes/ai-code-review/baseline.yml
# Recipe: AI code review — BASELINE (without FlareDispatch)## This is what you would actually maintain on plain GitHub Actions to replicate# the multi-agent code reviewer described at https://blog.cloudflare.com/ai-code-review/.# It is shown here ONLY as a comparison — see ./pr-review.run.ts for the# FlareDispatch version (60 lines of typed Effect-TS that subsumes all of this).## Notable costs of the GHA-only path:# - Three jobs with explicit `needs:` chains because GHA has no in-job fan-out# that can both read shared state AND produce per-shard artifacts.# - Diff + per-file slicing has to round-trip through `actions/upload-artifact`# so each matrix shard can see it (GHA does NOT share the workspace across# jobs).# - The matrix is hand-encoded because GHA cannot derive `matrix.include` from# a previous job's output without a separate "matrix-from-json" pattern.# - The coordinator step has to pull every per-agent artifact back down and# re-glue them, then call the GitHub REST API by hand to post inline# annotations (no first-class "check-run from JSON" action).# - GHA minutes burned per PR: ~7 jobs × runner spin-up overhead.# - Re-review continuity (prior findings) requires a separate# actions/cache@v4 keyed on (repo, pr) AND opt-in cache-write semantics —# and even then the cache is best-effort and may evict.# - Schedule-mode sweep for missed PRs needs a SECOND workflow file with its# own cron + a gh-cli loop that calls workflow_dispatch per PR.## Everything below is real, runnable YAML. The placeholders `${{ ... }}` and# `# CONFIGURE:` markers are the parts you'd actually have to fill in.name: ai-code-review-baselineon: pull_request: types: [opened, synchronize, ready_for_review] # Schedule-mode sweep — re-review every open PR daily, since webhook # deliveries can drop and PRs opened before this workflow existed are # otherwise never reviewed. schedule: - cron: "0 3 * * *"permissions: contents: read pull-requests: write checks: writejobs: # -------------------------------------------------------------------------- # 1. Sweep-mode fan-out. A scheduled tick has no PR context, so we have to # enumerate open PRs ourselves and re-dispatch this workflow per PR via # workflow_dispatch. (FlareDispatch handles this with one `schedules:` # block on the run — see pr-review-sweep.run.ts.) # -------------------------------------------------------------------------- sweep-enumerate: if: github.event_name == 'schedule' runs-on: ubuntu-latest steps: - uses: actions/github-script@v7 with: script: | const { data: prs } = await github.rest.pulls.list({ owner: context.repo.owner, repo: context.repo.repo, state: "open", per_page: 100, }); for (const pr of prs) { if (pr.draft) continue; if (pr.user.login.endsWith("[bot]")) continue; if (pr.labels.some((l) => l.name === "skip-ai-review")) continue; await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: "ai-code-review-baseline.yml", ref: pr.head.ref, inputs: { pr_number: String(pr.number) }, }); // Stagger to avoid bursting the model provider. await new Promise((r) => setTimeout(r, 5000)); } # -------------------------------------------------------------------------- # 2. Gate: drafts / bots / opt-out label. # -------------------------------------------------------------------------- gate: if: github.event_name == 'pull_request' runs-on: ubuntu-latest outputs: should_run: ${{ steps.gate.outputs.should_run }} steps: - id: gate uses: actions/github-script@v7 with: script: | const pr = context.payload.pull_request; const labels = pr.labels.map((l) => l.name); const isBot = pr.user.login.endsWith("[bot]"); const optOut = labels.includes("skip-ai-review"); const forceOnDraft = labels.includes("request-ai-review"); const draftBlocked = pr.draft && !forceOnDraft; const shouldRun = !isBot && !optOut && !draftBlocked; core.setOutput("should_run", String(shouldRun)); # -------------------------------------------------------------------------- # 3. Prepare the diff and classify risk tier. Outputs are consumed by the # matrix below — note we have to JSON-encode the agent list because GHA's # matrix strategy cannot read a step output directly. # -------------------------------------------------------------------------- prepare-diff: needs: gate if: needs.gate.outputs.should_run == 'true' runs-on: ubuntu-latest outputs: tier: ${{ steps.classify.outputs.tier }} model: ${{ steps.classify.outputs.model }} agents_json: ${{ steps.classify.outputs.agents_json }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} - name: Install review-agent CLI # CONFIGURE: review-agent is the multi-agent CLI from the blog post — # you have to package and host it yourself; GHA runners do not have it. run: | curl -fsSL "https://example.com/review-agent/install.sh" | bash - name: Slice diff into per-file patches run: | mkdir -p ./diff review-agent diff \ --base "${{ github.event.pull_request.base.sha }}" \ --exclude lockfiles,minified,generated \ --out ./diff - id: classify name: Classify risk tier run: | tier=$(review-agent risk-tier --diff ./diff) case "$tier" in trivial) agents='["code-quality"]' ; model="sonnet" ;; lite) agents='["security","code-quality","performance","documentation"]' ; model="sonnet" ;; *) agents='["security","performance","code-quality","documentation","release-management","compliance","agents-md"]' ; model="opus" ;; esac echo "tier=$tier" >> "$GITHUB_OUTPUT" echo "model=$model" >> "$GITHUB_OUTPUT" echo "agents_json=$agents" >> "$GITHUB_OUTPUT" - name: Upload diff artifact (shared with matrix below) uses: actions/upload-artifact@v4 with: name: review-diff path: ./diff retention-days: 1 # Best-effort: load this PR's previous findings so the coordinator can # auto-resolve fixed threads. actions/cache is eviction-prone — the # FlareDispatch equivalent (`io.priorExecution`) is durable. - name: Restore prior findings (best-effort) uses: actions/cache@v4 with: path: ./previous.json key: ai-review-prior-${{ github.repository }}-${{ github.event.pull_request.number }} # -------------------------------------------------------------------------- # 4. Fan out one job per agent. Each job re-clones, re-installs the agent # CLI, downloads the diff artifact, runs its slice, uploads findings. # Every shard pays the GHA-runner cold-start tax independently. # -------------------------------------------------------------------------- review: needs: prepare-diff runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 7 matrix: agent: ${{ fromJSON(needs.prepare-diff.outputs.agents_json) }} timeout-minutes: 12 # code-quality needs 10; we widen for all (no per-agent overrides in matrix). steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - name: Install review-agent CLI run: curl -fsSL "https://example.com/review-agent/install.sh" | bash - name: Download shared diff uses: actions/download-artifact@v4 with: name: review-diff path: ./diff - name: Run ${{ matrix.agent }} env: # CONFIGURE: model provider key. If you have N model providers you # want to fail over to, you have to wire the retry loop manually. ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | mkdir -p ./findings review-agent run "${{ matrix.agent }}" \ --diff ./diff \ --tier "${{ needs.prepare-diff.outputs.tier }}" \ --out "./findings/${{ matrix.agent }}.json" - name: Upload agent findings uses: actions/upload-artifact@v4 with: name: findings-${{ matrix.agent }} path: ./findings/${{ matrix.agent }}.json retention-days: 1 # -------------------------------------------------------------------------- # 5. Coordinator: pull every per-agent artifact, dedup, write the combined # check-run + inline annotations. The annotations call requires a manual # GitHub REST round-trip per finding (or batched, capped at 50 per call). # -------------------------------------------------------------------------- coordinate: needs: [prepare-diff, review] if: always() && needs.prepare-diff.result == 'success' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - name: Install review-agent CLI run: curl -fsSL "https://example.com/review-agent/install.sh" | bash - name: Download all agent findings uses: actions/download-artifact@v4 with: pattern: findings-* path: ./findings merge-multiple: true - name: Coordinate id: coord env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | previous_arg="" if [ -f ./previous.json ]; then previous_arg="--previous ./previous.json"; fi review-agent coordinate \ --in ./findings \ --model "${{ needs.prepare-diff.outputs.model }}" \ $previous_arg \ --json > ./review.json # Persist for the next push's re-review (best-effort cache writeback). cp ./review.json ./previous.json - name: Save findings for next push uses: actions/cache/save@v4 with: path: ./previous.json key: ai-review-prior-${{ github.repository }}-${{ github.event.pull_request.number }} - name: Post check-run summary + inline annotations uses: actions/github-script@v7 with: script: | const fs = require("fs"); const review = JSON.parse(fs.readFileSync("./review.json", "utf8")); // The annotations API caps at 50 per request — batch. const annotations = review.findings.map((f) => ({ path: f.path, start_line: f.startLine, end_line: f.endLine, annotation_level: f.level === "failure" ? "failure" : f.level === "warning" ? "warning" : "notice", title: f.title, message: f.message, })); const conclusion = review.verdict === "request-changes" ? "failure" : review.verdict === "comment" ? "neutral" : "success"; // Create the check-run. const { data: cr } = await github.rest.checks.create({ owner: context.repo.owner, repo: context.repo.repo, name: "ai-code-review", head_sha: context.payload.pull_request.head.sha, status: "completed", conclusion, output: { title: `Review (${review.tier})`, summary: `${review.critical} critical · ${review.warnings} warnings · ${review.suggestions} suggestions`, annotations: annotations.slice(0, 50), }, }); // Push remaining annotations (50 at a time). for (let i = 50; i < annotations.length; i += 50) { await github.rest.checks.update({ owner: context.repo.owner, repo: context.repo.repo, check_run_id: cr.id, output: { title: `Review (${review.tier})`, summary: `${review.critical} critical · ${review.warnings} warnings · ${review.suggestions} suggestions`, annotations: annotations.slice(i, i + 50), }, }); }