blob: a365df0c13fc089a2e6a90ee18515e06125fc683 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package claudetool
2
3import (
4 "bytes"
Josh Bleecher Snyderffecb1e2025-04-28 18:59:14 +00005 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07006 "context"
7 "encoding/json"
8 "fmt"
9 "io"
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +000010 "io/fs"
Earl Lee2e463fb2025-04-17 11:22:22 -070011 "log/slog"
12 "maps"
13 "os"
14 "os/exec"
15 "path/filepath"
Josh Bleecher Snyderffecb1e2025-04-28 18:59:14 +000016 "runtime"
Earl Lee2e463fb2025-04-17 11:22:22 -070017 "slices"
18 "strings"
Josh Bleecher Snyderffecb1e2025-04-28 18:59:14 +000019 "sync"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "time"
21
22 "golang.org/x/tools/go/packages"
23 "sketch.dev/ant"
24)
25
26// This file does differential quality analysis of a commit relative to a base commit.
27
28// Tool returns a tool spec for a CodeReview tool backed by r.
29func (r *CodeReviewer) Tool() *ant.Tool {
30 spec := &ant.Tool{
31 Name: "codereview",
32 Description: `Run an automated code review.`,
33 // If you modify this, update the termui template for prettier rendering.
34 InputSchema: ant.MustSchema(`{"type": "object"}`),
35 Run: r.Run,
36 }
37 return spec
38}
39
40func (r *CodeReviewer) Run(ctx context.Context, m json.RawMessage) (string, error) {
41 if err := r.RequireNormalGitState(ctx); err != nil {
42 slog.DebugContext(ctx, "CodeReviewer.Run: failed to check for normal git state", "err", err)
43 return "", err
44 }
45 if err := r.RequireNoUncommittedChanges(ctx); err != nil {
46 slog.DebugContext(ctx, "CodeReviewer.Run: failed to check for uncommitted changes", "err", err)
47 return "", err
48 }
49
50 // Check that the current commit is not the initial commit
51 currentCommit, err := r.CurrentCommit(ctx)
52 if err != nil {
53 slog.DebugContext(ctx, "CodeReviewer.Run: failed to get current commit", "err", err)
54 return "", err
55 }
56 if r.IsInitialCommit(currentCommit) {
57 slog.DebugContext(ctx, "CodeReviewer.Run: current commit is initial commit, nothing to review")
58 return "", fmt.Errorf("no new commits have been added, nothing to review")
59 }
60
61 // No matter what failures happen from here out, we will declare this to have been reviewed.
62 // This should help avoid the model getting blocked by a broken code review tool.
63 r.reviewed = append(r.reviewed, currentCommit)
64
65 changedFiles, err := r.changedFiles(ctx, r.initialCommit, currentCommit)
66 if err != nil {
67 slog.DebugContext(ctx, "CodeReviewer.Run: failed to get changed files", "err", err)
68 return "", err
69 }
70
71 // Prepare to analyze before/after for the impacted files.
72 // We use the current commit to determine what packages exist and are impacted.
73 // The packages in the initial commit may be different.
74 // Good enough for now.
75 // TODO: do better
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +000076 allPkgs, err := r.packagesForFiles(ctx, changedFiles)
Earl Lee2e463fb2025-04-17 11:22:22 -070077 if err != nil {
78 // TODO: log and skip to stuff that doesn't require packages
79 slog.DebugContext(ctx, "CodeReviewer.Run: failed to get packages for files", "err", err)
80 return "", err
81 }
82 allPkgList := slices.Collect(maps.Keys(allPkgs))
Earl Lee2e463fb2025-04-17 11:22:22 -070083
Josh Bleecher Snyderffecb1e2025-04-28 18:59:14 +000084 var errorMessages []string // problems we want the model to address
85 var infoMessages []string // info the model should consider
86
87 // Find potentially related files that should also be considered
88 // TODO: add some caching here, since this depends only on the initial commit and the changed files, not the details of the changes
Josh Bleecher Snyder15272c12025-04-28 19:36:15 -070089 // TODO: make "related files found" w/o other notes be a non-error response
90 // TODO: do some kind of decay--weight recency higher, add a cutoff
Josh Bleecher Snyderffecb1e2025-04-28 18:59:14 +000091 relatedFiles, err := r.findRelatedFiles(ctx, changedFiles)
92 if err != nil {
93 slog.DebugContext(ctx, "CodeReviewer.Run: failed to find related files", "err", err)
94 } else {
95 relatedMsg := r.formatRelatedFiles(relatedFiles)
96 if relatedMsg != "" {
97 infoMessages = append(infoMessages, relatedMsg)
98 }
99 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700100
101 testMsg, err := r.checkTests(ctx, allPkgList)
102 if err != nil {
103 slog.DebugContext(ctx, "CodeReviewer.Run: failed to check tests", "err", err)
104 return "", err
105 }
106 if testMsg != "" {
Josh Bleecher Snyderffecb1e2025-04-28 18:59:14 +0000107 errorMessages = append(errorMessages, testMsg)
Earl Lee2e463fb2025-04-17 11:22:22 -0700108 }
109
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000110 goplsMsg, err := r.checkGopls(ctx, changedFiles) // includes vet checks
Earl Lee2e463fb2025-04-17 11:22:22 -0700111 if err != nil {
112 slog.DebugContext(ctx, "CodeReviewer.Run: failed to check gopls", "err", err)
113 return "", err
114 }
115 if goplsMsg != "" {
Josh Bleecher Snyderffecb1e2025-04-28 18:59:14 +0000116 errorMessages = append(errorMessages, goplsMsg)
Earl Lee2e463fb2025-04-17 11:22:22 -0700117 }
118
Josh Bleecher Snyderffecb1e2025-04-28 18:59:14 +0000119 buf := new(strings.Builder)
120 if len(infoMessages) > 0 {
121 buf.WriteString("# Info\n\n")
122 buf.WriteString(strings.Join(infoMessages, "\n\n"))
123 buf.WriteString("\n\n")
Earl Lee2e463fb2025-04-17 11:22:22 -0700124 }
Josh Bleecher Snyderffecb1e2025-04-28 18:59:14 +0000125 if len(errorMessages) > 0 {
126 buf.WriteString("# Errors\n\n")
127 buf.WriteString(strings.Join(errorMessages, "\n\n"))
128 buf.WriteString("\n\nPlease fix before proceeding.\n")
129 }
130 if buf.Len() == 0 {
131 buf.WriteString("OK")
132 }
133 return buf.String(), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700134}
135
136func (r *CodeReviewer) initializeInitialCommitWorktree(ctx context.Context) error {
137 if r.initialWorktree != "" {
138 return nil
139 }
140 tmpDir, err := os.MkdirTemp("", "sketch-codereview-worktree")
141 if err != nil {
142 return err
143 }
144 worktreeCmd := exec.CommandContext(ctx, "git", "worktree", "add", "--detach", tmpDir, r.initialCommit)
145 worktreeCmd.Dir = r.repoRoot
146 out, err := worktreeCmd.CombinedOutput()
147 if err != nil {
148 return fmt.Errorf("unable to create worktree for initial commit: %w\n%s", err, out)
149 }
150 r.initialWorktree = tmpDir
151 return nil
152}
153
154func (r *CodeReviewer) checkTests(ctx context.Context, pkgList []string) (string, error) {
155 goTestArgs := []string{"test", "-json", "-v"}
156 goTestArgs = append(goTestArgs, pkgList...)
157
158 afterTestCmd := exec.CommandContext(ctx, "go", goTestArgs...)
159 afterTestCmd.Dir = r.repoRoot
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000160 afterTestOut, _ := afterTestCmd.Output()
161 // unfortunately, we can't short-circuit here even if all tests pass,
162 // because we need to check for skipped tests.
Earl Lee2e463fb2025-04-17 11:22:22 -0700163
164 err := r.initializeInitialCommitWorktree(ctx)
165 if err != nil {
166 return "", err
167 }
168
169 beforeTestCmd := exec.CommandContext(ctx, "go", goTestArgs...)
170 beforeTestCmd.Dir = r.initialWorktree
171 beforeTestOut, _ := beforeTestCmd.Output() // ignore error, interesting info is in the output
172
173 // Parse the jsonl test results
174 beforeResults, beforeParseErr := parseTestResults(beforeTestOut)
175 if beforeParseErr != nil {
176 return "", fmt.Errorf("unable to parse test results for initial commit: %w\n%s", beforeParseErr, beforeTestOut)
177 }
178 afterResults, afterParseErr := parseTestResults(afterTestOut)
179 if afterParseErr != nil {
180 return "", fmt.Errorf("unable to parse test results for current commit: %w\n%s", afterParseErr, afterTestOut)
181 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700182 testRegressions, err := r.compareTestResults(beforeResults, afterResults)
183 if err != nil {
184 return "", fmt.Errorf("failed to compare test results: %w", err)
185 }
186 // TODO: better output formatting?
187 res := r.formatTestRegressions(testRegressions)
188 return res, nil
189}
190
Earl Lee2e463fb2025-04-17 11:22:22 -0700191// GoplsIssue represents a single issue reported by gopls check
192type GoplsIssue struct {
193 Position string // File position in format "file:line:col-range"
194 Message string // Description of the issue
195}
196
197// checkGopls runs gopls check on the provided files in both the current and initial state,
198// compares the results, and reports any new issues introduced in the current state.
199func (r *CodeReviewer) checkGopls(ctx context.Context, changedFiles []string) (string, error) {
200 if len(changedFiles) == 0 {
201 return "", nil // no files to check
202 }
203
204 // Filter out non-Go files as gopls only works on Go files
205 // and verify they still exist (not deleted)
206 var goFiles []string
207 for _, file := range changedFiles {
208 if !strings.HasSuffix(file, ".go") {
209 continue // not a Go file
210 }
211
212 // Check if the file still exists (not deleted)
213 if _, err := os.Stat(file); os.IsNotExist(err) {
214 continue // file doesn't exist anymore (deleted)
215 }
216
217 goFiles = append(goFiles, file)
218 }
219
220 if len(goFiles) == 0 {
221 return "", nil // no Go files to check
222 }
223
224 // Run gopls check on the current state
225 goplsArgs := append([]string{"check"}, goFiles...)
226
227 afterGoplsCmd := exec.CommandContext(ctx, "gopls", goplsArgs...)
228 afterGoplsCmd.Dir = r.repoRoot
229 afterGoplsOut, err := afterGoplsCmd.CombinedOutput() // gopls returns non-zero if it finds issues
230 if err != nil {
231 // Check if the output looks like real gopls issues or if it's just error output
232 if !looksLikeGoplsIssues(afterGoplsOut) {
233 slog.WarnContext(ctx, "CodeReviewer.checkGopls: gopls check failed to run properly", "err", err, "output", string(afterGoplsOut))
234 return "", nil // Skip rather than failing the entire code review
235 }
236 // Otherwise, proceed with parsing - it's likely just the non-zero exit code due to found issues
237 }
238
239 // Parse the output
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000240 afterIssues := parseGoplsOutput(r.repoRoot, afterGoplsOut)
Earl Lee2e463fb2025-04-17 11:22:22 -0700241
242 // If no issues were found, we're done
243 if len(afterIssues) == 0 {
244 return "", nil
245 }
246
247 // Gopls detected issues in the current state, check if they existed in the initial state
248 initErr := r.initializeInitialCommitWorktree(ctx)
249 if initErr != nil {
250 return "", err
251 }
252
253 // For each file that exists in the initial commit, run gopls check
254 var initialFilesToCheck []string
255 for _, file := range goFiles {
256 // Get relative path for git operations
257 relFile, err := filepath.Rel(r.repoRoot, file)
258 if err != nil {
259 slog.WarnContext(ctx, "CodeReviewer.checkGopls: failed to get relative path", "repo_root", r.repoRoot, "file", file, "err", err)
260 continue
261 }
262
263 // Check if the file exists in the initial commit
264 checkCmd := exec.CommandContext(ctx, "git", "cat-file", "-e", fmt.Sprintf("%s:%s", r.initialCommit, relFile))
265 checkCmd.Dir = r.repoRoot
266 if err := checkCmd.Run(); err == nil {
267 // File exists in initial commit
268 initialFilePath := filepath.Join(r.initialWorktree, relFile)
269 initialFilesToCheck = append(initialFilesToCheck, initialFilePath)
270 }
271 }
272
273 // Run gopls check on the files that existed in the initial commit
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000274 var beforeIssues []GoplsIssue
Earl Lee2e463fb2025-04-17 11:22:22 -0700275 if len(initialFilesToCheck) > 0 {
276 beforeGoplsArgs := append([]string{"check"}, initialFilesToCheck...)
277 beforeGoplsCmd := exec.CommandContext(ctx, "gopls", beforeGoplsArgs...)
278 beforeGoplsCmd.Dir = r.initialWorktree
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000279 beforeGoplsOut, beforeCmdErr := beforeGoplsCmd.CombinedOutput()
Earl Lee2e463fb2025-04-17 11:22:22 -0700280 if beforeCmdErr != nil && !looksLikeGoplsIssues(beforeGoplsOut) {
281 // If gopls fails to run properly on the initial commit, log a warning and continue
282 // with empty before issues - this will be conservative and report more issues
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000283 slog.WarnContext(ctx, "CodeReviewer.checkGopls: gopls check failed on initial commit", "err", err, "output", string(beforeGoplsOut))
Earl Lee2e463fb2025-04-17 11:22:22 -0700284 } else {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000285 beforeIssues = parseGoplsOutput(r.initialWorktree, beforeGoplsOut)
Earl Lee2e463fb2025-04-17 11:22:22 -0700286 }
287 }
288
289 // Find new issues that weren't present in the initial state
290 goplsRegressions := findGoplsRegressions(beforeIssues, afterIssues)
291 if len(goplsRegressions) == 0 {
292 return "", nil // no new issues
293 }
294
295 // Format the results
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000296 return r.formatGoplsRegressions(goplsRegressions), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700297}
298
299// parseGoplsOutput parses the text output from gopls check
300// Each line has the format: '/path/to/file.go:448:22-26: unused parameter: path'
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000301func parseGoplsOutput(root string, output []byte) []GoplsIssue {
Earl Lee2e463fb2025-04-17 11:22:22 -0700302 var issues []GoplsIssue
303 lines := strings.Split(string(output), "\n")
304
305 for _, line := range lines {
306 line = strings.TrimSpace(line)
307 if line == "" {
308 continue
309 }
310
311 // Skip lines that look like error messages rather than gopls issues
312 if strings.HasPrefix(line, "Error:") ||
313 strings.HasPrefix(line, "Failed:") ||
314 strings.HasPrefix(line, "Warning:") ||
315 strings.HasPrefix(line, "gopls:") {
316 continue
317 }
318
319 // Find the first colon that separates the file path from the line number
320 firstColonIdx := strings.Index(line, ":")
321 if firstColonIdx < 0 {
322 continue // Invalid format
323 }
324
325 // Verify the part before the first colon looks like a file path
326 potentialPath := line[:firstColonIdx]
327 if !strings.HasSuffix(potentialPath, ".go") {
328 continue // Not a Go file path
329 }
330
331 // Find the position of the first message separator ': '
332 // This separates the position info from the message
333 messageStart := strings.Index(line, ": ")
334 if messageStart < 0 || messageStart <= firstColonIdx {
335 continue // Invalid format
336 }
337
338 // Extract position and message
339 position := line[:messageStart]
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000340 rel, err := filepath.Rel(root, position)
341 if err == nil {
342 position = rel
343 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700344 message := line[messageStart+2:] // Skip the ': ' separator
345
346 // Verify position has the expected format (at least 2 colons for line:col)
347 colonCount := strings.Count(position, ":")
348 if colonCount < 2 {
349 continue // Not enough position information
350 }
351
352 issues = append(issues, GoplsIssue{
353 Position: position,
354 Message: message,
355 })
356 }
357
358 return issues
359}
360
361// looksLikeGoplsIssues checks if the output appears to be actual gopls issues
362// rather than error messages about gopls itself failing
363func looksLikeGoplsIssues(output []byte) bool {
364 // If output is empty, it's not valid issues
365 if len(output) == 0 {
366 return false
367 }
368
369 // Check if output has at least one line that looks like a gopls issue
370 // A gopls issue looks like: '/path/to/file.go:123:45-67: message'
371 lines := strings.Split(string(output), "\n")
372 for _, line := range lines {
373 line = strings.TrimSpace(line)
374 if line == "" {
375 continue
376 }
377
378 // A gopls issue has at least two colons (file path, line number, column)
379 // and contains a colon followed by a space (separating position from message)
380 colonCount := strings.Count(line, ":")
381 hasSeparator := strings.Contains(line, ": ")
382
383 if colonCount >= 2 && hasSeparator {
384 // Check if it starts with a likely file path (ending in .go)
385 parts := strings.SplitN(line, ":", 2)
386 if strings.HasSuffix(parts[0], ".go") {
387 return true
388 }
389 }
390 }
391 return false
392}
393
394// normalizeGoplsPosition extracts just the file path from a position string
395func normalizeGoplsPosition(position string) string {
396 // Extract just the file path by taking everything before the first colon
397 parts := strings.Split(position, ":")
398 if len(parts) < 1 {
399 return position
400 }
401 return parts[0]
402}
403
404// findGoplsRegressions identifies gopls issues that are new in the after state
405func findGoplsRegressions(before, after []GoplsIssue) []GoplsIssue {
406 var regressions []GoplsIssue
407
408 // Build map of before issues for easier lookup
409 beforeIssueMap := make(map[string]map[string]bool) // file -> message -> exists
410 for _, issue := range before {
411 file := normalizeGoplsPosition(issue.Position)
412 if _, exists := beforeIssueMap[file]; !exists {
413 beforeIssueMap[file] = make(map[string]bool)
414 }
415 // Store both the exact message and the general issue type for fuzzy matching
416 beforeIssueMap[file][issue.Message] = true
417
418 // Extract the general issue type (everything before the first ':' in the message)
419 generalIssue := issue.Message
420 if colonIdx := strings.Index(issue.Message, ":"); colonIdx > 0 {
421 generalIssue = issue.Message[:colonIdx]
422 }
423 beforeIssueMap[file][generalIssue] = true
424 }
425
426 // Check each after issue to see if it's new
427 for _, afterIssue := range after {
428 file := normalizeGoplsPosition(afterIssue.Position)
429 isNew := true
430
431 if fileIssues, fileExists := beforeIssueMap[file]; fileExists {
432 // Check for exact message match
433 if fileIssues[afterIssue.Message] {
434 isNew = false
435 } else {
436 // Check for general issue type match
437 generalIssue := afterIssue.Message
438 if colonIdx := strings.Index(afterIssue.Message, ":"); colonIdx > 0 {
439 generalIssue = afterIssue.Message[:colonIdx]
440 }
441 if fileIssues[generalIssue] {
442 isNew = false
443 }
444 }
445 }
446
447 if isNew {
448 regressions = append(regressions, afterIssue)
449 }
450 }
451
452 // Sort regressions for deterministic output
453 slices.SortFunc(regressions, func(a, b GoplsIssue) int {
454 return strings.Compare(a.Position, b.Position)
455 })
456
457 return regressions
458}
459
460// formatGoplsRegressions generates a human-readable summary of gopls check regressions
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000461func (r *CodeReviewer) formatGoplsRegressions(regressions []GoplsIssue) string {
Earl Lee2e463fb2025-04-17 11:22:22 -0700462 if len(regressions) == 0 {
463 return ""
464 }
465
466 var sb strings.Builder
467 sb.WriteString("Gopls check issues detected:\n\n")
468
469 // Format each issue
470 for i, issue := range regressions {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000471 sb.WriteString(fmt.Sprintf("%d. %s: %s\n", i+1, filepath.Join(r.repoRoot, issue.Position), issue.Message))
Earl Lee2e463fb2025-04-17 11:22:22 -0700472 }
473
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000474 sb.WriteString("\nIMPORTANT: Only fix new gopls check issues in parts of the code that you have already edited.")
475 sb.WriteString(" Do not change existing code that was not part of your current edits.\n")
Earl Lee2e463fb2025-04-17 11:22:22 -0700476 return sb.String()
477}
478
479func (r *CodeReviewer) HasReviewed(commit string) bool {
480 return slices.Contains(r.reviewed, commit)
481}
482
483func (r *CodeReviewer) IsInitialCommit(commit string) bool {
484 return commit == r.initialCommit
485}
486
487// packagesForFiles returns maps of packages related to the given files:
488// 1. directPkgs: packages that directly contain the changed files
489// 2. allPkgs: all packages that might be affected, including downstream packages that depend on the direct packages
490// It may include false positives.
491// Files must be absolute paths!
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000492func (r *CodeReviewer) packagesForFiles(ctx context.Context, files []string) (allPkgs map[string]*packages.Package, err error) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700493 for _, f := range files {
494 if !filepath.IsAbs(f) {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000495 return nil, fmt.Errorf("path %q is not absolute", f)
Earl Lee2e463fb2025-04-17 11:22:22 -0700496 }
497 }
498 cfg := &packages.Config{
499 Mode: packages.LoadImports | packages.NeedEmbedFiles,
500 Context: ctx,
501 // Logf: func(msg string, args ...any) {
502 // slog.DebugContext(ctx, "loading go packages", "msg", fmt.Sprintf(msg, args...))
503 // },
504 // TODO: in theory, go.mod might not be in the repo root, and there might be multiple go.mod files.
505 // We can cross that bridge when we get there.
506 Dir: r.repoRoot,
507 Tests: true,
508 }
509 universe, err := packages.Load(cfg, "./...")
510 if err != nil {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000511 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -0700512 }
513 // Identify packages that directly contain the changed files
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000514 directPkgs := make(map[string]*packages.Package) // import path -> package
Earl Lee2e463fb2025-04-17 11:22:22 -0700515 for _, pkg := range universe {
Earl Lee2e463fb2025-04-17 11:22:22 -0700516 pkgFiles := allFiles(pkg)
Earl Lee2e463fb2025-04-17 11:22:22 -0700517 for _, file := range files {
518 if pkgFiles[file] {
519 // prefer test packages, as they contain strictly more files (right?)
520 prev := directPkgs[pkg.PkgPath]
521 if prev == nil || prev.ForTest == "" {
522 directPkgs[pkg.PkgPath] = pkg
523 }
524 }
525 }
526 }
527
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000528 allPkgs = maps.Clone(directPkgs)
Earl Lee2e463fb2025-04-17 11:22:22 -0700529
530 // Add packages that depend on the direct packages
531 addDependentPackages(universe, allPkgs)
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000532 return allPkgs, nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700533}
534
535// allFiles returns all files that might be referenced by the package.
536// It may contain false positives.
537func allFiles(p *packages.Package) map[string]bool {
538 files := make(map[string]bool)
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000539 // Add files from package info
Earl Lee2e463fb2025-04-17 11:22:22 -0700540 add := [][]string{p.GoFiles, p.CompiledGoFiles, p.OtherFiles, p.EmbedFiles, p.IgnoredFiles}
541 for _, extra := range add {
542 for _, file := range extra {
543 files[file] = true
544 }
545 }
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000546 // Add files from testdata directory
547 testdataDir := filepath.Join(p.Dir, "testdata")
548 if _, err := os.Stat(testdataDir); err == nil {
549 fsys := os.DirFS(p.Dir)
550 fs.WalkDir(fsys, "testdata", func(path string, d fs.DirEntry, err error) error {
551 if err == nil && !d.IsDir() {
552 files[filepath.Join(p.Dir, path)] = true
553 }
554 return nil
555 })
556 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700557 return files
558}
559
560// addDependentPackages adds to pkgs all packages from universe
561// that directly or indirectly depend on any package already in pkgs.
562func addDependentPackages(universe []*packages.Package, pkgs map[string]*packages.Package) {
563 for {
564 changed := false
565 for _, p := range universe {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000566 if strings.HasSuffix(p.PkgPath, ".test") { // ick, but I don't see another way
567 // skip test packages
568 continue
569 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700570 if _, ok := pkgs[p.PkgPath]; ok {
571 // already in pkgs
572 continue
573 }
574 for importPath := range p.Imports {
575 if _, ok := pkgs[importPath]; ok {
576 // imports a package dependent on pkgs, add it
577 pkgs[p.PkgPath] = p
578 changed = true
579 break
580 }
581 }
582 }
583 if !changed {
584 break
585 }
586 }
587}
588
589// testJSON is a union of BuildEvent and TestEvent
590type testJSON struct {
591 // TestEvent only:
592 // The Time field holds the time the event happened. It is conventionally omitted
593 // for cached test results.
594 Time time.Time `json:"Time"`
595 // BuildEvent only:
596 // The ImportPath field gives the package ID of the package being built.
597 // This matches the Package.ImportPath field of go list -json and the
598 // TestEvent.FailedBuild field of go test -json. Note that it does not
599 // match TestEvent.Package.
600 ImportPath string `json:"ImportPath"` // BuildEvent only
601 // TestEvent only:
602 // The Package field, if present, specifies the package being tested. When the
603 // go command runs parallel tests in -json mode, events from different tests are
604 // interlaced; the Package field allows readers to separate them.
605 Package string `json:"Package"`
606 // Action is used in both BuildEvent and TestEvent.
607 // It is the key to distinguishing between them.
608 // BuildEvent:
609 // build-output or build-fail
610 // TestEvent:
611 // start, run, pause, cont, pass, bench, fail, output, skip
612 Action string `json:"Action"`
613 // TestEvent only:
614 // The Test field, if present, specifies the test, example, or benchmark function
615 // that caused the event. Events for the overall package test do not set Test.
616 Test string `json:"Test"`
617 // TestEvent only:
618 // The Elapsed field is set for "pass" and "fail" events. It gives the time elapsed in seconds
619 // for the specific test or the overall package test that passed or failed.
620 Elapsed float64
621 // TestEvent:
622 // The Output field is set for Action == "output" and is a portion of the
623 // test's output (standard output and standard error merged together). The
624 // output is unmodified except that invalid UTF-8 output from a test is coerced
625 // into valid UTF-8 by use of replacement characters. With that one exception,
626 // the concatenation of the Output fields of all output events is the exact output
627 // of the test execution.
628 // BuildEvent:
629 // The Output field is set for Action == "build-output" and is a portion of
630 // the build's output. The concatenation of the Output fields of all output
631 // events is the exact output of the build. A single event may contain one
632 // or more lines of output and there may be more than one output event for
633 // a given ImportPath. This matches the definition of the TestEvent.Output
634 // field produced by go test -json.
635 Output string `json:"Output"`
636 // TestEvent only:
637 // The FailedBuild field is set for Action == "fail" if the test failure was caused
638 // by a build failure. It contains the package ID of the package that failed to
639 // build. This matches the ImportPath field of the "go list" output, as well as the
640 // BuildEvent.ImportPath field as emitted by "go build -json".
641 FailedBuild string `json:"FailedBuild"`
642}
643
644// parseTestResults converts test output in JSONL format into a slice of testJSON objects
645func parseTestResults(testOutput []byte) ([]testJSON, error) {
646 var results []testJSON
647 dec := json.NewDecoder(bytes.NewReader(testOutput))
648 for {
649 var event testJSON
650 if err := dec.Decode(&event); err != nil {
651 if err == io.EOF {
652 break
653 }
654 return nil, err
655 }
656 results = append(results, event)
657 }
658 return results, nil
659}
660
661// testStatus represents the status of a test in a given commit
662type testStatus int
663
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000664//go:generate go tool stringer -type=testStatus -trimprefix=testStatus
Earl Lee2e463fb2025-04-17 11:22:22 -0700665const (
666 testStatusUnknown testStatus = iota
667 testStatusPass
668 testStatusFail
669 testStatusBuildFail
670 testStatusSkip
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000671 testStatusNoTests // no tests exist for this package
Earl Lee2e463fb2025-04-17 11:22:22 -0700672)
673
Earl Lee2e463fb2025-04-17 11:22:22 -0700674// testRegression represents a test that regressed between commits
675type testRegression struct {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000676 Package string
677 Test string // empty for package tests
Earl Lee2e463fb2025-04-17 11:22:22 -0700678 BeforeStatus testStatus
679 AfterStatus testStatus
680 Output string // failure output in the after state
681}
682
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000683func (r *testRegression) Source() string {
684 if r.Test == "" {
685 return r.Package
Earl Lee2e463fb2025-04-17 11:22:22 -0700686 }
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000687 return fmt.Sprintf("%s.%s", r.Package, r.Test)
688}
Earl Lee2e463fb2025-04-17 11:22:22 -0700689
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000690type packageResult struct {
691 Status testStatus // overall status for the package
692 TestStatus map[string]testStatus // name -> status
693 TestOutput map[string][]string // name -> output parts
694}
Earl Lee2e463fb2025-04-17 11:22:22 -0700695
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000696// collectTestStatuses processes a slice of test events and returns rich status information
697func collectTestStatuses(results []testJSON) map[string]*packageResult {
698 m := make(map[string]*packageResult)
699
700 for _, event := range results {
701 pkg := event.Package
702 p, ok := m[pkg]
703 if !ok {
704 p = new(packageResult)
705 p.TestStatus = make(map[string]testStatus)
706 p.TestOutput = make(map[string][]string)
707 m[pkg] = p
Earl Lee2e463fb2025-04-17 11:22:22 -0700708 }
709
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000710 switch event.Action {
711 case "output":
712 p.TestOutput[event.Test] = append(p.TestOutput[event.Test], event.Output)
Earl Lee2e463fb2025-04-17 11:22:22 -0700713 continue
Earl Lee2e463fb2025-04-17 11:22:22 -0700714 case "pass":
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000715 if event.Test == "" {
716 p.Status = testStatusPass
717 } else {
718 p.TestStatus[event.Test] = testStatusPass
719 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700720 case "fail":
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000721 if event.Test == "" {
722 if event.FailedBuild != "" {
723 p.Status = testStatusBuildFail
724 } else {
725 p.Status = testStatusFail
726 }
727 } else {
728 p.TestStatus[event.Test] = testStatusFail
729 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700730 case "skip":
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000731 if event.Test == "" {
732 p.Status = testStatusNoTests
733 } else {
734 p.TestStatus[event.Test] = testStatusSkip
735 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700736 }
737 }
738
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000739 return m
Earl Lee2e463fb2025-04-17 11:22:22 -0700740}
741
742// compareTestResults identifies tests that have regressed between commits
743func (r *CodeReviewer) compareTestResults(beforeResults, afterResults []testJSON) ([]testRegression, error) {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000744 before := collectTestStatuses(beforeResults)
745 after := collectTestStatuses(afterResults)
746 var testLevelRegressions []testRegression
747 var packageLevelRegressions []testRegression
Earl Lee2e463fb2025-04-17 11:22:22 -0700748
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000749 afterPkgs := slices.Sorted(maps.Keys(after))
750 for _, pkg := range afterPkgs {
751 afterResult := after[pkg]
752 afterStatus := afterResult.Status
753 // Can't short-circuit here when tests are passing, because we need to check for skipped tests.
754 beforeResult, ok := before[pkg]
755 beforeStatus := testStatusNoTests
756 if ok {
757 beforeStatus = beforeResult.Status
758 }
759 // If things no longer build, stop at the package level.
760 // Otherwise, proceed to the test level, so we have more precise information.
761 if afterStatus == testStatusBuildFail && beforeStatus != testStatusBuildFail {
762 packageLevelRegressions = append(packageLevelRegressions, testRegression{
763 Package: pkg,
764 BeforeStatus: beforeStatus,
765 AfterStatus: afterStatus,
766 })
767 continue
768 }
769 tests := slices.Sorted(maps.Keys(afterResult.TestStatus))
770 for _, test := range tests {
771 afterStatus := afterResult.TestStatus[test]
772 switch afterStatus {
773 case testStatusPass:
774 continue
775 case testStatusUnknown:
776 slog.WarnContext(context.Background(), "unknown test status", "package", pkg, "test", test)
777 continue
778 }
779 beforeStatus := beforeResult.TestStatus[test]
780 if isRegression(beforeStatus, afterStatus) {
781 testLevelRegressions = append(testLevelRegressions, testRegression{
782 Package: pkg,
783 Test: test,
784 BeforeStatus: beforeStatus,
785 AfterStatus: afterStatus,
786 Output: strings.Join(afterResult.TestOutput[test], ""),
787 })
788 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700789 }
790 }
791
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000792 // If we have test-level regressions, report only those
793 // Otherwise, report package-level regressions
Earl Lee2e463fb2025-04-17 11:22:22 -0700794 var regressions []testRegression
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000795 if len(testLevelRegressions) > 0 {
796 regressions = testLevelRegressions
797 } else {
798 regressions = packageLevelRegressions
Earl Lee2e463fb2025-04-17 11:22:22 -0700799 }
800
801 // Sort regressions for consistent output
802 slices.SortFunc(regressions, func(a, b testRegression) int {
803 // First by package
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000804 if c := strings.Compare(a.Package, b.Package); c != 0 {
Earl Lee2e463fb2025-04-17 11:22:22 -0700805 return c
806 }
807 // Then by test name
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000808 return strings.Compare(a.Test, b.Test)
Earl Lee2e463fb2025-04-17 11:22:22 -0700809 })
810
811 return regressions, nil
812}
813
814// badnessLevels maps test status to a badness level
815// Higher values indicate worse status (more severe issues)
816var badnessLevels = map[testStatus]int{
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000817 testStatusBuildFail: 5, // Worst
818 testStatusFail: 4,
819 testStatusSkip: 3,
820 testStatusNoTests: 2,
Earl Lee2e463fb2025-04-17 11:22:22 -0700821 testStatusPass: 1,
822 testStatusUnknown: 0, // Least bad - avoids false positives
823}
824
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000825// regressionFormatter defines a mapping of before/after state pairs to descriptive messages
826type regressionKey struct {
827 before, after testStatus
828}
829
830var regressionMessages = map[regressionKey]string{
831 {testStatusUnknown, testStatusBuildFail}: "New test has build/vet errors",
832 {testStatusUnknown, testStatusFail}: "New test is failing",
833 {testStatusUnknown, testStatusSkip}: "New test is skipped",
834 {testStatusPass, testStatusBuildFail}: "Was passing, now has build/vet errors",
835 {testStatusPass, testStatusFail}: "Was passing, now failing",
836 {testStatusPass, testStatusSkip}: "Was passing, now skipped",
837 {testStatusNoTests, testStatusBuildFail}: "Previously had no tests, now has build/vet errors",
838 {testStatusNoTests, testStatusFail}: "Previously had no tests, now has failing tests",
839 {testStatusNoTests, testStatusSkip}: "Previously had no tests, now has skipped tests",
840 {testStatusSkip, testStatusBuildFail}: "Was skipped, now has build/vet errors",
841 {testStatusSkip, testStatusFail}: "Was skipped, now failing",
842 {testStatusFail, testStatusBuildFail}: "Was failing, now has build/vet errors",
843}
844
Earl Lee2e463fb2025-04-17 11:22:22 -0700845// isRegression determines if a test has regressed based on before and after status
846// A regression is defined as an increase in badness level
847func isRegression(before, after testStatus) bool {
848 // Higher badness level means worse status
849 return badnessLevels[after] > badnessLevels[before]
850}
851
852// formatTestRegressions generates a human-readable summary of test regressions
853func (r *CodeReviewer) formatTestRegressions(regressions []testRegression) string {
854 if len(regressions) == 0 {
855 return ""
856 }
857
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000858 buf := new(strings.Builder)
859 fmt.Fprintf(buf, "Test regressions detected between initial commit (%s) and HEAD:\n\n", r.initialCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -0700860
861 for i, reg := range regressions {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000862 fmt.Fprintf(buf, "%d: %v: ", i+1, reg.Source())
863 key := regressionKey{reg.BeforeStatus, reg.AfterStatus}
864 message, exists := regressionMessages[key]
865 if !exists {
866 message = "Regression detected"
Earl Lee2e463fb2025-04-17 11:22:22 -0700867 }
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000868 fmt.Fprintf(buf, "%s\n", message)
Earl Lee2e463fb2025-04-17 11:22:22 -0700869 }
870
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000871 return buf.String()
Earl Lee2e463fb2025-04-17 11:22:22 -0700872}
Josh Bleecher Snyderffecb1e2025-04-28 18:59:14 +0000873
874// RelatedFile represents a file historically related to the changed files
875type RelatedFile struct {
876 Path string // Path to the file
877 Correlation float64 // Correlation score (0.0-1.0)
878}
879
880// findRelatedFiles identifies files that are historically related to the changed files
881// by analyzing git commit history for co-occurrences.
882func (r *CodeReviewer) findRelatedFiles(ctx context.Context, changedFiles []string) ([]RelatedFile, error) {
883 commits, err := r.getCommitsTouchingFiles(ctx, changedFiles)
884 if err != nil {
885 return nil, fmt.Errorf("failed to get commits touching files: %w", err)
886 }
887 if len(commits) == 0 {
888 return nil, nil
889 }
890
891 relChanged := make(map[string]bool, len(changedFiles))
892 for _, file := range changedFiles {
893 rel, err := filepath.Rel(r.repoRoot, file)
894 if err != nil {
895 return nil, fmt.Errorf("failed to get relative path for %s: %w", file, err)
896 }
897 relChanged[rel] = true
898 }
899
900 historyFiles := make(map[string]int)
901 var historyMu sync.Mutex
902
903 maxWorkers := runtime.GOMAXPROCS(0)
904 semaphore := make(chan bool, maxWorkers)
905 var wg sync.WaitGroup
906
907 for _, commit := range commits {
908 wg.Add(1)
909 semaphore <- true // acquire
910
911 go func(commit string) {
912 defer wg.Done()
913 defer func() { <-semaphore }() // release
914 commitFiles, err := r.getFilesInCommit(ctx, commit)
915 if err != nil {
916 slog.WarnContext(ctx, "Failed to get files in commit", "commit", commit, "err", err)
917 return
918 }
919 incr := 0
920 for _, file := range commitFiles {
921 if relChanged[file] {
922 incr++
923 }
924 }
925 if incr == 0 {
926 return
927 }
928 historyMu.Lock()
929 defer historyMu.Unlock()
930 for _, file := range commitFiles {
931 historyFiles[file] += incr
932 }
933 }(commit)
934 }
935 wg.Wait()
936
937 // normalize
938 maxCount := 0
939 for _, count := range historyFiles {
940 maxCount = max(maxCount, count)
941 }
942 if maxCount == 0 {
943 return nil, nil
944 }
945
946 var relatedFiles []RelatedFile
947 for file, count := range historyFiles {
948 if relChanged[file] {
949 // Don't include inputs in the output.
950 continue
951 }
952 correlation := float64(count) / float64(maxCount)
953 // Require min correlation to avoid noise
954 if correlation >= 0.1 {
955 relatedFiles = append(relatedFiles, RelatedFile{Path: file, Correlation: correlation})
956 }
957 }
958
959 // Highest correlation first
960 slices.SortFunc(relatedFiles, func(a, b RelatedFile) int {
961 return -1 * cmp.Compare(a.Correlation, b.Correlation)
962 })
963
964 // Limit to 1 correlated file per input file.
965 // (Arbitrary limit, to be adjusted.)
966 maxFiles := len(changedFiles)
967 if len(relatedFiles) > maxFiles {
968 relatedFiles = relatedFiles[:maxFiles]
969 }
970
971 // TODO: add an LLM in the mix here (like the keyword search tool) to do a filtering pass,
972 // and then increase the strength of the wording in the relatedFiles message.
973
974 return relatedFiles, nil
975}
976
977// getCommitsTouchingFiles returns all commits that touch any of the specified files
978func (r *CodeReviewer) getCommitsTouchingFiles(ctx context.Context, files []string) ([]string, error) {
979 if len(files) == 0 {
980 return nil, nil
981 }
982 fileArgs := append([]string{"rev-list", "--all", "--"}, files...)
983 cmd := exec.CommandContext(ctx, "git", fileArgs...)
984 cmd.Dir = r.repoRoot
985 out, err := cmd.CombinedOutput()
986 if err != nil {
987 return nil, fmt.Errorf("failed to get commits: %w\n%s", err, out)
988 }
989 return nonEmptyTrimmedLines(out), nil
990}
991
992// getFilesInCommit returns all files changed in a specific commit
993func (r *CodeReviewer) getFilesInCommit(ctx context.Context, commit string) ([]string, error) {
994 cmd := exec.CommandContext(ctx, "git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit)
995 cmd.Dir = r.repoRoot
996 out, err := cmd.CombinedOutput()
997 if err != nil {
998 return nil, fmt.Errorf("failed to get files in commit: %w\n%s", err, out)
999 }
1000 return nonEmptyTrimmedLines(out), nil
1001}
1002
1003func nonEmptyTrimmedLines(b []byte) []string {
1004 var lines []string
1005 for line := range strings.Lines(string(b)) {
1006 line = strings.TrimSpace(line)
1007 if line != "" {
1008 lines = append(lines, line)
1009 }
1010 }
1011 return lines
1012}
1013
1014// formatRelatedFiles formats the related files list into a human-readable message
1015func (r *CodeReviewer) formatRelatedFiles(files []RelatedFile) string {
1016 if len(files) == 0 {
1017 return ""
1018 }
1019
1020 buf := new(strings.Builder)
1021
1022 fmt.Fprintf(buf, "Potentially related files:\n\n")
1023
1024 for _, file := range files {
1025 fmt.Fprintf(buf, "- %s (%0.0f%%)\n", file.Path, 100*file.Correlation)
1026 }
1027
1028 fmt.Fprintf(buf, "\nThese files have historically changed with the files you have modified. Consider whether they require updates as well.\n")
1029 return buf.String()
1030}