diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index 85ea1d6..e8f7f6b 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -64,12 +64,12 @@ func run(cmd *cobra.Command, args []string) error { }) } - changesets, tag, err := getChangesetsFromForge(ctx, f) + changesets, latestTag, stableTag, err := getChangesetsFromForge(ctx, f) if err != nil { return fmt.Errorf("failed to get changesets: %w", err) } - err = reconcileReleasePR(ctx, f, changesets, tag) + err = reconcileReleasePR(ctx, f, changesets, latestTag, stableTag) if err != nil { return fmt.Errorf("failed to reconcile release pr: %w", err) } @@ -77,36 +77,39 @@ func run(cmd *cobra.Command, args []string) error { return nil } -func getChangesetsFromForge(ctx context.Context, forge rp.Forge) ([]rp.Changeset, *rp.Tag, error) { - tag, err := forge.LatestTag(ctx) +func getChangesetsFromForge(ctx context.Context, forge rp.Forge) (changesets []rp.Changeset, latestTag *rp.Tag, stableTag *rp.Tag, err error) { + latestTag, stableTag, err = forge.LatestTags(ctx) if err != nil { - return nil, nil, err + return nil, nil, nil, err } - if tag != nil { - logger.InfoContext(ctx, "found previous tag", "tag.hash", tag.Hash, "tag.name", tag.Name) + if latestTag != nil { + logger.InfoContext(ctx, "found latest tag", "tag.hash", latestTag.Hash, "tag.name", latestTag.Name) } else { - logger.InfoContext(ctx, "no previous tag found") + logger.InfoContext(ctx, "no latest tag found") + } + if latestTag.Hash != stableTag.Hash { + logger.InfoContext(ctx, "found stable tag", "tag.hash", stableTag.Hash, "tag.name", stableTag.Name) } - releasableCommits, err := forge.CommitsSince(ctx, tag) + releasableCommits, err := forge.CommitsSince(ctx, stableTag) if err != nil { - return nil, nil, err + return nil, nil, nil, err } logger.InfoContext(ctx, "Found releasable commits", "length", len(releasableCommits)) - changesets, err := forge.Changesets(ctx, releasableCommits) + changesets, err = forge.Changesets(ctx, releasableCommits) if err != nil { - return nil, nil, err + return nil, nil, nil, err } logger.InfoContext(ctx, "Found changesets", "length", len(changesets)) - return changesets, tag, nil + return changesets, latestTag, stableTag, nil } -func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Changeset, tag *rp.Tag) error { +func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Changeset, latestTag *rp.Tag, stableTag *rp.Tag) error { rpBranch := fmt.Sprintf(RELEASER_PLEASER_BRANCH, flagBranch) rpBranchRef := plumbing.NewBranchReferenceName(rpBranch) // Check Forge for open PR @@ -131,7 +134,8 @@ func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Cha } } - nextVersion, err := rp.NextVersion(tag, changesets, releaseOverrides.NextVersionType) + versionBump := rp.VersionBumpFromChangesets(changesets) + nextVersion, err := rp.NextVersion(latestTag, stableTag, versionBump, releaseOverrides.NextVersionType) if err != nil { return err } diff --git a/forge.go b/forge.go index 8d99b23..29942d8 100644 --- a/forge.go +++ b/forge.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" + "github.com/blang/semver/v4" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/google/go-github/v63/github" @@ -32,8 +33,9 @@ type Forge interface { GitAuth() transport.AuthMethod - // LatestTag returns the last tag created on the main branch. If no tag is found, it returns nil. - LatestTag(context.Context) (*Tag, error) + // LatestTags returns the last stable tag created on the main branch. If there is a more recent pre-release tag, + // that is also returned. If no tag is found, it returns nil. + LatestTags(context.Context) (stable *Tag, prerelease *Tag, err error) // CommitsSince returns all commits to main branch after the Tag. The tag can be `nil`, in which case this // function should return all commits. @@ -82,24 +84,56 @@ func (g *GitHub) GitAuth() transport.AuthMethod { } } -func (g *GitHub) LatestTag(ctx context.Context) (*Tag, error) { - g.log.Debug("listing all tags in github repository") - // We only get the first page because the latest tag is returned as the first item - tags, _, err := g.client.Repositories.ListTags(ctx, g.options.Owner, g.options.Repo, nil) - if err != nil { - return nil, err +func (g *GitHub) LatestTags(ctx context.Context) (latest *Tag, stable *Tag, err error) { + g.log.DebugContext(ctx, "listing all tags in github repository") + + page := 1 + + for { + tags, resp, err := g.client.Repositories.ListTags( + ctx, g.options.Owner, g.options.Repo, + &github.ListOptions{Page: page, PerPage: GitHubPerPageMax}, + ) + if err != nil { + return nil, nil, err + } + + for _, ghTag := range tags { + tag := &Tag{ + Hash: ghTag.GetCommit().GetSHA(), + Name: ghTag.GetName(), + } + + version, err := semver.Parse(tag.Name) + if err != nil { + g.log.WarnContext( + ctx, "unable to parse tag as semver, skipping", + "tag.name", tag.Name, + "tag.hash", tag.Hash, + "error", err, + ) + continue + } + + if latest == nil { + latest = tag + } + if len(version.Pre) == 0 { + // Stable version tag + // We return once we have found the latest stable tag, not needed to look at every single tag. + return latest, tag, nil + } + } + + if page == resp.LastPage || resp.LastPage == 0 { + break + } + + page = resp.NextPage + } - if len(tags) > 0 { - // TODO: Is tags sorted? - tag := tags[0] - return &Tag{ - Hash: tag.GetCommit().GetSHA(), - Name: tag.GetName(), - }, nil - } - - return nil, nil + return nil, nil, nil } func (g *GitHub) CommitsSince(ctx context.Context, tag *Tag) ([]Commit, error) { diff --git a/versioning.go b/versioning.go index 93cd2c7..39ebfdc 100644 --- a/versioning.go +++ b/versioning.go @@ -8,56 +8,68 @@ import ( "github.com/leodido/go-conventionalcommits" ) -func NextVersion(currentTag *Tag, changesets []Changeset, nextVersionType NextVersionType) (string, error) { +func NextVersion(latestTag *Tag, stableTag *Tag, versionBump conventionalcommits.VersionBump, nextVersionType NextVersionType) (string, error) { // TODO: Validate for versioning after pre-releases - currentVersion := "v0.0.0" - if currentTag != nil { - currentVersion = currentTag.Name + latestVersion := "v0.0.0" + if latestTag != nil { + latestVersion = latestTag.Name + } + stableVersion := "v0.0.0" + if stableTag != nil { + stableVersion = stableTag.Name } // The lib can not handle v prefixes - currentVersion = strings.TrimPrefix(currentVersion, "v") + latestVersion = strings.TrimPrefix(latestVersion, "v") + stableVersion = strings.TrimPrefix(stableVersion, "v") - version, err := semver.Parse(currentVersion) + latest, err := semver.Parse(latestVersion) if err != nil { return "", err } - versionBump := maxVersionBump(changesets) + stable, err := semver.Parse(stableVersion) + if err != nil { + return "", err + } + + next := stable // Copy all fields + switch versionBump { case conventionalcommits.UnknownVersion: - // No new version, TODO: Throw error? + return "", fmt.Errorf("invalid latest bump (unknown)") case conventionalcommits.PatchVersion: - err = version.IncrementPatch() + err = next.IncrementPatch() case conventionalcommits.MinorVersion: - err = version.IncrementMinor() + err = next.IncrementMinor() case conventionalcommits.MajorVersion: - err = version.IncrementMajor() - } - if err != nil { - return "", err + err = next.IncrementMajor() } switch nextVersionType { + case NextVersionTypeUndefined, NextVersionTypeNormal: + next.Pre = make([]semver.PRVersion, 0) case NextVersionTypeAlpha, NextVersionTypeBeta, NextVersionTypeRC: id := uint64(0) - if version.Pre[0].String() == nextVersionType.String() { - if version.Pre[1].String() == "" || !version.Pre[1].IsNumeric() { + if len(latest.Pre) >= 2 && latest.Pre[0].String() == nextVersionType.String() { + if latest.Pre[1].String() == "" || !latest.Pre[1].IsNumeric() { return "", fmt.Errorf("invalid format of previous tag") } - id = version.Pre[1].VersionNum + 1 + id = latest.Pre[1].VersionNum + 1 } - setPRVersion(&version, nextVersionType.String(), id) - case NextVersionTypeUndefined, NextVersionTypeNormal: - version.Pre = make([]semver.PRVersion, 0) + setPRVersion(&next, nextVersionType.String(), id) } - return "v" + version.String(), nil + if err != nil { + return "", err + } + + return "v" + next.String(), nil } -func maxVersionBump(changesets []Changeset) conventionalcommits.VersionBump { +func VersionBumpFromChangesets(changesets []Changeset) conventionalcommits.VersionBump { bump := conventionalcommits.UnknownVersion for _, changeset := range changesets { diff --git a/versioning_test.go b/versioning_test.go new file mode 100644 index 0000000..a0b9e26 --- /dev/null +++ b/versioning_test.go @@ -0,0 +1,338 @@ +package rp + +import ( + "fmt" + "testing" + + "github.com/leodido/go-conventionalcommits" + "github.com/stretchr/testify/assert" +) + +func TestNextVersion(t *testing.T) { + type args struct { + latestTag *Tag + stableTag *Tag + versionBump conventionalcommits.VersionBump + nextVersionType NextVersionType + } + tests := []struct { + name string + args args + want string + wantErr assert.ErrorAssertionFunc + }{ + { + name: "simple bump (major)", + args: args{ + latestTag: &Tag{Name: "v1.1.1"}, + stableTag: &Tag{Name: "v1.1.1"}, + versionBump: conventionalcommits.MajorVersion, + nextVersionType: NextVersionTypeUndefined, + }, + want: "v2.0.0", + wantErr: assert.NoError, + }, + { + name: "simple bump (minor)", + args: args{ + latestTag: &Tag{Name: "v1.1.1"}, + stableTag: &Tag{Name: "v1.1.1"}, + versionBump: conventionalcommits.MinorVersion, + nextVersionType: NextVersionTypeUndefined, + }, + want: "v1.2.0", + wantErr: assert.NoError, + }, + { + name: "simple bump (patch)", + args: args{ + latestTag: &Tag{Name: "v1.1.1"}, + stableTag: &Tag{Name: "v1.1.1"}, + versionBump: conventionalcommits.PatchVersion, + nextVersionType: NextVersionTypeUndefined, + }, + want: "v1.1.2", + wantErr: assert.NoError, + }, + { + name: "normal to prerelease (major)", + args: args{ + latestTag: &Tag{Name: "v1.1.1"}, + stableTag: &Tag{Name: "v1.1.1"}, + versionBump: conventionalcommits.MajorVersion, + nextVersionType: NextVersionTypeRC, + }, + want: "v2.0.0-rc.0", + wantErr: assert.NoError, + }, + { + name: "normal to prerelease (minor)", + args: args{ + latestTag: &Tag{Name: "v1.1.1"}, + stableTag: &Tag{Name: "v1.1.1"}, + versionBump: conventionalcommits.MinorVersion, + nextVersionType: NextVersionTypeRC, + }, + want: "v1.2.0-rc.0", + wantErr: assert.NoError, + }, + { + name: "normal to prerelease (patch)", + args: args{ + latestTag: &Tag{Name: "v1.1.1"}, + stableTag: &Tag{Name: "v1.1.1"}, + versionBump: conventionalcommits.PatchVersion, + nextVersionType: NextVersionTypeRC, + }, + want: "v1.1.2-rc.0", + wantErr: assert.NoError, + }, + { + name: "prerelease bump (major)", + args: args{ + latestTag: &Tag{Name: "v2.0.0-rc.0"}, + stableTag: &Tag{Name: "v1.1.1"}, + versionBump: conventionalcommits.MajorVersion, + nextVersionType: NextVersionTypeRC, + }, + want: "v2.0.0-rc.1", + wantErr: assert.NoError, + }, + { + name: "prerelease bump (minor)", + args: args{ + latestTag: &Tag{Name: "v1.2.0-rc.0"}, + stableTag: &Tag{Name: "v1.1.1"}, + versionBump: conventionalcommits.MinorVersion, + nextVersionType: NextVersionTypeRC, + }, + want: "v1.2.0-rc.1", + wantErr: assert.NoError, + }, + { + name: "prerelease bump (patch)", + args: args{ + latestTag: &Tag{Name: "v1.1.2-rc.0"}, + stableTag: &Tag{Name: "v1.1.1"}, + versionBump: conventionalcommits.PatchVersion, + nextVersionType: NextVersionTypeRC, + }, + want: "v1.1.2-rc.1", + wantErr: assert.NoError, + }, + { + name: "prerelease different bump (major)", + args: args{ + latestTag: &Tag{Name: "v1.2.0-rc.0"}, + stableTag: &Tag{Name: "v1.1.1"}, + versionBump: conventionalcommits.MajorVersion, + nextVersionType: NextVersionTypeRC, + }, + want: "v2.0.0-rc.1", + wantErr: assert.NoError, + }, + { + name: "prerelease different bump (minor)", + args: args{ + latestTag: &Tag{Name: "v1.1.2-rc.0"}, + stableTag: &Tag{Name: "v1.1.1"}, + versionBump: conventionalcommits.MinorVersion, + nextVersionType: NextVersionTypeRC, + }, + want: "v1.2.0-rc.1", + wantErr: assert.NoError, + }, + { + name: "prerelease to prerelease", + args: args{ + latestTag: &Tag{Name: "v1.1.1-alpha.2"}, + stableTag: &Tag{Name: "v1.1.0"}, + versionBump: conventionalcommits.PatchVersion, + nextVersionType: NextVersionTypeRC, + }, + want: "v1.1.1-rc.0", + wantErr: assert.NoError, + }, + { + name: "prerelease to normal (explicit)", + args: args{ + latestTag: &Tag{Name: "v1.1.1-alpha.2"}, + stableTag: &Tag{Name: "v1.1.0"}, + versionBump: conventionalcommits.PatchVersion, + nextVersionType: NextVersionTypeNormal, + }, + want: "v1.1.1", + wantErr: assert.NoError, + }, + { + name: "prerelease to normal (implicit)", + args: args{ + latestTag: &Tag{Name: "v1.1.1-alpha.2"}, + stableTag: &Tag{Name: "v1.1.0"}, + versionBump: conventionalcommits.PatchVersion, + nextVersionType: NextVersionTypeUndefined, + }, + want: "v1.1.1", + wantErr: assert.NoError, + }, + { + name: "nil tag (major)", + args: args{ + latestTag: nil, + stableTag: nil, + versionBump: conventionalcommits.MajorVersion, + nextVersionType: NextVersionTypeUndefined, + }, + want: "v1.0.0", + wantErr: assert.NoError, + }, + { + name: "nil tag (minor)", + args: args{ + latestTag: nil, + stableTag: nil, + versionBump: conventionalcommits.MinorVersion, + nextVersionType: NextVersionTypeUndefined, + }, + want: "v0.1.0", + wantErr: assert.NoError, + }, + { + name: "nil tag (patch)", + args: args{ + latestTag: nil, + stableTag: nil, + versionBump: conventionalcommits.PatchVersion, + nextVersionType: NextVersionTypeUndefined, + }, + want: "v0.0.1", + wantErr: assert.NoError, + }, + { + name: "error on invalid tag semver", + args: args{ + latestTag: &Tag{Name: "foodazzle"}, + stableTag: &Tag{Name: "foodazzle"}, + versionBump: conventionalcommits.PatchVersion, + nextVersionType: NextVersionTypeRC, + }, + want: "", + wantErr: assert.Error, + }, + { + name: "error on invalid tag prerelease", + args: args{ + latestTag: &Tag{Name: "v1.1.1-rc.foo"}, + stableTag: &Tag{Name: "v1.1.1-rc.foo"}, + versionBump: conventionalcommits.PatchVersion, + nextVersionType: NextVersionTypeRC, + }, + want: "", + wantErr: assert.Error, + }, + { + name: "error on invalid bump", + args: args{ + latestTag: &Tag{Name: "v1.1.1"}, + stableTag: &Tag{Name: "v1.1.1"}, + versionBump: conventionalcommits.UnknownVersion, + nextVersionType: NextVersionTypeUndefined, + }, + want: "", + wantErr: assert.Error, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NextVersion(tt.args.latestTag, tt.args.stableTag, tt.args.versionBump, tt.args.nextVersionType) + if !tt.wantErr(t, err, fmt.Sprintf("NextVersion(%v, %v, %v, %v)", tt.args.latestTag, tt.args.stableTag, tt.args.versionBump, tt.args.nextVersionType)) { + return + } + assert.Equalf(t, tt.want, got, "NextVersion(%v, %v, %v, %v)", tt.args.latestTag, tt.args.stableTag, tt.args.versionBump, tt.args.nextVersionType) + }) + } +} + +func TestVersionBumpFromChangesets(t *testing.T) { + tests := []struct { + name string + changesets []Changeset + want conventionalcommits.VersionBump + }{ + { + name: "no entries (unknown)", + changesets: []Changeset{}, + want: conventionalcommits.UnknownVersion, + }, + { + name: "non-release type (unknown)", + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{Type: "docs"}}}}, + want: conventionalcommits.UnknownVersion, + }, + { + name: "single breaking (major)", + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{BreakingChange: true}}}}, + want: conventionalcommits.MajorVersion, + }, + { + name: "single feat (minor)", + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{Type: "feat"}}}}, + want: conventionalcommits.MinorVersion, + }, + { + name: "single fix (patch)", + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}}}, + want: conventionalcommits.PatchVersion, + }, + { + name: "multiple changesets (major)", + changesets: []Changeset{ + {ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}}, + {ChangelogEntries: []AnalyzedCommit{{BreakingChange: true}}}, + }, + want: conventionalcommits.MajorVersion, + }, + { + name: "multiple changesets (minor)", + changesets: []Changeset{ + {ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}}, + {ChangelogEntries: []AnalyzedCommit{{Type: "feat"}}}, + }, + want: conventionalcommits.MinorVersion, + }, + { + name: "multiple changesets (patch)", + changesets: []Changeset{ + {ChangelogEntries: []AnalyzedCommit{{Type: "docs"}}}, + {ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}}, + }, + want: conventionalcommits.PatchVersion, + }, + { + name: "multiple entries in one changeset (major)", + changesets: []Changeset{ + {ChangelogEntries: []AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}}}, + }, + want: conventionalcommits.MajorVersion, + }, + { + name: "multiple entries in one changeset (minor)", + changesets: []Changeset{ + {ChangelogEntries: []AnalyzedCommit{{Type: "fix"}, {Type: "feat"}}}, + }, + want: conventionalcommits.MinorVersion, + }, + { + name: "multiple entries in one changeset (patch)", + changesets: []Changeset{ + {ChangelogEntries: []AnalyzedCommit{{Type: "docs"}, {Type: "fix"}}}, + }, + want: conventionalcommits.PatchVersion, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, VersionBumpFromChangesets(tt.changesets), "VersionBumpFromChangesets(%v)", tt.changesets) + }) + } +}