Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ defaults:
workspace: worktree
notifiers: [desktop]

# AO-local code reviewer backend (`ao review run`). Optional — defaults to the
# worker agent when it has a reviewer adapter, otherwise codex.
review:
agent: claude-code # claude-code | codex (or `command:` for a custom shell reviewer)

projects:
my-app:
repo: owner/my-app
Expand All @@ -168,6 +173,8 @@ reactions:

CI fails → agent gets the logs and fixes it. Reviewer requests changes → agent addresses them. PR approved with green CI → you get a notification to merge.

The AO-local reviewer (`ao review run`) picks its backend in this order: a `--command` flag, then `projects.<id>.review`, then the global `review` block, then your worker agent (`defaults.agent`) if it ships a reviewer adapter, and finally codex. Set `review.agent: claude-code` to review with Claude Code on hosts without Codex/OpenAI credentials.

Keep the `$schema` line so editors can autocomplete and validate against [`schema/config.schema.json`](schema/config.schema.json).

See [`agent-orchestrator.yaml.example`](agent-orchestrator.yaml.example) for the full reference, or run `ao config-help` for the complete schema.
Expand Down
12 changes: 12 additions & 0 deletions agent-orchestrator.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ defaults:
workspace: worktree # worktree | clone
notifiers: [desktop] # desktop | slack | discord | webhook | composio | openclaw

# AO-local code reviewer backend (`ao review run`). Optional.
# Resolution precedence: `--command` flag > project.review > this block >
# worker agent (defaults.agent, when it has a reviewer adapter) > codex.
# `agent` and `command` are mutually exclusive.
# review:
# agent: claude-code # claude-code | codex
# # command: "bash ~/.config/agent-orchestrator/claude-reviewer.sh main"

# Installer-managed external plugins (optional)
# plugins:
# - name: owasp-auditor
Expand Down Expand Up @@ -108,6 +116,10 @@ projects:
# agentConfig:
# model: gpt-5-codex

# Per-project reviewer backend override (see top-level `review:` for keys)
# review:
# agent: claude-code

# Inline rules included in every agent prompt for this project
# agentRules: |
# Always run tests before pushing.
Expand Down
28 changes: 18 additions & 10 deletions packages/cli/src/commands/review.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import chalk from "chalk";
import type { Command } from "commander";
import {
createShellCodeReviewRunner,
createCodeReviewStore,
executeCodeReviewRun,
loadConfig,
Expand Down Expand Up @@ -91,7 +90,14 @@ function getNextQueuedRun(
}

export function registerReview(program: Command): void {
const review = program.command("review").description("Manage AO-local reviewer runs");
const review = program
.command("review")
.description(
"Manage AO-local reviewer runs. The reviewer backend resolves in order: " +
"--command flag > project review config > global review config > worker agent > codex. " +
"Configure a persistent backend with `review.agent` (claude-code|codex) or " +
"`review.command` globally, or under `projects.<id>.review` per project.",
);

review
.command("run")
Expand All @@ -100,7 +106,10 @@ export function registerReview(program: Command): void {
.option("--summary <text>", "Summary to store on the review run")
.option("--status <status>", "Initial run status (defaults to queued)")
.option("--execute", "Execute the review run immediately")
.option("--command <command>", "Shell command to execute as the reviewer")
.option(
"--command <command>",
"Shell command to execute as the reviewer (overrides review config)",
)
.option("--json", "Output as JSON")
.action(
async (
Expand Down Expand Up @@ -128,11 +137,7 @@ export function registerReview(program: Command): void {

if (opts.execute || opts.command) {
run = await executeCodeReviewRun(
{
config,
sessionManager,
...(opts.command ? { runReviewer: createShellCodeReviewRunner(opts.command) } : {}),
},
{ config, sessionManager, reviewCommand: opts.command },
{ projectId: run.projectId, runId: run.id },
);
}
Expand Down Expand Up @@ -164,7 +169,10 @@ export function registerReview(program: Command): void {
.description("Execute a queued AO-local reviewer run")
.argument("[project]", "Project ID (searches all projects if omitted)")
.option("--run <run>", "Review run ID or reviewer session ID")
.option("--command <command>", "Shell command to execute as the reviewer")
.option(
"--command <command>",
"Shell command to execute as the reviewer (overrides review config)",
)
.option("--force", "Execute even if the run is not queued")
.option("--json", "Output as JSON")
.action(
Expand Down Expand Up @@ -194,7 +202,7 @@ export function registerReview(program: Command): void {
config,
sessionManager,
force: opts.force,
...(opts.command ? { runReviewer: createShellCodeReviewRunner(opts.command) } : {}),
reviewCommand: opts.command,
},
{ projectId: target.projectId, runId: target.run.id },
);
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/lib/config-instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ defaults:
worker:
agent: claude-code # Optional override for worker sessions

# ── AO-local reviewer backend (optional) ───────────────────────────
# Backend for 'ao review run'. Precedence: --command flag > project.review >
# this block > worker agent (defaults.agent, if it has an adapter) > codex.
# 'agent' and 'command' are mutually exclusive.

review:
agent: claude-code # claude-code | codex
# command: "bash ~/.config/agent-orchestrator/claude-reviewer.sh main"

# ── Installer-managed marketplace plugins (optional) ───────────────
# External plugins are declared here. Built-ins do not need entries.

Expand Down
158 changes: 158 additions & 0 deletions packages/core/src/__tests__/code-review-backend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import {
buildClaudeCodeReviewArgs,
parseReviewerOutput,
resolveCodeReviewRunner,
runClaudeCodeReview,
runCodexCodeReview,
SUPPORTED_REVIEW_AGENTS,
} from "../code-review-manager.js";
import type { OrchestratorConfig, ProjectConfig, ReviewConfig } from "../types.js";

function makeConfig(overrides: {
defaultAgent?: string;
review?: ReviewConfig;
projectReview?: ReviewConfig;
}): { config: OrchestratorConfig; project: ProjectConfig } {
const project: ProjectConfig = {
name: "App",
path: "/tmp/app",
defaultBranch: "main",
sessionPrefix: "app",
...(overrides.projectReview ? { review: overrides.projectReview } : {}),
};

const config: OrchestratorConfig = {
configPath: "/tmp/ao/agent-orchestrator.yaml",
readyThresholdMs: 300_000,
defaults: {
runtime: "tmux",
agent: overrides.defaultAgent ?? "claude-code",
workspace: "worktree",
notifiers: [],
},
projects: { app: project },
notifiers: {},
notificationRouting: { urgent: [], action: [], warning: [], info: [] },
reactions: {},
...(overrides.review ? { review: overrides.review } : {}),
};

return { config, project };
}

describe("resolveCodeReviewRunner precedence", () => {
it("uses the explicit --command flag above everything else", () => {
const { config, project } = makeConfig({
defaultAgent: "claude-code",
review: { agent: "codex" },
projectReview: { agent: "codex" },
});
const runner = resolveCodeReviewRunner({ config, project, command: "echo hi" });
// Shell-command runners are fresh closures, distinct from the agent adapters.
expect(runner).not.toBe(runClaudeCodeReview);
expect(runner).not.toBe(runCodexCodeReview);
});

it("prefers the per-project review config over the global review config", () => {
const { config, project } = makeConfig({
defaultAgent: "claude-code",
review: { agent: "claude-code" },
projectReview: { agent: "codex" },
});
expect(resolveCodeReviewRunner({ config, project })).toBe(runCodexCodeReview);
});

it("prefers the global review config over the worker-agent fallback", () => {
const { config, project } = makeConfig({
defaultAgent: "claude-code",
review: { agent: "codex" },
});
expect(resolveCodeReviewRunner({ config, project })).toBe(runCodexCodeReview);
});

it("falls back to the worker agent when it has a known reviewer adapter", () => {
const { config, project } = makeConfig({ defaultAgent: "claude-code" });
expect(resolveCodeReviewRunner({ config, project })).toBe(runClaudeCodeReview);
});

it("falls back to Codex when the worker agent has no reviewer adapter", () => {
const { config, project } = makeConfig({ defaultAgent: "aider" });
expect(resolveCodeReviewRunner({ config, project })).toBe(runCodexCodeReview);
});

it("preserves Codex behavior when the worker agent is already codex", () => {
const { config, project } = makeConfig({ defaultAgent: "codex" });
expect(resolveCodeReviewRunner({ config, project })).toBe(runCodexCodeReview);
});

it("resolves review.command to a shell runner", () => {
const { config, project } = makeConfig({ review: { command: "echo hi" } });
const runner = resolveCodeReviewRunner({ config, project });
expect(runner).not.toBe(runClaudeCodeReview);
expect(runner).not.toBe(runCodexCodeReview);
});

it("throws a clear error for an unsupported review.agent", () => {
const { config, project } = makeConfig({ review: { agent: " collaborator" } });
expect(() => resolveCodeReviewRunner({ config, project })).toThrowError(
new RegExp(`Unsupported review.agent.*${SUPPORTED_REVIEW_AGENTS.join(", ")}`),
);
});

it("throws for an unsupported per-project review.agent", () => {
const { config, project } = makeConfig({ projectReview: { agent: "nope" } });
expect(() => resolveCodeReviewRunner({ config, project })).toThrow(/Unsupported review\.agent/);
});
});

describe("buildClaudeCodeReviewArgs", () => {
it("builds the expected read-only argv with the prompt", () => {
expect(buildClaudeCodeReviewArgs("REVIEW PROMPT")).toEqual([
"-p",
"REVIEW PROMPT",
"--permission-mode",
"bypassPermissions",
"--output-format",
"text",
]);
});
});

describe("parseReviewerOutput (claude-code style responses)", () => {
const findings = [
{
severity: "error" as const,
title: "Null deref",
body: "`user` may be undefined.",
filePath: "src/app.ts",
startLine: 10,
endLine: 12,
confidence: 0.9,
},
];

it("round-trips a plain JSON object response", () => {
const raw = JSON.stringify({ findings });
const parsed = parseReviewerOutput(raw);
expect(parsed).toHaveLength(1);
expect(parsed[0]).toMatchObject({
severity: "error",
title: "Null deref",
filePath: "src/app.ts",
startLine: 10,
endLine: 12,
});
});

it("round-trips a markdown-fenced JSON response", () => {
const raw = ["Here is my review:", "```json", JSON.stringify({ findings }), "```"].join("\n");
const parsed = parseReviewerOutput(raw);
expect(parsed).toHaveLength(1);
expect(parsed[0]?.title).toBe("Null deref");
});

it("treats an empty findings array as no findings", () => {
expect(parseReviewerOutput('{"findings":[]}')).toEqual([]);
});
});
Loading