package main import ( "bufio" "bytes" "encoding/json" "errors" "flag" "fmt" "io" "io/fs" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" ) // goListPackageJSON models the subset of fields we need from `go list -json` output. // The JSON can be quite large; we intentionally only decode what we use to keep memory reasonable. type goListPackageJSON struct { ImportPath string `json:"ImportPath"` Deps []string `json:"Deps"` } // runCommand executes a command and returns stdout as bytes with trimmed trailing newline. func runCommand(name string, args ...string) ([]byte, error) { cmd := exec.Command(name, args...) cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { return nil, err } return bytes.TrimRight(out, "\n"), nil } // listPackageWithDeps returns the full transitive dependency set for a package import path, // including the package itself. func listPackageWithDeps(importPath string) (map[string]struct{}, error) { cmd := exec.Command("go", "list", "-json", "-deps", importPath) cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("go list -json -deps %s failed: %w", importPath, err) } deps := make(map[string]struct{}) dec := json.NewDecoder(bytes.NewReader(out)) for { var pkg goListPackageJSON if err := dec.Decode(&pkg); err != nil { if errors.Is(err, io.EOF) { break } return nil, fmt.Errorf("decode go list json: %w", err) } if pkg.ImportPath != "" { deps[pkg.ImportPath] = struct{}{} } for _, d := range pkg.Deps { deps[d] = struct{}{} } } return deps, nil } // changedFiles returns a slice of file paths changed between baseRef and HEAD. func changedFiles(baseRef string) ([]string, error) { // Use triple-dot to include merge base with baseRef, typical for PR diffs. out, err := runCommand("git", "diff", "--name-only", baseRef+"...HEAD") if err != nil { return nil, fmt.Errorf("git diff failed: %w", err) } var files []string scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } files = append(files, line) } if err := scanner.Err(); err != nil { return nil, err } return files, nil } // mapFilesToPackages resolves a set of Go package import paths that directly contain the changed Go files. func mapFilesToPackages(files []string) (map[string]struct{}, error) { packages := make(map[string]struct{}) // Collect unique directories that contain changed Go files. dirSet := make(map[string]struct{}) for _, f := range files { if strings.HasPrefix(f, "vendor/") { continue } if filepath.Ext(f) != ".go" { continue } d := filepath.Dir(f) if d == "." { d = "." } dirSet[d] = struct{}{} } if len(dirSet) == 0 { return packages, nil } // Convert to a stable-ordered slice of directories to avoid nondeterminism. var dirs []string for d := range dirSet { // Ensure relative paths are treated as packages; prepend ./ for clarity. if strings.HasPrefix(d, "./") || d == "." { dirs = append(dirs, d) } else { dirs = append(dirs, "./"+d) } } sort.Strings(dirs) // `go list` accepts directories and returns their package import paths. args := append([]string{"list", "-f", "{{.ImportPath}}"}, dirs...) out, err := runCommand("go", args...) if err != nil { return nil, fmt.Errorf("go list for files failed: %w", err) } scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { pkg := strings.TrimSpace(scanner.Text()) if pkg != "" { packages[pkg] = struct{}{} } } if err := scanner.Err(); err != nil { return nil, err } return packages, nil } // computeAffectedPackages expands directly changed packages to include reverse dependencies across the module. // We query all packages with test dependencies (-test) to ensure test-only imports are considered. func computeAffectedPackages(directPkgs map[string]struct{}) (map[string]struct{}, error) { affected := make(map[string]struct{}) for p := range directPkgs { affected[p] = struct{}{} } if len(directPkgs) == 0 { return affected, nil } // Enumerate all packages in the module with their deps. // We stream decode concatenated JSON objects produced by `go list -json`. cmd := exec.Command("go", "list", "-json", "-deps", "-test", "./...") cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("go list -json failed: %w", err) } dec := json.NewDecoder(bytes.NewReader(out)) for { var pkg goListPackageJSON if err := dec.Decode(&pkg); err != nil { if errors.Is(err, io.EOF) { break } return nil, fmt.Errorf("decode go list json: %w", err) } // If this package is directly changed, it's already included. // If it depends (directly or transitively) on any changed package, include it. for changed := range directPkgs { if pkg.ImportPath == changed { affected[pkg.ImportPath] = struct{}{} break } // Linear scan over deps is acceptable given typical package counts. for _, dep := range pkg.Deps { if dep == changed { affected[pkg.ImportPath] = struct{}{} break } } } } return affected, nil } // listTestFunctions scans a directory for Go test files and returns names of functions // that match the pattern `func TestXxx(t *testing.T)`. func listTestFunctions(dir string) ([]string, error) { var tests []string // Regex to capture test function names. This is a simple heuristic suitable for our codebase. testFuncRe := regexp.MustCompile(`^func\s+(Test[\w\d_]+)\s*\(`) walkFn := func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if !strings.HasSuffix(d.Name(), "_test.go") { return nil } b, err := os.ReadFile(path) if err != nil { return err } scanner := bufio.NewScanner(bytes.NewReader(b)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if m := testFuncRe.FindStringSubmatch(line); m != nil { tests = append(tests, m[1]) } } return scanner.Err() } if err := filepath.WalkDir(dir, walkFn); err != nil { return nil, err } sort.Strings(tests) return tests, nil } func main() { baseRef := flag.String("base", "origin/main", "Git base ref to diff against (e.g., origin/main)") printAllOnChanges := flag.Bool("all-on-mod-change", true, "Run all tests if go.mod or go.sum changed") verbose := flag.Bool("v", false, "Enable verbose diagnostics to stderr") mode := flag.String("mode", "packages", "Output mode: 'packages' to print import paths; 'suites' to print e2e suite names") changedFilesCSV := flag.String("changed-files", "", "Comma-separated paths to treat as changed (bypass git)") changedFilesFile := flag.String("changed-files-file", "", "File with newline-separated paths to treat as changed") flag.Parse() // Determine the set of changed files: explicit list if provided, otherwise via git diff. var files []string if *changedFilesCSV != "" || *changedFilesFile != "" { if *changedFilesCSV != "" { parts := strings.Split(*changedFilesCSV, ",") for _, p := range parts { if s := strings.TrimSpace(p); s != "" { files = append(files, s) } } } if *changedFilesFile != "" { b, err := os.ReadFile(*changedFilesFile) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } scanner := bufio.NewScanner(bytes.NewReader(b)) for scanner.Scan() { if s := strings.TrimSpace(scanner.Text()); s != "" { files = append(files, s) } } if err := scanner.Err(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } } } else { var err error files, err = changedFiles(*baseRef) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } } if *verbose { fmt.Fprintln(os.Stderr, "Changed files vs base:") if len(files) == 0 { fmt.Fprintln(os.Stderr, " (none)") } else { for _, f := range files { fmt.Fprintln(os.Stderr, " ", f) } } } // Track module change and CI configuration changes to drive conservative behavior. moduleChanged := false ciChanged := false if *printAllOnChanges { for _, f := range files { if f == "go.mod" || f == "go.sum" { moduleChanged = true } if strings.HasPrefix(f, "scripts/") || strings.HasPrefix(f, ".github/workflows/") { ciChanged = true } } if (moduleChanged || ciChanged) && *mode == "packages" { if *verbose { if moduleChanged { fmt.Fprintln(os.Stderr, "Detected module file change (go.mod/go.sum); selecting all packages ./...") } if ciChanged { fmt.Fprintln(os.Stderr, "Detected CI/detector change (scripts/ or .github/workflows/); selecting all packages ./...") } } fmt.Println("./...") return } } directPkgs, err := mapFilesToPackages(files) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } if *verbose { // Stable dump of direct packages var dirs []string for p := range directPkgs { dirs = append(dirs, p) } sort.Strings(dirs) fmt.Fprintln(os.Stderr, "Directly changed packages:") if len(dirs) == 0 { fmt.Fprintln(os.Stderr, " (none)") } else { for _, p := range dirs { fmt.Fprintln(os.Stderr, " ", p) } } } switch *mode { case "packages": affected, err := computeAffectedPackages(directPkgs) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } if *verbose { var dbg []string for p := range affected { dbg = append(dbg, p) } sort.Strings(dbg) fmt.Fprintln(os.Stderr, "Final affected packages:") if len(dbg) == 0 { fmt.Fprintln(os.Stderr, " (none)") } else { for _, p := range dbg { fmt.Fprintln(os.Stderr, " ", p) } } } // Normalize and filter import paths: // - Strip test variant suffixes like "pkg [pkg.test]" // - Exclude e2e test packages (./test/e2e/...) normalized := make(map[string]struct{}) for p := range affected { // Trim Go test variant decorations that appear in `go list -test` if idx := strings.Index(p, " ["); idx != -1 { p = p[:idx] } // Exclude synthetic test packages like github.com/org/repo/pkg.name.test if strings.HasSuffix(p, ".test") { continue } if strings.Contains(p, "/test/e2e/") { continue } if p != "" { normalized[p] = struct{}{} } } var list []string for p := range normalized { list = append(list, p) } sort.Strings(list) for _, p := range list { fmt.Println(p) } case "suites": // Determine impacted suites by dependency mapping and direct e2e test changes, // then print exact test names for those suites. preflightRoot := "github.com/replicatedhq/troubleshoot/cmd/preflight" supportRoot := "github.com/replicatedhq/troubleshoot/cmd/troubleshoot" preflightDeps, err := listPackageWithDeps(preflightRoot) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } supportDeps, err := listPackageWithDeps(supportRoot) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } preflightHit := false supportHit := false // Track whether e2e test files were directly changed per suite and collect specific test names changedPreflightTests := make(map[string]struct{}) changedSupportTests := make(map[string]struct{}) preflightE2EChangedNonGo := false supportE2EChangedNonGo := false for _, f := range files { if strings.HasPrefix(f, "test/e2e/preflight/") { if strings.HasSuffix(f, "_test.go") { // Extract test names from just this file b, err := os.ReadFile(f) if err == nil { // ignore read errors; they will be caught later if needed scanner := bufio.NewScanner(bytes.NewReader(b)) re := regexp.MustCompile(`^func\s+(Test[\w\d_]+)\s*\(`) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if m := re.FindStringSubmatch(line); m != nil { changedPreflightTests[m[1]] = struct{}{} } } } preflightHit = true } else { // Non-go change under preflight e2e; run whole suite preflightE2EChangedNonGo = true preflightHit = true } } if strings.HasPrefix(f, "test/e2e/support-bundle/") { if strings.HasSuffix(f, "_test.go") { b, err := os.ReadFile(f) if err == nil { scanner := bufio.NewScanner(bytes.NewReader(b)) re := regexp.MustCompile(`^func\s+(Test[\w\d_]+)\s*\(`) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if m := re.FindStringSubmatch(line); m != nil { changedSupportTests[m[1]] = struct{}{} } } } supportHit = true } else { supportE2EChangedNonGo = true supportHit = true } } } for changed := range directPkgs { if !preflightHit { if _, ok := preflightDeps[changed]; ok { preflightHit = true } } if !supportHit { if _, ok := supportDeps[changed]; ok { supportHit = true } } if preflightHit && supportHit { break } } if *verbose { fmt.Fprintln(os.Stderr, "E2E suite impact:") fmt.Fprintf(os.Stderr, " preflight: %v\n", preflightHit) fmt.Fprintf(os.Stderr, " support-bundle: %v\n", supportHit) } // If module files or CI/detector changed, conservatively select all tests for both suites. if moduleChanged || ciChanged { preTests, err := listTestFunctions("test/e2e/preflight") if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } for _, tname := range preTests { fmt.Printf("preflight:%s\n", tname) } sbTests, err := listTestFunctions("test/e2e/support-bundle") if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } for _, tname := range sbTests { fmt.Printf("support-bundle:%s\n", tname) } return } // Collect tests for impacted suites and print as `:` if preflightHit || supportHit { if preflightHit { toPrint := make(map[string]struct{}) if preflightE2EChangedNonGo || len(changedPreflightTests) == 0 { // Run full suite if e2e non-go assets changed or no specific test names collected preTests, err := listTestFunctions("test/e2e/preflight") if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } for _, t := range preTests { toPrint[t] = struct{}{} } } else { for t := range changedPreflightTests { toPrint[t] = struct{}{} } } var list []string for t := range toPrint { list = append(list, t) } sort.Strings(list) for _, tname := range list { fmt.Printf("preflight:%s\n", tname) } } if supportHit { toPrint := make(map[string]struct{}) if supportE2EChangedNonGo || len(changedSupportTests) == 0 { sbTests, err := listTestFunctions("test/e2e/support-bundle") if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } for _, t := range sbTests { toPrint[t] = struct{}{} } } else { for t := range changedSupportTests { toPrint[t] = struct{}{} } } var list []string for t := range toPrint { list = append(list, t) } sort.Strings(list) for _, tname := range list { fmt.Printf("support-bundle:%s\n", tname) } } } default: fmt.Fprintln(os.Stderr, "unknown mode; use 'packages' or 'suites'") os.Exit(2) } }