diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index ebb3e75d..495898df 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -57,6 +57,49 @@ jobs: - name: Publish test coverage uses: codecov/codecov-action@v4 + tests-changes: + runs-on: ${{ matrix.os }} + continue-on-error: true + + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - ubuntu-latest + - windows-latest + + steps: + - name: Checkout repository and submodules + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.23.2 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: Set go env + run: | + echo "GOPATH=$(go env GOPATH)" >> $GITHUB_ENV + echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + go install gotest.tools/gotestsum@latest + + - name: Pull external libraries + run: | + make vendor + pip3 install wheel + + - name: Run tests + run: make testchanged + + #- name: Publish test coverage + # uses: codecov/codecov-action@v4 + fmt: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index edd1409a..142cea0b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.so *.dylib cli +changecalc/changecalc # Test binary, built with `go test -c` *.test diff --git a/Makefile b/Makefile index 13787fdd..adfe2534 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,17 @@ testonly: @echo "✓ Running tests ..." @gotestsum --format pkgname-and-test-fails --no-summary=skipped --raw-command go test -v -json -short -coverprofile=coverage.txt ./... +viewchanges: changecalc/changecalc + @changecalc/changecalc + +testchanges: changecalc/changecalc + @echo "✓ Running tests based on changes relative to main..." + changecalc/changecalc > changed-packages.txt || echo "./..." > changed-packages.txt + gotestsum --format pkgname-and-test-fails --no-summary=skipped --raw-command go test -v -json -short -coverprofile=coverage.txt $(shell cat changed-packages.txt) + +changecalc/changecalc: changecalc/*.go + @go build -o changecalc/changecalc changecalc/main.go + coverage: test @echo "✓ Opening coverage for unit tests ..." @go tool cover -html=coverage.txt @@ -36,5 +47,5 @@ vendor: @echo "✓ Filling vendor folder with library code ..." @go mod vendor -.PHONY: build vendor coverage test lint fmt +.PHONY: build vendor coverage test lint fmt viewchanges testchanges diff --git a/changecalc.yml b/changecalc.yml new file mode 100644 index 00000000..d4586f44 --- /dev/null +++ b/changecalc.yml @@ -0,0 +1,5 @@ +base_branch: main +go_mod: go.mod +reset_list: + - go.mod + - go.sum diff --git a/changecalc/main.go b/changecalc/main.go new file mode 100644 index 00000000..d7e06e6c --- /dev/null +++ b/changecalc/main.go @@ -0,0 +1,276 @@ +/* +When passed list of files as arguments, figures out the all the packages that need to be tested +(by following dependencies) and outputs them one per line. The output is suitable to be passed +to "go test" as parameters. + +If no arguments were passed, runs "git diff main --name-only -- ." and reads the list of there. +*/ +package main + +import ( + "bytes" + "fmt" + "iter" + "log" + "maps" + "os" + "os/exec" + "path/filepath" + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/tools/go/packages" + "gopkg.in/yaml.v2" +) + +type Config struct { + BaseBranch string `yaml:"base_branch"` + GoModName string `yaml:"go_mod"` + ResetList []string `yaml:"reset_list"` +} + +type ParsedConfig struct { + Config Config + ResetMap map[string]struct{} +} + +var empty = struct{}{} + +const CONFIG_NAME = "changecalc.yml" + +func main() { + var err error + initialPaths := os.Args[1:] + log.SetFlags(0) + + config := readConfig() + //fmt.Fprintf(os.Stderr, "config=%v\n", config) + + if len(initialPaths) == 0 { + initialPaths, err = GetChangedFiles(config.Config.BaseBranch) + if err != nil { + log.Fatal(err) + } + } + + //fmt.Fprintf(os.Stderr, "initialPaths=%v\n", initialPaths) + + moduleName, err := readModuleName(config.Config.GoModName) + if err != nil { + log.Fatalf("Failed to read module name from %s: %v", config.Config.GoModName, err) + } + + goPackages := make(map[string]struct{}, 128) + testDirs := make(map[string]struct{}, 128) + testdata := fmt.Sprintf("%ctestdata%c", filepath.Separator, filepath.Separator) + + for _, p := range initialPaths { + p = filepath.Clean(p) + if _, ok := config.ResetMap[p]; ok { + log.Fatalf("Found %s in changed paths, will not proceed.\n", p) + } + + dir := filepath.Dir(p) + if strings.HasSuffix(p, "_test.go") { + testDirs[dir] = empty + } else if strings.HasSuffix(p, ".go") { + pkg := filepath.Join(moduleName, dir) + goPackages[pkg] = empty + } else if strings.Contains(p, testdata) { + items := strings.Split(p, testdata) + testDirs[items[0]] = empty + } else { + // we're not parsing go:embed, instead assuming that data file is read by the closest go package + realDir := findNearestGoPackage(dir) + if realDir != "" { + pkg := filepath.Join(moduleName, realDir) + goPackages[pkg] = empty + } + } + } + + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedImports | packages.NeedDeps, + Dir: ".", + } + + allPkgs, err := packages.Load(cfg, "./...") + if err != nil { + log.Fatalf("Failed to load packages: %v", err) + } + + reverseDeps := make(map[string][]string) + for _, pkg := range allPkgs { + for imported := range pkg.Imports { + reverseDeps[imported] = append(reverseDeps[imported], pkg.PkgPath) + } + } + + dependentPackages := findDependents(maps.Keys(goPackages), reverseDeps) + + for pkg := range dependentPackages { + var dir string + if pkg == moduleName { + dir = "." + } else if strings.HasPrefix(pkg, moduleName+"/") { + dir = pkg[len(moduleName)+1:] + } else { + fmt.Fprintf(os.Stderr, "Internal error: %s\n", pkg) + continue + } + testDirs[dir] = empty + } + + belongsCache := make(map[string]bool, len(testDirs)*4) + + for dir := range testDirs { + if !checkIfBelongs(dir, belongsCache, config.Config.GoModName) { + //fmt.Fprintf(os.Stderr, "Excluding %s: part of another go module\n", dir) + continue + } + if dir != "." { + dir = "./" + dir + } + fmt.Println(dir) + } +} + +func readConfig() ParsedConfig { + config := Config{} + + data, err := os.ReadFile(CONFIG_NAME) + if err != nil { + log.Fatalf("reading config file: %s: %v", CONFIG_NAME, err) + } + + if err := yaml.Unmarshal(data, &config); err != nil { + log.Fatal(err) + } + + resetMap := make(map[string]struct{}, len(config.ResetList)) + for _, item := range config.ResetList { + resetMap[item] = empty + } + + return ParsedConfig{Config: config, ResetMap: resetMap} +} + +// Returns true if this directory belongs to the current go module (identified by MODULE_NAME_SOURCE file) +func checkIfBelongs(dir string, cache map[string]bool, rootIdName string) bool { + if dir == "." || dir == "" { + return true + } + + val, ok := cache[dir] + if ok { + return val + } + + result := false + + //fmt.Fprintf(os.Stderr, "Checking %s for go.mod\n", dir) + _, err := os.Stat(filepath.Join(dir, rootIdName)) + if err != nil { + // assuming it's not-found + result = checkIfBelongs(filepath.Dir(dir), cache, rootIdName) + } + // If we found go.mod, it's a root of another module. + // "go test" will fail if we pass this directory: + // % go test ./bundle/internal/tf/codegen/schema + // main module (github.com/databricks/cli) does not contain package github.com/databricks/cli/bundle/internal/tf/codegen/schema + cache[dir] = result + return result +} + +// readModuleName parses the go.mod file to extract the module name. +func readModuleName(goModPath string) (string, error) { + data, err := os.ReadFile(goModPath) + if err != nil { + return "", err + } + modFile, err := modfile.Parse(goModPath, data, nil) + if err != nil { + return "", err + } + return modFile.Module.Mod.Path, nil +} + +// resolvePackages converts file paths to fully qualified package names. +// Recursively find all packages that depend on the given list +func findDependents(initialPackages iter.Seq[string], reverseDeps map[string][]string) map[string]struct{} { + visited := make(map[string]struct{}) + var visit func(pkg string) + visit = func(pkg string) { + if _, seen := visited[pkg]; seen { + return + } + visited[pkg] = struct{}{} + for _, dependent := range reverseDeps[pkg] { + visit(dependent) + } + } + for pkg := range initialPackages { + visit(pkg) + } + return visited +} + +// findNearestGoPackage returns the closest parent (or itself) directory +// that contains at least one *.go file. If no such directory is found, +// it returns an empty string. +func findNearestGoPackage(directory string) string { + for { + if hasGoFiles(directory) { + return directory + } + + parent := filepath.Dir(directory) + + if parent == directory { + break + } + + directory = parent + } + + return "" +} + +// hasGoFiles checks if a directory contains any .go files +func hasGoFiles(directory string) bool { + files, err := os.ReadDir(directory) + if err != nil { + return false + } + + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(file.Name(), ".go") { + return true + } + } + return false +} + +// GetChangedFiles compares the current branch to the base branch +// and returns a slice of file paths that have been modified. +func GetChangedFiles(baseBranch string) ([]string, error) { + command := []string{"git", "diff", baseBranch, "--name-only", "--", "."} + cmd := exec.Command(command[0], command[1:]...) + + // Capture the command's output + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed to execute %s: %w", command, err) + } + + trimmedOut := strings.TrimSpace(out.String()) + if trimmedOut == "" { + return []string{}, nil + } + + changedFiles := strings.Split(trimmedOut, "\n") + return changedFiles, nil +} diff --git a/go.mod b/go.mod index 7141ed76..5772edc2 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( golang.org/x/sync v0.9.0 golang.org/x/term v0.26.0 golang.org/x/text v0.20.0 + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d gopkg.in/ini.v1 v1.67.0 // Apache 2.0 gopkg.in/yaml.v3 v3.0.1 )