diff --git a/cmd/review.go b/cmd/review.go new file mode 100644 index 00000000..5e5a5029 --- /dev/null +++ b/cmd/review.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var reviewCmd = &cobra.Command{ + Use: "review", + Short: "Continuous AI code review on commits", + Long: `hawk review provides continuous background code review. + +Run 'hawk review init' to install a post-commit hook, then every commit +is automatically reviewed using sight. View findings with 'hawk review tui' +or fix them with 'hawk review fix'.`, +} + +var reviewInitCmd = &cobra.Command{ + Use: "init", + Short: "Install post-commit hook for automatic reviews", + RunE: runReviewInit, +} + +var reviewInitForce bool + +func init() { + reviewInitCmd.Flags().BoolVarP(&reviewInitForce, "force", "f", false, "Overwrite existing post-commit hook") + reviewCmd.AddCommand(reviewInitCmd) + rootCmd.AddCommand(reviewCmd) +} + +const hookScript = `#!/bin/sh +# hawk review — continuous code review hook +# Installed by 'hawk review init' +SHA=$(git rev-parse HEAD) +hawk review run "$SHA" --background & +` + +func runReviewInit(_ *cobra.Command, _ []string) error { + gitDir, err := findGitDir() + if err != nil { + return err + } + + hooksDir := filepath.Join(gitDir, "hooks") + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + return fmt.Errorf("create hooks dir: %w", err) + } + + hookPath := filepath.Join(hooksDir, "post-commit") + + // Check for existing hook. + if _, err := os.Stat(hookPath); err == nil && !reviewInitForce { + existing, _ := os.ReadFile(hookPath) + if strings.Contains(string(existing), "hawk review") { + fmt.Println("✓ hawk review hook already installed") + return nil + } + return fmt.Errorf("post-commit hook already exists at %s\nUse --force to overwrite, or manually add:\n %s", hookPath, strings.TrimSpace(hookScript)) + } + + if err := os.WriteFile(hookPath, []byte(hookScript), 0o755); err != nil { + return fmt.Errorf("write hook: %w", err) + } + + // Ensure .hawk directory exists for the review DB. + projectDir, _ := os.Getwd() + _ = os.MkdirAll(filepath.Join(projectDir, ".hawk"), 0o755) + + fmt.Printf("✓ Installed post-commit hook at %s\n", hookPath) + fmt.Println(" Every commit will now be reviewed automatically.") + fmt.Println(" View reviews: hawk review status") + fmt.Println(" Interactive: hawk review tui") + return nil +} + +func findGitDir() (string, error) { + out, err := exec.Command("git", "rev-parse", "--git-dir").Output() + if err != nil { + return "", fmt.Errorf("not a git repository (run from inside a git repo)") + } + dir := strings.TrimSpace(string(out)) + if !filepath.IsAbs(dir) { + cwd, _ := os.Getwd() + dir = filepath.Join(cwd, dir) + } + return dir, nil +} diff --git a/cmd/review_analyze.go b/cmd/review_analyze.go new file mode 100644 index 00000000..dcda3fac --- /dev/null +++ b/cmd/review_analyze.go @@ -0,0 +1,251 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/GrayCodeAI/eyrie/client" + sightLib "github.com/GrayCodeAI/sight" + hawkSight "github.com/GrayCodeAI/hawk/sight" + "github.com/spf13/cobra" +) + +var ( + analyzeModel string + analyzeFix bool + analyzeWait bool + analyzeTimeout time.Duration +) + +var reviewAnalyzeCmd = &cobra.Command{ + Use: "analyze [files...]", + Short: "Run targeted code analysis", + Long: `Run on-demand analysis across your codebase. + +Types: + security Find security vulnerabilities + duplication Detect duplicated code + complexity Flag overly complex functions + dead-code Find unused exports and dead code + refactor Suggest refactoring opportunities + test-fixtures Find test helper opportunities + +Examples: + hawk review analyze security ./... + hawk review analyze complexity --fix main.go + hawk review analyze duplication ./internal/...`, + Args: cobra.MinimumNArgs(1), + RunE: runReviewAnalyze, +} + +func init() { + reviewAnalyzeCmd.Flags().StringVarP(&analyzeModel, "model", "m", "", "LLM model for analysis") + reviewAnalyzeCmd.Flags().BoolVar(&analyzeFix, "fix", false, "Auto-fix findings immediately") + reviewAnalyzeCmd.Flags().BoolVar(&analyzeWait, "wait", false, "Wait and display results (don't background)") + reviewAnalyzeCmd.Flags().DurationVar(&analyzeTimeout, "timeout", 3*time.Minute, "Analysis timeout") + reviewCmd.AddCommand(reviewAnalyzeCmd) +} + +var analysisPrompts = map[string]string{ + "security": `Analyze the following code for security vulnerabilities: +- Injection flaws (SQL, command, path traversal) +- Authentication/authorization issues +- Sensitive data exposure +- Insecure cryptography +- Missing input validation +Report each finding with file, line, severity, description, and fix.`, + + "duplication": `Analyze the following code for duplication: +- Repeated logic that could be extracted into shared functions +- Copy-pasted blocks with minor variations +- Similar patterns that could use generics or interfaces +Report each finding with the duplicated locations and a refactoring suggestion.`, + + "complexity": `Analyze the following code for excessive complexity: +- Functions with high cyclomatic complexity (>10) +- Deeply nested conditionals (>3 levels) +- Functions longer than 50 lines +- God objects or functions doing too many things +Report each finding with file, line, complexity metric, and simplification suggestion.`, + + "dead-code": `Analyze the following code for dead code: +- Unused exported functions/types +- Unreachable code paths +- Commented-out code blocks +- Unused variables and imports +Report each finding with file, line, and what can be safely removed.`, + + "refactor": `Analyze the following code for refactoring opportunities: +- Functions that violate single responsibility +- Missing error handling patterns +- Opportunities for better abstractions +- Interface segregation improvements +Report each finding with file, line, current issue, and suggested refactoring.`, + + "test-fixtures": `Analyze the following test code for improvement opportunities: +- Repeated test setup that could be shared fixtures +- Missing table-driven test patterns +- Test helpers that could reduce boilerplate +- Missing edge case coverage +Report each finding with file, line, and suggested improvement.`, +} + +func runReviewAnalyze(_ *cobra.Command, args []string) error { + analysisType := args[0] + prompt, ok := analysisPrompts[analysisType] + if !ok { + valid := make([]string, 0, len(analysisPrompts)) + for k := range analysisPrompts { + valid = append(valid, k) + } + return fmt.Errorf("unknown analysis type %q; valid types: %s", analysisType, strings.Join(valid, ", ")) + } + + // Collect files to analyze. + files := args[1:] + if len(files) == 0 { + files = []string{"./..."} + } + + // Get file contents via git ls-files + read. + content, err := getAnalysisContent(files) + if err != nil { + return fmt.Errorf("gather files: %w", err) + } + if content == "" { + fmt.Println("No files matched.") + return nil + } + + // Build sight bridge for analysis. + prov := provider + if prov == "" { + prov = client.DetectProvider() + } + eyrieClient := client.Client(&client.EyrieConfig{Provider: prov}) + + var opts []sightLib.Option + if analyzeModel != "" { + opts = append(opts, sightLib.WithModel(analyzeModel)) + } + opts = append(opts, sightLib.WithConcerns(analysisType)) + + bridge := hawkSight.NewBridge(eyrieClient, prov, opts...) + if !bridge.Ready() { + return fmt.Errorf("sight bridge not ready (check API key)") + } + + ctx := context.Background() + if analyzeTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, analyzeTimeout) + defer cancel() + } + + // Use the analysis prompt as a "diff" — sight will review it. + analysisInput := fmt.Sprintf("# Analysis Type: %s\n\n%s\n\n---\n\n%s", analysisType, prompt, content) + + fmt.Printf("Analyzing (%s)...\n", analysisType) + result, err := bridge.Review(ctx, analysisInput) + if err != nil { + return fmt.Errorf("analysis failed: %w", err) + } + + // Store as a review record. + projectDir, _ := os.Getwd() + store, err := OpenReviewStore(projectDir) + if err == nil { + defer store.Close() + sha := fmt.Sprintf("analyze-%s-%d", analysisType, time.Now().Unix()) + id, _ := store.Create(sha) + status := ReviewStatusPassed + if len(result.Findings) > 0 { + status = ReviewStatusOpen + } + _ = store.Update(id, status, result) + } + + // Print results. + if len(result.Findings) == 0 { + fmt.Printf("✓ No %s issues found.\n", analysisType) + return nil + } + + fmt.Printf("⚠ %d %s finding(s):\n\n", len(result.Findings), analysisType) + for i, f := range result.Findings { + sev := severityStyle(f.Severity.String()) + fmt.Printf(" %d. %s %s:%d\n", i+1, sev, f.File, f.Line) + fmt.Printf(" %s\n", f.Message) + if f.Fix != "" { + fmt.Printf(" Fix: %s\n", f.Fix) + } + fmt.Println() + } + + // Auto-fix if requested. + if analyzeFix && len(result.Findings) > 0 { + fmt.Println("Applying fixes...") + return autoFixAnalysis(result) + } + + return nil +} + +func getAnalysisContent(patterns []string) (string, error) { + // Use git ls-files to expand patterns, then read files. + args := append([]string{"ls-files", "--"}, patterns...) + out, err := exec.Command("git", args...).Output() + if err != nil { + // Fallback: treat patterns as literal file paths. + var b strings.Builder + for _, p := range patterns { + data, err := os.ReadFile(p) + if err != nil { + continue + } + b.WriteString(fmt.Sprintf("--- %s ---\n%s\n\n", p, string(data))) + } + return b.String(), nil + } + + files := strings.Split(strings.TrimSpace(string(out)), "\n") + var b strings.Builder + maxFiles := 30 // Cap to avoid token explosion. + for i, f := range files { + if i >= maxFiles || f == "" { + break + } + data, err := os.ReadFile(f) + if err != nil { + continue + } + b.WriteString(fmt.Sprintf("--- %s ---\n%s\n\n", f, string(data))) + } + return b.String(), nil +} + +func autoFixAnalysis(result *sightLib.Result) error { + var b strings.Builder + b.WriteString("Fix the following analysis findings:\n\n") + for i, f := range result.Findings { + b.WriteString(fmt.Sprintf("%d. [%s] %s:%d — %s\n", i+1, f.Severity, f.File, f.Line, f.Message)) + if f.Fix != "" { + b.WriteString(fmt.Sprintf(" Suggested: %s\n", f.Fix)) + } + } + b.WriteString("\nApply minimal, focused fixes. Commit with 'fix: address analysis findings'.") + + hawkBin, err := os.Executable() + if err != nil { + hawkBin = "hawk" + } + + cmd := exec.Command(hawkBin, "exec", "--auto", "full", b.String()) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/cmd/review_fix.go b/cmd/review_fix.go new file mode 100644 index 00000000..bdb72eaf --- /dev/null +++ b/cmd/review_fix.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +var ( + reviewFixAll bool + reviewFixWorktree bool + reviewFixModel string +) + +var reviewFixCmd = &cobra.Command{ + Use: "fix [id...]", + Short: "Auto-fix review findings using hawk exec", + Long: `Feeds review findings to hawk's engine which applies fixes and commits. +Without arguments, fixes all open reviews. Specify IDs to fix specific ones.`, + RunE: runReviewFix, +} + +func init() { + reviewFixCmd.Flags().BoolVarP(&reviewFixAll, "all", "a", false, "Fix all open reviews") + reviewFixCmd.Flags().BoolVarP(&reviewFixWorktree, "worktree", "w", false, "Run fixes in isolated worktree") + reviewFixCmd.Flags().StringVarP(&reviewFixModel, "model", "m", "", "Model for fix agent") + reviewCmd.AddCommand(reviewFixCmd) +} + +func runReviewFix(_ *cobra.Command, args []string) error { + projectDir, _ := os.Getwd() + store, err := OpenReviewStore(projectDir) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer store.Close() + + var reviews []*ReviewRecord + + if len(args) > 0 { + for _, ref := range args { + r, err := resolveReview(store, ref) + if err != nil { + return err + } + reviews = append(reviews, r) + } + } else { + reviews, err = store.ListOpen() + if err != nil { + return err + } + } + + if len(reviews) == 0 { + fmt.Println("No open reviews to fix.") + return nil + } + + for _, r := range reviews { + if err := fixReview(store, r); err != nil { + fmt.Printf("✗ Review #%d (%s): %v\n", r.ID, r.SHA[:8], err) + continue + } + fmt.Printf("✓ Review #%d (%s) fixed\n", r.ID, r.SHA[:8]) + } + return nil +} + +func fixReview(store *ReviewStore, r *ReviewRecord) error { + if len(r.Findings) == 0 { + return nil + } + + prompt := buildFixPrompt(r) + + // Invoke hawk exec with the fix prompt. + execArgs := []string{"exec", "--auto", "full"} + if reviewFixWorktree { + execArgs = append(execArgs, "--worktree") + } + if reviewFixModel != "" { + execArgs = append(execArgs, "--model", reviewFixModel) + } + execArgs = append(execArgs, prompt) + + hawkBin, err := os.Executable() + if err != nil { + hawkBin = "hawk" + } + + cmd := exec.Command(hawkBin, execArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Run(); err != nil { + return fmt.Errorf("hawk exec: %w", err) + } + + // Mark as fixed. + return store.SetStatus(r.ID, ReviewStatusFixed) +} + +func buildFixPrompt(r *ReviewRecord) string { + var b strings.Builder + b.WriteString("Fix the following code review findings from commit " + r.SHA[:8] + ":\n\n") + + for i, f := range r.Findings { + b.WriteString(strconv.Itoa(i+1) + ". [" + f.Severity.String() + "] " + f.File + ":" + strconv.Itoa(f.Line) + "\n") + b.WriteString(" " + f.Message + "\n") + if f.Fix != "" { + b.WriteString(" Suggested fix: " + f.Fix + "\n") + } + b.WriteString("\n") + } + + b.WriteString("Apply all fixes, then commit with message 'fix: address review findings for " + r.SHA[:8] + "'.\n") + b.WriteString("Do not introduce new issues. Keep changes minimal and focused.") + return b.String() +} diff --git a/cmd/review_read.go b/cmd/review_read.go new file mode 100644 index 00000000..20ccc567 --- /dev/null +++ b/cmd/review_read.go @@ -0,0 +1,264 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" +) + +var reviewStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show review queue summary", + RunE: runReviewStatus, +} + +var reviewShowCmd = &cobra.Command{ + Use: "show [sha|id]", + Short: "Display review findings for a commit", + Args: cobra.MaximumNArgs(1), + RunE: runReviewShow, +} + +var reviewCloseCmd = &cobra.Command{ + Use: "close ", + Short: "Close a review (mark as resolved)", + Args: cobra.ExactArgs(1), + RunE: runReviewClose, +} + +var reviewListCmd = &cobra.Command{ + Use: "list", + Short: "List all reviews", + RunE: runReviewList, +} + +var ( + reviewShowFormat string + reviewListLimit int +) + +func init() { + reviewShowCmd.Flags().StringVar(&reviewShowFormat, "format", "terminal", "Output format: terminal, json") + reviewListCmd.Flags().IntVarP(&reviewListLimit, "limit", "n", 20, "Max reviews to show") + reviewCmd.AddCommand(reviewStatusCmd) + reviewCmd.AddCommand(reviewShowCmd) + reviewCmd.AddCommand(reviewCloseCmd) + reviewCmd.AddCommand(reviewListCmd) +} + +func runReviewStatus(_ *cobra.Command, _ []string) error { + projectDir, _ := os.Getwd() + store, err := OpenReviewStore(projectDir) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer store.Close() + + summary, err := store.Summary() + if err != nil { + return err + } + + total := 0 + for _, v := range summary { + total += v + } + if total == 0 { + fmt.Println("No reviews yet. Run 'hawk review init' to start.") + return nil + } + + open := summary[ReviewStatusOpen] + passed := summary[ReviewStatusPassed] + fixed := summary[ReviewStatusFixed] + failed := summary[ReviewStatusFailed] + + fmt.Printf("Reviews: %d total", total) + if open > 0 { + fmt.Printf(" · %d open", open) + } + if passed > 0 { + fmt.Printf(" · %d passed", passed) + } + if fixed > 0 { + fmt.Printf(" · %d fixed", fixed) + } + if failed > 0 { + fmt.Printf(" · %d failed", failed) + } + fmt.Println() + + // Show open reviews briefly. + if open > 0 { + reviews, _ := store.ListOpen() + fmt.Println() + for _, r := range reviews { + fmt.Printf(" #%d %s [%s] %d findings\n", r.ID, r.SHA[:8], r.MaxSeverity, len(r.Findings)) + } + } + return nil +} + +func runReviewShow(_ *cobra.Command, args []string) error { + projectDir, _ := os.Getwd() + store, err := OpenReviewStore(projectDir) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer store.Close() + + var review *ReviewRecord + if len(args) == 0 { + // Show latest open review. + reviews, _ := store.ListOpen() + if len(reviews) == 0 { + fmt.Println("No open reviews.") + return nil + } + review = reviews[0] + } else { + review, err = resolveReview(store, args[0]) + if err != nil { + return err + } + } + + if reviewShowFormat == "json" { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(review) + } + + printReviewDetail(review) + return nil +} + +func runReviewClose(_ *cobra.Command, args []string) error { + projectDir, _ := os.Getwd() + store, err := OpenReviewStore(projectDir) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer store.Close() + + review, err := resolveReview(store, args[0]) + if err != nil { + return err + } + + if err := store.SetStatus(review.ID, ReviewStatusClosed); err != nil { + return err + } + fmt.Printf("✓ Closed review #%d (%s)\n", review.ID, review.SHA[:8]) + return nil +} + +func runReviewList(_ *cobra.Command, _ []string) error { + projectDir, _ := os.Getwd() + store, err := OpenReviewStore(projectDir) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer store.Close() + + reviews, err := store.ListAll(reviewListLimit) + if err != nil { + return err + } + if len(reviews) == 0 { + fmt.Println("No reviews yet.") + return nil + } + + for _, r := range reviews { + icon := statusIcon(r.Status) + findings := "" + if len(r.Findings) > 0 { + findings = fmt.Sprintf(" %d findings [%s]", len(r.Findings), r.MaxSeverity) + } + fmt.Printf("%s #%-3d %s %s%s %s\n", icon, r.ID, r.SHA[:8], r.Status, findings, r.CreatedAt.Format("Jan 02 15:04")) + } + return nil +} + +// resolveReview finds a review by numeric ID or SHA prefix. +func resolveReview(store *ReviewStore, ref string) (*ReviewRecord, error) { + if id, err := strconv.ParseInt(ref, 10, 64); err == nil { + r, err := store.Get(id) + if err != nil { + return nil, fmt.Errorf("review #%d not found", id) + } + return r, nil + } + r, err := store.GetBySHA(ref) + if err != nil { + return nil, fmt.Errorf("no review found for %s", ref) + } + return r, nil +} + +func printReviewDetail(r *ReviewRecord) { + header := lipgloss.NewStyle().Bold(true) + dim := lipgloss.NewStyle().Faint(true) + + fmt.Printf("%s Review #%d — %s\n", statusIcon(r.Status), r.ID, r.SHA[:8]) + fmt.Printf("%s\n", dim.Render(fmt.Sprintf("Status: %s · Created: %s · Tokens: %d", r.Status, r.CreatedAt.Format("2006-01-02 15:04"), r.TokensUsed))) + fmt.Println() + + if len(r.Findings) == 0 { + fmt.Println(header.Render("No findings — clean commit ✓")) + return + } + + fmt.Println(header.Render(fmt.Sprintf("%d Findings:", len(r.Findings)))) + fmt.Println() + + for i, f := range r.Findings { + sev := severityStyle(f.Severity.String()) + fmt.Printf(" %d. %s %s:%d\n", i+1, sev, f.File, f.Line) + fmt.Printf(" %s\n", f.Message) + if f.Fix != "" { + fmt.Printf(" %s %s\n", dim.Render("Fix:"), f.Fix) + } + fmt.Println() + } +} + +func statusIcon(s ReviewStatus) string { + switch s { + case ReviewStatusPassed: + return "✓" + case ReviewStatusOpen: + return "⚠" + case ReviewStatusFixed: + return "✓" + case ReviewStatusClosed: + return "✗" + case ReviewStatusFailed: + return "✗" + case ReviewStatusRunning: + return "⟳" + default: + return "·" + } +} + +func severityStyle(sev string) string { + switch strings.ToLower(sev) { + case "critical": + return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true).Render("[CRITICAL]") + case "high": + return lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Bold(true).Render("[HIGH]") + case "medium": + return lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render("[MEDIUM]") + case "low": + return lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Render("[LOW]") + default: + return lipgloss.NewStyle().Faint(true).Render("[INFO]") + } +} diff --git a/cmd/review_refine.go b/cmd/review_refine.go new file mode 100644 index 00000000..c4f14093 --- /dev/null +++ b/cmd/review_refine.go @@ -0,0 +1,174 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/spf13/cobra" +) + +var ( + refineMaxIter int + refineModel string + refineTimeout time.Duration +) + +var reviewRefineCmd = &cobra.Command{ + Use: "refine [id...]", + Short: "Iterative fix loop: fix, re-review, repeat until passing", + Long: `Runs in a loop: fix findings → re-review the new commit → fix again, +until all reviews pass or --max-iterations is reached. + +Uses an isolated worktree by default to avoid disrupting your working tree.`, + RunE: runReviewRefine, +} + +func init() { + reviewRefineCmd.Flags().IntVar(&refineMaxIter, "max-iterations", 3, "Maximum fix iterations") + reviewRefineCmd.Flags().StringVarP(&refineModel, "model", "m", "", "Model for fix agent") + reviewRefineCmd.Flags().DurationVar(&refineTimeout, "timeout", 5*time.Minute, "Timeout per review cycle") + reviewCmd.AddCommand(reviewRefineCmd) +} + +func runReviewRefine(_ *cobra.Command, args []string) error { + projectDir, _ := os.Getwd() + store, err := OpenReviewStore(projectDir) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer store.Close() + + // Collect target reviews. + var reviews []*ReviewRecord + if len(args) > 0 { + for _, ref := range args { + r, err := resolveReview(store, ref) + if err != nil { + return err + } + reviews = append(reviews, r) + } + } else { + reviews, err = store.ListOpen() + if err != nil { + return err + } + } + + if len(reviews) == 0 { + fmt.Println("No open reviews to refine.") + return nil + } + + fmt.Printf("Refining %d review(s), max %d iterations...\n\n", len(reviews), refineMaxIter) + + for iter := 1; iter <= refineMaxIter; iter++ { + fmt.Printf("── Iteration %d/%d ──\n", iter, refineMaxIter) + + // Fix all open reviews. + for _, r := range reviews { + if r.Status != ReviewStatusOpen { + continue + } + if err := fixReviewRefine(store, r); err != nil { + fmt.Printf(" ✗ #%d fix failed: %v\n", r.ID, err) + } else { + fmt.Printf(" ✓ #%d fix applied\n", r.ID) + } + } + + // Wait briefly for hook to fire, then re-review the latest commit. + latestSHA := getLatestCommitSHA() + if latestSHA == "" { + fmt.Println(" Could not determine latest commit.") + break + } + + fmt.Printf(" Reviewing %s...\n", latestSHA[:8]) + if err := runReviewOnSHA(store, latestSHA); err != nil { + fmt.Printf(" ✗ Review failed: %v\n", err) + break + } + + // Check if the new review passed. + newReview, _ := store.GetBySHA(latestSHA) + if newReview != nil && newReview.Status == ReviewStatusPassed { + fmt.Printf("\n✓ All clean after %d iteration(s)!\n", iter) + return nil + } + + // Update reviews list for next iteration. + if newReview != nil && newReview.Status == ReviewStatusOpen { + reviews = []*ReviewRecord{newReview} + } else { + reviews, _ = store.ListOpen() + if len(reviews) == 0 { + fmt.Printf("\n✓ All reviews resolved after %d iteration(s)!\n", iter) + return nil + } + } + } + + // Report remaining issues. + remaining, _ := store.ListOpen() + if len(remaining) > 0 { + fmt.Printf("\n⚠ %d review(s) still open after %d iterations.\n", len(remaining), refineMaxIter) + fmt.Println(" Run 'hawk review show' to inspect, or increase --max-iterations.") + } + return nil +} + +func fixReviewRefine(store *ReviewStore, r *ReviewRecord) error { + prompt := buildFixPrompt(r) + + hawkBin, err := os.Executable() + if err != nil { + hawkBin = "hawk" + } + + execArgs := []string{"exec", "--auto", "full"} + if refineModel != "" { + execArgs = append(execArgs, "--model", refineModel) + } + execArgs = append(execArgs, prompt) + + cmd := exec.Command(hawkBin, execArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return err + } + return store.SetStatus(r.ID, ReviewStatusFixed) +} + +func runReviewOnSHA(store *ReviewStore, sha string) error { + hawkBin, err := os.Executable() + if err != nil { + hawkBin = "hawk" + } + + args := []string{"review", "run", sha} + if refineTimeout > 0 { + args = append(args, "--timeout", refineTimeout.String()) + } + if refineModel != "" { + args = append(args, "--model", refineModel) + } + + cmd := exec.Command(hawkBin, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func getLatestCommitSHA() string { + out, err := exec.Command("git", "rev-parse", "HEAD").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/cmd/review_run.go b/cmd/review_run.go new file mode 100644 index 00000000..4ec34be3 --- /dev/null +++ b/cmd/review_run.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/GrayCodeAI/eyrie/client" + sightLib "github.com/GrayCodeAI/sight" + hawkSight "github.com/GrayCodeAI/hawk/sight" + "github.com/spf13/cobra" +) + +var ( + reviewRunBackground bool + reviewRunModel string + reviewRunConcerns string + reviewRunTimeout time.Duration +) + +var reviewRunCmd = &cobra.Command{ + Use: "run ", + Short: "Review a specific commit", + Args: cobra.ExactArgs(1), + RunE: runReviewRun, +} + +func init() { + reviewRunCmd.Flags().BoolVar(&reviewRunBackground, "background", false, "Run silently (for hook use)") + reviewRunCmd.Flags().StringVar(&reviewRunModel, "model", "", "LLM model for review") + reviewRunCmd.Flags().StringVar(&reviewRunConcerns, "concerns", "", "Comma-separated concerns") + reviewRunCmd.Flags().DurationVar(&reviewRunTimeout, "timeout", 3*time.Minute, "Review timeout") + reviewCmd.AddCommand(reviewRunCmd) +} + +func runReviewRun(_ *cobra.Command, args []string) error { + sha := args[0] + + // Resolve short SHA to full. + if len(sha) < 40 { + out, err := exec.Command("git", "rev-parse", sha).Output() + if err == nil { + sha = strings.TrimSpace(string(out)) + } + } + + projectDir, _ := os.Getwd() + store, err := OpenReviewStore(projectDir) + if err != nil { + return silentErr(err, "open review store") + } + defer store.Close() + + // Check if already reviewed. + if existing, _ := store.GetBySHA(sha); existing != nil && existing.Status != ReviewStatusFailed { + if !reviewRunBackground { + fmt.Printf("Commit %s already reviewed (status: %s)\n", sha[:8], existing.Status) + } + return nil + } + + // Create pending record. + id, err := store.Create(sha) + if err != nil { + return silentErr(err, "create review record") + } + _ = store.SetStatus(id, ReviewStatusRunning) + + // Get commit diff. + diff, err := getCommitDiff(sha) + if err != nil { + _ = store.SetStatus(id, ReviewStatusFailed) + return silentErr(err, "get commit diff") + } + if strings.TrimSpace(diff) == "" { + _ = store.SetStatus(id, ReviewStatusPassed) + if !reviewRunBackground { + fmt.Println("Empty diff — nothing to review.") + } + return nil + } + + // Build sight bridge. + prov := provider + if prov == "" { + prov = client.DetectProvider() + } + eyrieClient := client.Client(&client.EyrieConfig{Provider: prov}) + + var opts []sightLib.Option + if reviewRunModel != "" { + opts = append(opts, sightLib.WithModel(reviewRunModel)) + } + if reviewRunConcerns != "" { + concerns := strings.Split(reviewRunConcerns, ",") + for i := range concerns { + concerns[i] = strings.TrimSpace(concerns[i]) + } + opts = append(opts, sightLib.WithConcerns(concerns...)) + } + + bridge := hawkSight.NewBridge(eyrieClient, prov, opts...) + if !bridge.Ready() { + _ = store.SetStatus(id, ReviewStatusFailed) + return silentErr(fmt.Errorf("sight bridge not ready"), "init bridge") + } + + ctx := context.Background() + if reviewRunTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, reviewRunTimeout) + defer cancel() + } + + // Run review. + result, err := bridge.Review(ctx, diff) + if err != nil { + _ = store.SetStatus(id, ReviewStatusFailed) + return silentErr(err, "sight review") + } + + // Determine status based on findings. + status := ReviewStatusPassed + if len(result.Findings) > 0 { + status = ReviewStatusOpen + } + + if err := store.Update(id, status, result); err != nil { + return silentErr(err, "store result") + } + + if !reviewRunBackground { + printReviewSummary(sha, result) + } + return nil +} + +func getCommitDiff(sha string) (string, error) { + // For the first commit, diff against empty tree. + out, err := exec.Command("git", "diff-tree", "-p", sha).Output() + if err != nil { + // Fallback: diff against parent. + out, err = exec.Command("git", "diff", sha+"^", sha).Output() + if err != nil { + return "", fmt.Errorf("git diff for %s: %w", sha[:8], err) + } + } + return string(out), nil +} + +func printReviewSummary(sha string, result *sightLib.Result) { + if len(result.Findings) == 0 { + fmt.Printf("✓ %s — no issues found (%d files reviewed)\n", sha[:8], result.Stats.FilesReviewed) + return + } + fmt.Printf("⚠ %s — %d findings (max severity: %s)\n", sha[:8], len(result.Findings), result.MaxSeverity()) + for _, f := range result.Findings { + fmt.Printf(" [%s] %s:%d — %s\n", f.Severity, f.File, f.Line, f.Message) + } +} + +// silentErr suppresses errors in background mode, prints otherwise. +func silentErr(err error, context string) error { + if reviewRunBackground { + return nil + } + return fmt.Errorf("%s: %w", context, err) +} diff --git a/cmd/review_store.go b/cmd/review_store.go new file mode 100644 index 00000000..07060ddb --- /dev/null +++ b/cmd/review_store.go @@ -0,0 +1,253 @@ +package cmd + +import ( + "database/sql" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "sync" + "time" + + sightLib "github.com/GrayCodeAI/sight" +) + +// ReviewStatus represents the state of a review. +type ReviewStatus string + +const ( + ReviewStatusPending ReviewStatus = "pending" + ReviewStatusRunning ReviewStatus = "running" + ReviewStatusOpen ReviewStatus = "open" + ReviewStatusPassed ReviewStatus = "passed" + ReviewStatusFixed ReviewStatus = "fixed" + ReviewStatusClosed ReviewStatus = "closed" + ReviewStatusFailed ReviewStatus = "failed" // review process itself failed +) + +// ReviewRecord represents a persisted review in the SQLite store. +type ReviewRecord struct { + ID int64 + SHA string + Status ReviewStatus + Findings []sightLib.Finding + Report string + MaxSeverity string + TokensUsed int + CreatedAt time.Time + UpdatedAt time.Time +} + +const reviewSchema = ` +CREATE TABLE IF NOT EXISTS reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sha TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + findings_json TEXT DEFAULT '[]', + report TEXT DEFAULT '', + max_severity TEXT DEFAULT 'info', + tokens_used INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_reviews_sha ON reviews(sha); +CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status); +CREATE INDEX IF NOT EXISTS idx_reviews_created ON reviews(created_at DESC); +` + +// ReviewStore provides SQLite-backed review persistence. +type ReviewStore struct { + db *sql.DB + mu sync.RWMutex +} + +// OpenReviewStore opens or creates the review database. +func OpenReviewStore(projectDir string) (*ReviewStore, error) { + dbPath := filepath.Join(projectDir, ".hawk", "reviews.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open review db: %w", err) + } + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + _ = db.Close() + return nil, err + } + if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { + _ = db.Close() + return nil, err + } + + s := &ReviewStore{db: db} + if err := s.migrate(); err != nil { + _ = db.Close() + return nil, err + } + return s, nil +} + +func (s *ReviewStore) migrate() error { + _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS review_schema_version (version INTEGER PRIMARY KEY)`) + if err != nil { + return err + } + var current int + _ = s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM review_schema_version").Scan(¤t) + + migrations := []string{reviewSchema} + for i := current; i < len(migrations); i++ { + tx, err := s.db.Begin() + if err != nil { + return err + } + for _, stmt := range splitReviewStatements(migrations[i]) { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := tx.Exec(stmt); err != nil { + _ = tx.Rollback() + return fmt.Errorf("migration %d: %w", i+1, err) + } + } + if _, err := tx.Exec("INSERT INTO review_schema_version (version) VALUES (?)", i+1); err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + } + return nil +} + +// Create inserts a new review record and returns its ID. +func (s *ReviewStore) Create(sha string) (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + res, err := s.db.Exec( + "INSERT INTO reviews (sha, status) VALUES (?, ?)", + sha, ReviewStatusPending, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// Update sets the review result after completion. +func (s *ReviewStore) Update(id int64, status ReviewStatus, result *sightLib.Result) error { + s.mu.Lock() + defer s.mu.Unlock() + + findingsJSON, _ := json.Marshal(result.Findings) + maxSev := result.MaxSeverity().String() + + _, err := s.db.Exec( + `UPDATE reviews SET status=?, findings_json=?, report=?, max_severity=?, tokens_used=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`, + status, string(findingsJSON), result.Report, maxSev, result.Stats.TokensUsed, id, + ) + return err +} + +// SetStatus updates only the status field. +func (s *ReviewStore) SetStatus(id int64, status ReviewStatus) error { + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.db.Exec("UPDATE reviews SET status=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", status, id) + return err +} + +// Get retrieves a single review by ID. +func (s *ReviewStore) Get(id int64) (*ReviewRecord, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.scanOne(s.db.QueryRow( + "SELECT id, sha, status, findings_json, report, max_severity, tokens_used, created_at, updated_at FROM reviews WHERE id=?", id, + )) +} + +// GetBySHA retrieves the latest review for a commit. +func (s *ReviewStore) GetBySHA(sha string) (*ReviewRecord, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.scanOne(s.db.QueryRow( + "SELECT id, sha, status, findings_json, report, max_severity, tokens_used, created_at, updated_at FROM reviews WHERE sha=? ORDER BY created_at DESC LIMIT 1", sha, + )) +} + +// ListOpen returns all reviews with open/pending/running status. +func (s *ReviewStore) ListOpen() ([]*ReviewRecord, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.query("SELECT id, sha, status, findings_json, report, max_severity, tokens_used, created_at, updated_at FROM reviews WHERE status IN ('pending','running','open') ORDER BY created_at DESC") +} + +// ListAll returns all reviews ordered by creation time. +func (s *ReviewStore) ListAll(limit int) ([]*ReviewRecord, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.query(fmt.Sprintf("SELECT id, sha, status, findings_json, report, max_severity, tokens_used, created_at, updated_at FROM reviews ORDER BY created_at DESC LIMIT %d", limit)) +} + +// Summary returns counts by status. +func (s *ReviewStore) Summary() (map[ReviewStatus]int, error) { + s.mu.RLock() + defer s.mu.RUnlock() + rows, err := s.db.Query("SELECT status, COUNT(*) FROM reviews GROUP BY status") + if err != nil { + return nil, err + } + defer rows.Close() + m := make(map[ReviewStatus]int) + for rows.Next() { + var status string + var count int + if err := rows.Scan(&status, &count); err != nil { + return nil, err + } + m[ReviewStatus(status)] = count + } + return m, rows.Err() +} + +// Close closes the database. +func (s *ReviewStore) Close() error { + return s.db.Close() +} + +func (s *ReviewStore) scanOne(row *sql.Row) (*ReviewRecord, error) { + r := &ReviewRecord{} + var findingsJSON, status string + err := row.Scan(&r.ID, &r.SHA, &status, &findingsJSON, &r.Report, &r.MaxSeverity, &r.TokensUsed, &r.CreatedAt, &r.UpdatedAt) + if err != nil { + return nil, err + } + r.Status = ReviewStatus(status) + _ = json.Unmarshal([]byte(findingsJSON), &r.Findings) + return r, nil +} + +func (s *ReviewStore) query(q string) ([]*ReviewRecord, error) { + rows, err := s.db.Query(q) + if err != nil { + return nil, err + } + defer rows.Close() + var results []*ReviewRecord + for rows.Next() { + r := &ReviewRecord{} + var findingsJSON, status string + if err := rows.Scan(&r.ID, &r.SHA, &status, &findingsJSON, &r.Report, &r.MaxSeverity, &r.TokensUsed, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + r.Status = ReviewStatus(status) + _ = json.Unmarshal([]byte(findingsJSON), &r.Findings) + results = append(results, r) + } + return results, rows.Err() +} + +func splitReviewStatements(s string) []string { + return strings.Split(s, ";") +} diff --git a/cmd/review_test.go b/cmd/review_test.go new file mode 100644 index 00000000..282e21b9 --- /dev/null +++ b/cmd/review_test.go @@ -0,0 +1,239 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + sightLib "github.com/GrayCodeAI/sight" +) + +func TestReviewStore_CreateAndGet(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".hawk"), 0o755) + + store, err := OpenReviewStore(dir) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + id, err := store.Create("abc123def456") + if err != nil { + t.Fatalf("create: %v", err) + } + if id <= 0 { + t.Fatalf("expected positive ID, got %d", id) + } + + r, err := store.Get(id) + if err != nil { + t.Fatalf("get: %v", err) + } + if r.SHA != "abc123def456" { + t.Errorf("expected sha abc123def456, got %s", r.SHA) + } + if r.Status != ReviewStatusPending { + t.Errorf("expected pending status, got %s", r.Status) + } +} + +func TestReviewStore_Update(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".hawk"), 0o755) + + store, err := OpenReviewStore(dir) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + id, _ := store.Create("sha123") + + result := &sightLib.Result{ + Findings: []sightLib.Finding{ + {Concern: "security", Severity: sightLib.SeverityHigh, File: "main.go", Line: 10, Message: "SQL injection"}, + {Concern: "bugs", Severity: sightLib.SeverityMedium, File: "handler.go", Line: 20, Message: "nil deref"}, + }, + Stats: sightLib.Stats{TokensUsed: 500}, + } + + err = store.Update(id, ReviewStatusOpen, result) + if err != nil { + t.Fatalf("update: %v", err) + } + + r, _ := store.Get(id) + if r.Status != ReviewStatusOpen { + t.Errorf("expected open, got %s", r.Status) + } + if len(r.Findings) != 2 { + t.Errorf("expected 2 findings, got %d", len(r.Findings)) + } + if r.MaxSeverity != "high" { + t.Errorf("expected max severity high, got %s", r.MaxSeverity) + } + if r.TokensUsed != 500 { + t.Errorf("expected 500 tokens, got %d", r.TokensUsed) + } +} + +func TestReviewStore_GetBySHA(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".hawk"), 0o755) + + store, err := OpenReviewStore(dir) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + store.Create("sha_first") + store.Create("sha_second") + + r, err := store.GetBySHA("sha_second") + if err != nil { + t.Fatalf("get by sha: %v", err) + } + if r.SHA != "sha_second" { + t.Errorf("expected sha_second, got %s", r.SHA) + } +} + +func TestReviewStore_ListOpen(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".hawk"), 0o755) + + store, err := OpenReviewStore(dir) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + id1, _ := store.Create("sha1") + id2, _ := store.Create("sha2") + id3, _ := store.Create("sha3") + + store.SetStatus(id1, ReviewStatusOpen) + store.SetStatus(id2, ReviewStatusPassed) + store.SetStatus(id3, ReviewStatusOpen) + + open, err := store.ListOpen() + if err != nil { + t.Fatalf("list open: %v", err) + } + if len(open) != 2 { + t.Errorf("expected 2 open reviews, got %d", len(open)) + } +} + +func TestReviewStore_Summary(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".hawk"), 0o755) + + store, err := OpenReviewStore(dir) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + id1, _ := store.Create("s1") + id2, _ := store.Create("s2") + id3, _ := store.Create("s3") + + store.SetStatus(id1, ReviewStatusOpen) + store.SetStatus(id2, ReviewStatusPassed) + store.SetStatus(id3, ReviewStatusOpen) + + summary, err := store.Summary() + if err != nil { + t.Fatalf("summary: %v", err) + } + if summary[ReviewStatusOpen] != 2 { + t.Errorf("expected 2 open, got %d", summary[ReviewStatusOpen]) + } + if summary[ReviewStatusPassed] != 1 { + t.Errorf("expected 1 passed, got %d", summary[ReviewStatusPassed]) + } +} + +func TestReviewStore_SetStatus(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".hawk"), 0o755) + + store, err := OpenReviewStore(dir) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + id, _ := store.Create("sha_close") + store.SetStatus(id, ReviewStatusOpen) + store.SetStatus(id, ReviewStatusClosed) + + r, _ := store.Get(id) + if r.Status != ReviewStatusClosed { + t.Errorf("expected closed, got %s", r.Status) + } +} + +func TestBuildFixPrompt(t *testing.T) { + r := &ReviewRecord{ + SHA: "abc12345deadbeef0000000000000000000000ff", + Findings: []sightLib.Finding{ + {Severity: sightLib.SeverityHigh, File: "main.go", Line: 10, Message: "SQL injection", Fix: "use parameterized query"}, + }, + } + + prompt := buildFixPrompt(r) + if prompt == "" { + t.Fatal("expected non-empty prompt") + } + if !strings.Contains(prompt, "abc12345") { + t.Error("prompt should contain short SHA") + } + if !strings.Contains(prompt, "SQL injection") { + t.Error("prompt should contain finding message") + } + if !strings.Contains(prompt, "parameterized query") { + t.Error("prompt should contain suggested fix") + } +} + +func TestStatusIcon(t *testing.T) { + tests := []struct { + status ReviewStatus + want string + }{ + {ReviewStatusPassed, "✓"}, + {ReviewStatusOpen, "⚠"}, + {ReviewStatusFailed, "✗"}, + {ReviewStatusRunning, "⟳"}, + {ReviewStatusPending, "·"}, + } + for _, tt := range tests { + got := statusIcon(tt.status) + if got != tt.want { + t.Errorf("statusIcon(%s) = %q, want %q", tt.status, got, tt.want) + } + } +} + +func TestAnalysisPrompts_AllTypesExist(t *testing.T) { + expected := []string{"security", "duplication", "complexity", "dead-code", "refactor", "test-fixtures"} + for _, typ := range expected { + if _, ok := analysisPrompts[typ]; !ok { + t.Errorf("missing analysis prompt for type %q", typ) + } + } +} + +func TestHookScript_ContainsHawkReview(t *testing.T) { + if !strings.Contains(hookScript, "hawk review") { + t.Error("hook script should contain 'hawk review'") + } + if !strings.Contains(hookScript, "git rev-parse HEAD") { + t.Error("hook script should get HEAD sha") + } +} diff --git a/cmd/review_tui.go b/cmd/review_tui.go new file mode 100644 index 00000000..6bb7842d --- /dev/null +++ b/cmd/review_tui.go @@ -0,0 +1,190 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" +) + +var reviewTUICmd = &cobra.Command{ + Use: "tui", + Short: "Interactive review queue", + RunE: runReviewTUI, +} + +func init() { + reviewCmd.AddCommand(reviewTUICmd) +} + +// TUI model +type reviewTUIModel struct { + reviews []*ReviewRecord + cursor int + expanded bool // show findings for selected review + width int + height int + store *ReviewStore + quitting bool +} + +type reviewsLoadedMsg []*ReviewRecord + +func runReviewTUI(_ *cobra.Command, _ []string) error { + projectDir, _ := os.Getwd() + store, err := OpenReviewStore(projectDir) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + + m := reviewTUIModel{store: store} + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err = p.Run() + _ = store.Close() + return err +} + +func (m reviewTUIModel) Init() tea.Cmd { + return func() tea.Msg { + reviews, _ := m.store.ListAll(50) + return reviewsLoadedMsg(reviews) + } +} + +func (m reviewTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case reviewsLoadedMsg: + m.reviews = msg + + case tea.KeyMsg: + switch msg.String() { + case "q", "esc": + if m.expanded { + m.expanded = false + } else { + m.quitting = true + return m, tea.Quit + } + case "j", "down": + if m.cursor < len(m.reviews)-1 { + m.cursor++ + } + case "k", "up": + if m.cursor > 0 { + m.cursor-- + } + case "enter", " ": + m.expanded = !m.expanded + case "c": + // Close selected review. + if m.cursor < len(m.reviews) { + r := m.reviews[m.cursor] + _ = m.store.SetStatus(r.ID, ReviewStatusClosed) + return m, m.reload() + } + case "f": + // Fix selected review (exit TUI, run fix). + if m.cursor < len(m.reviews) { + m.quitting = true + return m, tea.Sequence(tea.Quit, func() tea.Msg { return nil }) + } + case "r": + return m, m.reload() + case "g": + m.cursor = 0 + case "G": + if len(m.reviews) > 0 { + m.cursor = len(m.reviews) - 1 + } + } + } + return m, nil +} + +func (m reviewTUIModel) reload() tea.Cmd { + return func() tea.Msg { + reviews, _ := m.store.ListAll(50) + return reviewsLoadedMsg(reviews) + } +} + +func (m reviewTUIModel) View() string { + if m.quitting { + return "" + } + + header := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) + dim := lipgloss.NewStyle().Faint(true) + selected := lipgloss.NewStyle().Bold(true).Background(lipgloss.Color("236")) + + var b strings.Builder + b.WriteString(header.Render(" hawk review") + dim.Render(" j/k:nav enter:expand c:close f:fix r:refresh q:quit") + "\n") + b.WriteString(strings.Repeat("─", reviewMin(m.width, 80)) + "\n") + + if len(m.reviews) == 0 { + b.WriteString("\n No reviews yet. Run 'hawk review init' to get started.\n") + return b.String() + } + + // Calculate visible area. + listHeight := m.height - 4 + if m.expanded { + listHeight = reviewMin(listHeight/2, 15) + } + + start := 0 + if m.cursor >= listHeight { + start = m.cursor - listHeight + 1 + } + + for i := start; i < len(m.reviews) && i < start+listHeight; i++ { + r := m.reviews[i] + icon := statusIcon(r.Status) + line := fmt.Sprintf(" %s #%-3d %s %-7s %d findings %s", + icon, r.ID, r.SHA[:8], r.Status, len(r.Findings), r.CreatedAt.Format("Jan 02 15:04")) + + if i == m.cursor { + b.WriteString(selected.Render(line)) + } else { + b.WriteString(line) + } + b.WriteString("\n") + } + + // Expanded detail view. + if m.expanded && m.cursor < len(m.reviews) { + r := m.reviews[m.cursor] + b.WriteString("\n" + strings.Repeat("─", reviewMin(m.width, 80)) + "\n") + b.WriteString(fmt.Sprintf(" Review #%d — %s [%s]\n\n", r.ID, r.SHA[:8], r.MaxSeverity)) + + if len(r.Findings) == 0 { + b.WriteString(" No findings — clean ✓\n") + } else { + remaining := m.height - listHeight - 6 + for i, f := range r.Findings { + if i >= remaining { + b.WriteString(fmt.Sprintf(" ... and %d more\n", len(r.Findings)-i)) + break + } + sev := f.Severity.String() + b.WriteString(fmt.Sprintf(" [%s] %s:%d — %s\n", sev, f.File, f.Line, f.Message)) + } + } + } + + return b.String() +} + +func reviewMin(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/daemon/daemon.go b/daemon/daemon.go index 4581ddb5..decf49cf 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -152,6 +152,7 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /v1/sessions/{id}/messages", s.handleGetMessages) s.mux.HandleFunc("DELETE /v1/sessions/{id}", s.handleDeleteSession) s.mux.HandleFunc("GET /v1/stats", s.handleStats) + s.RegisterReviewRoutes() } func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { diff --git a/daemon/routes_review.go b/daemon/routes_review.go new file mode 100644 index 00000000..d8d277fa --- /dev/null +++ b/daemon/routes_review.go @@ -0,0 +1,80 @@ +package daemon + +import ( + "encoding/json" + "fmt" + "net/http" + "os/exec" + "strings" +) + +// ReviewRequest is the JSON body for POST /v1/review. +type ReviewRequest struct { + SHA string `json:"sha"` + Background bool `json:"background,omitempty"` + Model string `json:"model,omitempty"` + Concerns string `json:"concerns,omitempty"` +} + +// ReviewResponse is the JSON response from POST /v1/review. +type ReviewResponse struct { + ID int64 `json:"id"` + SHA string `json:"sha"` + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +// RegisterReviewRoutes adds review endpoints to the daemon. +// Called from routes() if review support is enabled. +func (s *Server) RegisterReviewRoutes() { + s.mux.HandleFunc("POST /v1/review", s.handleReview) + s.mux.HandleFunc("GET /v1/review/status", s.handleReviewStatus) +} + +func (s *Server) handleReview(w http.ResponseWriter, r *http.Request) { + var req ReviewRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.SHA == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "sha is required"}) + return + } + + // Trigger review asynchronously via hawk review run. + go func() { + args := []string{"review", "run", req.SHA, "--background"} + if req.Model != "" { + args = append(args, "--model", req.Model) + } + if req.Concerns != "" { + args = append(args, "--concerns", req.Concerns) + } + _ = exec.Command("hawk", args...).Run() + }() + + resp := ReviewResponse{ + SHA: req.SHA, + Status: "queued", + Message: fmt.Sprintf("Review queued for %s", req.SHA[:minLen(len(req.SHA), 8)]), + } + writeJSON(w, http.StatusAccepted, resp) +} + +func (s *Server) handleReviewStatus(w http.ResponseWriter, _ *http.Request) { + // Run hawk review status and return output. + out, err := exec.Command("hawk", "review", "status").Output() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": strings.TrimSpace(string(out))}) +} + +func minLen(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/hooks/events.go b/hooks/events.go index 9d570ef7..dc740b67 100644 --- a/hooks/events.go +++ b/hooks/events.go @@ -30,6 +30,13 @@ const ( ProviderSwitch = "provider.switch" UserInput = "user.input" AgentResponse = "agent.response" + + // Review lifecycle events + ReviewQueued = "review.queued" + ReviewStarted = "review.started" + ReviewCompleted = "review.completed" + ReviewFailed = "review.failed" + ReviewFixed = "review.fixed" ) // Event represents a single lifecycle event emitted by the agent.