Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
68 changes: 68 additions & 0 deletions backend/internal/adapters/reviewer/claudecode/claudecode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Package claudecode is the claude-code reviewer adapter. claude-code is a
// prompt-driven agent, so this reviewer feeds AO's review prompt (authored
// centrally and passed in ReviewInvocation.Prompt) to the worker claude-code
// adapter's launch-command construction (binary resolution, flags). The reviewer
// contract stays prompt-agnostic, so a one-shot CLI reviewer (e.g. greptile) can
// ignore the prompt entirely.
package claudecode

import (
"context"

workeragent "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

// Reviewer is the claude-code code-review adapter.
type Reviewer struct {
agent ports.Agent
}

// New builds the claude-code reviewer adapter.
func New() *Reviewer {
return &Reviewer{agent: workeragent.New()}
}

// Harness identifies this reviewer in the reviewer registry.
func (r *Reviewer) Harness() domain.ReviewerHarness {
return domain.ReviewerClaudeCode
}

var _ ports.Reviewer = (*Reviewer)(nil)

// ReviewCommand builds a claude-code invocation that reviews the worker's
// checkout for the PR, with the review prompt baked in.
func (r *Reviewer) ReviewCommand(ctx context.Context, inv ports.ReviewInvocation) (ports.ReviewCommandSpec, error) {
argv, err := r.agent.GetLaunchCommand(ctx, ports.LaunchConfig{
SessionID: inv.ReviewerID,
WorkspacePath: inv.WorkspacePath,
Prompt: inv.Prompt,
SystemPrompt: inv.SystemPrompt,
})
if err != nil {
return ports.ReviewCommandSpec{}, err
}
return ports.ReviewCommandSpec{Argv: argv}, nil
}

// PreLaunch runs any reviewer-specific preflight. For Claude Code this records
// the worker checkout as trusted before the headless reviewer pane starts.
func (r *Reviewer) PreLaunch(ctx context.Context, inv ports.ReviewInvocation) error {
pl, ok := r.agent.(interface {
PreLaunch(context.Context, ports.LaunchConfig) error
})
if !ok {
return nil
}
return pl.PreLaunch(ctx, ports.LaunchConfig{
SessionID: inv.ReviewerID,
WorkspacePath: inv.WorkspacePath,
})
}

// ReviewMessage is the text injected into an already-running reviewer pane to
// review a new commit — AO's central review prompt.
func (r *Reviewer) ReviewMessage(_ context.Context, inv ports.ReviewInvocation) (string, error) {
return inv.Prompt, nil
}
58 changes: 58 additions & 0 deletions backend/internal/adapters/reviewer/registry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Package reviewer is the single source of truth for the code-review adapters
// the daemon ships. It mirrors the worker agent registry but is a separate set:
// adding a reviewer (claude-code today, greptile tomorrow) is one edit here and
// does not widen the worker AgentHarness vocabulary.
package reviewer

import (
"fmt"

"github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer/claudecode"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

// Adapter is a registered reviewer: a ports.Reviewer that names its harness.
type Adapter interface {
ports.Reviewer
Harness() domain.ReviewerHarness
}

// Constructors returns every reviewer adapter the daemon ships. Add a reviewer
// here (and to domain.AllReviewerHarnesses) to register it.
func Constructors() []Adapter {
return []Adapter{
claudecode.New(),
}
}

// Resolver maps a reviewer harness onto its adapter.
type Resolver struct {
reviewers map[domain.ReviewerHarness]ports.Reviewer
}

var _ ports.ReviewerResolver = (*Resolver)(nil)

// NewResolver builds a Resolver from the shipped reviewer adapters. It fails if
// two adapters claim the same harness, or if a registered harness is not in the
// domain reviewer vocabulary (the two must stay in sync).
func NewResolver() (*Resolver, error) {
m := make(map[domain.ReviewerHarness]ports.Reviewer)
for _, a := range Constructors() {
h := a.Harness()
if !h.IsKnown() {
return nil, fmt.Errorf("reviewer adapter %q is not in domain.AllReviewerHarnesses", h)
}
if _, dup := m[h]; dup {
return nil, fmt.Errorf("reviewer harness %q is registered twice", h)
}
m[h] = a
}
return &Resolver{reviewers: m}, nil
}

// Reviewer returns the adapter for a harness, ok=false when none is registered.
func (r *Resolver) Reviewer(harness domain.ReviewerHarness) (ports.Reviewer, bool) {
rv, ok := r.reviewers[harness]
return rv, ok
}
44 changes: 44 additions & 0 deletions backend/internal/adapters/reviewer/registry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package reviewer

import (
"testing"

"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)

// TestRegistryMatchesDomainVocabulary enforces that the shipped reviewer
// adapters and domain.AllReviewerHarnesses stay in sync: every registered
// adapter is a known reviewer harness, and every known harness has an adapter.
func TestRegistryMatchesDomainVocabulary(t *testing.T) {
registered := map[domain.ReviewerHarness]bool{}
for _, a := range Constructors() {
h := a.Harness()
if !h.IsKnown() {
t.Errorf("adapter harness %q is not in domain.AllReviewerHarnesses", h)
}
if registered[h] {
t.Errorf("reviewer harness %q registered twice", h)
}
registered[h] = true
}
for _, h := range domain.AllReviewerHarnesses {
if !registered[h] {
t.Errorf("reviewer harness %q has no registered adapter", h)
}
}
}

func TestNewResolverResolvesShippedReviewers(t *testing.T) {
resolver, err := NewResolver()
if err != nil {
t.Fatalf("NewResolver: %v", err)
}
for _, h := range domain.AllReviewerHarnesses {
if _, ok := resolver.Reviewer(h); !ok {
t.Errorf("resolver missing reviewer %q", h)
}
}
if _, ok := resolver.Reviewer("nope"); ok {
t.Error("resolver returned an adapter for an unknown harness")
}
}
104 changes: 104 additions & 0 deletions backend/internal/cli/review.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package cli

import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"

"github.com/spf13/cobra"
)

// reviewRun mirrors the daemon's domain.ReviewRun for the CLI client.
type reviewRun struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
Harness string `json:"harness"`
PRURL string `json:"prUrl"`
TargetSHA string `json:"targetSha"`
Status string `json:"status"`
Verdict string `json:"verdict"`
Body string `json:"body"`
CreatedAt time.Time `json:"createdAt"`
}

// reviewRunResponse mirrors controllers.ReviewRunResponse.
type reviewRunResponse struct {
Review reviewRun `json:"review"`
ReviewerHandleID string `json:"reviewerHandleId"`
}

// submitReviewRequest mirrors controllers.SubmitReviewInput.
type submitReviewRequest struct {
RunID string `json:"runId"`
Verdict string `json:"verdict"`
Body string `json:"body"`
}

type reviewSubmitOptions struct {
session string
runID string
verdict string
body string
}

func newReviewCommand(ctx *commandContext) *cobra.Command {
cmd := &cobra.Command{
Use: "review",
Short: "Manage AO code reviews of a worker's PR",
}
cmd.AddCommand(newReviewSubmitCommand(ctx))
return cmd
}

func newReviewSubmitCommand(ctx *commandContext) *cobra.Command {
var opts reviewSubmitOptions
cmd := &cobra.Command{
Use: "submit [worker-session-id]",
Short: "Record a reviewer's result for a worker's PR",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return ctx.submitReview(cmd, args, opts)
},
}
cmd.Flags().StringVar(&opts.session, "session", "", "Worker session id (or pass it as the positional argument)")
cmd.Flags().StringVar(&opts.runID, "run", "", "Review run id (required)")
cmd.Flags().StringVar(&opts.verdict, "verdict", "", "Review verdict: approved or changes_requested (required)")
cmd.Flags().StringVar(&opts.body, "body", "", "Path to a Markdown file with the review body")
return cmd
}

func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts reviewSubmitOptions) error {
session := strings.TrimSpace(opts.session)
if len(args) == 1 {
session = strings.TrimSpace(args[0])
}
if session == "" {
return usageError{errors.New("usage: worker session id is required (positional or --session)")}
}
runID := strings.TrimSpace(opts.runID)
if runID == "" {
return usageError{errors.New("usage: --run is required")}
}
verdict := strings.TrimSpace(opts.verdict)
if verdict == "" {
return usageError{errors.New("usage: --verdict is required (approved or changes_requested)")}
}
var body string
if path := strings.TrimSpace(opts.body); path != "" {
raw, err := os.ReadFile(path)
if err != nil {
return usageError{fmt.Errorf("read body file: %w", err)}
}
body = string(raw)
}
path := "sessions/" + url.PathEscape(session) + "/reviews/submit"
var res reviewRunResponse
if err := c.postJSON(cmd.Context(), path, submitReviewRequest{RunID: runID, Verdict: verdict, Body: body}, &res); err != nil {
return err
}
_, err := fmt.Fprintf(cmd.OutOrStdout(), "recorded %s review for %s\n", res.Review.Verdict, session)
return err
}
100 changes: 100 additions & 0 deletions backend/internal/cli/review_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package cli

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)

// reviewCapture records the method/path/body of the request the CLI made.
type reviewCapture struct {
method string
path string
body string
}

func reviewServer(t *testing.T, status int, respBody string) (*httptest.Server, *reviewCapture) {
t.Helper()
capture := &reviewCapture{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
capture.method = r.Method
capture.path = r.URL.Path
capture.body = string(body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, respBody)
}))
t.Cleanup(srv.Close)
return srv, capture
}

func aliveDeps() Deps { return Deps{ProcessAlive: func(int) bool { return true }} }

func TestReviewSubmitReadsBodyFile(t *testing.T) {
cfg := setConfigEnv(t)
srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"changes_requested"}}`)
writeRunFileFor(t, cfg, srv)

bodyFile := filepath.Join(t.TempDir(), "review.md")
if err := os.WriteFile(bodyFile, []byte("please fix"), 0o600); err != nil {
t.Fatal(err)
}

_, errOut, err := executeCLI(t, aliveDeps(),
"review", "submit", "mer-1", "--run", "run-1", "--verdict", "changes_requested", "--body", bodyFile)
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
if capture.method != http.MethodPost || capture.path != "/api/v1/sessions/mer-1/reviews/submit" {
t.Fatalf("request = %s %s", capture.method, capture.path)
}
var req submitReviewRequest
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
t.Fatalf("decode body: %v", err)
}
if req.RunID != "run-1" || req.Verdict != "changes_requested" || req.Body != "please fix" {
t.Fatalf("request = %+v", req)
}
}

func TestReviewSubmitUsesSessionFlag(t *testing.T) {
cfg := setConfigEnv(t)
srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"approved"}}`)
writeRunFileFor(t, cfg, srv)

if _, errOut, err := executeCLI(t, aliveDeps(), "review", "submit", "--session", "mer-7", "--run", "run-7", "--verdict", "approved"); err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
if capture.path != "/api/v1/sessions/mer-7/reviews/submit" {
t.Fatalf("path = %q, want mer-7", capture.path)
}
}

func TestReviewSubmitMissingVerdictIsUsageError(t *testing.T) {
setConfigEnv(t)
_, _, err := executeCLI(t, aliveDeps(), "review", "submit", "mer-1", "--run", "run-1")
if got := ExitCode(err); got != 2 {
t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err)
}
}

func TestReviewSubmitMissingWorkerIsUsageError(t *testing.T) {
setConfigEnv(t)
_, _, err := executeCLI(t, aliveDeps(), "review", "submit", "--run", "run-1", "--verdict", "approved")
if got := ExitCode(err); got != 2 {
t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err)
}
}

func TestReviewSubmitMissingRunIsUsageError(t *testing.T) {
setConfigEnv(t)
_, _, err := executeCLI(t, aliveDeps(), "review", "submit", "mer-1", "--verdict", "approved")
if got := ExitCode(err); got != 2 {
t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err)
}
}
1 change: 1 addition & 0 deletions backend/internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ func NewRootCommand(deps Deps) *cobra.Command {
root.AddCommand(newProjectCommand(ctx))
root.AddCommand(newSessionCommand(ctx))
root.AddCommand(newOrchestratorCommand(ctx))
root.AddCommand(newReviewCommand(ctx))
root.AddCommand(newCompletionCommand())
root.AddCommand(newVersionCommand())

Expand Down
Loading
Loading