Skip to content

Commit 4bc41d1

Browse files
wesmclaude
andauthored
refactor: split cmd/roborev/main.go into focused files (#375)
## Summary - Extract ~3,500 lines from `main.go` into 19 focused files, reducing it to ~60 lines (global vars + root command setup) - Follows the established pattern already used by `analyze.go`, `ci.go`, `fix.go`, `run.go`, etc.: same `main` package, direct global access, `xxxCmd()` returns `*cobra.Command` - No behavioral changes; all tests pass unmodified ## New files | File | Contents | |------|----------| | `env.go` | Git environment filtering, binary detection helpers | | `daemon_lifecycle.go` | Test seam vars, daemon start/stop/restart, registerRepo | | `daemon_client.go` | HTTP client helpers (waitForJob, showReview, findJobForCommit, etc.) | | `helpers.go` | exitError, shortRef, shortJobRef, autoInstallHooks | | `update.go` | Update command + PID-tracking restart logic | | `review.go` | Review command + runLocalReview | | `daemon_cmd.go` | Daemon subcommand group + daemonRunCmd | | `init_cmd.go` | Init command | | `hook.go` | install-hook, uninstall-hook | | `wait.go` | Wait command | | `status.go` | Status command | | `list.go` | List command | | `show.go` | Show command | | `comment.go` | Comment, respond, address commands | | `stream.go` | Stream command | | `skills.go` | Skills command | | `sync.go` | Sync command group | | `check_agents.go` | Check-agents command | | `version.go` | Version command | ## Test plan - [x] `go build ./...` passes - [x] `go vet ./...` passes - [x] `go test ./cmd/roborev/...` passes (tests unmodified) - [x] `go test ./...` full suite passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0de3013 commit 4bc41d1

20 files changed

+3714
-3475
lines changed

cmd/roborev/check_agents.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"sort"
9+
"strings"
10+
"time"
11+
12+
"github.com/roborev-dev/roborev/internal/agent"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
func checkAgentsCmd() *cobra.Command {
17+
var (
18+
timeoutSecs int
19+
agentFilter string
20+
largePrompt bool
21+
)
22+
23+
cmd := &cobra.Command{
24+
Use: "check-agents",
25+
Short: "Check which agents are available and responding",
26+
Long: `Check which agents are installed and can produce output.
27+
28+
For each agent found on PATH, runs a short smoke-test prompt with a timeout
29+
to verify the agent is actually functional.
30+
31+
Examples:
32+
roborev check-agents # Check all agents
33+
roborev check-agents --agent codex # Check only codex
34+
roborev check-agents --timeout 30 # 30 second timeout per agent
35+
roborev check-agents --large-prompt # Test with 33KB+ prompt (Windows limit check)`,
36+
RunE: func(cmd *cobra.Command, args []string) error {
37+
names := agent.Available()
38+
sort.Strings(names)
39+
40+
timeout := time.Duration(timeoutSecs) * time.Second
41+
smokePrompt := "Respond with exactly: OK"
42+
if largePrompt {
43+
smokePrompt = "Respond with exactly: OK\n" +
44+
strings.Repeat("// padding line\n", 2200)
45+
}
46+
47+
// Use current directory as repo path for the smoke test
48+
repoPath, err := os.Getwd()
49+
if err != nil {
50+
repoPath = "."
51+
}
52+
53+
var passed, failed, skipped int
54+
55+
for _, name := range names {
56+
if name == "test" {
57+
continue
58+
}
59+
if agentFilter != "" && name != agentFilter {
60+
continue
61+
}
62+
63+
a, _ := agent.Get(name)
64+
if a == nil {
65+
continue
66+
}
67+
68+
cmdName := ""
69+
if ca, ok := a.(agent.CommandAgent); ok {
70+
cmdName = ca.CommandName()
71+
}
72+
73+
if !agent.IsAvailable(name) {
74+
fmt.Printf(" - %-14s %s (not found in PATH)\n", name, cmdName)
75+
skipped++
76+
continue
77+
}
78+
79+
path, _ := exec.LookPath(cmdName)
80+
fmt.Printf(" ? %-14s %s (%s) ... ", name, cmdName, path)
81+
82+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
83+
result, err := a.Review(ctx, repoPath, "HEAD", smokePrompt, nil)
84+
cancel()
85+
86+
if err != nil {
87+
fmt.Printf("FAIL\n")
88+
// Indent each line of the error for readability
89+
for line := range strings.SplitSeq(err.Error(), "\n") {
90+
line = strings.TrimSpace(line)
91+
if line != "" {
92+
fmt.Printf(" %s\n", line)
93+
}
94+
}
95+
failed++
96+
} else if strings.TrimSpace(result) == "" {
97+
fmt.Printf("FAIL (empty response)\n")
98+
failed++
99+
} else {
100+
fmt.Printf("OK (%d bytes)\n", len(result))
101+
passed++
102+
}
103+
}
104+
105+
fmt.Printf("\n%d passed, %d failed, %d skipped\n", passed, failed, skipped)
106+
if failed > 0 {
107+
return fmt.Errorf("%d agent(s) failed health check", failed)
108+
}
109+
return nil
110+
},
111+
}
112+
113+
cmd.SilenceUsage = true
114+
cmd.Flags().IntVar(&timeoutSecs, "timeout", 60, "timeout in seconds per agent")
115+
cmd.Flags().StringVar(&agentFilter, "agent", "", "check only this agent")
116+
cmd.Flags().BoolVar(&largePrompt, "large-prompt", false,
117+
"use a 33KB+ prompt to test Windows command-line limits")
118+
119+
return cmd
120+
}

cmd/roborev/comment.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"os/exec"
11+
"strconv"
12+
"strings"
13+
14+
"github.com/roborev-dev/roborev/internal/git"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
func commentCmd() *cobra.Command {
19+
var (
20+
commenter string
21+
message string
22+
forceJobID bool
23+
)
24+
25+
cmd := &cobra.Command{
26+
Use: "comment <job_id|sha> [message]",
27+
Short: "Add a comment to a review",
28+
Long: `Add a comment or note to a review.
29+
30+
The first argument can be either a job ID (numeric) or a commit SHA.
31+
Using job IDs is recommended since they are displayed in the TUI.
32+
33+
Examples:
34+
roborev comment 42 "Fixed the null pointer issue"
35+
roborev comment 42 -m "Added missing error handling"
36+
roborev comment abc123 "Addressed by refactoring"
37+
roborev comment 42 # Opens editor for message
38+
roborev comment --job 1234567 "msg" # Force numeric arg as job ID`,
39+
Args: cobra.RangeArgs(1, 2),
40+
RunE: func(cmd *cobra.Command, args []string) error {
41+
ref := args[0]
42+
43+
// Check if ref is a job ID (numeric) or SHA
44+
var jobID int64
45+
var sha string
46+
47+
if forceJobID {
48+
// --job flag: treat ref as job ID
49+
id, err := strconv.ParseInt(ref, 10, 64)
50+
if err != nil {
51+
return fmt.Errorf("--job requires numeric job ID, got %q", ref)
52+
}
53+
jobID = id
54+
} else {
55+
// Auto-detect: try git object first, then job ID
56+
// A numeric string could be either - check if it resolves as a git object first
57+
if root, err := git.GetRepoRoot("."); err == nil {
58+
if resolved, err := git.ResolveSHA(root, ref); err == nil {
59+
sha = resolved
60+
}
61+
}
62+
63+
// If not a valid git object, try parsing as job ID
64+
if sha == "" {
65+
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
66+
jobID = id
67+
} else {
68+
// Not a valid git object or job ID - use ref as-is
69+
sha = ref
70+
}
71+
}
72+
}
73+
74+
// Ensure daemon is running
75+
if err := ensureDaemon(); err != nil {
76+
return fmt.Errorf("daemon not running: %w", err)
77+
}
78+
79+
// Message can be positional argument or flag
80+
if len(args) > 1 {
81+
message = args[1]
82+
}
83+
84+
// If no message provided, open editor
85+
if message == "" {
86+
editor := os.Getenv("EDITOR")
87+
if editor == "" {
88+
editor = "vim"
89+
}
90+
91+
tmpfile, err := os.CreateTemp("", "roborev-comment-*.md")
92+
if err != nil {
93+
return fmt.Errorf("create temp file: %w", err)
94+
}
95+
tmpfile.Close()
96+
defer os.Remove(tmpfile.Name())
97+
98+
editorCmd := exec.Command(editor, tmpfile.Name())
99+
editorCmd.Stdin = os.Stdin
100+
editorCmd.Stdout = os.Stdout
101+
editorCmd.Stderr = os.Stderr
102+
if err := editorCmd.Run(); err != nil {
103+
return fmt.Errorf("editor failed: %w", err)
104+
}
105+
106+
content, err := os.ReadFile(tmpfile.Name())
107+
if err != nil {
108+
return fmt.Errorf("read comment: %w", err)
109+
}
110+
message = strings.TrimSpace(string(content))
111+
}
112+
113+
if message == "" {
114+
return fmt.Errorf("empty comment, aborting")
115+
}
116+
117+
if commenter == "" {
118+
commenter = os.Getenv("USER")
119+
if commenter == "" {
120+
commenter = "anonymous"
121+
}
122+
}
123+
124+
// Build request with either job_id or sha
125+
reqData := map[string]any{
126+
"commenter": commenter,
127+
"comment": message,
128+
}
129+
if jobID != 0 {
130+
reqData["job_id"] = jobID
131+
} else {
132+
reqData["sha"] = sha
133+
}
134+
135+
reqBody, _ := json.Marshal(reqData)
136+
137+
addr := getDaemonAddr()
138+
resp, err := http.Post(addr+"/api/comment", "application/json", bytes.NewReader(reqBody))
139+
if err != nil {
140+
return fmt.Errorf("failed to connect to daemon: %w", err)
141+
}
142+
defer resp.Body.Close()
143+
144+
if resp.StatusCode != http.StatusCreated {
145+
body, _ := io.ReadAll(resp.Body)
146+
return fmt.Errorf("failed to add comment: %s", body)
147+
}
148+
149+
fmt.Println("Comment added successfully")
150+
return nil
151+
},
152+
}
153+
154+
cmd.Flags().StringVar(&commenter, "commenter", "", "commenter name (default: $USER)")
155+
cmd.Flags().StringVarP(&message, "message", "m", "", "comment message (opens editor if not provided)")
156+
cmd.Flags().BoolVar(&forceJobID, "job", false, "force argument to be treated as job ID (not SHA)")
157+
158+
return cmd
159+
}
160+
161+
// respondCmd returns an alias for commentCmd
162+
func respondCmd() *cobra.Command {
163+
cmd := commentCmd()
164+
cmd.Use = "respond <job_id|sha> [message]"
165+
cmd.Short = "Alias for 'comment' - add a comment to a review"
166+
return cmd
167+
}
168+
169+
func addressCmd() *cobra.Command {
170+
var unaddress bool
171+
172+
cmd := &cobra.Command{
173+
Use: "address <job_id>",
174+
Short: "Mark a review as addressed",
175+
Args: cobra.ExactArgs(1),
176+
RunE: func(cmd *cobra.Command, args []string) error {
177+
// Ensure daemon is running
178+
if err := ensureDaemon(); err != nil {
179+
return fmt.Errorf("daemon not running: %w", err)
180+
}
181+
182+
jobID, err := strconv.ParseInt(args[0], 10, 64)
183+
if err != nil || jobID <= 0 {
184+
return fmt.Errorf("invalid job_id: %s", args[0])
185+
}
186+
187+
addressed := !unaddress
188+
reqBody, _ := json.Marshal(map[string]any{
189+
"job_id": jobID,
190+
"addressed": addressed,
191+
})
192+
193+
addr := getDaemonAddr()
194+
resp, err := http.Post(addr+"/api/review/address", "application/json", bytes.NewReader(reqBody))
195+
if err != nil {
196+
return fmt.Errorf("failed to connect to daemon: %w", err)
197+
}
198+
defer resp.Body.Close()
199+
200+
if resp.StatusCode != http.StatusOK {
201+
body, _ := io.ReadAll(resp.Body)
202+
return fmt.Errorf("failed to mark review: %s", body)
203+
}
204+
205+
if addressed {
206+
fmt.Printf("Job %d marked as addressed\n", jobID)
207+
} else {
208+
fmt.Printf("Job %d marked as unaddressed\n", jobID)
209+
}
210+
return nil
211+
},
212+
}
213+
214+
cmd.Flags().BoolVar(&unaddress, "unaddress", false, "mark as unaddressed instead")
215+
216+
return cmd
217+
}

0 commit comments

Comments
 (0)