1package release
2
3import (
4	"fmt"
5	"github.com/aws/aws-sdk-go-v2/internal/repotools"
6	"github.com/aws/aws-sdk-go-v2/internal/repotools/changelog"
7	"github.com/aws/aws-sdk-go-v2/internal/repotools/git"
8	"github.com/aws/aws-sdk-go-v2/internal/repotools/gomod"
9	"log"
10	"path"
11	"path/filepath"
12	"sort"
13)
14
15// ModuleFinder is a type that
16type ModuleFinder interface {
17	Root() string
18
19	ModulesRel() (map[string][]string, error)
20}
21
22// Calculate calculates the modules to be released and their next versions based on the Git history, previous tags,
23// module configuration, and associated changelog annotaitons.
24func Calculate(finder ModuleFinder, tags git.ModuleTags, config repotools.Config, annotations []changelog.Annotation) (map[string]*Module, error) {
25	rootDir := finder.Root()
26
27	repositoryModules, err := finder.ModulesRel()
28	if err != nil {
29		log.Fatalf("failed to modules: %v", err)
30	}
31
32	moduleAnnotations := make(map[string][]changelog.Annotation)
33	for _, annotation := range annotations {
34		for _, am := range annotation.Modules {
35			moduleAnnotations[am] = append(moduleAnnotations[am], annotation)
36		}
37	}
38
39	modules := make(map[string]*Module)
40	var repositoryModuleTombstonePaths []string
41
42	for moduleDir := range tags {
43		if _, ok := repositoryModules[moduleDir]; !ok {
44			repositoryModuleTombstonePaths = append(repositoryModuleTombstonePaths, moduleDir)
45		}
46	}
47
48	for moduleDir := range repositoryModules {
49		moduleFile, err := gomod.LoadModuleFile(filepath.Join(rootDir, moduleDir), nil, true)
50		if err != nil {
51			return nil, fmt.Errorf("failed to load module file: %w", err)
52		}
53
54		modulePath, err := gomod.GetModulePath(moduleFile)
55		if err != nil {
56			return nil, fmt.Errorf("failed to read module path: %w", err)
57		}
58
59		var latestVersion string
60		var hasChanges bool
61
62		latestVersion, ok := tags.Latest(moduleDir)
63		if ok {
64			startTag, err := git.ToModuleTag(moduleDir, latestVersion)
65			if err != nil {
66				log.Fatalf("failed to convert module path and version to tag: %v", err)
67			}
68
69			changes, err := git.Changes(finder.Root(), startTag, "HEAD", moduleDir)
70			if err != nil {
71				log.Fatalf("failed to get git changes: %v", err)
72			}
73
74			subModulePaths := repositoryModules[moduleDir]
75
76			ignoredModulePaths := make([]string, 0, len(subModulePaths)+len(repositoryModuleTombstonePaths))
77			ignoredModulePaths = append(ignoredModulePaths, subModulePaths...)
78
79			if len(repositoryModuleTombstonePaths) > 0 {
80				ignoredModulePaths = append(ignoredModulePaths, repositoryModuleTombstonePaths...)
81				// IsModuleChanged expects the provided list of ignored modules paths to be sorted
82				sort.Strings(ignoredModulePaths)
83			}
84
85			hasChanges, err = gomod.IsModuleChanged(moduleDir, ignoredModulePaths, changes)
86			if err != nil {
87				return nil, fmt.Errorf("failed to determine module changes: %w", err)
88			}
89
90			if !hasChanges {
91				// Check if any of the submodules have been "carved out" of this module since the last tagged release
92				for _, subModuleDir := range subModulePaths {
93					if _, ok := tags.Latest(subModuleDir); ok {
94						continue
95					}
96
97					treeFiles, err := git.LsTree(rootDir, startTag, subModuleDir)
98					if err != nil {
99						return nil, fmt.Errorf("failed to list git tree: %v", err)
100					}
101
102					carvedOut, err := isModuleCarvedOut(treeFiles, repositoryModules[subModuleDir])
103					if err != nil {
104						return nil, err
105					}
106					if carvedOut {
107						hasChanges = true
108						break
109					}
110				}
111			}
112		}
113
114		var changeReason ModuleChange
115		if hasChanges && len(latestVersion) > 0 {
116			changeReason |= SourceChange
117		} else if len(latestVersion) == 0 {
118			changeReason |= NewModule
119		}
120
121		modules[modulePath] = &Module{
122			File:              moduleFile,
123			RelativeRepoPath:  moduleDir,
124			Latest:            latestVersion,
125			Changes:           changeReason,
126			ChangeAnnotations: moduleAnnotations[moduleDir],
127			ModuleConfig:      config.Modules[moduleDir],
128		}
129	}
130
131	if err := CalculateDependencyUpdates(modules); err != nil {
132		return nil, err
133	}
134
135	for moduleDir := range modules {
136		if modules[moduleDir].Changes == 0 || config.Modules[moduleDir].NoTag {
137			delete(modules, moduleDir)
138		}
139	}
140
141	return modules, nil
142}
143
144// isModuleCarvedOut takes a list of files for a (new) submodule directory. The list of files are the files that are located
145// in the submodule directory path from the parent's previous tagged release. Returns true the new submodule has been
146// carved out of the parent module directory it is located under. This is determined by looking through the file list
147// and determining if Go source is present but no `go.mod` file existed.
148func isModuleCarvedOut(files []string, subModules []string) (bool, error) {
149	hasGoSource := false
150	hasGoMod := false
151
152	isChildPathCache := make(map[string]bool)
153
154	for _, file := range files {
155		dir, fileName := path.Split(file)
156		dir = path.Clean(dir)
157
158		isGoMod := gomod.IsGoMod(fileName)
159		isGoSource := gomod.IsGoSource(fileName)
160
161		if !(isGoMod || isGoSource) {
162			continue
163		}
164
165		if isChild, ok := isChildPathCache[dir]; (isChild && ok) || (!ok && gomod.IsSubmodulePath(dir, subModules)) {
166			isChildPathCache[dir] = true
167			continue
168		} else {
169			isChildPathCache[dir] = false
170		}
171
172		if isGoSource {
173			hasGoSource = true
174		} else if isGoMod {
175			hasGoMod = true
176		}
177
178		if hasGoMod && hasGoSource {
179			break
180		}
181	}
182
183	return !hasGoMod && hasGoSource, nil
184}
185