From 05a30154f71c89d0bdd639e49f1effff69ee37c0 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 17 Mar 2026 23:39:17 +0100 Subject: [PATCH] AUTO/YOLO agent pipeline: fix CI, code review, fix review via claude -p MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the agent pipeline for PR autopilot modes: 1. Fix CI (hammer): when checks fail, spawn `claude -p` with the failing check details. AUTO uses --permission-mode acceptEdits with explicit tool whitelist; YOLO uses --permission-mode bypassPermissions. 2. Code review: when checks pass, spawn `claude -p` in read-only mode to review the diff. Outputs structured JSON findings (critical/important/minor). 3. Fix review: when Critical or Important issues are found, spawn `claude -p` to address them, then re-review (max 2 cycles). Pipeline state tracked on TrackedPR: AgentRunning (mutex-like string preventing double-spawn), ReviewState, ReviewFindings, ReviewCycle, AgentCostUSD. Stale agent timeout (20min) clears crashed processes. Review state resets on check regression so the full pipeline re-runs after CI fixes. TUI shows: agent status with elapsed time, review findings by severity with file:line locations, accumulated cost in PR header. New file: daemon/internal/pr/agent.go — claudeBin, cloneForAgent, command builders, spawn functions, parseReviewOutput, agentComplete callback. --- daemon/internal/pr/agent.go | 339 ++++++++++++++++++ daemon/internal/pr/agent_test.go | 186 ++++++++++ daemon/internal/pr/model.go | 71 ++++ daemon/internal/pr/model_test.go | 110 ++++++ daemon/internal/pr/poller.go | 71 +++- daemon/internal/pr/poller_integration_test.go | 4 + tui/internal/client/client.go | 15 + tui/internal/tui/pr_zoom.go | 63 ++++ 8 files changed, 848 insertions(+), 11 deletions(-) create mode 100644 daemon/internal/pr/agent.go create mode 100644 daemon/internal/pr/agent_test.go diff --git a/daemon/internal/pr/agent.go b/daemon/internal/pr/agent.go new file mode 100644 index 0000000..0dd5ab4 --- /dev/null +++ b/daemon/internal/pr/agent.go @@ -0,0 +1,339 @@ +package pr + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "strings" + "time" +) + +// claudeBinFunc is the function used to locate the claude CLI binary. +// Tests can replace it to point at a mock script. +var claudeBinFunc = defaultClaudeBin + +func claudeBin() string { return claudeBinFunc() } + +func defaultClaudeBin() string { + // Try common paths for launchd context where PATH is minimal. + for _, p := range []string{ + "/usr/local/bin/claude", + "/opt/homebrew/bin/claude", + } { + if _, err := os.Stat(p); err == nil { + return p + } + } + home, _ := os.UserHomeDir() + if home != "" { + p := home + "/.local/bin/claude" + if _, err := os.Stat(p); err == nil { + return p + } + } + if p, err := exec.LookPath("claude"); err == nil { + return p + } + return "claude" +} + +// cloneForAgent clones the repo into a temp directory, checks out the PR +// branch, and returns the work directory. Caller must clean up via os.RemoveAll. +func cloneForAgent(owner, repo, branch string) (string, error) { + tmpDir, err := os.MkdirTemp("", "ccc-agent-*") + if err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + repoSlug := fmt.Sprintf("%s/%s", owner, repo) + cmd := exec.Command(ghBin(), "repo", "clone", repoSlug, tmpDir, + "--", "--branch", branch, "--single-branch", "--depth=50") + if output, err := cmd.CombinedOutput(); err != nil { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("clone %s branch %s: %v: %s", repoSlug, branch, err, output) + } + return tmpDir, nil +} + +// --- command builders --- + +func buildFixCICmd(pr *TrackedPR, workDir string) *exec.Cmd { + var failing []string + for _, c := range pr.Checks { + if c.Conclusion == "FAILURE" { + s := c.Name + if c.Detail != "" { + s += ": " + c.Detail + } + failing = append(failing, "- "+s) + } + } + + prompt := fmt.Sprintf( + "CI checks are failing on PR #%d in %s/%s (branch %s).\n\n"+ + "Failing checks:\n%s\n\n"+ + "Steps:\n"+ + "1. Read the failing check logs if available\n"+ + "2. Identify and fix the root cause\n"+ + "3. Run tests locally to verify the fix\n"+ + "4. Commit and push to the current branch\n\n"+ + "Do not change test expectations unless the test itself is wrong.", + pr.Number, pr.Owner, pr.Repo, pr.HeadBranch, + strings.Join(failing, "\n"), + ) + + args := []string{ + "-p", prompt, + "--no-session-persistence", + "--max-budget-usd", "5", + "--model", "sonnet", + } + + switch pr.AutopilotMode { + case PRYolo: + args = append(args, "--permission-mode", "bypassPermissions") + default: + args = append(args, + "--permission-mode", "acceptEdits", + "--allowedTools", "Bash Edit Write Read Glob Grep", + ) + } + + cmd := exec.Command(claudeBin(), args...) + cmd.Dir = workDir + return cmd +} + +func buildCodeReviewCmd(pr *TrackedPR, workDir string) *exec.Cmd { + prompt := fmt.Sprintf( + "Review the changes on this branch (%s → %s) for code quality.\n\n"+ + "Use `git diff %s...HEAD` to see the changes.\n\n"+ + "Output ONLY a JSON array of findings. Each finding:\n"+ + `{"severity": "critical|important|minor", "file": "path", "line": 42, "message": "description"}`+"\n\n"+ + "Focus on: bugs, security issues, correctness, missing error handling, logic errors.\n"+ + "Do NOT flag style, formatting, or documentation issues.\n"+ + "If the code is clean, output: []\n"+ + "Output the JSON array and nothing else.", + pr.HeadBranch, pr.BaseBranch, pr.BaseBranch, + ) + + args := []string{ + "-p", prompt, + "--no-session-persistence", + "--max-budget-usd", "3", + "--model", "sonnet", + "--allowedTools", "Read Glob Grep Bash", + } + + cmd := exec.Command(claudeBin(), args...) + cmd.Dir = workDir + return cmd +} + +func buildFixReviewCmd(pr *TrackedPR, workDir string) *exec.Cmd { + var issues []string + for _, f := range pr.ReviewFindings { + if f.Severity != SeverityCritical && f.Severity != SeverityImportant { + continue + } + loc := f.File + if f.Line > 0 { + loc += fmt.Sprintf(":%d", f.Line) + } + issues = append(issues, fmt.Sprintf("- [%s] %s — %s", f.Severity, loc, f.Message)) + } + + prompt := fmt.Sprintf( + "Code review found the following issues on this PR. Fix them:\n\n%s\n\n"+ + "After fixing:\n"+ + "1. Run tests to verify nothing is broken\n"+ + "2. Commit and push to the current branch", + strings.Join(issues, "\n"), + ) + + args := []string{ + "-p", prompt, + "--no-session-persistence", + "--max-budget-usd", "5", + "--model", "sonnet", + } + + switch pr.AutopilotMode { + case PRYolo: + args = append(args, "--permission-mode", "bypassPermissions") + default: + args = append(args, + "--permission-mode", "acceptEdits", + "--allowedTools", "Bash Edit Write Read Glob Grep", + ) + } + + cmd := exec.Command(claudeBin(), args...) + cmd.Dir = workDir + return cmd +} + +// --- spawn functions --- + +const agentTimeout = 15 * time.Minute + +func (p *Poller) spawnFixCI(pr *TrackedPR) { + key := fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number) + owner, repo, branch := pr.Owner, pr.Repo, pr.HeadBranch + + go func() { + workDir, err := cloneForAgent(owner, repo, branch) + if err != nil { + p.agentComplete(key, "fix_ci", err, nil) + return + } + defer os.RemoveAll(workDir) + + ctx, cancel := context.WithTimeout(context.Background(), agentTimeout) + defer cancel() + + cmd := buildFixCICmd(pr, workDir) + cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) + cmd.Dir = workDir + + output, err := cmd.CombinedOutput() + p.agentComplete(key, "fix_ci", err, output) + }() +} + +func (p *Poller) spawnCodeReview(pr *TrackedPR) { + key := fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number) + owner, repo, branch := pr.Owner, pr.Repo, pr.HeadBranch + + go func() { + workDir, err := cloneForAgent(owner, repo, branch) + if err != nil { + p.agentComplete(key, "review", err, nil) + return + } + defer os.RemoveAll(workDir) + + ctx, cancel := context.WithTimeout(context.Background(), agentTimeout) + defer cancel() + + cmd := buildCodeReviewCmd(pr, workDir) + cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) + cmd.Dir = workDir + + output, err := cmd.CombinedOutput() + p.agentComplete(key, "review", err, output) + }() +} + +func (p *Poller) spawnFixReview(pr *TrackedPR) { + key := fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number) + owner, repo, branch := pr.Owner, pr.Repo, pr.HeadBranch + + go func() { + workDir, err := cloneForAgent(owner, repo, branch) + if err != nil { + p.agentComplete(key, "fix_review", err, nil) + return + } + defer os.RemoveAll(workDir) + + ctx, cancel := context.WithTimeout(context.Background(), agentTimeout) + defer cancel() + + cmd := buildFixReviewCmd(pr, workDir) + cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) + cmd.Dir = workDir + + output, err := cmd.CombinedOutput() + p.agentComplete(key, "fix_review", err, output) + }() +} + +// --- completion callback --- + +func (p *Poller) agentComplete(key, agentType string, err error, output []byte) { + p.mu.Lock() + pr, ok := p.tracked[key] + if !ok { + p.mu.Unlock() + return + } + + pr.AgentRunning = "" + + if err != nil { + pr.Timeline = append(pr.Timeline, PREvent{ + Time: time.Now(), Icon: "✗", + Message: fmt.Sprintf("Agent %s failed: %v", agentType, err), + }) + log.Printf("pr: agent %s for %s failed: %v", agentType, key, err) + } else { + msg := fmt.Sprintf("Agent %s completed", agentType) + + if agentType == "review" && output != nil { + findings, parseErr := parseReviewOutput(output) + if parseErr != nil { + log.Printf("pr: review parse failed for %s: %v", key, parseErr) + pr.ReviewState = "clean" + } else { + pr.ReviewFindings = findings + if pr.HasActionableFindings() { + pr.ReviewState = "has_issues" + actionable := 0 + for _, f := range findings { + if f.Severity == SeverityCritical || f.Severity == SeverityImportant { + actionable++ + } + } + msg += fmt.Sprintf(" — %d actionable issues", actionable) + } else { + pr.ReviewState = "clean" + msg += " — clean" + } + } + } + + if agentType == "fix_review" { + // After fixing review issues, reset for re-review. + pr.ReviewState = "" + pr.ReviewFindings = nil + } + + pr.Timeline = append(pr.Timeline, PREvent{ + Time: time.Now(), Icon: "🤖", Message: msg, + }) + log.Printf("pr: %s for %s", msg, key) + } + + p.save() + p.mu.Unlock() + + if p.onChange != nil { + p.onChange() + } +} + +// --- output parsing --- + +// parseReviewOutput extracts ReviewFindings from claude -p output. +// The model is instructed to output a raw JSON array of findings. +func parseReviewOutput(output []byte) ([]ReviewFinding, error) { + text := strings.TrimSpace(string(output)) + + // The output may have surrounding text; extract the JSON array. + start := strings.Index(text, "[") + end := strings.LastIndex(text, "]") + if start == -1 || end == -1 || end <= start { + // No JSON array found — treat as clean. + return nil, nil + } + jsonStr := text[start : end+1] + + var findings []ReviewFinding + if err := json.Unmarshal([]byte(jsonStr), &findings); err != nil { + return nil, fmt.Errorf("parse findings JSON: %w", err) + } + return findings, nil +} diff --git a/daemon/internal/pr/agent_test.go b/daemon/internal/pr/agent_test.go new file mode 100644 index 0000000..13387c5 --- /dev/null +++ b/daemon/internal/pr/agent_test.go @@ -0,0 +1,186 @@ +package pr + +import ( + "os" + "strings" + "testing" +) + +// === claudeBin === + +func TestClaudeBin_Fallback(t *testing.T) { + old := claudeBinFunc + defer func() { claudeBinFunc = old }() + + claudeBinFunc = func() string { return "/test/claude" } + if got := claudeBin(); got != "/test/claude" { + t.Errorf("claudeBin() = %q, want /test/claude", got) + } +} + +func TestDefaultClaudeBin_LookPath(t *testing.T) { + // Just verify it doesn't panic and returns something. + bin := defaultClaudeBin() + if bin == "" { + t.Error("defaultClaudeBin() should not return empty string") + } +} + +// === buildFixCICmd === + +func TestBuildFixCICmd_Auto(t *testing.T) { + pr := &TrackedPR{ + Owner: "test", Repo: "repo", Number: 1, + HeadBranch: "fix/thing", + AutopilotMode: PRAuto, + Checks: []Check{ + {Name: "ci", Conclusion: "FAILURE", Detail: "tests failed"}, + {Name: "lint", Conclusion: "SUCCESS"}, + }, + } + cmd := buildFixCICmd(pr, "/tmp/test") + + args := strings.Join(cmd.Args, " ") + if !strings.Contains(args, "--permission-mode acceptEdits") { + t.Error("AUTO should use acceptEdits") + } + if !strings.Contains(args, "--allowedTools") { + t.Error("AUTO should have allowedTools") + } + if !strings.Contains(args, "ci: tests failed") { + t.Error("prompt should contain failing check details") + } + if cmd.Dir != "/tmp/test" { + t.Errorf("Dir = %q, want /tmp/test", cmd.Dir) + } +} + +func TestBuildFixCICmd_Yolo(t *testing.T) { + pr := &TrackedPR{ + Owner: "test", Repo: "repo", Number: 1, + HeadBranch: "fix/thing", + AutopilotMode: PRYolo, + Checks: []Check{{Name: "ci", Conclusion: "FAILURE"}}, + } + cmd := buildFixCICmd(pr, "/tmp/test") + + args := strings.Join(cmd.Args, " ") + if !strings.Contains(args, "--permission-mode bypassPermissions") { + t.Error("YOLO should use bypassPermissions") + } +} + +// === buildCodeReviewCmd === + +func TestBuildCodeReviewCmd(t *testing.T) { + pr := &TrackedPR{ + Owner: "test", Repo: "repo", Number: 1, + HeadBranch: "feat/new", BaseBranch: "main", + } + cmd := buildCodeReviewCmd(pr, "/tmp/test") + + args := strings.Join(cmd.Args, " ") + if !strings.Contains(args, "Read Glob Grep Bash") { + t.Error("review should have read-only tools") + } + if !strings.Contains(args, "git diff main...HEAD") { + t.Error("prompt should reference base branch diff") + } +} + +// === buildFixReviewCmd === + +func TestBuildFixReviewCmd(t *testing.T) { + pr := &TrackedPR{ + Owner: "test", Repo: "repo", Number: 1, + HeadBranch: "fix/thing", + AutopilotMode: PRAuto, + ReviewFindings: []ReviewFinding{ + {Severity: SeverityCritical, File: "cmd/main.go", Line: 42, Message: "SQL injection"}, + {Severity: SeverityMinor, File: "util.go", Message: "unused var"}, + }, + } + cmd := buildFixReviewCmd(pr, "/tmp/test") + + args := strings.Join(cmd.Args, " ") + if !strings.Contains(args, "SQL injection") { + t.Error("prompt should contain critical finding") + } + if strings.Contains(args, "unused var") { + t.Error("prompt should NOT contain minor finding") + } +} + +// === parseReviewOutput === + +func TestParseReviewOutput_Clean(t *testing.T) { + findings, err := parseReviewOutput([]byte("[]")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(findings) != 0 { + t.Errorf("expected 0 findings, got %d", len(findings)) + } +} + +func TestParseReviewOutput_WithFindings(t *testing.T) { + input := `Here are the findings: +[ + {"severity": "critical", "file": "cmd/main.go", "line": 42, "message": "SQL injection"}, + {"severity": "important", "file": "auth.go", "message": "Missing check"}, + {"severity": "minor", "file": "util.go", "line": 10, "message": "Unused param"} +] +Done.` + findings, err := parseReviewOutput([]byte(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(findings) != 3 { + t.Fatalf("expected 3 findings, got %d", len(findings)) + } + if findings[0].Severity != SeverityCritical { + t.Errorf("findings[0].Severity = %q, want critical", findings[0].Severity) + } + if findings[0].Line != 42 { + t.Errorf("findings[0].Line = %d, want 42", findings[0].Line) + } +} + +func TestParseReviewOutput_NoJSON(t *testing.T) { + findings, err := parseReviewOutput([]byte("The code looks great!")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(findings) != 0 { + t.Errorf("no JSON array should return empty findings, got %d", len(findings)) + } +} + +func TestParseReviewOutput_InvalidJSON(t *testing.T) { + _, err := parseReviewOutput([]byte("[{bad json}]")) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestParseReviewOutput_Empty(t *testing.T) { + findings, err := parseReviewOutput([]byte("")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(findings) != 0 { + t.Errorf("empty output should return empty findings") + } +} + +// === cloneForAgent (mock test) === + +func TestCloneForAgent_BadRepo(t *testing.T) { + if os.Getenv("CI") != "" { + t.Skip("skipping network test in CI") + } + _, err := cloneForAgent("nonexistent-owner-xxx", "nonexistent-repo-xxx", "main") + if err == nil { + t.Error("expected error cloning nonexistent repo") + } +} diff --git a/daemon/internal/pr/model.go b/daemon/internal/pr/model.go index 764683c..ada826b 100644 --- a/daemon/internal/pr/model.go +++ b/daemon/internal/pr/model.go @@ -39,6 +39,23 @@ type PREvent struct { Message string `json:"message"` } +// ReviewSeverity classifies code review findings. +type ReviewSeverity string + +const ( + SeverityCritical ReviewSeverity = "critical" + SeverityImportant ReviewSeverity = "important" + SeverityMinor ReviewSeverity = "minor" +) + +// ReviewFinding is a single issue found by code review. +type ReviewFinding struct { + Severity ReviewSeverity `json:"severity"` + File string `json:"file"` + Line int `json:"line,omitempty"` + Message string `json:"message"` +} + // TrackedPR represents a PR being monitored by the daemon. type TrackedPR struct { Owner string `json:"owner"` @@ -68,6 +85,15 @@ type TrackedPR struct { MergeMethod string `json:"merge_method"` // "squash", "merge", "rebase", "aviator", "" = unset MergeTriggered bool `json:"merge_triggered"` // true once auto-merge has been fired; resets on check regression RunReview bool `json:"run_review"` // run code-review skill on creation + + // Agent pipeline state + AgentRunning string `json:"agent_running,omitempty"` // "" | "fix_ci" | "review" | "fix_review" + AgentStartedAt time.Time `json:"agent_started_at,omitempty"` + ReviewState string `json:"review_state,omitempty"` // "" | "pending" | "clean" | "has_issues" + ReviewFindings []ReviewFinding `json:"review_findings,omitempty"` + ReviewCycle int `json:"review_cycle,omitempty"` + MaxReviewCycles int `json:"max_review_cycles,omitempty"` // default 2 + AgentCostUSD float64 `json:"agent_cost_usd,omitempty"` } // PR autopilot modes. @@ -170,3 +196,48 @@ func (pr *TrackedPR) HasFailingChecks() bool { func (pr *TrackedPR) NeedsAttention() bool { return pr.State == StateChecksFailing } + +// IsAgentRunning returns true if a claude -p agent is currently working on this PR. +func (pr *TrackedPR) IsAgentRunning() bool { + return pr.AgentRunning != "" +} + +// ShouldReview returns true if the PR should be code-reviewed. +func (pr *TrackedPR) ShouldReview() bool { + if pr.AutopilotMode == PROff { + return false + } + if pr.State != StateChecksPassing && pr.State != StateApproved { + return false + } + // Already reviewed or in progress. + if pr.ReviewState != "" { + return false + } + return true +} + +// ShouldFixReview returns true if review findings should be addressed. +func (pr *TrackedPR) ShouldFixReview() bool { + if pr.AutopilotMode == PROff { + return false + } + if pr.ReviewState != "has_issues" { + return false + } + maxCycles := pr.MaxReviewCycles + if maxCycles == 0 { + maxCycles = 2 + } + return pr.ReviewCycle < maxCycles +} + +// HasActionableFindings returns true if there are Critical or Important findings. +func (pr *TrackedPR) HasActionableFindings() bool { + for _, f := range pr.ReviewFindings { + if f.Severity == SeverityCritical || f.Severity == SeverityImportant { + return true + } + } + return false +} diff --git a/daemon/internal/pr/model_test.go b/daemon/internal/pr/model_test.go index 819c7cf..07b1f88 100644 --- a/daemon/internal/pr/model_test.go +++ b/daemon/internal/pr/model_test.go @@ -408,3 +408,113 @@ func TestShouldHammer_CustomMaxAttempts(t *testing.T) { t.Error("at custom max=5, count=5 should not hammer") } } + +// === IsAgentRunning === + +func TestIsAgentRunning(t *testing.T) { + pr := TrackedPR{} + if pr.IsAgentRunning() { + t.Error("empty AgentRunning should not be running") + } + pr.AgentRunning = "fix_ci" + if !pr.IsAgentRunning() { + t.Error("AgentRunning=fix_ci should be running") + } +} + +// === ShouldReview === + +func TestShouldReview_ChecksPassing(t *testing.T) { + pr := TrackedPR{AutopilotMode: PRAuto, State: StateChecksPassing} + if !pr.ShouldReview() { + t.Error("AUTO + checks_passing + no review state should review") + } +} + +func TestShouldReview_AutopilotOff(t *testing.T) { + pr := TrackedPR{AutopilotMode: PROff, State: StateChecksPassing} + if pr.ShouldReview() { + t.Error("autopilot off should not review") + } +} + +func TestShouldReview_ChecksFailing(t *testing.T) { + pr := TrackedPR{AutopilotMode: PRAuto, State: StateChecksFailing} + if pr.ShouldReview() { + t.Error("checks failing should not review") + } +} + +func TestShouldReview_AlreadyReviewed(t *testing.T) { + pr := TrackedPR{AutopilotMode: PRAuto, State: StateChecksPassing, ReviewState: "clean"} + if pr.ShouldReview() { + t.Error("already reviewed should not review again") + } +} + +func TestShouldReview_Approved(t *testing.T) { + pr := TrackedPR{AutopilotMode: PRAuto, State: StateApproved} + if !pr.ShouldReview() { + t.Error("approved state should also trigger review") + } +} + +// === ShouldFixReview === + +func TestShouldFixReview_HasIssues(t *testing.T) { + pr := TrackedPR{AutopilotMode: PRAuto, ReviewState: "has_issues"} + if !pr.ShouldFixReview() { + t.Error("has_issues + cycle 0 should fix review") + } +} + +func TestShouldFixReview_MaxCycles(t *testing.T) { + pr := TrackedPR{AutopilotMode: PRAuto, ReviewState: "has_issues", ReviewCycle: 2} + if pr.ShouldFixReview() { + t.Error("at max cycles should not fix review") + } +} + +func TestShouldFixReview_Clean(t *testing.T) { + pr := TrackedPR{AutopilotMode: PRAuto, ReviewState: "clean"} + if pr.ShouldFixReview() { + t.Error("clean review should not fix") + } +} + +func TestShouldFixReview_Off(t *testing.T) { + pr := TrackedPR{AutopilotMode: PROff, ReviewState: "has_issues"} + if pr.ShouldFixReview() { + t.Error("autopilot off should not fix review") + } +} + +// === HasActionableFindings === + +func TestHasActionableFindings_Critical(t *testing.T) { + pr := TrackedPR{ReviewFindings: []ReviewFinding{{Severity: SeverityCritical}}} + if !pr.HasActionableFindings() { + t.Error("critical finding should be actionable") + } +} + +func TestHasActionableFindings_Important(t *testing.T) { + pr := TrackedPR{ReviewFindings: []ReviewFinding{{Severity: SeverityImportant}}} + if !pr.HasActionableFindings() { + t.Error("important finding should be actionable") + } +} + +func TestHasActionableFindings_MinorOnly(t *testing.T) { + pr := TrackedPR{ReviewFindings: []ReviewFinding{{Severity: SeverityMinor}}} + if pr.HasActionableFindings() { + t.Error("minor-only findings should not be actionable") + } +} + +func TestHasActionableFindings_Empty(t *testing.T) { + pr := TrackedPR{} + if pr.HasActionableFindings() { + t.Error("no findings should not be actionable") + } +} diff --git a/daemon/internal/pr/poller.go b/daemon/internal/pr/poller.go index fd458db..374a6a4 100644 --- a/daemon/internal/pr/poller.go +++ b/daemon/internal/pr/poller.go @@ -348,9 +348,12 @@ func (p *Poller) pollOne(owner, repo string, number int) bool { }) } - // Reset MergeTriggered if checks have regressed so we can re-fire later. + // Reset MergeTriggered and review state if checks have regressed. if pr.State == StateChecksFailing || pr.State == StateChecksRunning { pr.MergeTriggered = false + pr.ReviewState = "" + pr.ReviewFindings = nil + pr.ReviewCycle = 0 } // Auto-merge once — don't re-fire on every poll cycle. @@ -359,16 +362,62 @@ func (p *Poller) pollOne(owner, repo string, number int) bool { go p.triggerMerge(pr) } - // Auto-hammer if CI failing and hammer mode on. - if pr.ShouldHammer() && pr.State == StateChecksFailing && pr.State != oldState { - pr.HammerCount++ - pr.Timeline = append(pr.Timeline, PREvent{ - Time: time.Now(), - Icon: "🔨", - Message: fmt.Sprintf("Hammer attempt %d/%d", pr.HammerCount, pr.MaxHammer), - }) - log.Printf("pr: hammer %s/%s#%d attempt %d", pr.Owner, pr.Repo, pr.Number, pr.HammerCount) - // TODO: spawn fix-CI agent here + // === Agent pipeline (AUTO/YOLO) === + if pr.AutopilotMode != PROff { + // Clear stale agent state (crashed/killed process). + if pr.IsAgentRunning() && time.Since(pr.AgentStartedAt) > 20*time.Minute { + pr.Timeline = append(pr.Timeline, PREvent{ + Time: time.Now(), Icon: "✗", + Message: fmt.Sprintf("Agent %s timed out — clearing", pr.AgentRunning), + }) + log.Printf("pr: agent %s for %s timed out", pr.AgentRunning, key) + pr.AgentRunning = "" + } + + if !pr.IsAgentRunning() { + // Step 1: Fix CI — when checks just started failing. + if pr.ShouldHammer() && pr.State == StateChecksFailing && pr.State != oldState { + pr.HammerCount++ + pr.AgentRunning = "fix_ci" + pr.AgentStartedAt = time.Now() + pr.Timeline = append(pr.Timeline, PREvent{ + Time: time.Now(), Icon: "🔨", + Message: fmt.Sprintf("Hammer %d/%d — spawning fix-CI agent", pr.HammerCount, pr.MaxHammer), + }) + log.Printf("pr: hammer %s attempt %d — spawning agent", key, pr.HammerCount) + p.spawnFixCI(pr) + } + + // Step 2: Code review — when checks pass and not yet reviewed. + if pr.ShouldReview() { + pr.AgentRunning = "review" + pr.AgentStartedAt = time.Now() + pr.ReviewState = "pending" + pr.Timeline = append(pr.Timeline, PREvent{ + Time: time.Now(), Icon: "🔍", + Message: "Spawning code review agent", + }) + log.Printf("pr: spawning code review for %s", key) + p.spawnCodeReview(pr) + } + + // Step 3: Fix review findings — when review found actionable issues. + if pr.ShouldFixReview() { + pr.AgentRunning = "fix_review" + pr.AgentStartedAt = time.Now() + pr.ReviewCycle++ + maxCycles := pr.MaxReviewCycles + if maxCycles == 0 { + maxCycles = 2 + } + pr.Timeline = append(pr.Timeline, PREvent{ + Time: time.Now(), Icon: "🔧", + Message: fmt.Sprintf("Fixing review issues (cycle %d/%d)", pr.ReviewCycle, maxCycles), + }) + log.Printf("pr: spawning fix-review for %s cycle %d", key, pr.ReviewCycle) + p.spawnFixReview(pr) + } + } } return pr.State != oldState diff --git a/daemon/internal/pr/poller_integration_test.go b/daemon/internal/pr/poller_integration_test.go index 1284a80..dadba92 100644 --- a/daemon/internal/pr/poller_integration_test.go +++ b/daemon/internal/pr/poller_integration_test.go @@ -689,6 +689,7 @@ func TestPollOne_HammerOnChecksFailing(t *testing.T) { tracked.Hammer = true tracked.AutopilotMode = PRAuto tracked.MaxHammer = 3 + tracked.ReviewState = "clean" // skip review — this test is about hammering p.pollOne("test", "repo", 1) @@ -708,6 +709,9 @@ func TestPollOne_HammerOnChecksFailing(t *testing.T) { if pr.HammerCount != 1 { t.Errorf("hammerCount = %d, want 1", pr.HammerCount) } + if pr.AgentRunning != "fix_ci" { + t.Errorf("AgentRunning = %q, want fix_ci", pr.AgentRunning) + } } func TestPollOne_NoHammerOnSameState(t *testing.T) { diff --git a/tui/internal/client/client.go b/tui/internal/client/client.go index f6e012a..69bfc4c 100644 --- a/tui/internal/client/client.go +++ b/tui/internal/client/client.go @@ -70,6 +70,21 @@ type TrackedPR struct { MergeMethod string `json:"merge_method"` CreatedAt time.Time `json:"created_at"` Timeline []PREvent `json:"timeline"` + + // Agent pipeline + AgentRunning string `json:"agent_running,omitempty"` + AgentStartedAt time.Time `json:"agent_started_at,omitempty"` + ReviewState string `json:"review_state,omitempty"` + ReviewFindings []ReviewFinding `json:"review_findings,omitempty"` + AgentCostUSD float64 `json:"agent_cost_usd,omitempty"` +} + +// ReviewFinding is a code review issue found by an agent. +type ReviewFinding struct { + Severity string `json:"severity"` + File string `json:"file"` + Line int `json:"line,omitempty"` + Message string `json:"message"` } type PRCheck struct { diff --git a/tui/internal/tui/pr_zoom.go b/tui/internal/tui/pr_zoom.go index 3d4f600..1d589c8 100644 --- a/tui/internal/tui/pr_zoom.go +++ b/tui/internal/tui/pr_zoom.go @@ -3,11 +3,19 @@ package tui import ( "fmt" "strings" + "time" "github.com/charmbracelet/lipgloss" "github.com/pchaganti/claude-session-manager/tui/internal/client" ) +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) +} + // renderPRZoom renders the PR detail panel. func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) string { if width < 10 || height < 4 { @@ -62,6 +70,10 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri } else { infoParts = append(infoParts, lipgloss.NewStyle().Foreground(colorWaiting).Render("⎇ unset")) } + if pr.AgentCostUSD > 0 { + infoParts = append(infoParts, lipgloss.NewStyle().Foreground(colorDimFg). + Render(fmt.Sprintf("$%.2f", pr.AgentCostUSD))) + } headerLines = append(headerLines, " "+lipgloss.NewStyle().Foreground(colorDimFg). Render(strings.Join(infoParts, " "))) @@ -102,6 +114,57 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri } } + // Agent status section. + if pr.AgentRunning != "" { + bodyLines = append(bodyLines, sep) + elapsed := time.Since(pr.AgentStartedAt) + agentLabel := pr.AgentRunning + bodyLines = append(bodyLines, styleSectionLabel.Render("── Agent")) + bodyLines = append(bodyLines, fmt.Sprintf(" %s %s running (%s)", + lipgloss.NewStyle().Foreground(colorWaiting).Render("🤖"), + lipgloss.NewStyle().Foreground(colorFg).Render(agentLabel), + lipgloss.NewStyle().Foreground(colorDimFg).Render(formatDuration(elapsed)), + )) + } + + // Code review findings section. + if len(pr.ReviewFindings) > 0 { + bodyLines = append(bodyLines, sep) + actionable := 0 + for _, f := range pr.ReviewFindings { + if f.Severity == "critical" || f.Severity == "important" { + actionable++ + } + } + bodyLines = append(bodyLines, styleSectionLabel.Render( + fmt.Sprintf("── Code Review (%d issues, %d actionable)", len(pr.ReviewFindings), actionable))) + for _, f := range pr.ReviewFindings { + var icon string + switch f.Severity { + case "critical": + icon = styleDestructive.Render("✗") + case "important": + icon = lipgloss.NewStyle().Foreground(colorOrange).Render("⚠") + default: + icon = lipgloss.NewStyle().Foreground(colorDimFg).Render("○") + } + sev := lipgloss.NewStyle().Foreground(colorDimFg).Render("[" + f.Severity + "]") + loc := f.File + if f.Line > 0 { + loc += fmt.Sprintf(":%d", f.Line) + } + locStyled := lipgloss.NewStyle().Foreground(colorFg).Render(loc) + msg := lipgloss.NewStyle().Foreground(colorDimFg).Italic(true). + Render(truncateMiddle(f.Message, innerWidth-40)) + bodyLines = append(bodyLines, fmt.Sprintf(" %s %s %s — %s", icon, sev, locStyled, msg)) + } + } else if pr.ReviewState == "clean" { + bodyLines = append(bodyLines, sep) + bodyLines = append(bodyLines, styleSectionLabel.Render("── Code Review")) + bodyLines = append(bodyLines, " "+styleSafe.Render("✓")+" "+ + lipgloss.NewStyle().Foreground(colorDimFg).Render("Clean — no issues found")) + } + // Reviews section. if len(pr.Reviews) > 0 { bodyLines = append(bodyLines, sep)