diff --git a/cmd/roborev/insights.go b/cmd/roborev/insights.go new file mode 100644 index 00000000..0434b48b --- /dev/null +++ b/cmd/roborev/insights.go @@ -0,0 +1,375 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/roborev-dev/roborev/internal/config" + "github.com/roborev-dev/roborev/internal/daemon" + "github.com/roborev-dev/roborev/internal/git" + "github.com/roborev-dev/roborev/internal/prompt" + "github.com/roborev-dev/roborev/internal/storage" + "github.com/spf13/cobra" +) + +func insightsCmd() *cobra.Command { + var ( + repoPath string + branch string + since string + agentName string + model string + reasoning string + wait bool + jsonOutput bool + ) + + cmd := &cobra.Command{ + Use: "insights", + Short: "Analyze review patterns and suggest guideline improvements", + Long: `Analyze failing code reviews to identify recurring patterns and suggest +improvements to review guidelines. + +This is an LLM-powered command that: +1. Queries completed reviews (focusing on failures) from the database +2. Includes the current review_guidelines from .roborev.toml as context +3. Sends the batch to an agent with a structured analysis prompt +4. Returns actionable recommendations for guideline changes + +The agent produces: +- Recurring finding patterns across reviews +- Hotspot areas (files/packages that concentrate failures) +- Noise candidates (findings consistently dismissed without code changes) +- Guideline gaps (patterns flagged by reviews but not in guidelines) +- Suggested guideline additions (concrete text for .roborev.toml) + +Examples: + roborev insights # Analyze last 30 days of reviews + roborev insights --since 7d # Last 7 days only + roborev insights --branch main # Only reviews on main branch + roborev insights --repo /path/to/repo # Specific repo + roborev insights --agent gemini --wait # Use specific agent, wait for result + roborev insights --json # Output job info as JSON`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runInsights(cmd, insightsOptions{ + repoPath: repoPath, + branch: branch, + since: since, + agentName: agentName, + model: model, + reasoning: reasoning, + wait: wait, + jsonOutput: jsonOutput, + }) + }, + } + + cmd.Flags().StringVar(&repoPath, "repo", "", "scope to a single repo (default: current repo if tracked)") + cmd.Flags().StringVar(&branch, "branch", "", "scope to a single branch") + cmd.Flags().StringVar(&since, "since", "30d", "time window for reviews (e.g., 7d, 30d, 90d)") + cmd.Flags().StringVar(&agentName, "agent", "", "agent to use for analysis (default: from config)") + cmd.Flags().StringVar(&model, "model", "", "model for agent") + cmd.Flags().StringVar(&reasoning, "reasoning", "", "reasoning level: fast, standard, or thorough") + cmd.Flags().BoolVar(&wait, "wait", true, "wait for completion and display result") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "output job info as JSON") + registerAgentCompletion(cmd) + registerReasoningCompletion(cmd) + + return cmd +} + +type insightsOptions struct { + repoPath string + branch string + since string + agentName string + model string + reasoning string + wait bool + jsonOutput bool +} + +func runInsights(cmd *cobra.Command, opts insightsOptions) error { + // Resolve repo path — use main repo root so worktrees and subdirectories + // match the path stored in the daemon's database. + repoRoot := opts.repoPath + if repoRoot == "" { + workDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + root, err := git.GetRepoRoot(workDir) + if err != nil { + return fmt.Errorf("not in a git repository (use --repo to specify one)") + } + repoRoot = root + } else { + var err error + repoRoot, err = filepath.Abs(repoRoot) + if err != nil { + return fmt.Errorf("resolve repo path: %w", err) + } + // Validate that --repo points at an actual git repository + if _, err := git.GetRepoRoot(repoRoot); err != nil { + return fmt.Errorf("--repo %q is not a git repository", opts.repoPath) + } + } + // Canonicalize to main repo root (handles worktrees and subdirectories) + if mainRoot, err := git.GetMainRepoRoot(repoRoot); err == nil { + repoRoot = mainRoot + } + + // Parse --since duration + sinceTime, err := parseSinceDuration(opts.since) + if err != nil { + return fmt.Errorf("invalid --since value %q: %w", opts.since, err) + } + + // Ensure daemon is running + if err := ensureDaemon(); err != nil { + return err + } + + if !opts.jsonOutput { + cmd.Printf("Gathering failing reviews since %s...\n", sinceTime.Format("2006-01-02")) + } + + // Fetch failing reviews from daemon API + reviews, err := fetchFailingReviews(serverAddr, repoRoot, opts.branch, sinceTime) + if err != nil { + return fmt.Errorf("fetch reviews: %w", err) + } + + if len(reviews) == 0 { + cmd.Println("No failing reviews found in the specified time window.") + return nil + } + + if !opts.jsonOutput { + cmd.Printf("Found %d failing review(s). Building analysis prompt...\n", len(reviews)) + } + + // Load current review guidelines and resolve prompt size budget + cfg, _ := config.LoadGlobal() + maxPromptSize := config.ResolveMaxPromptSize(repoRoot, cfg) + guidelines := "" + if repoCfg, err := config.LoadRepoConfig(repoRoot); err == nil && repoCfg != nil { + guidelines = repoCfg.ReviewGuidelines + } + + // Build the insights prompt + insightsPrompt := prompt.BuildInsightsPrompt(prompt.InsightsData{ + Reviews: reviews, + Guidelines: guidelines, + RepoName: filepath.Base(repoRoot), + Since: sinceTime, + MaxPromptSize: maxPromptSize, + }) + + // Enqueue as a task job + branch := git.GetCurrentBranch(repoRoot) + reqBody, _ := json.Marshal(daemon.EnqueueRequest{ + RepoPath: repoRoot, + GitRef: "insights", + Branch: branch, + Agent: opts.agentName, + Model: opts.model, + Reasoning: opts.reasoning, + CustomPrompt: insightsPrompt, + }) + + resp, err := http.Post(serverAddr+"/api/enqueue", "application/json", bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("failed to connect to daemon: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("enqueue failed: %s", body) + } + + var job storage.ReviewJob + if err := json.Unmarshal(body, &job); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + // JSON output mode + if opts.jsonOutput { + result := map[string]any{ + "job_id": job.ID, + "agent": job.Agent, + "reviews_analyzed": len(reviews), + "since": sinceTime.Format(time.RFC3339), + } + enc := json.NewEncoder(cmd.OutOrStdout()) + return enc.Encode(result) + } + + cmd.Printf("Enqueued insights job %d (agent: %s, analyzing %d reviews)\n", job.ID, job.Agent, len(reviews)) + + // Wait for completion + if opts.wait { + return waitForPromptJob(cmd, serverAddr, job.ID, false, promptPollInterval) + } + + return nil +} + +// maxInsightsReviews is the maximum number of failing reviews to collect. +// We stop paginating once we have this many. +const maxInsightsReviews = 100 + +// fetchFailingReviews queries the daemon API for done jobs with failing verdicts +// in the given time window, then fetches review output for each. It paginates +// through results to avoid silently dropping failures beyond a single page. +func fetchFailingReviews(addr, repoPath, branch string, since time.Time) ([]prompt.InsightsReview, error) { + client := &http.Client{Timeout: 30 * time.Second} + + var reviews []prompt.InsightsReview + pageSize := 100 + offset := 0 + + for { + // Build query for done jobs, excluding task and fix jobs + params := url.Values{} + params.Set("status", "done") + params.Set("repo", repoPath) + params.Set("limit", fmt.Sprintf("%d", pageSize)) + params.Set("offset", fmt.Sprintf("%d", offset)) + params.Set("exclude_job_type", "task") + if branch != "" { + params.Set("branch", branch) + } + + resp, err := client.Get(fmt.Sprintf("%s/api/jobs?%s", addr, params.Encode())) + if err != nil { + return nil, fmt.Errorf("query jobs: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("server error (%d): %s", resp.StatusCode, body) + } + + var jobsResp struct { + Jobs []storage.ReviewJob `json:"jobs"` + HasMore bool `json:"has_more"` + } + if err := json.NewDecoder(resp.Body).Decode(&jobsResp); err != nil { + resp.Body.Close() + return nil, fmt.Errorf("parse jobs response: %w", err) + } + resp.Body.Close() + + if len(jobsResp.Jobs) == 0 { + break + } + + // Filter and collect failing reviews within the time window. + // Note: jobs are ordered by id DESC (enqueue order), not by + // finished_at, so we cannot stop early on the first out-of-window + // job — a slower job with a lower ID could still finish in-window. + for _, job := range jobsResp.Jobs { + // Skip jobs finished outside the time window + if job.FinishedAt != nil && job.FinishedAt.Before(since) { + continue + } + + // Skip fix jobs (belt-and-suspenders with exclude_job_type) + if job.IsFixJob() { + continue + } + + // Only include failing verdicts + if job.Verdict == nil || *job.Verdict != "F" { + continue + } + + // Fetch the review output + review, err := fetchReviewForInsights(client, addr, job.ID) + if err != nil { + continue // Skip reviews we can't fetch + } + + reviews = append(reviews, prompt.InsightsReviewFromJob(job, review.Output, review.Closed)) + + if len(reviews) >= maxInsightsReviews { + return reviews, nil + } + } + + // Stop if no more pages + if !jobsResp.HasMore { + break + } + + offset += pageSize + } + + return reviews, nil +} + +// fetchReviewForInsights fetches a review by job ID +func fetchReviewForInsights(client *http.Client, addr string, jobID int64) (*storage.Review, error) { + resp, err := client.Get(fmt.Sprintf("%s/api/review?job_id=%d", addr, jobID)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + + var review storage.Review + if err := json.NewDecoder(resp.Body).Decode(&review); err != nil { + return nil, err + } + return &review, nil +} + +// parseSinceDuration parses a duration string like "7d", "30d", "90d" into a time.Time. +func parseSinceDuration(s string) (time.Time, error) { + s = strings.TrimSpace(s) + if s == "" { + return time.Now().AddDate(0, 0, -30), nil + } + + // Try standard Go duration first (e.g., "720h") + if d, err := time.ParseDuration(s); err == nil { + return time.Now().Add(-d), nil + } + + // Parse day-based durations (e.g., "7d", "30d") + if strings.HasSuffix(s, "d") { + var days int + if _, err := fmt.Sscanf(s, "%dd", &days); err == nil && days > 0 { + return time.Now().AddDate(0, 0, -days), nil + } + } + + // Parse week-based durations (e.g., "2w", "4w") + if strings.HasSuffix(s, "w") { + var weeks int + if _, err := fmt.Sscanf(s, "%dw", &weeks); err == nil && weeks > 0 { + return time.Now().AddDate(0, 0, -weeks*7), nil + } + } + + return time.Time{}, fmt.Errorf("expected format like 7d, 4w, or 720h") +} diff --git a/cmd/roborev/insights_test.go b/cmd/roborev/insights_test.go new file mode 100644 index 00000000..5fcc1a41 --- /dev/null +++ b/cmd/roborev/insights_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "testing" + "time" +) + +func TestParseSinceDuration(t *testing.T) { + tests := []struct { + input string + wantErr bool + checkFn func(t *testing.T, got time.Time) + }{ + { + input: "7d", + checkFn: func(t *testing.T, got time.Time) { + t.Helper() + expected := time.Now().AddDate(0, 0, -7) + diff := got.Sub(expected) + if diff < -time.Second || diff > time.Second { + t.Errorf("7d: got %v, want ~%v", got, expected) + } + }, + }, + { + input: "30d", + checkFn: func(t *testing.T, got time.Time) { + t.Helper() + expected := time.Now().AddDate(0, 0, -30) + diff := got.Sub(expected) + if diff < -time.Second || diff > time.Second { + t.Errorf("30d: got %v, want ~%v", got, expected) + } + }, + }, + { + input: "2w", + checkFn: func(t *testing.T, got time.Time) { + t.Helper() + expected := time.Now().AddDate(0, 0, -14) + diff := got.Sub(expected) + if diff < -time.Second || diff > time.Second { + t.Errorf("2w: got %v, want ~%v", got, expected) + } + }, + }, + { + input: "720h", + checkFn: func(t *testing.T, got time.Time) { + t.Helper() + expected := time.Now().Add(-720 * time.Hour) + diff := got.Sub(expected) + if diff < -time.Second || diff > time.Second { + t.Errorf("720h: got %v, want ~%v", got, expected) + } + }, + }, + { + input: "", + checkFn: func(t *testing.T, got time.Time) { + t.Helper() + expected := time.Now().AddDate(0, 0, -30) + diff := got.Sub(expected) + if diff < -time.Second || diff > time.Second { + t.Errorf("empty: got %v, want ~%v (30d default)", got, expected) + } + }, + }, + {input: "invalid", wantErr: true}, + {input: "0d", wantErr: true}, + {input: "-5d", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := parseSinceDuration(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("parseSinceDuration(%q) expected error, got %v", tt.input, got) + } + return + } + if err != nil { + t.Fatalf("parseSinceDuration(%q) unexpected error: %v", tt.input, err) + } + if tt.checkFn != nil { + tt.checkFn(t, got) + } + }) + } +} diff --git a/cmd/roborev/main.go b/cmd/roborev/main.go index 59495d69..389c5a0f 100644 --- a/cmd/roborev/main.go +++ b/cmd/roborev/main.go @@ -40,6 +40,7 @@ func main() { rootCmd.AddCommand(refineCmd()) rootCmd.AddCommand(runCmd()) rootCmd.AddCommand(analyzeCmd()) + rootCmd.AddCommand(insightsCmd()) rootCmd.AddCommand(fixCmd()) rootCmd.AddCommand(compactCmd()) rootCmd.AddCommand(promptCmd()) // hidden alias for backward compatibility diff --git a/internal/prompt/insights.go b/internal/prompt/insights.go new file mode 100644 index 00000000..77be800c --- /dev/null +++ b/internal/prompt/insights.go @@ -0,0 +1,210 @@ +package prompt + +import ( + "fmt" + "strings" + "time" + + "github.com/roborev-dev/roborev/internal/storage" +) + +// InsightsSystemPrompt is the instruction for analyzing review patterns +const InsightsSystemPrompt = `You are a code review insights analyst. Your task is to analyze a collection of failing code reviews and identify actionable patterns that can improve future reviews. + +You will be given: +1. A set of failing review outputs with metadata (agent, date, git ref, addressed status) +2. The current project review guidelines (if any) + +Your analysis should produce: + +## 1. Recurring Finding Patterns + +Identify clusters of similar findings that appear across multiple reviews. For each pattern: +- Name the pattern concisely +- Count how many reviews contain it +- Give 1-2 representative examples with file/line references if available +- Assess whether this pattern represents a real code quality issue or review noise + +## 2. Hotspot Areas + +Identify files, packages, or directories that concentrate failures. List them with: +- Path or package name +- Number of failing reviews touching this area +- Common finding types in this area + +## 3. Noise Candidates + +Identify finding types that are consistently present in reviews that were closed/addressed without corresponding code changes. These suggest the review guideline should suppress them. For each: +- Describe the finding type +- Note how many times it appeared and was dismissed +- Suggest whether to suppress it entirely or refine the criteria + +## 4. Guideline Gaps + +Identify patterns the reviews keep flagging that are NOT mentioned in the current guidelines. For each: +- Describe what the reviews are catching +- Explain why it should (or shouldn't) be codified as a guideline +- Suggest specific guideline text if appropriate + +## 5. Suggested Guideline Additions + +Provide concrete text snippets that could be added to the project's review_guidelines configuration. Format each as a ready-to-use guideline entry. + +Be specific and actionable. Avoid vague recommendations. Base everything on evidence from the provided reviews.` + +// InsightsData holds the data needed to build an insights prompt +type InsightsData struct { + Reviews []InsightsReview + Guidelines string + RepoName string + Since time.Time + MaxReviews int // Cap for number of reviews to include + MaxOutputPerReview int // Cap for individual review output size + MaxPromptSize int // Overall prompt size budget (0 = use MaxPromptSize default) +} + +// InsightsReview is a simplified review record for the insights prompt +type InsightsReview struct { + JobID int64 + Agent string + GitRef string + Branch string + FinishedAt *time.Time + Output string + Closed bool + Verdict string // "P" or "F" +} + +// BuildInsightsPrompt constructs the full prompt for insights analysis. +// It prioritizes unaddressed (open) findings over addressed (closed) ones +// when truncating to fit within size limits. +func BuildInsightsPrompt(data InsightsData) string { + var sb strings.Builder + + promptBudget := data.MaxPromptSize + if promptBudget <= 0 { + promptBudget = MaxPromptSize + } + + sb.WriteString(InsightsSystemPrompt) + sb.WriteString("\n\n") + + // Current guidelines section — cap at 10% of prompt budget so + // oversized guidelines don't crowd out review data. + sb.WriteString("## Current Review Guidelines\n\n") + if data.Guidelines != "" { + guidelines := data.Guidelines + guidelineCap := max(promptBudget/10, 1024) + if len(guidelines) > guidelineCap { + guidelines = guidelines[:guidelineCap] + "\n... (guidelines truncated)" + } + sb.WriteString(guidelines) + } else { + sb.WriteString("(No review guidelines configured for this project)") + } + sb.WriteString("\n\n") + + // Separate into open (unaddressed) and closed (addressed) reviews + var open, closed []InsightsReview + for _, r := range data.Reviews { + if r.Closed { + closed = append(closed, r) + } else { + open = append(open, r) + } + } + + maxReviews := data.MaxReviews + if maxReviews <= 0 { + maxReviews = 50 + } + maxOutput := data.MaxOutputPerReview + if maxOutput <= 0 { + maxOutput = 8192 + } + // Prioritize open reviews, fill remaining slots with closed + var selected []InsightsReview + for _, r := range open { + if len(selected) >= maxReviews { + break + } + selected = append(selected, r) + } + for _, r := range closed { + if len(selected) >= maxReviews { + break + } + selected = append(selected, r) + } + + // Reviews section + sb.WriteString("## Failing Reviews\n\n") + fmt.Fprintf(&sb, "Showing %d failing review(s) since %s.\n\n", + len(selected), data.Since.Format("2006-01-02")) + + for i, r := range selected { + // Build the review entry in a temporary buffer so we can + // check size before committing it to the prompt. + var entry strings.Builder + fmt.Fprintf(&entry, "### Review %d (Job #%d)\n\n", i+1, r.JobID) + fmt.Fprintf(&entry, "- **Agent:** %s\n", r.Agent) + fmt.Fprintf(&entry, "- **Git Ref:** %s\n", r.GitRef) + if r.Branch != "" { + fmt.Fprintf(&entry, "- **Branch:** %s\n", r.Branch) + } + if r.FinishedAt != nil { + fmt.Fprintf(&entry, "- **Date:** %s\n", r.FinishedAt.Format("2006-01-02 15:04")) + } + status := "unaddressed" + if r.Closed { + status = "addressed/closed" + } + fmt.Fprintf(&entry, "- **Status:** %s\n", status) + entry.WriteString("\n") + + output := r.Output + if len(output) > maxOutput { + output = output[:maxOutput] + "\n... (truncated)" + } + entry.WriteString(output) + if !strings.HasSuffix(output, "\n") { + entry.WriteString("\n") + } + entry.WriteString("\n") + + // Pre-check: would adding this entry exceed the budget? + if sb.Len()+entry.Len() > promptBudget-256 { + remaining := len(selected) - i + fmt.Fprintf(&sb, "\n(Remaining %d reviews omitted due to prompt size limits)\n", remaining) + break + } + + sb.WriteString(entry.String()) + } + + if len(data.Reviews) > len(selected) { + fmt.Fprintf(&sb, "\nNote: %d additional failing reviews were omitted (showing most recent %d, prioritizing unaddressed findings).\n", + len(data.Reviews)-len(selected), len(selected)) + } + + return sb.String() +} + +// InsightsReviewFromJob converts a ReviewJob (with verdict) to an InsightsReview. +// The review output must be fetched separately. +func InsightsReviewFromJob(job storage.ReviewJob, output string, closed bool) InsightsReview { + verdict := "" + if job.Verdict != nil { + verdict = *job.Verdict + } + return InsightsReview{ + JobID: job.ID, + Agent: job.Agent, + GitRef: job.GitRef, + Branch: job.Branch, + FinishedAt: job.FinishedAt, + Output: output, + Closed: closed, + Verdict: verdict, + } +} diff --git a/internal/prompt/insights_test.go b/internal/prompt/insights_test.go new file mode 100644 index 00000000..903a5c11 --- /dev/null +++ b/internal/prompt/insights_test.go @@ -0,0 +1,211 @@ +package prompt + +import ( + "strings" + "testing" + "time" +) + +func TestBuildInsightsPrompt_Basic(t *testing.T) { + now := time.Now() + finished := now.Add(-1 * time.Hour) + data := InsightsData{ + Reviews: []InsightsReview{ + { + JobID: 1, + Agent: "codex", + GitRef: "abc1234", + Branch: "main", + FinishedAt: &finished, + Output: "Found SQL injection in handler.go:42", + Closed: false, + Verdict: "F", + }, + { + JobID: 2, + Agent: "gemini", + GitRef: "def5678", + FinishedAt: &finished, + Output: "Missing error handling in parser.go:10", + Closed: true, + Verdict: "F", + }, + }, + Guidelines: "Always check error returns", + RepoName: "myrepo", + Since: now.Add(-30 * 24 * time.Hour), + } + + result := BuildInsightsPrompt(data) + + // Should contain the system prompt + if !strings.Contains(result, "code review insights analyst") { + t.Error("missing system prompt") + } + + // Should contain guidelines + if !strings.Contains(result, "Always check error returns") { + t.Error("missing guidelines") + } + + // Should contain review data + if !strings.Contains(result, "SQL injection") { + t.Error("missing review 1 output") + } + if !strings.Contains(result, "Missing error handling") { + t.Error("missing review 2 output") + } + + // Should show addressed status + if !strings.Contains(result, "unaddressed") { + t.Error("missing unaddressed status") + } + if !strings.Contains(result, "addressed/closed") { + t.Error("missing addressed status") + } + + // Should contain agents + if !strings.Contains(result, "codex") { + t.Error("missing agent name") + } +} + +func TestBuildInsightsPrompt_NoGuidelines(t *testing.T) { + data := InsightsData{ + Reviews: []InsightsReview{ + {JobID: 1, Agent: "test", Output: "finding"}, + }, + Since: time.Now().Add(-7 * 24 * time.Hour), + } + + result := BuildInsightsPrompt(data) + + if !strings.Contains(result, "No review guidelines configured") { + t.Error("should indicate no guidelines") + } +} + +func TestBuildInsightsPrompt_PrioritizesOpen(t *testing.T) { + data := InsightsData{ + MaxReviews: 2, + Reviews: []InsightsReview{ + {JobID: 1, Agent: "test", Output: "open finding 1", Closed: false}, + {JobID: 2, Agent: "test", Output: "open finding 2", Closed: false}, + {JobID: 3, Agent: "test", Output: "open finding 3", Closed: false}, + {JobID: 4, Agent: "test", Output: "closed finding", Closed: true}, + }, + Since: time.Now().Add(-7 * 24 * time.Hour), + } + + result := BuildInsightsPrompt(data) + + // Should include open findings + if !strings.Contains(result, "open finding 1") { + t.Error("missing open finding 1") + } + if !strings.Contains(result, "open finding 2") { + t.Error("missing open finding 2") + } + + // Should NOT include closed finding (exceeded cap) + if strings.Contains(result, "closed finding") { + t.Error("should not include closed finding when open reviews fill cap") + } + + // Should note omitted reviews + if !strings.Contains(result, "additional failing reviews were omitted") { + t.Error("should mention omitted reviews") + } +} + +func TestBuildInsightsPrompt_TruncatesLongOutput(t *testing.T) { + longOutput := strings.Repeat("x", 10000) + data := InsightsData{ + Reviews: []InsightsReview{ + {JobID: 1, Agent: "test", Output: longOutput}, + }, + MaxOutputPerReview: 100, + Since: time.Now().Add(-7 * 24 * time.Hour), + } + + result := BuildInsightsPrompt(data) + + if !strings.Contains(result, "... (truncated)") { + t.Error("should truncate long output") + } + + // Should not contain the full output + if strings.Contains(result, longOutput) { + t.Error("should not contain full long output") + } +} + +func TestBuildInsightsPrompt_Empty(t *testing.T) { + data := InsightsData{ + Since: time.Now().Add(-7 * 24 * time.Hour), + } + + result := BuildInsightsPrompt(data) + + if !strings.Contains(result, "0 failing review(s)") { + t.Error("should show 0 reviews") + } +} + +func TestBuildInsightsPrompt_RespectsPromptBudget(t *testing.T) { + // Create reviews that would exceed a small budget + var reviews []InsightsReview + for i := range 20 { + reviews = append(reviews, InsightsReview{ + JobID: int64(i + 1), + Agent: "test", + Output: strings.Repeat("finding text ", 100), // ~1300 bytes each + }) + } + + budget := 5000 + data := InsightsData{ + Reviews: reviews, + Since: time.Now().Add(-7 * 24 * time.Hour), + MaxPromptSize: budget, + } + + result := BuildInsightsPrompt(data) + + if len(result) > budget { + t.Errorf("prompt size %d exceeds budget %d", len(result), budget) + } + + // Should mention omitted reviews + if !strings.Contains(result, "omitted due to prompt size limits") { + t.Error("should mention size-limited omissions") + } +} + +func TestBuildInsightsPrompt_TruncatesLargeGuidelines(t *testing.T) { + hugeGuidelines := strings.Repeat("guideline rule ", 10000) // ~150KB + data := InsightsData{ + Reviews: []InsightsReview{ + {JobID: 1, Agent: "test", Output: "a finding"}, + }, + Guidelines: hugeGuidelines, + Since: time.Now().Add(-7 * 24 * time.Hour), + MaxPromptSize: 10000, + } + + result := BuildInsightsPrompt(data) + + if len(result) > data.MaxPromptSize { + t.Errorf("prompt size %d exceeds budget %d", len(result), data.MaxPromptSize) + } + + // Guidelines should be truncated + if !strings.Contains(result, "guidelines truncated") { + t.Error("should truncate oversized guidelines") + } + + // Should still include the review + if !strings.Contains(result, "a finding") { + t.Error("review data should still be present after guidelines truncation") + } +}