contrib/git-changes-action/detector/module/depgraph.go
package moduledetector
import (
"fmt"
"github.com/ethereum/go-ethereum/common"
"github.com/kendru/darwin/go/depgraph"
"github.com/vishalkuo/bimap"
"golang.org/x/mod/modfile"
"os"
"path"
"path/filepath"
"strings"
)
// getDependencyGraph returns a dependency graph of all the modules in the go.work file that refer to other modules in the go.work file
// returns a map of module (./my_module)->(./my_module_dependency1,./my_module_dependency2).
// nolint: cyclop
func getDependencyGraph(repoPath string) (moduleDeps map[string][]string, err error) {
moduleDeps = make(map[string][]string)
// parse the go.work file
goWorkPath := path.Join(repoPath, "go.work")
if !common.FileExist(goWorkPath) {
return nil, fmt.Errorf("go.work file not found in %s", repoPath)
}
//nolint: gosec
workFile, err := os.ReadFile(goWorkPath)
if err != nil {
return nil, fmt.Errorf("failed to read go.work file: %w", err)
}
parsedWorkFile, err := modfile.ParseWork(goWorkPath, workFile, nil)
if err != nil {
return nil, fmt.Errorf("failed to parse go.work file: %w", err)
}
// map of module->dependencies + replaces
var dependencies map[string]map[string]struct{}
// bidirectional map of module->module
// moduleRelativeName <-> modulePublicName
var dependencyNames *bimap.BiMap[string, string]
// iterate through each module in the go.work file
// create a list of dependencies for each module
// and module names
dependencies, dependencyNames, err = makeDepMaps(repoPath, parsedWorkFile.Use)
if err != nil {
return nil, fmt.Errorf("failed to create dependency maps: %w", err)
}
depGraph := depgraph.New()
// build the dependency graph
for _, module := range parsedWorkFile.Use {
for dep := range dependencies[module.Path] {
// check if the full package name (e.g. github.com/myorg/myrepo/mymodule) is in the list of modules. If it is, add it as a dependency after renaming
renamedDep, hasDep := dependencyNames.GetInverse(dep)
if hasDep {
err = depGraph.DependOn(module.Path, renamedDep)
if err != nil {
return nil, fmt.Errorf("failed to add dependency %s -> %s: %w", module.Path, dep, err)
}
}
if isRelativeDep(dep) {
// if the dependency is relative, add it as a dependency
err = depGraph.DependOn(module.Path, dep)
if err != nil {
return nil, fmt.Errorf("failed to add dependency %s -> %s: %w", module.Path, dep, err)
}
}
}
}
for _, module := range parsedWorkFile.Use {
for dep := range depGraph.Dependencies(module.Path) {
moduleDeps[module.Path] = append(moduleDeps[module.Path], dep)
}
}
return moduleDeps, nil
}
// makeDepMaps builds a
// 1. module->dependency map
// 2. bidirectional map of module<->module (moduleRelativeName to modulePublicName).
func makeDepMaps(repoPath string, uses []*modfile.Use) (dependencies map[string]map[string]struct{}, dependencyNames *bimap.BiMap[string, string], err error) {
// map of module->depndencies
dependencies = make(map[string]map[string]struct{})
// bidirectional map of module->module name
// Maps relative to public names, used to filer out all external libraries/packages.
dependencyNames = bimap.NewBiMap[string, string]()
// iterate through each module in the go.work file
// 1. Create a list of dependencies for each module
// 2. Map public names to private names for each module
//nolint: gosec
for _, module := range uses {
modContents, err := os.ReadFile(filepath.Join(repoPath, module.Path, "go.mod"))
if err != nil {
return dependencies, dependencyNames, fmt.Errorf("failed to read module file %s: %w", module.Path, err)
}
parsedModFile, err := modfile.Parse(module.Path, modContents, nil)
if err != nil {
return dependencies, dependencyNames, fmt.Errorf("failed to parse module file %s: %w", module.Path, err)
}
dependencyNames.Insert(module.Path, parsedModFile.Module.Mod.Path)
dependencies[module.Path] = make(map[string]struct{})
// include all requires and replaces, as they are dependencies
for _, require := range parsedModFile.Require {
dependencies[module.Path][convertRelPath(repoPath, module.Path, require.Mod.Path)] = struct{}{}
}
for _, require := range parsedModFile.Replace {
dependencies[module.Path][convertRelPath(repoPath, module.Path, require.New.Path)] = struct{}{}
}
}
return dependencies, dependencyNames, nil
}
// isRelativeDep returns true if the dependency is relative to the module (starts with ./ or ../).
func isRelativeDep(path string) bool {
return strings.HasPrefix(path, "./") || strings.HasPrefix(path, "../")
}
// convertRelPath converts a path relative to a module to a path relative to the repository root.
// it does nothing if the path does not start with ./ or ../.
func convertRelPath(repoPath string, modulePath, dependency string) string {
if !isRelativeDep(dependency) {
return dependency
}
// repo/./module => repo/module
fullModulePath := filepath.Join(repoPath, modulePath)
// repo/module/../dependency => repo/dependency
fullDependencyPath := filepath.Join(fullModulePath, dependency)
// repo/dependency => dependency
trimmedPath := strings.TrimPrefix(fullDependencyPath, repoPath)
if len(trimmedPath) == 0 {
return "."
}
return fmt.Sprintf(".%s", trimmedPath)
}