blob: 14dce0442c6ea715e8c67ca73f59f3b80c210ffa [file] [log] [blame]
package claudetool
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"maps"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"golang.org/x/tools/go/packages"
"sketch.dev/ant"
)
// This file does differential quality analysis of a commit relative to a base commit.
// Tool returns a tool spec for a CodeReview tool backed by r.
func (r *CodeReviewer) Tool() *ant.Tool {
spec := &ant.Tool{
Name: "codereview",
Description: `Run an automated code review.`,
// If you modify this, update the termui template for prettier rendering.
InputSchema: ant.MustSchema(`{"type": "object"}`),
Run: r.Run,
}
return spec
}
func (r *CodeReviewer) Run(ctx context.Context, m json.RawMessage) (string, error) {
if err := r.RequireNormalGitState(ctx); err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to check for normal git state", "err", err)
return "", err
}
if err := r.RequireNoUncommittedChanges(ctx); err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to check for uncommitted changes", "err", err)
return "", err
}
// Check that the current commit is not the initial commit
currentCommit, err := r.CurrentCommit(ctx)
if err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to get current commit", "err", err)
return "", err
}
if r.IsInitialCommit(currentCommit) {
slog.DebugContext(ctx, "CodeReviewer.Run: current commit is initial commit, nothing to review")
return "", fmt.Errorf("no new commits have been added, nothing to review")
}
// No matter what failures happen from here out, we will declare this to have been reviewed.
// This should help avoid the model getting blocked by a broken code review tool.
r.reviewed = append(r.reviewed, currentCommit)
changedFiles, err := r.changedFiles(ctx, r.initialCommit, currentCommit)
if err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to get changed files", "err", err)
return "", err
}
// Prepare to analyze before/after for the impacted files.
// We use the current commit to determine what packages exist and are impacted.
// The packages in the initial commit may be different.
// Good enough for now.
// TODO: do better
directPkgs, allPkgs, err := r.packagesForFiles(ctx, changedFiles)
if err != nil {
// TODO: log and skip to stuff that doesn't require packages
slog.DebugContext(ctx, "CodeReviewer.Run: failed to get packages for files", "err", err)
return "", err
}
allPkgList := slices.Collect(maps.Keys(allPkgs))
directPkgList := slices.Collect(maps.Keys(directPkgs))
var msgs []string
testMsg, err := r.checkTests(ctx, allPkgList)
if err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to check tests", "err", err)
return "", err
}
if testMsg != "" {
msgs = append(msgs, testMsg)
}
vetMsg, err := r.checkVet(ctx, directPkgList)
if err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to check vet", "err", err)
return "", err
}
if vetMsg != "" {
msgs = append(msgs, vetMsg)
}
goplsMsg, err := r.checkGopls(ctx, changedFiles)
if err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to check gopls", "err", err)
return "", err
}
if goplsMsg != "" {
msgs = append(msgs, goplsMsg)
}
if len(msgs) == 0 {
slog.DebugContext(ctx, "CodeReviewer.Run: no issues found")
return "OK", nil
}
slog.DebugContext(ctx, "CodeReviewer.Run: found issues", "issues", msgs)
return strings.Join(msgs, "\n\n"), nil
}
func (r *CodeReviewer) initializeInitialCommitWorktree(ctx context.Context) error {
if r.initialWorktree != "" {
return nil
}
tmpDir, err := os.MkdirTemp("", "sketch-codereview-worktree")
if err != nil {
return err
}
worktreeCmd := exec.CommandContext(ctx, "git", "worktree", "add", "--detach", tmpDir, r.initialCommit)
worktreeCmd.Dir = r.repoRoot
out, err := worktreeCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("unable to create worktree for initial commit: %w\n%s", err, out)
}
r.initialWorktree = tmpDir
return nil
}
func (r *CodeReviewer) checkTests(ctx context.Context, pkgList []string) (string, error) {
goTestArgs := []string{"test", "-json", "-v"}
goTestArgs = append(goTestArgs, pkgList...)
afterTestCmd := exec.CommandContext(ctx, "go", goTestArgs...)
afterTestCmd.Dir = r.repoRoot
afterTestOut, afterTestErr := afterTestCmd.Output()
if afterTestErr == nil {
return "", nil // all tests pass, we're good!
}
err := r.initializeInitialCommitWorktree(ctx)
if err != nil {
return "", err
}
beforeTestCmd := exec.CommandContext(ctx, "go", goTestArgs...)
beforeTestCmd.Dir = r.initialWorktree
beforeTestOut, _ := beforeTestCmd.Output() // ignore error, interesting info is in the output
// Parse the jsonl test results
beforeResults, beforeParseErr := parseTestResults(beforeTestOut)
if beforeParseErr != nil {
return "", fmt.Errorf("unable to parse test results for initial commit: %w\n%s", beforeParseErr, beforeTestOut)
}
afterResults, afterParseErr := parseTestResults(afterTestOut)
if afterParseErr != nil {
return "", fmt.Errorf("unable to parse test results for current commit: %w\n%s", afterParseErr, afterTestOut)
}
testRegressions, err := r.compareTestResults(beforeResults, afterResults)
if err != nil {
return "", fmt.Errorf("failed to compare test results: %w", err)
}
// TODO: better output formatting?
res := r.formatTestRegressions(testRegressions)
return res, nil
}
// VetIssue represents a single issue found by go vet
type VetIssue struct {
Position string `json:"posn"`
Message string `json:"message"`
// Ignoring suggested_fixes for now as we don't need them for comparison
}
// VetResult represents the JSON output of go vet -json for a single package
type VetResult map[string][]VetIssue // category -> issues
// VetResults represents the full JSON output of go vet -json
type VetResults map[string]VetResult // package path -> result
// checkVet runs go vet on the provided packages in both the current and initial state,
// compares the results, and reports any new vet issues introduced in the current state.
func (r *CodeReviewer) checkVet(ctx context.Context, pkgList []string) (string, error) {
if len(pkgList) == 0 {
return "", nil // no packages to check
}
// Run vet on the current state with JSON output
goVetArgs := []string{"vet", "-json"}
goVetArgs = append(goVetArgs, pkgList...)
afterVetCmd := exec.CommandContext(ctx, "go", goVetArgs...)
afterVetCmd.Dir = r.repoRoot
afterVetOut, afterVetErr := afterVetCmd.CombinedOutput() // ignore error, we'll parse the output regar
if afterVetErr != nil {
slog.WarnContext(ctx, "CodeReviewer.checkVet: (after) go vet failed", "err", afterVetErr, "output", string(afterVetOut))
return "", nil // nothing more we can do here
}
// Parse the JSON output (even if vet returned an error, as it does when issues are found)
afterVetResults, err := parseVetJSON(afterVetOut)
if err != nil {
return "", fmt.Errorf("failed to parse vet output for current state: %w", err)
}
// If no issues were found, we're done
if len(afterVetResults) == 0 || !vetResultsHaveIssues(afterVetResults) {
return "", nil
}
// Vet detected issues in the current state, check if they existed in the initial state
err = r.initializeInitialCommitWorktree(ctx)
if err != nil {
return "", err
}
beforeVetCmd := exec.CommandContext(ctx, "go", goVetArgs...)
beforeVetCmd.Dir = r.initialWorktree
beforeVetOut, _ := beforeVetCmd.CombinedOutput() // ignore error, we'll parse the output anyway
// Parse the JSON output for the initial state
beforeVetResults, err := parseVetJSON(beforeVetOut)
if err != nil {
return "", fmt.Errorf("failed to parse vet output for initial state: %w", err)
}
// Find new issues that weren't present in the initial state
vetRegressions := findVetRegressions(beforeVetResults, afterVetResults)
if !vetResultsHaveIssues(vetRegressions) {
return "", nil // no new issues
}
// Format the results
return formatVetRegressions(vetRegressions), nil
}
// parseVetJSON parses the JSON output from go vet -json
func parseVetJSON(output []byte) (VetResults, error) {
// The output contains multiple JSON objects, one per package
// We need to parse them separately
results := make(VetResults)
// Process the output by collecting JSON chunks between # comment lines
lines := strings.Split(string(output), "\n")
currentChunk := strings.Builder{}
// Helper function to process accumulated JSON chunks
processChunk := func() {
chunk := strings.TrimSpace(currentChunk.String())
if chunk == "" || !strings.HasPrefix(chunk, "{") {
return // Skip empty chunks or non-JSON chunks
}
// Try to parse the chunk as JSON
var result VetResults
if err := json.Unmarshal([]byte(chunk), &result); err != nil {
return // Skip invalid JSON
}
// Merge with our results
for pkg, issues := range result {
results[pkg] = issues
}
// Reset the chunk builder
currentChunk.Reset()
}
// Process lines
for _, line := range lines {
// If we hit a comment line, process the previous chunk and start a new one
if strings.HasPrefix(strings.TrimSpace(line), "#") {
processChunk()
continue
}
// Add the line to the current chunk
currentChunk.WriteString(line)
currentChunk.WriteString("\n")
}
// Process the final chunk
processChunk()
return results, nil
}
// vetResultsHaveIssues checks if there are any actual issues in the vet results
func vetResultsHaveIssues(results VetResults) bool {
for _, pkgResult := range results {
for _, issues := range pkgResult {
if len(issues) > 0 {
return true
}
}
}
return false
}
// findVetRegressions identifies vet issues that are new in the after state
func findVetRegressions(before, after VetResults) VetResults {
regressions := make(VetResults)
// Go through all packages in the after state
for pkgPath, afterPkgResults := range after {
beforePkgResults, pkgExistedBefore := before[pkgPath]
// Initialize package in regressions if it has issues
if !pkgExistedBefore {
// If the package didn't exist before, all issues are new
regressions[pkgPath] = afterPkgResults
continue
}
// Compare issues by category
for category, afterIssues := range afterPkgResults {
beforeIssues, categoryExistedBefore := beforePkgResults[category]
if !categoryExistedBefore {
// If this category didn't exist before, all issues are new
if regressions[pkgPath] == nil {
regressions[pkgPath] = make(VetResult)
}
regressions[pkgPath][category] = afterIssues
continue
}
// Compare individual issues
var newIssues []VetIssue
for _, afterIssue := range afterIssues {
if !issueExistsIn(afterIssue, beforeIssues) {
newIssues = append(newIssues, afterIssue)
}
}
// Add new issues to regressions
if len(newIssues) > 0 {
if regressions[pkgPath] == nil {
regressions[pkgPath] = make(VetResult)
}
regressions[pkgPath][category] = newIssues
}
}
}
return regressions
}
// issueExistsIn checks if an issue already exists in a list of issues
// using a looser comparison that's resilient to position changes
func issueExistsIn(issue VetIssue, issues []VetIssue) bool {
issueFile := extractFilePath(issue.Position)
for _, existing := range issues {
// Main comparison is by message content, which is likely stable
if issue.Message == existing.Message {
// If messages match exactly, consider it the same issue even if position changed
return true
}
// As a secondary check, if the issue is in the same file and has similar message,
// it's likely the same issue that might have been slightly reworded or relocated
existingFile := extractFilePath(existing.Position)
if issueFile == existingFile && messagesSimilar(issue.Message, existing.Message) {
return true
}
}
return false
}
// extractFilePath gets just the file path from a position string like "/path/to/file.go:10:15"
func extractFilePath(position string) string {
parts := strings.Split(position, ":")
if len(parts) >= 1 {
return parts[0]
}
return position // fallback to the full position if we can't parse it
}
// messagesSimilar checks if two messages are similar enough to be considered the same issue
// This is a simple implementation that could be enhanced with more sophisticated text comparison
func messagesSimilar(msg1, msg2 string) bool {
// For now, simple similarity check: if one is a substring of the other
return strings.Contains(msg1, msg2) || strings.Contains(msg2, msg1)
}
// formatVetRegressions generates a human-readable summary of vet regressions
func formatVetRegressions(regressions VetResults) string {
if !vetResultsHaveIssues(regressions) {
return ""
}
var sb strings.Builder
sb.WriteString("Go vet issues detected:\n\n")
// Get sorted list of packages for deterministic output
pkgPaths := make([]string, 0, len(regressions))
for pkgPath := range regressions {
pkgPaths = append(pkgPaths, pkgPath)
}
slices.Sort(pkgPaths)
issueCount := 1
for _, pkgPath := range pkgPaths {
pkgResult := regressions[pkgPath]
// Get sorted list of categories
categories := make([]string, 0, len(pkgResult))
for category := range pkgResult {
categories = append(categories, category)
}
slices.Sort(categories)
for _, category := range categories {
issues := pkgResult[category]
// Skip empty issue lists (shouldn't happen, but just in case)
if len(issues) == 0 {
continue
}
// Sort issues by position for deterministic output
slices.SortFunc(issues, func(a, b VetIssue) int {
return strings.Compare(a.Position, b.Position)
})
// Format each issue
for _, issue := range issues {
sb.WriteString(fmt.Sprintf("%d. [%s] %s: %s\n",
issueCount,
category,
issue.Position,
issue.Message))
issueCount++
}
}
}
sb.WriteString("\nPlease fix these issues before proceeding.")
return sb.String()
}
// GoplsIssue represents a single issue reported by gopls check
type GoplsIssue struct {
Position string // File position in format "file:line:col-range"
Message string // Description of the issue
}
// checkGopls runs gopls check on the provided files in both the current and initial state,
// compares the results, and reports any new issues introduced in the current state.
func (r *CodeReviewer) checkGopls(ctx context.Context, changedFiles []string) (string, error) {
if len(changedFiles) == 0 {
return "", nil // no files to check
}
// Filter out non-Go files as gopls only works on Go files
// and verify they still exist (not deleted)
var goFiles []string
for _, file := range changedFiles {
if !strings.HasSuffix(file, ".go") {
continue // not a Go file
}
// Check if the file still exists (not deleted)
if _, err := os.Stat(file); os.IsNotExist(err) {
continue // file doesn't exist anymore (deleted)
}
goFiles = append(goFiles, file)
}
if len(goFiles) == 0 {
return "", nil // no Go files to check
}
// Run gopls check on the current state
goplsArgs := append([]string{"check"}, goFiles...)
afterGoplsCmd := exec.CommandContext(ctx, "gopls", goplsArgs...)
afterGoplsCmd.Dir = r.repoRoot
afterGoplsOut, err := afterGoplsCmd.CombinedOutput() // gopls returns non-zero if it finds issues
if err != nil {
// Check if the output looks like real gopls issues or if it's just error output
if !looksLikeGoplsIssues(afterGoplsOut) {
slog.WarnContext(ctx, "CodeReviewer.checkGopls: gopls check failed to run properly", "err", err, "output", string(afterGoplsOut))
return "", nil // Skip rather than failing the entire code review
}
// Otherwise, proceed with parsing - it's likely just the non-zero exit code due to found issues
}
// Parse the output
afterIssues := parseGoplsOutput(afterGoplsOut)
// If no issues were found, we're done
if len(afterIssues) == 0 {
return "", nil
}
// Gopls detected issues in the current state, check if they existed in the initial state
initErr := r.initializeInitialCommitWorktree(ctx)
if initErr != nil {
return "", err
}
// For each file that exists in the initial commit, run gopls check
var initialFilesToCheck []string
for _, file := range goFiles {
// Get relative path for git operations
relFile, err := filepath.Rel(r.repoRoot, file)
if err != nil {
slog.WarnContext(ctx, "CodeReviewer.checkGopls: failed to get relative path", "repo_root", r.repoRoot, "file", file, "err", err)
continue
}
// Check if the file exists in the initial commit
checkCmd := exec.CommandContext(ctx, "git", "cat-file", "-e", fmt.Sprintf("%s:%s", r.initialCommit, relFile))
checkCmd.Dir = r.repoRoot
if err := checkCmd.Run(); err == nil {
// File exists in initial commit
initialFilePath := filepath.Join(r.initialWorktree, relFile)
initialFilesToCheck = append(initialFilesToCheck, initialFilePath)
}
}
// Run gopls check on the files that existed in the initial commit
beforeIssues := []GoplsIssue{}
if len(initialFilesToCheck) > 0 {
beforeGoplsArgs := append([]string{"check"}, initialFilesToCheck...)
beforeGoplsCmd := exec.CommandContext(ctx, "gopls", beforeGoplsArgs...)
beforeGoplsCmd.Dir = r.initialWorktree
var beforeGoplsOut []byte
var beforeCmdErr error
beforeGoplsOut, beforeCmdErr = beforeGoplsCmd.CombinedOutput()
if beforeCmdErr != nil && !looksLikeGoplsIssues(beforeGoplsOut) {
// If gopls fails to run properly on the initial commit, log a warning and continue
// with empty before issues - this will be conservative and report more issues
slog.WarnContext(ctx, "CodeReviewer.checkGopls: gopls check failed on initial commit",
"err", err, "output", string(beforeGoplsOut))
// Continue with empty beforeIssues
} else {
beforeIssues = parseGoplsOutput(beforeGoplsOut)
}
}
// Find new issues that weren't present in the initial state
goplsRegressions := findGoplsRegressions(beforeIssues, afterIssues)
if len(goplsRegressions) == 0 {
return "", nil // no new issues
}
// Format the results
return formatGoplsRegressions(goplsRegressions), nil
}
// parseGoplsOutput parses the text output from gopls check
// Each line has the format: '/path/to/file.go:448:22-26: unused parameter: path'
func parseGoplsOutput(output []byte) []GoplsIssue {
var issues []GoplsIssue
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Skip lines that look like error messages rather than gopls issues
if strings.HasPrefix(line, "Error:") ||
strings.HasPrefix(line, "Failed:") ||
strings.HasPrefix(line, "Warning:") ||
strings.HasPrefix(line, "gopls:") {
continue
}
// Find the first colon that separates the file path from the line number
firstColonIdx := strings.Index(line, ":")
if firstColonIdx < 0 {
continue // Invalid format
}
// Verify the part before the first colon looks like a file path
potentialPath := line[:firstColonIdx]
if !strings.HasSuffix(potentialPath, ".go") {
continue // Not a Go file path
}
// Find the position of the first message separator ': '
// This separates the position info from the message
messageStart := strings.Index(line, ": ")
if messageStart < 0 || messageStart <= firstColonIdx {
continue // Invalid format
}
// Extract position and message
position := line[:messageStart]
message := line[messageStart+2:] // Skip the ': ' separator
// Verify position has the expected format (at least 2 colons for line:col)
colonCount := strings.Count(position, ":")
if colonCount < 2 {
continue // Not enough position information
}
issues = append(issues, GoplsIssue{
Position: position,
Message: message,
})
}
return issues
}
// looksLikeGoplsIssues checks if the output appears to be actual gopls issues
// rather than error messages about gopls itself failing
func looksLikeGoplsIssues(output []byte) bool {
// If output is empty, it's not valid issues
if len(output) == 0 {
return false
}
// Check if output has at least one line that looks like a gopls issue
// A gopls issue looks like: '/path/to/file.go:123:45-67: message'
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// A gopls issue has at least two colons (file path, line number, column)
// and contains a colon followed by a space (separating position from message)
colonCount := strings.Count(line, ":")
hasSeparator := strings.Contains(line, ": ")
if colonCount >= 2 && hasSeparator {
// Check if it starts with a likely file path (ending in .go)
parts := strings.SplitN(line, ":", 2)
if strings.HasSuffix(parts[0], ".go") {
return true
}
}
}
return false
}
// normalizeGoplsPosition extracts just the file path from a position string
func normalizeGoplsPosition(position string) string {
// Extract just the file path by taking everything before the first colon
parts := strings.Split(position, ":")
if len(parts) < 1 {
return position
}
return parts[0]
}
// findGoplsRegressions identifies gopls issues that are new in the after state
func findGoplsRegressions(before, after []GoplsIssue) []GoplsIssue {
var regressions []GoplsIssue
// Build map of before issues for easier lookup
beforeIssueMap := make(map[string]map[string]bool) // file -> message -> exists
for _, issue := range before {
file := normalizeGoplsPosition(issue.Position)
if _, exists := beforeIssueMap[file]; !exists {
beforeIssueMap[file] = make(map[string]bool)
}
// Store both the exact message and the general issue type for fuzzy matching
beforeIssueMap[file][issue.Message] = true
// Extract the general issue type (everything before the first ':' in the message)
generalIssue := issue.Message
if colonIdx := strings.Index(issue.Message, ":"); colonIdx > 0 {
generalIssue = issue.Message[:colonIdx]
}
beforeIssueMap[file][generalIssue] = true
}
// Check each after issue to see if it's new
for _, afterIssue := range after {
file := normalizeGoplsPosition(afterIssue.Position)
isNew := true
if fileIssues, fileExists := beforeIssueMap[file]; fileExists {
// Check for exact message match
if fileIssues[afterIssue.Message] {
isNew = false
} else {
// Check for general issue type match
generalIssue := afterIssue.Message
if colonIdx := strings.Index(afterIssue.Message, ":"); colonIdx > 0 {
generalIssue = afterIssue.Message[:colonIdx]
}
if fileIssues[generalIssue] {
isNew = false
}
}
}
if isNew {
regressions = append(regressions, afterIssue)
}
}
// Sort regressions for deterministic output
slices.SortFunc(regressions, func(a, b GoplsIssue) int {
return strings.Compare(a.Position, b.Position)
})
return regressions
}
// formatGoplsRegressions generates a human-readable summary of gopls check regressions
func formatGoplsRegressions(regressions []GoplsIssue) string {
if len(regressions) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("Gopls check issues detected:\n\n")
// Format each issue
for i, issue := range regressions {
sb.WriteString(fmt.Sprintf("%d. %s: %s\n", i+1, issue.Position, issue.Message))
}
sb.WriteString("\nIMPORTANT: Only fix new gopls check issues in parts of the code that you have already edited. ")
sb.WriteString("Do not change existing code that was not part of your current edits.")
return sb.String()
}
func (r *CodeReviewer) HasReviewed(commit string) bool {
return slices.Contains(r.reviewed, commit)
}
func (r *CodeReviewer) IsInitialCommit(commit string) bool {
return commit == r.initialCommit
}
// packagesForFiles returns maps of packages related to the given files:
// 1. directPkgs: packages that directly contain the changed files
// 2. allPkgs: all packages that might be affected, including downstream packages that depend on the direct packages
// It may include false positives.
// Files must be absolute paths!
func (r *CodeReviewer) packagesForFiles(ctx context.Context, files []string) (directPkgs, allPkgs map[string]*packages.Package, err error) {
for _, f := range files {
if !filepath.IsAbs(f) {
return nil, nil, fmt.Errorf("path %q is not absolute", f)
}
}
cfg := &packages.Config{
Mode: packages.LoadImports | packages.NeedEmbedFiles,
Context: ctx,
// Logf: func(msg string, args ...any) {
// slog.DebugContext(ctx, "loading go packages", "msg", fmt.Sprintf(msg, args...))
// },
// TODO: in theory, go.mod might not be in the repo root, and there might be multiple go.mod files.
// We can cross that bridge when we get there.
Dir: r.repoRoot,
Tests: true,
}
universe, err := packages.Load(cfg, "./...")
if err != nil {
return nil, nil, err
}
// Identify packages that directly contain the changed files
directPkgs = make(map[string]*packages.Package) // import path -> package
for _, pkg := range universe {
// fmt.Println("pkg:", pkg.PkgPath)
pkgFiles := allFiles(pkg)
// fmt.Println("pkgFiles:", pkgFiles)
for _, file := range files {
if pkgFiles[file] {
// prefer test packages, as they contain strictly more files (right?)
prev := directPkgs[pkg.PkgPath]
if prev == nil || prev.ForTest == "" {
directPkgs[pkg.PkgPath] = pkg
}
}
}
}
// Create a copy of directPkgs to expand with dependencies
allPkgs = make(map[string]*packages.Package)
for k, v := range directPkgs {
allPkgs[k] = v
}
// Add packages that depend on the direct packages
addDependentPackages(universe, allPkgs)
return directPkgs, allPkgs, nil
}
// allFiles returns all files that might be referenced by the package.
// It may contain false positives.
func allFiles(p *packages.Package) map[string]bool {
files := make(map[string]bool)
add := [][]string{p.GoFiles, p.CompiledGoFiles, p.OtherFiles, p.EmbedFiles, p.IgnoredFiles}
for _, extra := range add {
for _, file := range extra {
files[file] = true
}
}
return files
}
// addDependentPackages adds to pkgs all packages from universe
// that directly or indirectly depend on any package already in pkgs.
func addDependentPackages(universe []*packages.Package, pkgs map[string]*packages.Package) {
for {
changed := false
for _, p := range universe {
if _, ok := pkgs[p.PkgPath]; ok {
// already in pkgs
continue
}
for importPath := range p.Imports {
if _, ok := pkgs[importPath]; ok {
// imports a package dependent on pkgs, add it
pkgs[p.PkgPath] = p
changed = true
break
}
}
}
if !changed {
break
}
}
}
// testJSON is a union of BuildEvent and TestEvent
type testJSON struct {
// TestEvent only:
// The Time field holds the time the event happened. It is conventionally omitted
// for cached test results.
Time time.Time `json:"Time"`
// BuildEvent only:
// The ImportPath field gives the package ID of the package being built.
// This matches the Package.ImportPath field of go list -json and the
// TestEvent.FailedBuild field of go test -json. Note that it does not
// match TestEvent.Package.
ImportPath string `json:"ImportPath"` // BuildEvent only
// TestEvent only:
// The Package field, if present, specifies the package being tested. When the
// go command runs parallel tests in -json mode, events from different tests are
// interlaced; the Package field allows readers to separate them.
Package string `json:"Package"`
// Action is used in both BuildEvent and TestEvent.
// It is the key to distinguishing between them.
// BuildEvent:
// build-output or build-fail
// TestEvent:
// start, run, pause, cont, pass, bench, fail, output, skip
Action string `json:"Action"`
// TestEvent only:
// The Test field, if present, specifies the test, example, or benchmark function
// that caused the event. Events for the overall package test do not set Test.
Test string `json:"Test"`
// TestEvent only:
// The Elapsed field is set for "pass" and "fail" events. It gives the time elapsed in seconds
// for the specific test or the overall package test that passed or failed.
Elapsed float64
// TestEvent:
// The Output field is set for Action == "output" and is a portion of the
// test's output (standard output and standard error merged together). The
// output is unmodified except that invalid UTF-8 output from a test is coerced
// into valid UTF-8 by use of replacement characters. With that one exception,
// the concatenation of the Output fields of all output events is the exact output
// of the test execution.
// BuildEvent:
// The Output field is set for Action == "build-output" and is a portion of
// the build's output. The concatenation of the Output fields of all output
// events is the exact output of the build. A single event may contain one
// or more lines of output and there may be more than one output event for
// a given ImportPath. This matches the definition of the TestEvent.Output
// field produced by go test -json.
Output string `json:"Output"`
// TestEvent only:
// The FailedBuild field is set for Action == "fail" if the test failure was caused
// by a build failure. It contains the package ID of the package that failed to
// build. This matches the ImportPath field of the "go list" output, as well as the
// BuildEvent.ImportPath field as emitted by "go build -json".
FailedBuild string `json:"FailedBuild"`
}
// parseTestResults converts test output in JSONL format into a slice of testJSON objects
func parseTestResults(testOutput []byte) ([]testJSON, error) {
var results []testJSON
dec := json.NewDecoder(bytes.NewReader(testOutput))
for {
var event testJSON
if err := dec.Decode(&event); err != nil {
if err == io.EOF {
break
}
return nil, err
}
results = append(results, event)
}
return results, nil
}
// testStatus represents the status of a test in a given commit
type testStatus int
const (
testStatusUnknown testStatus = iota
testStatusPass
testStatusFail
testStatusBuildFail
testStatusSkip
)
// testInfo represents information about a specific test
type testInfo struct {
Package string
Test string // empty for package tests
}
// String returns a human-readable string representation of the test
func (t testInfo) String() string {
if t.Test == "" {
return t.Package
}
return fmt.Sprintf("%s.%s", t.Package, t.Test)
}
// testRegression represents a test that regressed between commits
type testRegression struct {
Info testInfo
BeforeStatus testStatus
AfterStatus testStatus
Output string // failure output in the after state
}
// collectTestStatuses processes a slice of test events and returns a map of test statuses
func collectTestStatuses(results []testJSON) map[testInfo]testStatus {
statuses := make(map[testInfo]testStatus)
failedBuilds := make(map[string]bool) // track packages with build failures
testOutputs := make(map[testInfo][]string) // collect output for failing tests
// First pass: identify build failures
for _, result := range results {
if result.Action == "fail" && result.FailedBuild != "" {
failedBuilds[result.FailedBuild] = true
}
}
// Second pass: collect test statuses
for _, result := range results {
info := testInfo{Package: result.Package, Test: result.Test}
// Skip output events for now, we'll process them in a separate pass
if result.Action == "output" {
if result.Test != "" { // only collect output for actual tests, not package messages
testOutputs[info] = append(testOutputs[info], result.Output)
}
continue
}
// Handle BuildEvent output
if result.Action == "build-fail" {
// Mark all tests in this package as build failures
for ti := range statuses {
if ti.Package == result.ImportPath {
statuses[ti] = testStatusBuildFail
}
}
continue
}
// Check if the package has a build failure
if _, hasBuildFailure := failedBuilds[result.Package]; hasBuildFailure {
statuses[info] = testStatusBuildFail
continue
}
// Handle test events
switch result.Action {
case "pass":
statuses[info] = testStatusPass
case "fail":
statuses[info] = testStatusFail
case "skip":
statuses[info] = testStatusSkip
}
}
return statuses
}
// compareTestResults identifies tests that have regressed between commits
func (r *CodeReviewer) compareTestResults(beforeResults, afterResults []testJSON) ([]testRegression, error) {
beforeStatuses := collectTestStatuses(beforeResults)
afterStatuses := collectTestStatuses(afterResults)
// Collect output for failing tests
testOutputMap := make(map[testInfo]string)
for _, result := range afterResults {
if result.Action == "output" {
info := testInfo{Package: result.Package, Test: result.Test}
testOutputMap[info] += result.Output
}
}
var regressions []testRegression
// Look for tests that regressed
for info, afterStatus := range afterStatuses {
// Skip tests that are passing or skipped in the after state
if afterStatus == testStatusPass || afterStatus == testStatusSkip {
continue
}
// Get the before status (default to unknown if not present)
beforeStatus, exists := beforeStatuses[info]
if !exists {
beforeStatus = testStatusUnknown
}
// Log warning if we encounter unexpected unknown status in the 'after' state
if afterStatus == testStatusUnknown {
slog.WarnContext(context.Background(), "Unexpected unknown test status encountered",
"package", info.Package, "test", info.Test)
}
// Check for regressions
if isRegression(beforeStatus, afterStatus) {
regressions = append(regressions, testRegression{
Info: info,
BeforeStatus: beforeStatus,
AfterStatus: afterStatus,
Output: testOutputMap[info],
})
}
}
// Sort regressions for consistent output
slices.SortFunc(regressions, func(a, b testRegression) int {
// First by package
if c := strings.Compare(a.Info.Package, b.Info.Package); c != 0 {
return c
}
// Then by test name
return strings.Compare(a.Info.Test, b.Info.Test)
})
return regressions, nil
}
// badnessLevels maps test status to a badness level
// Higher values indicate worse status (more severe issues)
var badnessLevels = map[testStatus]int{
testStatusBuildFail: 4, // Worst
testStatusFail: 3,
testStatusSkip: 2,
testStatusPass: 1,
testStatusUnknown: 0, // Least bad - avoids false positives
}
// isRegression determines if a test has regressed based on before and after status
// A regression is defined as an increase in badness level
func isRegression(before, after testStatus) bool {
// Higher badness level means worse status
return badnessLevels[after] > badnessLevels[before]
}
// formatTestRegressions generates a human-readable summary of test regressions
func (r *CodeReviewer) formatTestRegressions(regressions []testRegression) string {
if len(regressions) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Test regressions detected between initial commit (%s) and HEAD:\n\n", r.initialCommit))
for i, reg := range regressions {
// Describe the regression
sb.WriteString(fmt.Sprintf("%d. %s: ", i+1, reg.Info.String()))
switch {
case reg.BeforeStatus == testStatusUnknown && reg.AfterStatus == testStatusFail:
sb.WriteString("New test is failing")
case reg.BeforeStatus == testStatusUnknown && reg.AfterStatus == testStatusBuildFail:
sb.WriteString("New test has build errors")
case reg.BeforeStatus == testStatusPass && reg.AfterStatus == testStatusFail:
sb.WriteString("Was passing, now failing")
case reg.BeforeStatus == testStatusPass && reg.AfterStatus == testStatusBuildFail:
sb.WriteString("Was passing, now has build errors")
case reg.BeforeStatus == testStatusSkip && reg.AfterStatus == testStatusFail:
sb.WriteString("Was skipped, now failing")
case reg.BeforeStatus == testStatusSkip && reg.AfterStatus == testStatusBuildFail:
sb.WriteString("Was skipped, now has build errors")
default:
sb.WriteString("Regression detected")
}
sb.WriteString("\n")
// Add failure output with indentation for readability
if reg.Output != "" {
outputLines := strings.Split(strings.TrimSpace(reg.Output), "\n")
// Limit output to first 10 lines to avoid overwhelming feedback
shownLines := min(len(outputLines), 10)
sb.WriteString(" Output:\n")
for _, line := range outputLines[:shownLines] {
sb.WriteString(fmt.Sprintf(" | %s\n", line))
}
if shownLines < len(outputLines) {
sb.WriteString(fmt.Sprintf(" | ... (%d more lines)\n", len(outputLines)-shownLines))
}
}
sb.WriteString("\n")
}
sb.WriteString("Please fix these test failures before proceeding.")
return sb.String()
}