blob: 014fc13f2a0c8917de47e65a18287228ed13ecd2 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package claudetool
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +00009 "io/fs"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "log/slog"
11 "maps"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "slices"
16 "strings"
17 "time"
18
19 "golang.org/x/tools/go/packages"
20 "sketch.dev/ant"
21)
22
23// This file does differential quality analysis of a commit relative to a base commit.
24
25// Tool returns a tool spec for a CodeReview tool backed by r.
26func (r *CodeReviewer) Tool() *ant.Tool {
27 spec := &ant.Tool{
28 Name: "codereview",
29 Description: `Run an automated code review.`,
30 // If you modify this, update the termui template for prettier rendering.
31 InputSchema: ant.MustSchema(`{"type": "object"}`),
32 Run: r.Run,
33 }
34 return spec
35}
36
37func (r *CodeReviewer) Run(ctx context.Context, m json.RawMessage) (string, error) {
38 if err := r.RequireNormalGitState(ctx); err != nil {
39 slog.DebugContext(ctx, "CodeReviewer.Run: failed to check for normal git state", "err", err)
40 return "", err
41 }
42 if err := r.RequireNoUncommittedChanges(ctx); err != nil {
43 slog.DebugContext(ctx, "CodeReviewer.Run: failed to check for uncommitted changes", "err", err)
44 return "", err
45 }
46
47 // Check that the current commit is not the initial commit
48 currentCommit, err := r.CurrentCommit(ctx)
49 if err != nil {
50 slog.DebugContext(ctx, "CodeReviewer.Run: failed to get current commit", "err", err)
51 return "", err
52 }
53 if r.IsInitialCommit(currentCommit) {
54 slog.DebugContext(ctx, "CodeReviewer.Run: current commit is initial commit, nothing to review")
55 return "", fmt.Errorf("no new commits have been added, nothing to review")
56 }
57
58 // No matter what failures happen from here out, we will declare this to have been reviewed.
59 // This should help avoid the model getting blocked by a broken code review tool.
60 r.reviewed = append(r.reviewed, currentCommit)
61
62 changedFiles, err := r.changedFiles(ctx, r.initialCommit, currentCommit)
63 if err != nil {
64 slog.DebugContext(ctx, "CodeReviewer.Run: failed to get changed files", "err", err)
65 return "", err
66 }
67
68 // Prepare to analyze before/after for the impacted files.
69 // We use the current commit to determine what packages exist and are impacted.
70 // The packages in the initial commit may be different.
71 // Good enough for now.
72 // TODO: do better
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +000073 allPkgs, err := r.packagesForFiles(ctx, changedFiles)
Earl Lee2e463fb2025-04-17 11:22:22 -070074 if err != nil {
75 // TODO: log and skip to stuff that doesn't require packages
76 slog.DebugContext(ctx, "CodeReviewer.Run: failed to get packages for files", "err", err)
77 return "", err
78 }
79 allPkgList := slices.Collect(maps.Keys(allPkgs))
Earl Lee2e463fb2025-04-17 11:22:22 -070080
81 var msgs []string
82
83 testMsg, err := r.checkTests(ctx, allPkgList)
84 if err != nil {
85 slog.DebugContext(ctx, "CodeReviewer.Run: failed to check tests", "err", err)
86 return "", err
87 }
88 if testMsg != "" {
89 msgs = append(msgs, testMsg)
90 }
91
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +000092 goplsMsg, err := r.checkGopls(ctx, changedFiles) // includes vet checks
Earl Lee2e463fb2025-04-17 11:22:22 -070093 if err != nil {
94 slog.DebugContext(ctx, "CodeReviewer.Run: failed to check gopls", "err", err)
95 return "", err
96 }
97 if goplsMsg != "" {
98 msgs = append(msgs, goplsMsg)
99 }
100
101 if len(msgs) == 0 {
102 slog.DebugContext(ctx, "CodeReviewer.Run: no issues found")
103 return "OK", nil
104 }
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000105
106 msgs = append(msgs, "Please fix before proceeding.")
Earl Lee2e463fb2025-04-17 11:22:22 -0700107 slog.DebugContext(ctx, "CodeReviewer.Run: found issues", "issues", msgs)
108 return strings.Join(msgs, "\n\n"), nil
109}
110
111func (r *CodeReviewer) initializeInitialCommitWorktree(ctx context.Context) error {
112 if r.initialWorktree != "" {
113 return nil
114 }
115 tmpDir, err := os.MkdirTemp("", "sketch-codereview-worktree")
116 if err != nil {
117 return err
118 }
119 worktreeCmd := exec.CommandContext(ctx, "git", "worktree", "add", "--detach", tmpDir, r.initialCommit)
120 worktreeCmd.Dir = r.repoRoot
121 out, err := worktreeCmd.CombinedOutput()
122 if err != nil {
123 return fmt.Errorf("unable to create worktree for initial commit: %w\n%s", err, out)
124 }
125 r.initialWorktree = tmpDir
126 return nil
127}
128
129func (r *CodeReviewer) checkTests(ctx context.Context, pkgList []string) (string, error) {
130 goTestArgs := []string{"test", "-json", "-v"}
131 goTestArgs = append(goTestArgs, pkgList...)
132
133 afterTestCmd := exec.CommandContext(ctx, "go", goTestArgs...)
134 afterTestCmd.Dir = r.repoRoot
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000135 afterTestOut, _ := afterTestCmd.Output()
136 // unfortunately, we can't short-circuit here even if all tests pass,
137 // because we need to check for skipped tests.
Earl Lee2e463fb2025-04-17 11:22:22 -0700138
139 err := r.initializeInitialCommitWorktree(ctx)
140 if err != nil {
141 return "", err
142 }
143
144 beforeTestCmd := exec.CommandContext(ctx, "go", goTestArgs...)
145 beforeTestCmd.Dir = r.initialWorktree
146 beforeTestOut, _ := beforeTestCmd.Output() // ignore error, interesting info is in the output
147
148 // Parse the jsonl test results
149 beforeResults, beforeParseErr := parseTestResults(beforeTestOut)
150 if beforeParseErr != nil {
151 return "", fmt.Errorf("unable to parse test results for initial commit: %w\n%s", beforeParseErr, beforeTestOut)
152 }
153 afterResults, afterParseErr := parseTestResults(afterTestOut)
154 if afterParseErr != nil {
155 return "", fmt.Errorf("unable to parse test results for current commit: %w\n%s", afterParseErr, afterTestOut)
156 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700157 testRegressions, err := r.compareTestResults(beforeResults, afterResults)
158 if err != nil {
159 return "", fmt.Errorf("failed to compare test results: %w", err)
160 }
161 // TODO: better output formatting?
162 res := r.formatTestRegressions(testRegressions)
163 return res, nil
164}
165
Earl Lee2e463fb2025-04-17 11:22:22 -0700166// GoplsIssue represents a single issue reported by gopls check
167type GoplsIssue struct {
168 Position string // File position in format "file:line:col-range"
169 Message string // Description of the issue
170}
171
172// checkGopls runs gopls check on the provided files in both the current and initial state,
173// compares the results, and reports any new issues introduced in the current state.
174func (r *CodeReviewer) checkGopls(ctx context.Context, changedFiles []string) (string, error) {
175 if len(changedFiles) == 0 {
176 return "", nil // no files to check
177 }
178
179 // Filter out non-Go files as gopls only works on Go files
180 // and verify they still exist (not deleted)
181 var goFiles []string
182 for _, file := range changedFiles {
183 if !strings.HasSuffix(file, ".go") {
184 continue // not a Go file
185 }
186
187 // Check if the file still exists (not deleted)
188 if _, err := os.Stat(file); os.IsNotExist(err) {
189 continue // file doesn't exist anymore (deleted)
190 }
191
192 goFiles = append(goFiles, file)
193 }
194
195 if len(goFiles) == 0 {
196 return "", nil // no Go files to check
197 }
198
199 // Run gopls check on the current state
200 goplsArgs := append([]string{"check"}, goFiles...)
201
202 afterGoplsCmd := exec.CommandContext(ctx, "gopls", goplsArgs...)
203 afterGoplsCmd.Dir = r.repoRoot
204 afterGoplsOut, err := afterGoplsCmd.CombinedOutput() // gopls returns non-zero if it finds issues
205 if err != nil {
206 // Check if the output looks like real gopls issues or if it's just error output
207 if !looksLikeGoplsIssues(afterGoplsOut) {
208 slog.WarnContext(ctx, "CodeReviewer.checkGopls: gopls check failed to run properly", "err", err, "output", string(afterGoplsOut))
209 return "", nil // Skip rather than failing the entire code review
210 }
211 // Otherwise, proceed with parsing - it's likely just the non-zero exit code due to found issues
212 }
213
214 // Parse the output
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000215 afterIssues := parseGoplsOutput(r.repoRoot, afterGoplsOut)
Earl Lee2e463fb2025-04-17 11:22:22 -0700216
217 // If no issues were found, we're done
218 if len(afterIssues) == 0 {
219 return "", nil
220 }
221
222 // Gopls detected issues in the current state, check if they existed in the initial state
223 initErr := r.initializeInitialCommitWorktree(ctx)
224 if initErr != nil {
225 return "", err
226 }
227
228 // For each file that exists in the initial commit, run gopls check
229 var initialFilesToCheck []string
230 for _, file := range goFiles {
231 // Get relative path for git operations
232 relFile, err := filepath.Rel(r.repoRoot, file)
233 if err != nil {
234 slog.WarnContext(ctx, "CodeReviewer.checkGopls: failed to get relative path", "repo_root", r.repoRoot, "file", file, "err", err)
235 continue
236 }
237
238 // Check if the file exists in the initial commit
239 checkCmd := exec.CommandContext(ctx, "git", "cat-file", "-e", fmt.Sprintf("%s:%s", r.initialCommit, relFile))
240 checkCmd.Dir = r.repoRoot
241 if err := checkCmd.Run(); err == nil {
242 // File exists in initial commit
243 initialFilePath := filepath.Join(r.initialWorktree, relFile)
244 initialFilesToCheck = append(initialFilesToCheck, initialFilePath)
245 }
246 }
247
248 // Run gopls check on the files that existed in the initial commit
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000249 var beforeIssues []GoplsIssue
Earl Lee2e463fb2025-04-17 11:22:22 -0700250 if len(initialFilesToCheck) > 0 {
251 beforeGoplsArgs := append([]string{"check"}, initialFilesToCheck...)
252 beforeGoplsCmd := exec.CommandContext(ctx, "gopls", beforeGoplsArgs...)
253 beforeGoplsCmd.Dir = r.initialWorktree
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000254 beforeGoplsOut, beforeCmdErr := beforeGoplsCmd.CombinedOutput()
Earl Lee2e463fb2025-04-17 11:22:22 -0700255 if beforeCmdErr != nil && !looksLikeGoplsIssues(beforeGoplsOut) {
256 // If gopls fails to run properly on the initial commit, log a warning and continue
257 // with empty before issues - this will be conservative and report more issues
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000258 slog.WarnContext(ctx, "CodeReviewer.checkGopls: gopls check failed on initial commit", "err", err, "output", string(beforeGoplsOut))
Earl Lee2e463fb2025-04-17 11:22:22 -0700259 } else {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000260 beforeIssues = parseGoplsOutput(r.initialWorktree, beforeGoplsOut)
Earl Lee2e463fb2025-04-17 11:22:22 -0700261 }
262 }
263
264 // Find new issues that weren't present in the initial state
265 goplsRegressions := findGoplsRegressions(beforeIssues, afterIssues)
266 if len(goplsRegressions) == 0 {
267 return "", nil // no new issues
268 }
269
270 // Format the results
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000271 return r.formatGoplsRegressions(goplsRegressions), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700272}
273
274// parseGoplsOutput parses the text output from gopls check
275// Each line has the format: '/path/to/file.go:448:22-26: unused parameter: path'
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000276func parseGoplsOutput(root string, output []byte) []GoplsIssue {
Earl Lee2e463fb2025-04-17 11:22:22 -0700277 var issues []GoplsIssue
278 lines := strings.Split(string(output), "\n")
279
280 for _, line := range lines {
281 line = strings.TrimSpace(line)
282 if line == "" {
283 continue
284 }
285
286 // Skip lines that look like error messages rather than gopls issues
287 if strings.HasPrefix(line, "Error:") ||
288 strings.HasPrefix(line, "Failed:") ||
289 strings.HasPrefix(line, "Warning:") ||
290 strings.HasPrefix(line, "gopls:") {
291 continue
292 }
293
294 // Find the first colon that separates the file path from the line number
295 firstColonIdx := strings.Index(line, ":")
296 if firstColonIdx < 0 {
297 continue // Invalid format
298 }
299
300 // Verify the part before the first colon looks like a file path
301 potentialPath := line[:firstColonIdx]
302 if !strings.HasSuffix(potentialPath, ".go") {
303 continue // Not a Go file path
304 }
305
306 // Find the position of the first message separator ': '
307 // This separates the position info from the message
308 messageStart := strings.Index(line, ": ")
309 if messageStart < 0 || messageStart <= firstColonIdx {
310 continue // Invalid format
311 }
312
313 // Extract position and message
314 position := line[:messageStart]
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000315 rel, err := filepath.Rel(root, position)
316 if err == nil {
317 position = rel
318 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 message := line[messageStart+2:] // Skip the ': ' separator
320
321 // Verify position has the expected format (at least 2 colons for line:col)
322 colonCount := strings.Count(position, ":")
323 if colonCount < 2 {
324 continue // Not enough position information
325 }
326
327 issues = append(issues, GoplsIssue{
328 Position: position,
329 Message: message,
330 })
331 }
332
333 return issues
334}
335
336// looksLikeGoplsIssues checks if the output appears to be actual gopls issues
337// rather than error messages about gopls itself failing
338func looksLikeGoplsIssues(output []byte) bool {
339 // If output is empty, it's not valid issues
340 if len(output) == 0 {
341 return false
342 }
343
344 // Check if output has at least one line that looks like a gopls issue
345 // A gopls issue looks like: '/path/to/file.go:123:45-67: message'
346 lines := strings.Split(string(output), "\n")
347 for _, line := range lines {
348 line = strings.TrimSpace(line)
349 if line == "" {
350 continue
351 }
352
353 // A gopls issue has at least two colons (file path, line number, column)
354 // and contains a colon followed by a space (separating position from message)
355 colonCount := strings.Count(line, ":")
356 hasSeparator := strings.Contains(line, ": ")
357
358 if colonCount >= 2 && hasSeparator {
359 // Check if it starts with a likely file path (ending in .go)
360 parts := strings.SplitN(line, ":", 2)
361 if strings.HasSuffix(parts[0], ".go") {
362 return true
363 }
364 }
365 }
366 return false
367}
368
369// normalizeGoplsPosition extracts just the file path from a position string
370func normalizeGoplsPosition(position string) string {
371 // Extract just the file path by taking everything before the first colon
372 parts := strings.Split(position, ":")
373 if len(parts) < 1 {
374 return position
375 }
376 return parts[0]
377}
378
379// findGoplsRegressions identifies gopls issues that are new in the after state
380func findGoplsRegressions(before, after []GoplsIssue) []GoplsIssue {
381 var regressions []GoplsIssue
382
383 // Build map of before issues for easier lookup
384 beforeIssueMap := make(map[string]map[string]bool) // file -> message -> exists
385 for _, issue := range before {
386 file := normalizeGoplsPosition(issue.Position)
387 if _, exists := beforeIssueMap[file]; !exists {
388 beforeIssueMap[file] = make(map[string]bool)
389 }
390 // Store both the exact message and the general issue type for fuzzy matching
391 beforeIssueMap[file][issue.Message] = true
392
393 // Extract the general issue type (everything before the first ':' in the message)
394 generalIssue := issue.Message
395 if colonIdx := strings.Index(issue.Message, ":"); colonIdx > 0 {
396 generalIssue = issue.Message[:colonIdx]
397 }
398 beforeIssueMap[file][generalIssue] = true
399 }
400
401 // Check each after issue to see if it's new
402 for _, afterIssue := range after {
403 file := normalizeGoplsPosition(afterIssue.Position)
404 isNew := true
405
406 if fileIssues, fileExists := beforeIssueMap[file]; fileExists {
407 // Check for exact message match
408 if fileIssues[afterIssue.Message] {
409 isNew = false
410 } else {
411 // Check for general issue type match
412 generalIssue := afterIssue.Message
413 if colonIdx := strings.Index(afterIssue.Message, ":"); colonIdx > 0 {
414 generalIssue = afterIssue.Message[:colonIdx]
415 }
416 if fileIssues[generalIssue] {
417 isNew = false
418 }
419 }
420 }
421
422 if isNew {
423 regressions = append(regressions, afterIssue)
424 }
425 }
426
427 // Sort regressions for deterministic output
428 slices.SortFunc(regressions, func(a, b GoplsIssue) int {
429 return strings.Compare(a.Position, b.Position)
430 })
431
432 return regressions
433}
434
435// formatGoplsRegressions generates a human-readable summary of gopls check regressions
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000436func (r *CodeReviewer) formatGoplsRegressions(regressions []GoplsIssue) string {
Earl Lee2e463fb2025-04-17 11:22:22 -0700437 if len(regressions) == 0 {
438 return ""
439 }
440
441 var sb strings.Builder
442 sb.WriteString("Gopls check issues detected:\n\n")
443
444 // Format each issue
445 for i, issue := range regressions {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000446 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 -0700447 }
448
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000449 sb.WriteString("\nIMPORTANT: Only fix new gopls check issues in parts of the code that you have already edited.")
450 sb.WriteString(" Do not change existing code that was not part of your current edits.\n")
Earl Lee2e463fb2025-04-17 11:22:22 -0700451 return sb.String()
452}
453
454func (r *CodeReviewer) HasReviewed(commit string) bool {
455 return slices.Contains(r.reviewed, commit)
456}
457
458func (r *CodeReviewer) IsInitialCommit(commit string) bool {
459 return commit == r.initialCommit
460}
461
462// packagesForFiles returns maps of packages related to the given files:
463// 1. directPkgs: packages that directly contain the changed files
464// 2. allPkgs: all packages that might be affected, including downstream packages that depend on the direct packages
465// It may include false positives.
466// Files must be absolute paths!
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000467func (r *CodeReviewer) packagesForFiles(ctx context.Context, files []string) (allPkgs map[string]*packages.Package, err error) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700468 for _, f := range files {
469 if !filepath.IsAbs(f) {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000470 return nil, fmt.Errorf("path %q is not absolute", f)
Earl Lee2e463fb2025-04-17 11:22:22 -0700471 }
472 }
473 cfg := &packages.Config{
474 Mode: packages.LoadImports | packages.NeedEmbedFiles,
475 Context: ctx,
476 // Logf: func(msg string, args ...any) {
477 // slog.DebugContext(ctx, "loading go packages", "msg", fmt.Sprintf(msg, args...))
478 // },
479 // TODO: in theory, go.mod might not be in the repo root, and there might be multiple go.mod files.
480 // We can cross that bridge when we get there.
481 Dir: r.repoRoot,
482 Tests: true,
483 }
484 universe, err := packages.Load(cfg, "./...")
485 if err != nil {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000486 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -0700487 }
488 // Identify packages that directly contain the changed files
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000489 directPkgs := make(map[string]*packages.Package) // import path -> package
Earl Lee2e463fb2025-04-17 11:22:22 -0700490 for _, pkg := range universe {
Earl Lee2e463fb2025-04-17 11:22:22 -0700491 pkgFiles := allFiles(pkg)
Earl Lee2e463fb2025-04-17 11:22:22 -0700492 for _, file := range files {
493 if pkgFiles[file] {
494 // prefer test packages, as they contain strictly more files (right?)
495 prev := directPkgs[pkg.PkgPath]
496 if prev == nil || prev.ForTest == "" {
497 directPkgs[pkg.PkgPath] = pkg
498 }
499 }
500 }
501 }
502
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000503 allPkgs = maps.Clone(directPkgs)
Earl Lee2e463fb2025-04-17 11:22:22 -0700504
505 // Add packages that depend on the direct packages
506 addDependentPackages(universe, allPkgs)
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000507 return allPkgs, nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700508}
509
510// allFiles returns all files that might be referenced by the package.
511// It may contain false positives.
512func allFiles(p *packages.Package) map[string]bool {
513 files := make(map[string]bool)
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000514 // Add files from package info
Earl Lee2e463fb2025-04-17 11:22:22 -0700515 add := [][]string{p.GoFiles, p.CompiledGoFiles, p.OtherFiles, p.EmbedFiles, p.IgnoredFiles}
516 for _, extra := range add {
517 for _, file := range extra {
518 files[file] = true
519 }
520 }
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000521 // Add files from testdata directory
522 testdataDir := filepath.Join(p.Dir, "testdata")
523 if _, err := os.Stat(testdataDir); err == nil {
524 fsys := os.DirFS(p.Dir)
525 fs.WalkDir(fsys, "testdata", func(path string, d fs.DirEntry, err error) error {
526 if err == nil && !d.IsDir() {
527 files[filepath.Join(p.Dir, path)] = true
528 }
529 return nil
530 })
531 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700532 return files
533}
534
535// addDependentPackages adds to pkgs all packages from universe
536// that directly or indirectly depend on any package already in pkgs.
537func addDependentPackages(universe []*packages.Package, pkgs map[string]*packages.Package) {
538 for {
539 changed := false
540 for _, p := range universe {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000541 if strings.HasSuffix(p.PkgPath, ".test") { // ick, but I don't see another way
542 // skip test packages
543 continue
544 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700545 if _, ok := pkgs[p.PkgPath]; ok {
546 // already in pkgs
547 continue
548 }
549 for importPath := range p.Imports {
550 if _, ok := pkgs[importPath]; ok {
551 // imports a package dependent on pkgs, add it
552 pkgs[p.PkgPath] = p
553 changed = true
554 break
555 }
556 }
557 }
558 if !changed {
559 break
560 }
561 }
562}
563
564// testJSON is a union of BuildEvent and TestEvent
565type testJSON struct {
566 // TestEvent only:
567 // The Time field holds the time the event happened. It is conventionally omitted
568 // for cached test results.
569 Time time.Time `json:"Time"`
570 // BuildEvent only:
571 // The ImportPath field gives the package ID of the package being built.
572 // This matches the Package.ImportPath field of go list -json and the
573 // TestEvent.FailedBuild field of go test -json. Note that it does not
574 // match TestEvent.Package.
575 ImportPath string `json:"ImportPath"` // BuildEvent only
576 // TestEvent only:
577 // The Package field, if present, specifies the package being tested. When the
578 // go command runs parallel tests in -json mode, events from different tests are
579 // interlaced; the Package field allows readers to separate them.
580 Package string `json:"Package"`
581 // Action is used in both BuildEvent and TestEvent.
582 // It is the key to distinguishing between them.
583 // BuildEvent:
584 // build-output or build-fail
585 // TestEvent:
586 // start, run, pause, cont, pass, bench, fail, output, skip
587 Action string `json:"Action"`
588 // TestEvent only:
589 // The Test field, if present, specifies the test, example, or benchmark function
590 // that caused the event. Events for the overall package test do not set Test.
591 Test string `json:"Test"`
592 // TestEvent only:
593 // The Elapsed field is set for "pass" and "fail" events. It gives the time elapsed in seconds
594 // for the specific test or the overall package test that passed or failed.
595 Elapsed float64
596 // TestEvent:
597 // The Output field is set for Action == "output" and is a portion of the
598 // test's output (standard output and standard error merged together). The
599 // output is unmodified except that invalid UTF-8 output from a test is coerced
600 // into valid UTF-8 by use of replacement characters. With that one exception,
601 // the concatenation of the Output fields of all output events is the exact output
602 // of the test execution.
603 // BuildEvent:
604 // The Output field is set for Action == "build-output" and is a portion of
605 // the build's output. The concatenation of the Output fields of all output
606 // events is the exact output of the build. A single event may contain one
607 // or more lines of output and there may be more than one output event for
608 // a given ImportPath. This matches the definition of the TestEvent.Output
609 // field produced by go test -json.
610 Output string `json:"Output"`
611 // TestEvent only:
612 // The FailedBuild field is set for Action == "fail" if the test failure was caused
613 // by a build failure. It contains the package ID of the package that failed to
614 // build. This matches the ImportPath field of the "go list" output, as well as the
615 // BuildEvent.ImportPath field as emitted by "go build -json".
616 FailedBuild string `json:"FailedBuild"`
617}
618
619// parseTestResults converts test output in JSONL format into a slice of testJSON objects
620func parseTestResults(testOutput []byte) ([]testJSON, error) {
621 var results []testJSON
622 dec := json.NewDecoder(bytes.NewReader(testOutput))
623 for {
624 var event testJSON
625 if err := dec.Decode(&event); err != nil {
626 if err == io.EOF {
627 break
628 }
629 return nil, err
630 }
631 results = append(results, event)
632 }
633 return results, nil
634}
635
636// testStatus represents the status of a test in a given commit
637type testStatus int
638
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000639//go:generate go tool stringer -type=testStatus -trimprefix=testStatus
Earl Lee2e463fb2025-04-17 11:22:22 -0700640const (
641 testStatusUnknown testStatus = iota
642 testStatusPass
643 testStatusFail
644 testStatusBuildFail
645 testStatusSkip
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000646 testStatusNoTests // no tests exist for this package
Earl Lee2e463fb2025-04-17 11:22:22 -0700647)
648
Earl Lee2e463fb2025-04-17 11:22:22 -0700649// testRegression represents a test that regressed between commits
650type testRegression struct {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000651 Package string
652 Test string // empty for package tests
Earl Lee2e463fb2025-04-17 11:22:22 -0700653 BeforeStatus testStatus
654 AfterStatus testStatus
655 Output string // failure output in the after state
656}
657
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000658func (r *testRegression) Source() string {
659 if r.Test == "" {
660 return r.Package
Earl Lee2e463fb2025-04-17 11:22:22 -0700661 }
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000662 return fmt.Sprintf("%s.%s", r.Package, r.Test)
663}
Earl Lee2e463fb2025-04-17 11:22:22 -0700664
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000665type packageResult struct {
666 Status testStatus // overall status for the package
667 TestStatus map[string]testStatus // name -> status
668 TestOutput map[string][]string // name -> output parts
669}
Earl Lee2e463fb2025-04-17 11:22:22 -0700670
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000671// collectTestStatuses processes a slice of test events and returns rich status information
672func collectTestStatuses(results []testJSON) map[string]*packageResult {
673 m := make(map[string]*packageResult)
674
675 for _, event := range results {
676 pkg := event.Package
677 p, ok := m[pkg]
678 if !ok {
679 p = new(packageResult)
680 p.TestStatus = make(map[string]testStatus)
681 p.TestOutput = make(map[string][]string)
682 m[pkg] = p
Earl Lee2e463fb2025-04-17 11:22:22 -0700683 }
684
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000685 switch event.Action {
686 case "output":
687 p.TestOutput[event.Test] = append(p.TestOutput[event.Test], event.Output)
Earl Lee2e463fb2025-04-17 11:22:22 -0700688 continue
Earl Lee2e463fb2025-04-17 11:22:22 -0700689 case "pass":
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000690 if event.Test == "" {
691 p.Status = testStatusPass
692 } else {
693 p.TestStatus[event.Test] = testStatusPass
694 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700695 case "fail":
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000696 if event.Test == "" {
697 if event.FailedBuild != "" {
698 p.Status = testStatusBuildFail
699 } else {
700 p.Status = testStatusFail
701 }
702 } else {
703 p.TestStatus[event.Test] = testStatusFail
704 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700705 case "skip":
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000706 if event.Test == "" {
707 p.Status = testStatusNoTests
708 } else {
709 p.TestStatus[event.Test] = testStatusSkip
710 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700711 }
712 }
713
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000714 return m
Earl Lee2e463fb2025-04-17 11:22:22 -0700715}
716
717// compareTestResults identifies tests that have regressed between commits
718func (r *CodeReviewer) compareTestResults(beforeResults, afterResults []testJSON) ([]testRegression, error) {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000719 before := collectTestStatuses(beforeResults)
720 after := collectTestStatuses(afterResults)
721 var testLevelRegressions []testRegression
722 var packageLevelRegressions []testRegression
Earl Lee2e463fb2025-04-17 11:22:22 -0700723
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000724 afterPkgs := slices.Sorted(maps.Keys(after))
725 for _, pkg := range afterPkgs {
726 afterResult := after[pkg]
727 afterStatus := afterResult.Status
728 // Can't short-circuit here when tests are passing, because we need to check for skipped tests.
729 beforeResult, ok := before[pkg]
730 beforeStatus := testStatusNoTests
731 if ok {
732 beforeStatus = beforeResult.Status
733 }
734 // If things no longer build, stop at the package level.
735 // Otherwise, proceed to the test level, so we have more precise information.
736 if afterStatus == testStatusBuildFail && beforeStatus != testStatusBuildFail {
737 packageLevelRegressions = append(packageLevelRegressions, testRegression{
738 Package: pkg,
739 BeforeStatus: beforeStatus,
740 AfterStatus: afterStatus,
741 })
742 continue
743 }
744 tests := slices.Sorted(maps.Keys(afterResult.TestStatus))
745 for _, test := range tests {
746 afterStatus := afterResult.TestStatus[test]
747 switch afterStatus {
748 case testStatusPass:
749 continue
750 case testStatusUnknown:
751 slog.WarnContext(context.Background(), "unknown test status", "package", pkg, "test", test)
752 continue
753 }
754 beforeStatus := beforeResult.TestStatus[test]
755 if isRegression(beforeStatus, afterStatus) {
756 testLevelRegressions = append(testLevelRegressions, testRegression{
757 Package: pkg,
758 Test: test,
759 BeforeStatus: beforeStatus,
760 AfterStatus: afterStatus,
761 Output: strings.Join(afterResult.TestOutput[test], ""),
762 })
763 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700764 }
765 }
766
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000767 // If we have test-level regressions, report only those
768 // Otherwise, report package-level regressions
Earl Lee2e463fb2025-04-17 11:22:22 -0700769 var regressions []testRegression
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000770 if len(testLevelRegressions) > 0 {
771 regressions = testLevelRegressions
772 } else {
773 regressions = packageLevelRegressions
Earl Lee2e463fb2025-04-17 11:22:22 -0700774 }
775
776 // Sort regressions for consistent output
777 slices.SortFunc(regressions, func(a, b testRegression) int {
778 // First by package
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000779 if c := strings.Compare(a.Package, b.Package); c != 0 {
Earl Lee2e463fb2025-04-17 11:22:22 -0700780 return c
781 }
782 // Then by test name
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000783 return strings.Compare(a.Test, b.Test)
Earl Lee2e463fb2025-04-17 11:22:22 -0700784 })
785
786 return regressions, nil
787}
788
789// badnessLevels maps test status to a badness level
790// Higher values indicate worse status (more severe issues)
791var badnessLevels = map[testStatus]int{
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000792 testStatusBuildFail: 5, // Worst
793 testStatusFail: 4,
794 testStatusSkip: 3,
795 testStatusNoTests: 2,
Earl Lee2e463fb2025-04-17 11:22:22 -0700796 testStatusPass: 1,
797 testStatusUnknown: 0, // Least bad - avoids false positives
798}
799
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000800// regressionFormatter defines a mapping of before/after state pairs to descriptive messages
801type regressionKey struct {
802 before, after testStatus
803}
804
805var regressionMessages = map[regressionKey]string{
806 {testStatusUnknown, testStatusBuildFail}: "New test has build/vet errors",
807 {testStatusUnknown, testStatusFail}: "New test is failing",
808 {testStatusUnknown, testStatusSkip}: "New test is skipped",
809 {testStatusPass, testStatusBuildFail}: "Was passing, now has build/vet errors",
810 {testStatusPass, testStatusFail}: "Was passing, now failing",
811 {testStatusPass, testStatusSkip}: "Was passing, now skipped",
812 {testStatusNoTests, testStatusBuildFail}: "Previously had no tests, now has build/vet errors",
813 {testStatusNoTests, testStatusFail}: "Previously had no tests, now has failing tests",
814 {testStatusNoTests, testStatusSkip}: "Previously had no tests, now has skipped tests",
815 {testStatusSkip, testStatusBuildFail}: "Was skipped, now has build/vet errors",
816 {testStatusSkip, testStatusFail}: "Was skipped, now failing",
817 {testStatusFail, testStatusBuildFail}: "Was failing, now has build/vet errors",
818}
819
Earl Lee2e463fb2025-04-17 11:22:22 -0700820// isRegression determines if a test has regressed based on before and after status
821// A regression is defined as an increase in badness level
822func isRegression(before, after testStatus) bool {
823 // Higher badness level means worse status
824 return badnessLevels[after] > badnessLevels[before]
825}
826
827// formatTestRegressions generates a human-readable summary of test regressions
828func (r *CodeReviewer) formatTestRegressions(regressions []testRegression) string {
829 if len(regressions) == 0 {
830 return ""
831 }
832
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000833 buf := new(strings.Builder)
834 fmt.Fprintf(buf, "Test regressions detected between initial commit (%s) and HEAD:\n\n", r.initialCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -0700835
836 for i, reg := range regressions {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000837 fmt.Fprintf(buf, "%d: %v: ", i+1, reg.Source())
838 key := regressionKey{reg.BeforeStatus, reg.AfterStatus}
839 message, exists := regressionMessages[key]
840 if !exists {
841 message = "Regression detected"
Earl Lee2e463fb2025-04-17 11:22:22 -0700842 }
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000843 fmt.Fprintf(buf, "%s\n", message)
Earl Lee2e463fb2025-04-17 11:22:22 -0700844 }
845
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000846 return buf.String()
Earl Lee2e463fb2025-04-17 11:22:22 -0700847}