Skip to content

Commit cbcbb38

Browse files
mariusvniekerkdarrenhaaswesmclaude
authored
Follow up to #514: make insights a daemon-owned job type (#554)
Follow-up to #514. Because #514 is opened from a cross-repo branch (`darrenhaas:darren/insights-command`), GitHub cannot stack this PR directly on that branch inside `roborev-dev/roborev`. This PR carries the follow-up work against `main` and references #514 for context. ## Meaningful changes - add a first-class `insights` job type so the daemon, worker, storage layer, and TUI all treat insights runs as stored-prompt task work - move insights dataset assembly into the daemon enqueue path, including history selection, branch filtering, review/comment loading, and prompt construction - simplify the CLI to a thin enqueue client and remove the CLI-side N+1 `/api/jobs` + `/api/review` + `/api/comments` fan-out - keep review comments in the analysis corpus, exclude compact jobs from the insights dataset, and use the requested branch for insights filtering/metadata - return a clean skipped response when no matching failing reviews exist instead of forcing the CLI to prefetch history - add daemon, CLI, and storage tests that lock in the new insights job contract ## Validation - `go fmt ./...` - `go vet ./...` - `go test ./...` --------- Co-authored-by: Darren Haas <361714+darrenhaas@users.noreply.github.com> Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a9f4c7d commit cbcbb38

File tree

15 files changed

+1253
-35
lines changed

15 files changed

+1253
-35
lines changed

cmd/roborev/insights.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"time"
13+
14+
"github.com/roborev-dev/roborev/internal/daemon"
15+
"github.com/roborev-dev/roborev/internal/git"
16+
"github.com/roborev-dev/roborev/internal/storage"
17+
"github.com/spf13/cobra"
18+
)
19+
20+
func insightsCmd() *cobra.Command {
21+
var (
22+
repoPath string
23+
branch string
24+
since string
25+
agentName string
26+
model string
27+
reasoning string
28+
wait bool
29+
jsonOutput bool
30+
)
31+
32+
cmd := &cobra.Command{
33+
Use: "insights",
34+
Short: "Analyze review patterns and suggest guideline improvements",
35+
Long: `Analyze failing code reviews to identify recurring patterns and suggest
36+
improvements to review guidelines.
37+
38+
This is an LLM-powered command that:
39+
1. Queries completed reviews (focusing on failures) from the database
40+
2. Includes the current review_guidelines from .roborev.toml as context
41+
3. Sends the batch to an agent with a structured analysis prompt
42+
4. Returns actionable recommendations for guideline changes
43+
44+
The agent produces:
45+
- Recurring finding patterns across reviews
46+
- Hotspot areas (files/packages that concentrate failures)
47+
- Noise candidates (findings consistently dismissed without code changes)
48+
- Guideline gaps (patterns flagged by reviews but not in guidelines)
49+
- Suggested guideline additions (concrete text for .roborev.toml)
50+
51+
Examples:
52+
roborev insights # Analyze last 30 days of reviews
53+
roborev insights --since 7d # Last 7 days only
54+
roborev insights --branch main # Only reviews on main branch
55+
roborev insights --repo /path/to/repo # Specific repo
56+
roborev insights --agent gemini --wait # Use specific agent, wait for result
57+
roborev insights --json # Output job info as JSON`,
58+
SilenceUsage: true,
59+
RunE: func(cmd *cobra.Command, args []string) error {
60+
return runInsights(cmd, insightsOptions{
61+
repoPath: repoPath,
62+
branch: branch,
63+
since: since,
64+
agentName: agentName,
65+
model: model,
66+
reasoning: reasoning,
67+
wait: wait,
68+
jsonOutput: jsonOutput,
69+
})
70+
},
71+
}
72+
73+
cmd.Flags().StringVar(&repoPath, "repo", "", "scope to a single repo (default: current repo if tracked)")
74+
cmd.Flags().StringVar(&branch, "branch", "", "scope to a single branch")
75+
cmd.Flags().StringVar(&since, "since", "30d", "time window for reviews (e.g., 7d, 30d, 90d)")
76+
cmd.Flags().StringVar(&agentName, "agent", "", "agent to use for analysis (default: from config)")
77+
cmd.Flags().StringVar(&model, "model", "", "model for agent")
78+
cmd.Flags().StringVar(&reasoning, "reasoning", "", "reasoning level: fast, standard, or thorough")
79+
cmd.Flags().BoolVar(&wait, "wait", true, "wait for completion and display result")
80+
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output job info as JSON")
81+
registerAgentCompletion(cmd)
82+
registerReasoningCompletion(cmd)
83+
84+
return cmd
85+
}
86+
87+
type insightsOptions struct {
88+
repoPath string
89+
branch string
90+
since string
91+
agentName string
92+
model string
93+
reasoning string
94+
wait bool
95+
jsonOutput bool
96+
}
97+
98+
func runInsights(cmd *cobra.Command, opts insightsOptions) error {
99+
repoPath := opts.repoPath
100+
if repoPath == "" {
101+
workDir, err := os.Getwd()
102+
if err != nil {
103+
return fmt.Errorf("get working directory: %w", err)
104+
}
105+
repoPath = workDir
106+
} else {
107+
var err error
108+
repoPath, err = filepath.Abs(repoPath)
109+
if err != nil {
110+
return fmt.Errorf("resolve repo path: %w", err)
111+
}
112+
}
113+
114+
if _, err := git.GetRepoRoot(repoPath); err != nil {
115+
if opts.repoPath == "" {
116+
return fmt.Errorf("not in a git repository (use --repo to specify one)")
117+
}
118+
return fmt.Errorf("--repo %q is not a git repository", opts.repoPath)
119+
}
120+
121+
// Parse --since duration
122+
sinceTime, err := parseSinceDuration(opts.since)
123+
if err != nil {
124+
return fmt.Errorf("invalid --since value %q: %w", opts.since, err)
125+
}
126+
127+
// Ensure daemon is running
128+
if err := ensureDaemon(); err != nil {
129+
return err
130+
}
131+
132+
if !opts.jsonOutput {
133+
cmd.Printf("Queueing insights analysis since %s...\n", sinceTime.Format("2006-01-02"))
134+
}
135+
136+
reqBody, _ := json.Marshal(daemon.EnqueueRequest{
137+
RepoPath: repoPath,
138+
GitRef: "insights",
139+
Branch: opts.branch,
140+
Since: sinceTime.Format(time.RFC3339),
141+
Agent: opts.agentName,
142+
Model: opts.model,
143+
Reasoning: opts.reasoning,
144+
JobType: storage.JobTypeInsights,
145+
})
146+
147+
ep := getDaemonEndpoint()
148+
resp, err := ep.HTTPClient(30*time.Second).Post(ep.BaseURL()+"/api/enqueue", "application/json", bytes.NewReader(reqBody))
149+
if err != nil {
150+
return fmt.Errorf("failed to connect to daemon: %w", err)
151+
}
152+
defer resp.Body.Close()
153+
154+
body, err := io.ReadAll(resp.Body)
155+
if err != nil {
156+
return fmt.Errorf("failed to read response: %w", err)
157+
}
158+
159+
if resp.StatusCode == http.StatusOK {
160+
var skipped struct {
161+
Skipped bool `json:"skipped"`
162+
Reason string `json:"reason"`
163+
}
164+
if err := json.Unmarshal(body, &skipped); err == nil && skipped.Skipped {
165+
if opts.jsonOutput {
166+
enc := json.NewEncoder(cmd.OutOrStdout())
167+
return enc.Encode(map[string]any{
168+
"skipped": true,
169+
"reason": skipped.Reason,
170+
"since": sinceTime.Format(time.RFC3339),
171+
})
172+
}
173+
cmd.Println(skipped.Reason)
174+
return nil
175+
}
176+
}
177+
178+
if resp.StatusCode != http.StatusCreated {
179+
return fmt.Errorf("enqueue failed: %s", body)
180+
}
181+
182+
var job storage.ReviewJob
183+
if err := json.Unmarshal(body, &job); err != nil {
184+
return fmt.Errorf("failed to parse response: %w", err)
185+
}
186+
187+
// JSON output mode
188+
if opts.jsonOutput {
189+
result := map[string]any{
190+
"job_id": job.ID,
191+
"agent": job.Agent,
192+
"since": sinceTime.Format(time.RFC3339),
193+
}
194+
enc := json.NewEncoder(cmd.OutOrStdout())
195+
return enc.Encode(result)
196+
}
197+
198+
cmd.Printf("Enqueued insights job %d (agent: %s)\n", job.ID, job.Agent)
199+
200+
// Wait for completion
201+
if opts.wait {
202+
return waitForPromptJob(cmd, ep, job.ID, false, promptPollInterval)
203+
}
204+
205+
return nil
206+
}
207+
208+
// parseSinceDuration parses a duration string like "7d", "30d", "90d" into a time.Time.
209+
func parseSinceDuration(s string) (time.Time, error) {
210+
s = strings.TrimSpace(s)
211+
if s == "" {
212+
return time.Now().AddDate(0, 0, -30), nil
213+
}
214+
215+
// Try standard Go duration first (e.g., "720h")
216+
if d, err := time.ParseDuration(s); err == nil {
217+
if d <= 0 {
218+
return time.Time{},
219+
fmt.Errorf("duration must be positive, got %s", s)
220+
}
221+
return time.Now().Add(-d), nil
222+
}
223+
224+
// Parse day-based durations (e.g., "7d", "30d")
225+
if strings.HasSuffix(s, "d") {
226+
var days int
227+
if _, err := fmt.Sscanf(s, "%dd", &days); err == nil && days > 0 {
228+
return time.Now().AddDate(0, 0, -days), nil
229+
}
230+
}
231+
232+
// Parse week-based durations (e.g., "2w", "4w")
233+
if strings.HasSuffix(s, "w") {
234+
var weeks int
235+
if _, err := fmt.Sscanf(s, "%dw", &weeks); err == nil && weeks > 0 {
236+
return time.Now().AddDate(0, 0, -weeks*7), nil
237+
}
238+
}
239+
240+
return time.Time{}, fmt.Errorf("expected format like 7d, 4w, or 720h")
241+
}

cmd/roborev/insights_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
"time"
9+
10+
"github.com/roborev-dev/roborev/internal/daemon"
11+
"github.com/roborev-dev/roborev/internal/storage"
12+
"github.com/spf13/cobra"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestParseSinceDuration(t *testing.T) {
18+
t.Parallel()
19+
20+
tests := []struct {
21+
input string
22+
wantErr bool
23+
checkFn func(t *testing.T, got time.Time)
24+
}{
25+
{
26+
input: "7d",
27+
checkFn: func(t *testing.T, got time.Time) {
28+
t.Helper()
29+
expected := time.Now().AddDate(0, 0, -7)
30+
assertDurationApprox(t, expected, got, "7d")
31+
},
32+
},
33+
{
34+
input: "30d",
35+
checkFn: func(t *testing.T, got time.Time) {
36+
t.Helper()
37+
expected := time.Now().AddDate(0, 0, -30)
38+
assertDurationApprox(t, expected, got, "30d")
39+
},
40+
},
41+
{
42+
input: "2w",
43+
checkFn: func(t *testing.T, got time.Time) {
44+
t.Helper()
45+
expected := time.Now().AddDate(0, 0, -14)
46+
assertDurationApprox(t, expected, got, "2w")
47+
},
48+
},
49+
{
50+
input: "720h",
51+
checkFn: func(t *testing.T, got time.Time) {
52+
t.Helper()
53+
expected := time.Now().Add(-720 * time.Hour)
54+
assertDurationApprox(t, expected, got, "720h")
55+
},
56+
},
57+
{
58+
input: "",
59+
checkFn: func(t *testing.T, got time.Time) {
60+
t.Helper()
61+
expected := time.Now().AddDate(0, 0, -30)
62+
assertDurationApprox(t, expected, got, "empty")
63+
},
64+
},
65+
{input: "invalid", wantErr: true},
66+
{input: "0d", wantErr: true},
67+
{input: "-5d", wantErr: true},
68+
{input: "-5h", wantErr: true},
69+
}
70+
71+
for _, tt := range tests {
72+
t.Run(tt.input, func(t *testing.T) {
73+
got, err := parseSinceDuration(tt.input)
74+
if tt.wantErr {
75+
require.Error(t, err)
76+
return
77+
}
78+
79+
require.NoError(t, err)
80+
if tt.checkFn != nil {
81+
tt.checkFn(t, got)
82+
}
83+
})
84+
}
85+
}
86+
87+
func TestRunInsights_EnqueuesInsightsJob(t *testing.T) {
88+
repo := NewGitTestRepo(t)
89+
repo.CommitFile("README.md", "hello\n", "init")
90+
repo.Run("checkout", "-b", "feature")
91+
92+
var enqueued daemon.EnqueueRequest
93+
mux := http.NewServeMux()
94+
mux.HandleFunc("/api/enqueue", func(w http.ResponseWriter, r *http.Request) {
95+
err := json.NewDecoder(r.Body).Decode(&enqueued)
96+
assert.NoError(t, err)
97+
w.WriteHeader(http.StatusCreated)
98+
writeJSON(w, storage.ReviewJob{
99+
ID: 99,
100+
Agent: "codex",
101+
Status: storage.JobStatusQueued,
102+
})
103+
})
104+
105+
daemonFromHandler(t, mux)
106+
107+
cmd := &cobra.Command{}
108+
cmd.SetOut(&bytes.Buffer{})
109+
cmd.SetErr(&bytes.Buffer{})
110+
111+
err := runInsights(cmd, insightsOptions{
112+
repoPath: repo.Dir,
113+
branch: "main",
114+
since: "7d",
115+
wait: false,
116+
})
117+
require.NoError(t, err)
118+
119+
require.Equal(t, storage.JobTypeInsights, enqueued.JobType)
120+
require.Equal(t, "insights", enqueued.GitRef)
121+
assert.Equal(t, "main", enqueued.Branch)
122+
assert.Empty(t, enqueued.CustomPrompt)
123+
124+
since, err := time.Parse(time.RFC3339, enqueued.Since)
125+
require.NoError(t, err)
126+
assert.WithinDuration(t, time.Now().AddDate(0, 0, -7), since, 2*time.Second)
127+
}
128+
129+
func assertDurationApprox(
130+
t *testing.T, expected, got time.Time, label string,
131+
) {
132+
t.Helper()
133+
diff := got.Sub(expected)
134+
assert.LessOrEqual(t, diff, time.Second, "%s upper bound", label)
135+
assert.GreaterOrEqual(t, diff, -time.Second, "%s lower bound", label)
136+
}

cmd/roborev/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ func main() {
4343
rootCmd.AddCommand(refineCmd())
4444
rootCmd.AddCommand(runCmd())
4545
rootCmd.AddCommand(analyzeCmd())
46+
rootCmd.AddCommand(insightsCmd())
4647
rootCmd.AddCommand(fixCmd())
4748
rootCmd.AddCommand(compactCmd())
4849
rootCmd.AddCommand(promptCmd()) // hidden alias for backward compatibility

0 commit comments

Comments
 (0)