< Summary - go-semantic-release Coverage

Line coverage
83%
Covered lines: 101
Uncovered lines: 20
Coverable lines: 121
Total lines: 269
Line coverage: 83.4%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Plan0%0068.42%
planRepo0%0093.33%
planIndependent0%0083.33%
countPrereleaseTags0%0073.33%

File(s)

/home/runner/work/go-semantic-release/go-semantic-release/internal/app/release_planner.go

#LineLine coverage
 1package app
 2
 3import (
 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.
 15func 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.
 24type 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.
 34func 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.
 53func (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,
 1160) (*domain.ReleasePlan, error) {
 1161  tags, err := p.git.ListTags(ctx)
 062  if err != nil {
 063    return nil, fmt.Errorf("listing tags: %w", err)
 064  }
 65
 1166  plan := &domain.ReleasePlan{
 1167    DryRun: dryRun,
 1168    Policy: policy,
 1169  }
 1170
 1171  if branch, err := p.git.CurrentBranch(ctx); err == nil {
 1172    plan.Branch = branch
 073  } else {
 074    p.logger.Warn("could not determine current branch", "error", err)
 075  }
 76
 377  if releaseMode == domain.ReleaseModeIndependent {
 378    return p.planIndependent(projects, commits, tags, policy, plan)
 379  }
 880  return p.planRepo(projects, commits, tags, policy, plan)
 81}
 82
 83func (p *ReleasePlanner) planRepo(
 84  projects []domain.Project,
 85  commits []domain.Commit,
 86  tags []domain.Tag,
 87  policy *domain.BranchPolicy,
 88  plan *domain.ReleasePlan,
 889) (*domain.ReleasePlan, error) {
 890  // Derive the tag-lookup prefix from the project's TagPrefix. Root projects
 891  // (TagPrefix == "") use unprefixed tags like "v1.0.0" and must look up with
 892  // "". Named projects with an explicit prefix (e.g. "sun-neovim/") have tags
 893  // like "sun-neovim/v0.1.1" and must look up with "sun-neovim".
 894  tagLookupPrefix := ""
 195  if len(projects) > 0 && projects[0].TagPrefix != "" {
 196    tagLookupPrefix = strings.TrimSuffix(projects[0].TagPrefix, "/")
 197  }
 898  latestTag, err := p.tagService.FindLatestTag(tags, tagLookupPrefix)
 199  if err != nil {
 1100    return nil, fmt.Errorf("finding latest tag: %w", err)
 1101  }
 7102  currentVersion := domain.ZeroVersion()
 7103
 6104  if latestTag != nil {
 6105    currentVersion = latestTag.Version
 6106    // Trim commits to only those newer than the last release tag so that
 6107    // commits already counted in a prior release are not re-analyzed.
 6108    commits = commitsAfterHash(commits, buildCommitIndex(commits), latestTag.Hash)
 6109  }
 110
 7111  counter := 0
 3112  if policy != nil && policy.IsPrerelease() && !policy.IsMaintenance() {
 3113    base := nextBaseVersion(currentVersion, commits, p.typeMapping)
 3114    counter = p.countPrereleaseTags(tags, tagLookupPrefix, base, policy.Channel)
 3115  }
 116
 7117  nextVersion, releaseType, err := p.versionCalc.Calculate(currentVersion, commits, policy, p.typeMapping, counter)
 0118  if err != nil {
 0119    return nil, fmt.Errorf("calculating version: %w", err)
 0120  }
 121
 7122  project := domain.Project{Name: "", Path: ".", Type: domain.ProjectTypeRoot}
 6123  if len(projects) > 0 {
 6124    project = projects[0]
 6125  }
 126
 7127  plan.Projects = []domain.ProjectReleasePlan{{
 7128    Project:        project,
 7129    CurrentVersion: currentVersion,
 7130    NextVersion:    nextVersion,
 7131    ReleaseType:    releaseType,
 7132    Commits:        commits,
 7133    ShouldRelease:  releaseType.IsReleasable(),
 7134    Reason:         buildReason(releaseType, len(commits)),
 7135  }}
 7136
 7137  return plan, nil
 138}
 139
 140func (p *ReleasePlanner) planIndependent(
 141  projects []domain.Project,
 142  commits []domain.Commit,
 143  tags []domain.Tag,
 144  policy *domain.BranchPolicy,
 145  plan *domain.ReleasePlan,
 3146) (*domain.ReleasePlan, error) {
 3147  // Build a position index once for all per-project filtering below.
 3148  // commits is ordered newest-first (git log default), so lower index = newer.
 3149  commitIndex := buildCommitIndex(commits)
 3150
 3151  impactMap := p.impactAnalyzer.Analyze(projects, commits)
 3152
 3153  plan.Projects = make([]domain.ProjectReleasePlan, 0, len(projects))
 3154
 3155  for _, proj := range projects {
 4156    projectCommits := impactMap[proj.Name]
 4157
 4158    latestTag, err := p.tagService.FindLatestTag(tags, proj.Name)
 1159    if err != nil {
 1160      return nil, domain.NewProjectError(proj.Name, "find latest tag", err)
 1161    }
 3162    currentVersion := domain.ZeroVersion()
 3163    if latestTag != nil {
 3164      currentVersion = latestTag.Version
 3165      // Trim to only commits newer than the last release tag so that
 3166      // commits already counted in a prior release are not re-analyzed.
 3167      projectCommits = commitsAfterHash(projectCommits, commitIndex, latestTag.Hash)
 3168    }
 169
 3170    counter := 0
 0171    if policy != nil && policy.IsPrerelease() && !policy.IsMaintenance() {
 0172      base := nextBaseVersion(currentVersion, projectCommits, p.typeMapping)
 0173      counter = p.countPrereleaseTags(tags, proj.Name, base, policy.Channel)
 0174    }
 175
 3176    nextVersion, releaseType, err := p.versionCalc.Calculate(currentVersion, projectCommits, policy, p.typeMapping, coun
 0177    if err != nil {
 0178      return nil, domain.NewProjectError(proj.Name, "calculate version", err)
 0179    }
 180
 3181    plan.Projects = append(plan.Projects, domain.ProjectReleasePlan{
 3182      Project:        proj,
 3183      CurrentVersion: currentVersion,
 3184      NextVersion:    nextVersion,
 3185      ReleaseType:    releaseType,
 3186      Commits:        projectCommits,
 3187      ShouldRelease:  releaseType.IsReleasable(),
 3188      Reason:         buildReason(releaseType, len(projectCommits)),
 3189    })
 190  }
 191
 2192  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.
 3206func (p *ReleasePlanner) countPrereleaseTags(tags []domain.Tag, project string, base domain.Version, channel string) int
 3207  prefix := channel + "."
 3208  count := 0
 3209  for _, tag := range tags {
 6210    proj, ver, err := p.tagService.ParseTag(tag.Name)
 0211    if err != nil {
 0212      continue
 213    }
 0214    if proj != project {
 0215      continue
 216    }
 4217    if ver.Major != base.Major || ver.Minor != base.Minor || ver.Patch != base.Patch {
 4218      continue
 219    }
 2220    if strings.HasPrefix(ver.Prerelease, prefix) {
 2221      count++
 2222    }
 223  }
 3224  return count
 225}
 226
 227func 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.
 236func 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.
 253func 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}