claudetool/codereview: add go generate support

Automatically run 'go generate' as part of codereview,
since sketch sometimes forgets to do it.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: safe9348e7c24beffk
diff --git a/claudetool/codereview/codereview.go b/claudetool/codereview/codereview.go
index 11f897e..085479e 100644
--- a/claudetool/codereview/codereview.go
+++ b/claudetool/codereview/codereview.go
@@ -176,8 +176,8 @@
 	}
 	uncommitted := new(strings.Builder)
 	for _, status := range statuses {
-		if !r.initialStatusesContainFile(status.Path) {
-			fmt.Fprintf(uncommitted, "%s %s\n", status.Path, status.RawStatus)
+		if !statusesContainFile(r.initialStatus, status.Path) {
+			fmt.Fprintf(uncommitted, "%s %s\n", status.RawStatus, status.Path)
 		}
 	}
 	if uncommitted.Len() > 0 {
@@ -186,8 +186,8 @@
 	return nil
 }
 
-func (r *CodeReviewer) initialStatusesContainFile(file string) bool {
-	for _, s := range r.initialStatus {
+func statusesContainFile(statuses []fileStatus, file string) bool {
+	for _, s := range statuses {
 		if s.Path == file {
 			return true
 		}
@@ -195,6 +195,15 @@
 	return false
 }
 
+// parseGitStatusLine parses a single line from git status --porcelain output.
+// Returns the file path and status, or empty strings if the line should be ignored.
+func parseGitStatusLine(line string) (path, status string) {
+	if len(line) <= 3 {
+		return "", "" // empty line or invalid format
+	}
+	return strings.TrimSpace(line[3:]), line[:2]
+}
+
 type fileStatus struct {
 	Path      string
 	RawStatus string // always 2 characters
@@ -210,14 +219,10 @@
 	}
 	var statuses []fileStatus
 	for line := range strings.Lines(string(out)) {
-		if len(line) == 0 {
-			continue
+		path, status := parseGitStatusLine(line)
+		if path == "" {
+			continue // empty or invalid line
 		}
-		if len(line) < 3 {
-			return nil, fmt.Errorf("invalid status line: %s", line)
-		}
-		path := line[3:]
-		status := line[:2]
 		absPath := r.absPath(path)
 		statuses = append(statuses, fileStatus{Path: absPath, RawStatus: status})
 	}
@@ -343,7 +348,7 @@
 			continue
 		}
 		path := r.absPath(line)
-		if r.initialStatusesContainFile(path) {
+		if statusesContainFile(r.initialStatus, path) {
 			continue
 		}
 		files = append(files, path)
@@ -351,6 +356,54 @@
 	return files, nil
 }
 
+// runGenerate runs go generate on all packages and returns a list of files changed.
+// Errors returned will be reported to the LLM.
+func (r *CodeReviewer) runGenerate(ctx context.Context, packages []string) ([]string, error) {
+	if len(packages) == 0 {
+		return nil, nil
+	}
+
+	args := []string{"generate"}
+	for _, pkg := range packages {
+		// Sigh. Working around test packages is a PITA.
+		if strings.HasSuffix(pkg, ".test") || strings.HasSuffix(pkg, "_test") {
+			continue
+		}
+		args = append(args, pkg)
+	}
+	gen := exec.CommandContext(ctx, "go", args...)
+	gen.Dir = r.repoRoot
+	out, err := gen.CombinedOutput()
+	if err != nil {
+		return nil, fmt.Errorf("$ go %s\n%s", strings.Join(args, " "), out)
+	}
+
+	status := exec.CommandContext(ctx, "git", "status", "--porcelain")
+	status.Dir = r.repoRoot
+	statusOut, err := status.CombinedOutput()
+	if err != nil {
+		return nil, fmt.Errorf("unable to get git status: %w", err)
+	}
+
+	var changed []string
+	for line := range strings.Lines(string(statusOut)) {
+		path, _ := parseGitStatusLine(line)
+		if path == "" {
+			continue
+		}
+		changed = append(changed, filepath.Join(r.repoRoot, path))
+	}
+
+	return changed, nil
+}
+
+// isGoRepository checks if the repository contains a go.mod file
+// TODO: check in subdirs?
+func (r *CodeReviewer) isGoRepository() bool {
+	_, err := os.Stat(filepath.Join(r.repoRoot, "go.mod"))
+	return err == nil
+}
+
 // ModTidy runs go mod tidy if go module files have changed.
 // Returns a list of files changed by go mod tidy (empty if none).
 func (r *CodeReviewer) ModTidy(ctx context.Context) ([]string, error) {
@@ -403,11 +456,10 @@
 	var changedByTidy []string
 
 	for line := range strings.Lines(string(statusOut)) {
-		if len(line) <= 3 {
-			// empty line, defensiveness to avoid panics
-			continue
+		file, _ := parseGitStatusLine(line)
+		if file == "" {
+			continue // empty or invalid line
 		}
-		file := line[3:]
 		if !isGoModFile(file) {
 			continue
 		}