mirror of https://github.com/databricks/cli.git
Add change calculation tool
Add a new tool changecalc that calculates all packages that need to be re-tested given a list of changed files. Without a list, uses git to compare with main branch. 'make viewchanges' runs the tool. 'make testchanges' runs the test suite over affected packages. New tests-changes github job is added in shadow mode that takes advantage of this tool.
This commit is contained in:
parent
4c1042132b
commit
b0c46452df
|
@ -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
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
*.so
|
||||
*.dylib
|
||||
cli
|
||||
changecalc/changecalc
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
|
13
Makefile
13
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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
base_branch: main
|
||||
go_mod: go.mod
|
||||
reset_list:
|
||||
- go.mod
|
||||
- go.sum
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue