blob: 239599116fd54e069d36281feec540e8fe26e181 [file] [log] [blame]
// affectedtests determines, for a given GitHub PR, which acceptance tests it affects.
//
// Example usage: git diff HEAD~ > tmp.diff && go run affectedtests.go -diff tmp.diff
//
// It is also possible to get the diff from a PR: go run affectedtests.go -pr 2771
// However, this mode only reads the changed files from the PR and does not (currently)
// take into account new resources/tests that might have been added in this PR.
//
// This script currently only works for changes to resources.
// It is a TODO to make it work for changes to tests, data sources, and common utilities.
// It also currently does not pick up tests that use configs from other files.
package main
import (
"flag"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/ioutil"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
)
func main() {
diff := flag.String("diff", "", "file containing git diff to use when determining changed files")
pr := flag.Uint("pr", 0, "PR # to use to determine changed files")
flag.Parse()
if (*pr == 0 && *diff == "") || (*pr != 0 && *diff != "") {
fmt.Println("Exactly one of -pr and -diff must be set")
flag.Usage()
os.Exit(1)
}
_, scriptPath, _, ok := runtime.Caller(0)
if !ok {
log.Fatal("Could not get current working directory")
}
tpgDir := scriptPath
for !strings.HasPrefix(filepath.Base(tpgDir), "terraform-provider-") && tpgDir != "/" {
tpgDir = filepath.Clean(tpgDir + "/..")
}
if tpgDir == "/" {
log.Fatal("Script was run outside of google provider directory")
}
repo := strings.TrimPrefix(filepath.Base(tpgDir), "terraform-provider-")
googleDir := tpgDir + "/" + repo
providerFiles, err := readProviderFiles(googleDir)
if err != nil {
log.Fatal(err)
}
var diffVal string
if *diff == "" {
diffVal, err = getDiffFromPR(*pr, repo)
if err != nil {
log.Fatal(err)
}
} else {
d, err := ioutil.ReadFile(*diff)
if err != nil {
log.Fatal(err)
}
diffVal = string(d)
}
tests := map[string]struct{}{}
for _, r := range getChangedResourcesFromDiff(diffVal, repo) {
rn, err := getResourceName(r, googleDir, providerFiles)
if err != nil {
log.Fatal(err)
}
if rn == "" {
log.Fatalf("Could not find resource represented by %s", r)
}
log.Printf("File %s matches resource %s", r, rn)
ts, err := getTestsAffectedBy(rn, googleDir)
if err != nil {
log.Fatal(err)
}
for _, t := range ts {
tests[t] = struct{}{}
}
}
testnames := []string{}
for tn := range tests {
testnames = append(testnames, tn)
}
sort.Strings(testnames)
for _, tn := range testnames {
fmt.Println(tn)
}
}
func readProviderFiles(googleDir string) ([]string, error) {
pfs := []string{}
dir, err := ioutil.ReadDir(googleDir)
if err != nil {
return nil, err
}
for _, f := range dir {
if strings.HasPrefix(f.Name(), "provider") {
p, err := ioutil.ReadFile(googleDir + "/" + f.Name())
if err != nil {
return nil, err
}
pfs = append(pfs, string(p))
}
}
return pfs, nil
}
func getDiffFromPR(pr uint, repo string) (string, error) {
resp, err := http.Get(fmt.Sprintf("https://github.com/hashicorp/terraform-provider-%s/pull/%d.diff", repo, pr))
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
func getChangedResourcesFromDiff(diff, repo string) []string {
results := []string{}
for _, l := range strings.Split(diff, "\n") {
if strings.HasPrefix(l, "+++ b/") {
log.Println("Found addition: " + l)
fName := strings.TrimPrefix(l, "+++ b/"+repo+"/")
if strings.HasPrefix(fName, "resource_") && !strings.HasSuffix(fName, "_test.go") {
results = append(results, fName)
}
}
}
log.Printf("PR contains resource files %v", results)
return results
}
func getResourceName(fName, googleDir string, providerFiles []string) (string, error) {
resourceFile, err := parser.ParseFile(token.NewFileSet(), googleDir+"/"+fName, nil, parser.AllErrors)
if err != nil {
return "", err
}
// Loop through all the top-level objects in the resource file.
// One of them is the resource definition: something like ResourceComputeInstance()
for k := range resourceFile.Scope.Objects {
// Matches the line in the provider file where the resource is defined,
// e.g. "google_compute_instance": ResourceComputeInstance()
re := regexp.MustCompile(`"(.*)":\s*` + k + `\(\)`)
// Check all the provider files to see if they have a line that matches
// that regexp. If so, return the resource name.
for _, pf := range providerFiles {
sm := re.FindStringSubmatch(pf)
if len(sm) > 1 {
log.Println("Full match is " + sm[0])
return sm[1], nil
}
}
}
return "", nil
}
func getTestsAffectedBy(rn, googleDir string) ([]string, error) {
lines, err := getLinesContainingResourceName(rn, googleDir)
if err != nil {
return nil, err
}
results := []string{}
for _, line := range lines {
fset := token.NewFileSet()
p, err := parser.ParseFile(fset, line.file, nil, parser.AllErrors)
if err != nil {
return nil, err
}
// Find the top-level func containing this offset
def := findFuncContainingOffset(line.offset, fset, p)
if def == "" {
// We couldn't find the place in the file that contains this offset, just skip and move on
continue
}
// Go back through and find the test that calls the definition we just found
results = append(results, findTestsCallingFunc(p, def)...)
}
return results, nil
}
func findFuncContainingOffset(offset int, fset *token.FileSet, p *ast.File) string {
for k, sc := range p.Scope.Objects {
d := sc.Decl.(ast.Node)
if fset.Position(d.Pos()).Offset < offset && offset < fset.Position(d.End()).Offset {
return k
}
}
return ""
}
func findTestsCallingFunc(p *ast.File, funcName string) []string {
results := []string{}
for objName, sc := range p.Scope.Objects {
if !strings.HasPrefix(objName, "Test") {
continue
}
d, ok := sc.Decl.(*ast.FuncDecl)
if !ok {
continue
}
// Starting at each Test, see if there's a path to the func we just found.
ast.Inspect(d, func(n ast.Node) bool {
if n, ok := n.(*ast.Ident); ok {
if n.Name == funcName {
results = append(results, objName)
}
}
return true
})
}
return results
}
type location struct {
file string
offset int
}
func getLinesContainingResourceName(rn, googleDir string) ([]location, error) {
results := []location{}
resDef := regexp.MustCompile(fmt.Sprintf(`resource "%s"`, rn))
dir, err := ioutil.ReadDir(googleDir)
if err != nil {
return nil, err
}
for _, f := range dir {
if f.IsDir() {
continue
}
fPath := googleDir + "/" + f.Name()
contents, err := ioutil.ReadFile(fPath)
if err != nil {
return nil, err
}
matches := resDef.FindAllIndex(contents, -1)
for _, loc := range matches {
// the full match is at contents[loc[0]:loc[1]], but we only need one value
results = append(results, location{fPath, loc[0]})
}
}
return results, nil
}