| | | 1 | | package app |
| | | 2 | | |
| | | 3 | | import ( |
| | | 4 | | "context" |
| | | 5 | | "fmt" |
| | | 6 | | "strings" |
| | | 7 | | |
| | | 8 | | "github.com/jedi-knights/go-semantic-release/internal/domain" |
| | | 9 | | "github.com/jedi-knights/go-semantic-release/internal/ports" |
| | | 10 | | ) |
| | | 11 | | |
| | | 12 | | // nextBaseVersion computes the bumped Major.Minor.Patch for the given commits |
| | | 13 | | // without applying any prerelease suffix. Used by the planner to determine |
| | | 14 | | // which base version to search for when counting existing prerelease tags. |
| | | 15 | | func nextBaseVersion(current domain.Version, commits []domain.Commit, typeMapping map[string]domain.ReleaseType) domain. |
| | | 16 | | bump := aggregateBump(commits, typeMapping) |
| | | 17 | | if !bump.IsReleasable() { |
| | | 18 | | return current |
| | | 19 | | } |
| | | 20 | | return current.Bump(bump) |
| | | 21 | | } |
| | | 22 | | |
| | | 23 | | // ReleasePlanner builds a release plan for the repository. |
| | | 24 | | type ReleasePlanner struct { |
| | | 25 | | git ports.GitRepository |
| | | 26 | | tagService ports.TagService |
| | | 27 | | versionCalc ports.VersionCalculator |
| | | 28 | | impactAnalyzer ports.ProjectImpactAnalyzer |
| | | 29 | | logger ports.Logger |
| | | 30 | | typeMapping map[string]domain.ReleaseType |
| | | 31 | | } |
| | | 32 | | |
| | | 33 | | // NewReleasePlanner creates a release planner. |
| | | 34 | | func NewReleasePlanner( |
| | | 35 | | git ports.GitRepository, |
| | | 36 | | tagService ports.TagService, |
| | | 37 | | versionCalc ports.VersionCalculator, |
| | | 38 | | impactAnalyzer ports.ProjectImpactAnalyzer, |
| | | 39 | | logger ports.Logger, |
| | | 40 | | typeMapping map[string]domain.ReleaseType, |
| | | 41 | | ) *ReleasePlanner { |
| | | 42 | | return &ReleasePlanner{ |
| | | 43 | | git: git, |
| | | 44 | | tagService: tagService, |
| | | 45 | | versionCalc: versionCalc, |
| | | 46 | | impactAnalyzer: impactAnalyzer, |
| | | 47 | | logger: logger, |
| | | 48 | | typeMapping: typeMapping, |
| | | 49 | | } |
| | | 50 | | } |
| | | 51 | | |
| | | 52 | | // Plan builds a release plan for the given projects and commits. |
| | | 53 | | func (p *ReleasePlanner) Plan( |
| | | 54 | | ctx context.Context, |
| | | 55 | | projects []domain.Project, |
| | | 56 | | commits []domain.Commit, |
| | | 57 | | releaseMode domain.ReleaseMode, |
| | | 58 | | policy *domain.BranchPolicy, |
| | | 59 | | dryRun bool, |
| | 11 | 60 | | ) (*domain.ReleasePlan, error) { |
| | 11 | 61 | | tags, err := p.git.ListTags(ctx) |
| | 0 | 62 | | if err != nil { |
| | 0 | 63 | | return nil, fmt.Errorf("listing tags: %w", err) |
| | 0 | 64 | | } |
| | | 65 | | |
| | 11 | 66 | | plan := &domain.ReleasePlan{ |
| | 11 | 67 | | DryRun: dryRun, |
| | 11 | 68 | | Policy: policy, |
| | 11 | 69 | | } |
| | 11 | 70 | | |
| | 11 | 71 | | if branch, err := p.git.CurrentBranch(ctx); err == nil { |
| | 11 | 72 | | plan.Branch = branch |
| | 0 | 73 | | } else { |
| | 0 | 74 | | p.logger.Warn("could not determine current branch", "error", err) |
| | 0 | 75 | | } |
| | | 76 | | |
| | 3 | 77 | | if releaseMode == domain.ReleaseModeIndependent { |
| | 3 | 78 | | return p.planIndependent(projects, commits, tags, policy, plan) |
| | 3 | 79 | | } |
| | 8 | 80 | | return p.planRepo(projects, commits, tags, policy, plan) |
| | | 81 | | } |
| | | 82 | | |
| | | 83 | | func (p *ReleasePlanner) planRepo( |
| | | 84 | | projects []domain.Project, |
| | | 85 | | commits []domain.Commit, |
| | | 86 | | tags []domain.Tag, |
| | | 87 | | policy *domain.BranchPolicy, |
| | | 88 | | plan *domain.ReleasePlan, |
| | 8 | 89 | | ) (*domain.ReleasePlan, error) { |
| | 8 | 90 | | // Derive the tag-lookup prefix from the project's TagPrefix. Root projects |
| | 8 | 91 | | // (TagPrefix == "") use unprefixed tags like "v1.0.0" and must look up with |
| | 8 | 92 | | // "". Named projects with an explicit prefix (e.g. "sun-neovim/") have tags |
| | 8 | 93 | | // like "sun-neovim/v0.1.1" and must look up with "sun-neovim". |
| | 8 | 94 | | tagLookupPrefix := "" |
| | 1 | 95 | | if len(projects) > 0 && projects[0].TagPrefix != "" { |
| | 1 | 96 | | tagLookupPrefix = strings.TrimSuffix(projects[0].TagPrefix, "/") |
| | 1 | 97 | | } |
| | 8 | 98 | | latestTag, err := p.tagService.FindLatestTag(tags, tagLookupPrefix) |
| | 1 | 99 | | if err != nil { |
| | 1 | 100 | | return nil, fmt.Errorf("finding latest tag: %w", err) |
| | 1 | 101 | | } |
| | 7 | 102 | | currentVersion := domain.ZeroVersion() |
| | 7 | 103 | | |
| | 6 | 104 | | if latestTag != nil { |
| | 6 | 105 | | currentVersion = latestTag.Version |
| | 6 | 106 | | // Trim commits to only those newer than the last release tag so that |
| | 6 | 107 | | // commits already counted in a prior release are not re-analyzed. |
| | 6 | 108 | | commits = commitsAfterHash(commits, buildCommitIndex(commits), latestTag.Hash) |
| | 6 | 109 | | } |
| | | 110 | | |
| | 7 | 111 | | counter := 0 |
| | 3 | 112 | | if policy != nil && policy.IsPrerelease() && !policy.IsMaintenance() { |
| | 3 | 113 | | base := nextBaseVersion(currentVersion, commits, p.typeMapping) |
| | 3 | 114 | | counter = p.countPrereleaseTags(tags, tagLookupPrefix, base, policy.Channel) |
| | 3 | 115 | | } |
| | | 116 | | |
| | 7 | 117 | | nextVersion, releaseType, err := p.versionCalc.Calculate(currentVersion, commits, policy, p.typeMapping, counter) |
| | 0 | 118 | | if err != nil { |
| | 0 | 119 | | return nil, fmt.Errorf("calculating version: %w", err) |
| | 0 | 120 | | } |
| | | 121 | | |
| | 7 | 122 | | project := domain.Project{Name: "", Path: ".", Type: domain.ProjectTypeRoot} |
| | 6 | 123 | | if len(projects) > 0 { |
| | 6 | 124 | | project = projects[0] |
| | 6 | 125 | | } |
| | | 126 | | |
| | 7 | 127 | | plan.Projects = []domain.ProjectReleasePlan{{ |
| | 7 | 128 | | Project: project, |
| | 7 | 129 | | CurrentVersion: currentVersion, |
| | 7 | 130 | | NextVersion: nextVersion, |
| | 7 | 131 | | ReleaseType: releaseType, |
| | 7 | 132 | | Commits: commits, |
| | 7 | 133 | | ShouldRelease: releaseType.IsReleasable(), |
| | 7 | 134 | | Reason: buildReason(releaseType, len(commits)), |
| | 7 | 135 | | }} |
| | 7 | 136 | | |
| | 7 | 137 | | return plan, nil |
| | | 138 | | } |
| | | 139 | | |
| | | 140 | | func (p *ReleasePlanner) planIndependent( |
| | | 141 | | projects []domain.Project, |
| | | 142 | | commits []domain.Commit, |
| | | 143 | | tags []domain.Tag, |
| | | 144 | | policy *domain.BranchPolicy, |
| | | 145 | | plan *domain.ReleasePlan, |
| | 3 | 146 | | ) (*domain.ReleasePlan, error) { |
| | 3 | 147 | | // Build a position index once for all per-project filtering below. |
| | 3 | 148 | | // commits is ordered newest-first (git log default), so lower index = newer. |
| | 3 | 149 | | commitIndex := buildCommitIndex(commits) |
| | 3 | 150 | | |
| | 3 | 151 | | impactMap := p.impactAnalyzer.Analyze(projects, commits) |
| | 3 | 152 | | |
| | 3 | 153 | | plan.Projects = make([]domain.ProjectReleasePlan, 0, len(projects)) |
| | 3 | 154 | | |
| | 3 | 155 | | for _, proj := range projects { |
| | 4 | 156 | | projectCommits := impactMap[proj.Name] |
| | 4 | 157 | | |
| | 4 | 158 | | latestTag, err := p.tagService.FindLatestTag(tags, proj.Name) |
| | 1 | 159 | | if err != nil { |
| | 1 | 160 | | return nil, domain.NewProjectError(proj.Name, "find latest tag", err) |
| | 1 | 161 | | } |
| | 3 | 162 | | currentVersion := domain.ZeroVersion() |
| | 3 | 163 | | if latestTag != nil { |
| | 3 | 164 | | currentVersion = latestTag.Version |
| | 3 | 165 | | // Trim to only commits newer than the last release tag so that |
| | 3 | 166 | | // commits already counted in a prior release are not re-analyzed. |
| | 3 | 167 | | projectCommits = commitsAfterHash(projectCommits, commitIndex, latestTag.Hash) |
| | 3 | 168 | | } |
| | | 169 | | |
| | 3 | 170 | | counter := 0 |
| | 0 | 171 | | if policy != nil && policy.IsPrerelease() && !policy.IsMaintenance() { |
| | 0 | 172 | | base := nextBaseVersion(currentVersion, projectCommits, p.typeMapping) |
| | 0 | 173 | | counter = p.countPrereleaseTags(tags, proj.Name, base, policy.Channel) |
| | 0 | 174 | | } |
| | | 175 | | |
| | 3 | 176 | | nextVersion, releaseType, err := p.versionCalc.Calculate(currentVersion, projectCommits, policy, p.typeMapping, coun |
| | 0 | 177 | | if err != nil { |
| | 0 | 178 | | return nil, domain.NewProjectError(proj.Name, "calculate version", err) |
| | 0 | 179 | | } |
| | | 180 | | |
| | 3 | 181 | | plan.Projects = append(plan.Projects, domain.ProjectReleasePlan{ |
| | 3 | 182 | | Project: proj, |
| | 3 | 183 | | CurrentVersion: currentVersion, |
| | 3 | 184 | | NextVersion: nextVersion, |
| | 3 | 185 | | ReleaseType: releaseType, |
| | 3 | 186 | | Commits: projectCommits, |
| | 3 | 187 | | ShouldRelease: releaseType.IsReleasable(), |
| | 3 | 188 | | Reason: buildReason(releaseType, len(projectCommits)), |
| | 3 | 189 | | }) |
| | | 190 | | } |
| | | 191 | | |
| | 2 | 192 | | return plan, nil |
| | | 193 | | } |
| | | 194 | | |
| | | 195 | | // countPrereleaseTags counts existing prerelease tags for a specific project, |
| | | 196 | | // base version (Major.Minor.Patch), and channel. The result is used as the |
| | | 197 | | // counter N in the {channel}.{N} prerelease suffix so each RC tag in a cycle |
| | | 198 | | // is unique and increments automatically. |
| | | 199 | | // |
| | | 200 | | // Matching rule: the prerelease field must begin with "{channel}." — the dot |
| | | 201 | | // boundary prevents a channel named "rc" from matching a hand-crafted tag |
| | | 202 | | // whose prerelease starts with "rca" or similar. Tags whose prerelease contains |
| | | 203 | | // additional dot-separated segments (e.g. "rc.1.2") are accepted as valid |
| | | 204 | | // counter tags; this is intentional to remain compatible with any tooling that |
| | | 205 | | // writes counters in the legacy format. |
| | 3 | 206 | | func (p *ReleasePlanner) countPrereleaseTags(tags []domain.Tag, project string, base domain.Version, channel string) int |
| | 3 | 207 | | prefix := channel + "." |
| | 3 | 208 | | count := 0 |
| | 3 | 209 | | for _, tag := range tags { |
| | 6 | 210 | | proj, ver, err := p.tagService.ParseTag(tag.Name) |
| | 0 | 211 | | if err != nil { |
| | 0 | 212 | | continue |
| | | 213 | | } |
| | 0 | 214 | | if proj != project { |
| | 0 | 215 | | continue |
| | | 216 | | } |
| | 4 | 217 | | if ver.Major != base.Major || ver.Minor != base.Minor || ver.Patch != base.Patch { |
| | 4 | 218 | | continue |
| | | 219 | | } |
| | 2 | 220 | | if strings.HasPrefix(ver.Prerelease, prefix) { |
| | 2 | 221 | | count++ |
| | 2 | 222 | | } |
| | | 223 | | } |
| | 3 | 224 | | return count |
| | | 225 | | } |
| | | 226 | | |
| | | 227 | | func buildReason(rt domain.ReleaseType, commitCount int) string { |
| | | 228 | | if !rt.IsReleasable() { |
| | | 229 | | return "no releasable changes" |
| | | 230 | | } |
| | | 231 | | return fmt.Sprintf("%d commit(s) require %s bump", commitCount, rt) |
| | | 232 | | } |
| | | 233 | | |
| | | 234 | | // buildCommitIndex constructs a position map for a newest-first commit slice. |
| | | 235 | | // Lower index means more recent; this is the natural order from git log. |
| | | 236 | | func buildCommitIndex(commits []domain.Commit) map[string]int { |
| | | 237 | | idx := make(map[string]int, len(commits)) |
| | | 238 | | for i := range commits { |
| | | 239 | | idx[commits[i].Hash] = i |
| | | 240 | | } |
| | | 241 | | return idx |
| | | 242 | | } |
| | | 243 | | |
| | | 244 | | // commitsAfterHash returns the subset of commits that are newer than the commit |
| | | 245 | | // identified by sinceHash, as determined by position in the globally-ordered |
| | | 246 | | // newest-first index. Commits at a lower index than sinceHash's position are |
| | | 247 | | // newer; commits at the same or higher index were already included in the |
| | | 248 | | // release that created sinceHash. |
| | | 249 | | // |
| | | 250 | | // If sinceHash is empty or not present in the index (first release or the tag |
| | | 251 | | // commit is outside the fetched window), all commits are returned unchanged so |
| | | 252 | | // the first-release path is handled correctly. |
| | | 253 | | func commitsAfterHash(commits []domain.Commit, index map[string]int, sinceHash string) []domain.Commit { |
| | | 254 | | if sinceHash == "" { |
| | | 255 | | return commits |
| | | 256 | | } |
| | | 257 | | cutoff, ok := index[sinceHash] |
| | | 258 | | if !ok { |
| | | 259 | | // Tag commit not in the fetched window — treat every commit as new. |
| | | 260 | | return commits |
| | | 261 | | } |
| | | 262 | | result := make([]domain.Commit, 0, cutoff) |
| | | 263 | | for i := range commits { |
| | | 264 | | if pos, exists := index[commits[i].Hash]; exists && pos < cutoff { |
| | | 265 | | result = append(result, commits[i]) |
| | | 266 | | } |
| | | 267 | | } |
| | | 268 | | return result |
| | | 269 | | } |