diff --git a/internal/changelog/changelog.go b/changelog.go similarity index 73% rename from internal/changelog/changelog.go rename to changelog.go index 1d0fd67..286faf4 100644 --- a/internal/changelog/changelog.go +++ b/changelog.go @@ -1,12 +1,15 @@ -package changelog +package rp import ( "bytes" _ "embed" "html/template" "log" +) - "github.com/apricote/releaser-pleaser/internal/commitparser" +const ( + ChangelogFile = "CHANGELOG.md" + ChangelogHeader = "# Changelog" ) var ( @@ -24,9 +27,9 @@ func init() { } } -func NewChangelogEntry(commits []commitparser.AnalyzedCommit, version, link, prefix, suffix string) (string, error) { - features := make([]commitparser.AnalyzedCommit, 0) - fixes := make([]commitparser.AnalyzedCommit, 0) +func NewChangelogEntry(commits []AnalyzedCommit, version, link, prefix, suffix string) (string, error) { + features := make([]AnalyzedCommit, 0) + fixes := make([]AnalyzedCommit, 0) for _, commit := range commits { switch commit.Type { @@ -51,4 +54,5 @@ func NewChangelogEntry(commits []commitparser.AnalyzedCommit, version, link, pre } return changelog.String(), nil + } diff --git a/internal/changelog/changelog.md.tpl b/changelog.md.tpl similarity index 100% rename from internal/changelog/changelog.md.tpl rename to changelog.md.tpl diff --git a/internal/changelog/changelog_test.go b/changelog_test.go similarity index 79% rename from internal/changelog/changelog_test.go rename to changelog_test.go index 384e30f..3fabff8 100644 --- a/internal/changelog/changelog_test.go +++ b/changelog_test.go @@ -1,12 +1,9 @@ -package changelog +package rp import ( "testing" "github.com/stretchr/testify/assert" - - "github.com/apricote/releaser-pleaser/internal/commitparser" - "github.com/apricote/releaser-pleaser/internal/git" ) func ptr[T any](input T) *T { @@ -15,7 +12,7 @@ func ptr[T any](input T) *T { func Test_NewChangelogEntry(t *testing.T) { type args struct { - analyzedCommits []commitparser.AnalyzedCommit + analyzedCommits []AnalyzedCommit version string link string prefix string @@ -30,7 +27,7 @@ func Test_NewChangelogEntry(t *testing.T) { { name: "empty", args: args{ - analyzedCommits: []commitparser.AnalyzedCommit{}, + analyzedCommits: []AnalyzedCommit{}, version: "1.0.0", link: "https://example.com/1.0.0", }, @@ -40,9 +37,9 @@ func Test_NewChangelogEntry(t *testing.T) { { name: "single feature", args: args{ - analyzedCommits: []commitparser.AnalyzedCommit{ + analyzedCommits: []AnalyzedCommit{ { - Commit: git.Commit{}, + Commit: Commit{}, Type: "feat", Description: "Foobar!", }, @@ -56,9 +53,9 @@ func Test_NewChangelogEntry(t *testing.T) { { name: "single fix", args: args{ - analyzedCommits: []commitparser.AnalyzedCommit{ + analyzedCommits: []AnalyzedCommit{ { - Commit: git.Commit{}, + Commit: Commit{}, Type: "fix", Description: "Foobar!", }, @@ -72,25 +69,25 @@ func Test_NewChangelogEntry(t *testing.T) { { name: "multiple commits with scopes", args: args{ - analyzedCommits: []commitparser.AnalyzedCommit{ + analyzedCommits: []AnalyzedCommit{ { - Commit: git.Commit{}, + Commit: Commit{}, Type: "feat", Description: "Blabla!", }, { - Commit: git.Commit{}, + Commit: Commit{}, Type: "feat", Description: "So awesome!", Scope: ptr("awesome"), }, { - Commit: git.Commit{}, + Commit: Commit{}, Type: "fix", Description: "Foobar!", }, { - Commit: git.Commit{}, + Commit: Commit{}, Type: "fix", Description: "So sad!", Scope: ptr("sad"), @@ -115,9 +112,9 @@ func Test_NewChangelogEntry(t *testing.T) { { name: "prefix", args: args{ - analyzedCommits: []commitparser.AnalyzedCommit{ + analyzedCommits: []AnalyzedCommit{ { - Commit: git.Commit{}, + Commit: Commit{}, Type: "fix", Description: "Foobar!", }, @@ -138,9 +135,9 @@ func Test_NewChangelogEntry(t *testing.T) { { name: "suffix", args: args{ - analyzedCommits: []commitparser.AnalyzedCommit{ + analyzedCommits: []AnalyzedCommit{ { - Commit: git.Commit{}, + Commit: Commit{}, Type: "fix", Description: "Foobar!", }, diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index de10927..7d5862b 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -6,11 +6,6 @@ import ( "github.com/spf13/cobra" rp "github.com/apricote/releaser-pleaser" - "github.com/apricote/releaser-pleaser/internal/commitparser/conventionalcommits" - "github.com/apricote/releaser-pleaser/internal/forge" - "github.com/apricote/releaser-pleaser/internal/forge/github" - "github.com/apricote/releaser-pleaser/internal/updater" - "github.com/apricote/releaser-pleaser/internal/versioning" ) var runCmd = &cobra.Command{ @@ -46,9 +41,9 @@ func run(cmd *cobra.Command, _ []string) error { "repo", flagRepo, ) - var f forge.Forge + var forge rp.Forge - forgeOptions := forge.Options{ + forgeOptions := rp.ForgeOptions{ Repository: flagRepo, BaseBranch: flagBranch, } @@ -58,23 +53,23 @@ func run(cmd *cobra.Command, _ []string) error { // f = rp.NewGitLab(forgeOptions) case "github": logger.DebugContext(ctx, "using forge GitHub") - f = github.New(logger, &github.Options{ - Options: forgeOptions, - Owner: flagOwner, - Repo: flagRepo, + forge = rp.NewGitHub(logger, &rp.GitHubOptions{ + ForgeOptions: forgeOptions, + Owner: flagOwner, + Repo: flagRepo, }) } extraFiles := parseExtraFiles(flagExtraFiles) releaserPleaser := rp.New( - f, + forge, logger, flagBranch, - conventionalcommits.NewParser(), - versioning.SemVerNextVersion, + rp.NewConventionalCommitsParser(), + rp.SemVerNextVersion, extraFiles, - []updater.NewUpdater{updater.Generic}, + []rp.Updater{&rp.GenericUpdater{}}, ) return releaserPleaser.Run(ctx) diff --git a/internal/commitparser/conventionalcommits/conventionalcommits.go b/commits.go similarity index 61% rename from internal/commitparser/conventionalcommits/conventionalcommits.go rename to commits.go index 665a02d..f0c64e9 100644 --- a/internal/commitparser/conventionalcommits/conventionalcommits.go +++ b/commits.go @@ -1,32 +1,54 @@ -package conventionalcommits +package rp import ( "fmt" "github.com/leodido/go-conventionalcommits" "github.com/leodido/go-conventionalcommits/parser" - - "github.com/apricote/releaser-pleaser/internal/commitparser" - "github.com/apricote/releaser-pleaser/internal/git" ) -type Parser struct { +type Commit struct { + Hash string + Message string + + PullRequest *PullRequest +} + +type PullRequest struct { + ID int + Title string + Description string +} + +type AnalyzedCommit struct { + Commit + Type string + Description string + Scope *string + BreakingChange bool +} + +type CommitParser interface { + Analyze(commits []Commit) ([]AnalyzedCommit, error) +} + +type ConventionalCommitsParser struct { machine conventionalcommits.Machine } -func NewParser() *Parser { +func NewConventionalCommitsParser() *ConventionalCommitsParser { parserMachine := parser.NewMachine( parser.WithBestEffort(), parser.WithTypes(conventionalcommits.TypesConventional), ) - return &Parser{ + return &ConventionalCommitsParser{ machine: parserMachine, } } -func (c *Parser) Analyze(commits []git.Commit) ([]commitparser.AnalyzedCommit, error) { - analyzedCommits := make([]commitparser.AnalyzedCommit, 0, len(commits)) +func (c *ConventionalCommitsParser) Analyze(commits []Commit) ([]AnalyzedCommit, error) { + analyzedCommits := make([]AnalyzedCommit, 0, len(commits)) for _, commit := range commits { msg, err := c.machine.Parse([]byte(commit.Message)) @@ -41,7 +63,7 @@ func (c *Parser) Analyze(commits []git.Commit) ([]commitparser.AnalyzedCommit, e commitVersionBump := conventionalCommit.VersionBump(conventionalcommits.DefaultStrategy) if commitVersionBump > conventionalcommits.UnknownVersion { // We only care about releasable commits - analyzedCommits = append(analyzedCommits, commitparser.AnalyzedCommit{ + analyzedCommits = append(analyzedCommits, AnalyzedCommit{ Commit: commit, Type: conventionalCommit.Type, Description: conventionalCommit.Description, diff --git a/internal/commitparser/conventionalcommits/conventionalcommits_test.go b/commits_test.go similarity index 62% rename from internal/commitparser/conventionalcommits/conventionalcommits_test.go rename to commits_test.go index 837035b..e58a718 100644 --- a/internal/commitparser/conventionalcommits/conventionalcommits_test.go +++ b/commits_test.go @@ -1,30 +1,27 @@ -package conventionalcommits +package rp import ( "testing" "github.com/stretchr/testify/assert" - - "github.com/apricote/releaser-pleaser/internal/commitparser" - "github.com/apricote/releaser-pleaser/internal/git" ) func TestAnalyzeCommits(t *testing.T) { tests := []struct { name string - commits []git.Commit - expectedCommits []commitparser.AnalyzedCommit + commits []Commit + expectedCommits []AnalyzedCommit wantErr assert.ErrorAssertionFunc }{ { name: "empty commits", - commits: []git.Commit{}, - expectedCommits: []commitparser.AnalyzedCommit{}, + commits: []Commit{}, + expectedCommits: []AnalyzedCommit{}, wantErr: assert.NoError, }, { name: "malformed commit message", - commits: []git.Commit{ + commits: []Commit{ { Message: "aksdjaklsdjka", }, @@ -34,17 +31,17 @@ func TestAnalyzeCommits(t *testing.T) { }, { name: "drops unreleasable", - commits: []git.Commit{ + commits: []Commit{ { Message: "chore: foobar", }, }, - expectedCommits: []commitparser.AnalyzedCommit{}, + expectedCommits: []AnalyzedCommit{}, wantErr: assert.NoError, }, { name: "highest bump (patch)", - commits: []git.Commit{ + commits: []Commit{ { Message: "chore: foobar", }, @@ -52,9 +49,9 @@ func TestAnalyzeCommits(t *testing.T) { Message: "fix: blabla", }, }, - expectedCommits: []commitparser.AnalyzedCommit{ + expectedCommits: []AnalyzedCommit{ { - Commit: git.Commit{Message: "fix: blabla"}, + Commit: Commit{Message: "fix: blabla"}, Type: "fix", Description: "blabla", }, @@ -63,7 +60,7 @@ func TestAnalyzeCommits(t *testing.T) { }, { name: "highest bump (minor)", - commits: []git.Commit{ + commits: []Commit{ { Message: "fix: blabla", }, @@ -71,14 +68,14 @@ func TestAnalyzeCommits(t *testing.T) { Message: "feat: foobar", }, }, - expectedCommits: []commitparser.AnalyzedCommit{ + expectedCommits: []AnalyzedCommit{ { - Commit: git.Commit{Message: "fix: blabla"}, + Commit: Commit{Message: "fix: blabla"}, Type: "fix", Description: "blabla", }, { - Commit: git.Commit{Message: "feat: foobar"}, + Commit: Commit{Message: "feat: foobar"}, Type: "feat", Description: "foobar", }, @@ -88,7 +85,7 @@ func TestAnalyzeCommits(t *testing.T) { { name: "highest bump (major)", - commits: []git.Commit{ + commits: []Commit{ { Message: "fix: blabla", }, @@ -96,14 +93,14 @@ func TestAnalyzeCommits(t *testing.T) { Message: "feat!: foobar", }, }, - expectedCommits: []commitparser.AnalyzedCommit{ + expectedCommits: []AnalyzedCommit{ { - Commit: git.Commit{Message: "fix: blabla"}, + Commit: Commit{Message: "fix: blabla"}, Type: "fix", Description: "blabla", }, { - Commit: git.Commit{Message: "feat!: foobar"}, + Commit: Commit{Message: "feat!: foobar"}, Type: "feat", Description: "foobar", BreakingChange: true, @@ -114,7 +111,7 @@ func TestAnalyzeCommits(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - analyzedCommits, err := NewParser().Analyze(tt.commits) + analyzedCommits, err := NewConventionalCommitsParser().Analyze(tt.commits) if !tt.wantErr(t, err) { return } diff --git a/internal/forge/github/github.go b/forge.go similarity index 66% rename from internal/forge/github/github.go rename to forge.go index 46f203a..1086564 100644 --- a/internal/forge/github/github.go +++ b/forge.go @@ -1,4 +1,4 @@ -package github +package rp import ( "context" @@ -13,27 +13,75 @@ import ( "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" - - "github.com/apricote/releaser-pleaser/internal/forge" - "github.com/apricote/releaser-pleaser/internal/git" - "github.com/apricote/releaser-pleaser/internal/pointer" - "github.com/apricote/releaser-pleaser/internal/releasepr" ) const ( - PerPageMax = 100 - PRStateOpen = "open" - PRStateClosed = "closed" - EnvAPIToken = "GITHUB_TOKEN" // nolint:gosec // Not actually a hardcoded credential - EnvUsername = "GITHUB_USER" - EnvRepository = "GITHUB_REPOSITORY" - LabelColor = "dedede" + GitHubPerPageMax = 100 + GitHubPRStateOpen = "open" + GitHubPRStateClosed = "closed" + GitHubEnvAPIToken = "GITHUB_TOKEN" // nolint:gosec // Not actually a hardcoded credential + GitHubEnvUsername = "GITHUB_USER" + GitHubEnvRepository = "GITHUB_REPOSITORY" + GitHubLabelColor = "dedede" ) -var _ forge.Forge = &GitHub{} +type Forge interface { + RepoURL() string + CloneURL() string + ReleaseURL(version string) string + + GitAuth() transport.AuthMethod + + // 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) (Releases, 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. + CommitsSince(context.Context, *Tag) ([]Commit, error) + + // EnsureLabelsExist verifies that all desired labels are available on the repository. If labels are missing, they + // are created them. + EnsureLabelsExist(context.Context, []Label) error + + // PullRequestForBranch returns the open pull request between the branch and ForgeOptions.BaseBranch. If no open PR + // exists, it returns nil. + PullRequestForBranch(context.Context, string) (*ReleasePullRequest, error) + + // CreatePullRequest opens a new pull/merge request for the ReleasePullRequest. + CreatePullRequest(context.Context, *ReleasePullRequest) error + + // UpdatePullRequest updates the pull/merge request identified through the ID of + // the ReleasePullRequest to the current description and title. + UpdatePullRequest(context.Context, *ReleasePullRequest) error + + // SetPullRequestLabels updates the pull/merge request identified through the ID of + // the ReleasePullRequest to the current labels. + SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []Label) error + + // ClosePullRequest closes the pull/merge request identified through the ID of + // the ReleasePullRequest, as it is no longer required. + ClosePullRequest(context.Context, *ReleasePullRequest) error + + // PendingReleases returns a list of ReleasePullRequest. The list should contain all pull/merge requests that are + // merged and have the matching label. + PendingReleases(context.Context, Label) ([]*ReleasePullRequest, error) + + // CreateRelease creates a release on the Forge, pointing at the commit with the passed in details. + CreateRelease(ctx context.Context, commit Commit, title, changelog string, prerelease, latest bool) error +} + +type ForgeOptions struct { + Repository string + BaseBranch string +} + +var _ Forge = &GitHub{} + +// var _ Forge = &GitLab{} type GitHub struct { - options *Options + options *GitHubOptions client *github.Client log *slog.Logger @@ -58,24 +106,24 @@ func (g *GitHub) GitAuth() transport.AuthMethod { } } -func (g *GitHub) LatestTags(ctx context.Context) (git.Releases, error) { +func (g *GitHub) LatestTags(ctx context.Context) (Releases, error) { g.log.DebugContext(ctx, "listing all tags in github repository") page := 1 - var releases git.Releases + var releases Releases for { tags, resp, err := g.client.Repositories.ListTags( ctx, g.options.Owner, g.options.Repo, - &github.ListOptions{Page: page, PerPage: PerPageMax}, + &github.ListOptions{Page: page, PerPage: GitHubPerPageMax}, ) if err != nil { - return git.Releases{}, err + return Releases{}, err } for _, ghTag := range tags { - tag := &git.Tag{ + tag := &Tag{ Hash: ghTag.GetCommit().GetSHA(), Name: ghTag.GetName(), } @@ -112,7 +160,7 @@ func (g *GitHub) LatestTags(ctx context.Context) (git.Releases, error) { return releases, nil } -func (g *GitHub) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) { +func (g *GitHub) CommitsSince(ctx context.Context, tag *Tag) ([]Commit, error) { var repositoryCommits []*github.RepositoryCommit var err error if tag != nil { @@ -125,9 +173,9 @@ func (g *GitHub) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, return nil, err } - var commits = make([]git.Commit, 0, len(repositoryCommits)) + var commits = make([]Commit, 0, len(repositoryCommits)) for _, ghCommit := range repositoryCommits { - commit := git.Commit{ + commit := Commit{ Hash: ghCommit.GetSHA(), Message: ghCommit.GetCommit().GetMessage(), } @@ -142,7 +190,7 @@ func (g *GitHub) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, return commits, nil } -func (g *GitHub) commitsSinceTag(ctx context.Context, tag *git.Tag) ([]*github.RepositoryCommit, error) { +func (g *GitHub) commitsSinceTag(ctx context.Context, tag *Tag) ([]*github.RepositoryCommit, error) { head := g.options.BaseBranch log := g.log.With("base", tag.Hash, "head", head) log.Debug("comparing commits", "base", tag.Hash, "head", head) @@ -156,7 +204,7 @@ func (g *GitHub) commitsSinceTag(ctx context.Context, tag *git.Tag) ([]*github.R ctx, g.options.Owner, g.options.Repo, tag.Hash, head, &github.ListOptions{ Page: page, - PerPage: PerPageMax, + PerPage: GitHubPerPageMax, }) if err != nil { return nil, err @@ -196,7 +244,7 @@ func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryComm SHA: head, ListOptions: github.ListOptions{ Page: page, - PerPage: PerPageMax, + PerPage: GitHubPerPageMax, }, }) if err != nil { @@ -206,7 +254,7 @@ func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryComm if repositoryCommits == nil && resp.LastPage > 0 { // Pre-initialize slice on first request log.Debug("found commits", "pages", resp.LastPage) - repositoryCommits = make([]*github.RepositoryCommit, 0, resp.LastPage*PerPageMax) + repositoryCommits = make([]*github.RepositoryCommit, 0, resp.LastPage*GitHubPerPageMax) } repositoryCommits = append(repositoryCommits, commits...) @@ -221,7 +269,7 @@ func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryComm return repositoryCommits, nil } -func (g *GitHub) prForCommit(ctx context.Context, commit git.Commit) (*git.PullRequest, error) { +func (g *GitHub) prForCommit(ctx context.Context, commit Commit) (*PullRequest, error) { // We naively look up the associated PR for each commit through the "List pull requests associated with a commit" // endpoint. This requires len(commits) requests. // Using the "List pull requests" endpoint might be faster, as it allows us to fetch 100 arbitrary PRs per request, @@ -237,7 +285,7 @@ func (g *GitHub) prForCommit(ctx context.Context, commit git.Commit) (*git.PullR ctx, g.options.Owner, g.options.Repo, commit.Hash, &github.ListOptions{ Page: page, - PerPage: PerPageMax, + PerPage: GitHubPerPageMax, }) if err != nil { return nil, err @@ -266,7 +314,7 @@ func (g *GitHub) prForCommit(ctx context.Context, commit git.Commit) (*git.PullR return gitHubPRToPullRequest(pullrequest), nil } -func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []releasepr.Label) error { +func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []Label) error { existingLabels := make([]string, 0, len(labels)) page := 1 @@ -277,7 +325,7 @@ func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []releasepr.Label ctx, g.options.Owner, g.options.Repo, &github.ListOptions{ Page: page, - PerPage: PerPageMax, + PerPage: GitHubPerPageMax, }) if err != nil { return err @@ -299,8 +347,8 @@ func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []releasepr.Label _, _, err := g.client.Issues.CreateLabel( ctx, g.options.Owner, g.options.Repo, &github.Label{ - Name: pointer.Pointer(string(label)), - Color: pointer.Pointer(LabelColor), + Name: Pointer(string(label)), + Color: Pointer(GitHubLabelColor), }, ) if err != nil { @@ -312,13 +360,13 @@ func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []releasepr.Label return nil } -func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*releasepr.ReleasePullRequest, error) { +func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*ReleasePullRequest, error) { page := 1 for { prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit(ctx, g.options.Owner, g.options.Repo, branch, &github.ListOptions{ Page: page, - PerPage: PerPageMax, + PerPage: GitHubPerPageMax, }) if err != nil { var ghErr *github.ErrorResponse @@ -331,7 +379,7 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*rele } for _, pr := range prs { - if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == PRStateOpen { + if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == GitHubPRStateOpen { return gitHubPRToReleasePullRequest(pr), nil } } @@ -345,7 +393,7 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*rele return nil, nil } -func (g *GitHub) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error { +func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest) error { ghPR, _, err := g.client.PullRequests.Create( ctx, g.options.Owner, g.options.Repo, &github.NewPullRequest{ @@ -362,7 +410,7 @@ func (g *GitHub) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePul // TODO: String ID? pr.ID = ghPR.GetNumber() - err = g.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels) + err = g.SetPullRequestLabels(ctx, pr, []Label{}, pr.Labels) if err != nil { return err } @@ -370,7 +418,7 @@ func (g *GitHub) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePul return nil } -func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error { +func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *ReleasePullRequest) error { _, _, err := g.client.PullRequests.Edit( ctx, g.options.Owner, g.options.Repo, pr.ID, &github.PullRequest{ @@ -385,7 +433,7 @@ func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *releasepr.ReleasePul return nil } -func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error { +func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []Label) error { for _, label := range remove { _, err := g.client.Issues.RemoveLabelForIssue( ctx, g.options.Owner, g.options.Repo, @@ -412,11 +460,11 @@ func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *releasepr.Release return nil } -func (g *GitHub) ClosePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error { +func (g *GitHub) ClosePullRequest(ctx context.Context, pr *ReleasePullRequest) error { _, _, err := g.client.PullRequests.Edit( ctx, g.options.Owner, g.options.Repo, pr.ID, &github.PullRequest{ - State: pointer.Pointer(PRStateClosed), + State: Pointer(GitHubPRStateClosed), }, ) if err != nil { @@ -426,20 +474,20 @@ func (g *GitHub) ClosePullRequest(ctx context.Context, pr *releasepr.ReleasePull return nil } -func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) { +func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel Label) ([]*ReleasePullRequest, error) { page := 1 - var prs []*releasepr.ReleasePullRequest + var prs []*ReleasePullRequest for { ghPRs, resp, err := g.client.PullRequests.List( ctx, g.options.Owner, g.options.Repo, &github.PullRequestListOptions{ - State: PRStateClosed, + State: GitHubPRStateClosed, Base: g.options.BaseBranch, ListOptions: github.ListOptions{ Page: page, - PerPage: PerPageMax, + PerPage: GitHubPerPageMax, }, }) if err != nil { @@ -449,7 +497,7 @@ func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel releasepr.Lab if prs == nil && resp.LastPage > 0 { // Pre-initialize slice on first request g.log.Debug("found pending releases", "pages", resp.LastPage) - prs = make([]*releasepr.ReleasePullRequest, 0, (resp.LastPage-1)*PerPageMax) + prs = make([]*ReleasePullRequest, 0, (resp.LastPage-1)*GitHubPerPageMax) } for _, pr := range ghPRs { @@ -478,7 +526,7 @@ func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel releasepr.Lab return prs, nil } -func (g *GitHub) CreateRelease(ctx context.Context, commit git.Commit, title, changelog string, preRelease, latest bool) error { +func (g *GitHub) CreateRelease(ctx context.Context, commit Commit, title, changelog string, preRelease, latest bool) error { makeLatest := "" if latest { makeLatest = "true" @@ -503,29 +551,29 @@ func (g *GitHub) CreateRelease(ctx context.Context, commit git.Commit, title, ch return nil } -func gitHubPRToPullRequest(pr *github.PullRequest) *git.PullRequest { - return &git.PullRequest{ +func gitHubPRToPullRequest(pr *github.PullRequest) *PullRequest { + return &PullRequest{ ID: pr.GetNumber(), Title: pr.GetTitle(), Description: pr.GetBody(), } } -func gitHubPRToReleasePullRequest(pr *github.PullRequest) *releasepr.ReleasePullRequest { - labels := make([]releasepr.Label, 0, len(pr.Labels)) +func gitHubPRToReleasePullRequest(pr *github.PullRequest) *ReleasePullRequest { + labels := make([]Label, 0, len(pr.Labels)) for _, label := range pr.Labels { - labelName := releasepr.Label(label.GetName()) - if slices.Contains(releasepr.KnownLabels, releasepr.Label(label.GetName())) { + labelName := Label(label.GetName()) + if slices.Contains(KnownLabels, Label(label.GetName())) { labels = append(labels, labelName) } } - var releaseCommit *git.Commit + var releaseCommit *Commit if pr.MergeCommitSHA != nil { - releaseCommit = &git.Commit{Hash: pr.GetMergeCommitSHA()} + releaseCommit = &Commit{Hash: pr.GetMergeCommitSHA()} } - return &releasepr.ReleasePullRequest{ + return &ReleasePullRequest{ ID: pr.GetNumber(), Title: pr.GetTitle(), Description: pr.GetBody(), @@ -536,16 +584,16 @@ func gitHubPRToReleasePullRequest(pr *github.PullRequest) *releasepr.ReleasePull } } -func (g *Options) autodiscover() { - if apiToken := os.Getenv(EnvAPIToken); apiToken != "" { +func (g *GitHubOptions) autodiscover() { + if apiToken := os.Getenv(GitHubEnvAPIToken); apiToken != "" { g.APIToken = apiToken } // TODO: Check if there is a better solution for cloning/pushing locally - if username := os.Getenv(EnvUsername); username != "" { + if username := os.Getenv(GitHubEnvUsername); username != "" { g.Username = username } - if envRepository := os.Getenv(EnvRepository); envRepository != "" { + if envRepository := os.Getenv(GitHubEnvRepository); envRepository != "" { // GITHUB_REPOSITORY=apricote/releaser-pleaser parts := strings.Split(envRepository, "/") if len(parts) == 2 { @@ -556,8 +604,8 @@ func (g *Options) autodiscover() { } } -type Options struct { - forge.Options +type GitHubOptions struct { + ForgeOptions Owner string Repo string @@ -566,7 +614,7 @@ type Options struct { Username string } -func New(log *slog.Logger, options *Options) *GitHub { +func NewGitHub(log *slog.Logger, options *GitHubOptions) *GitHub { options.autodiscover() client := github.NewClient(nil) @@ -583,3 +631,29 @@ func New(log *slog.Logger, options *Options) *GitHub { return gh } + +type GitLab struct { + options ForgeOptions +} + +func (g *GitLab) autodiscover() { + // Read settings from GitLab-CI env vars +} + +func NewGitLab(options ForgeOptions) *GitLab { + gl := &GitLab{ + options: options, + } + + gl.autodiscover() + + return gl +} + +func (g *GitLab) RepoURL() string { + return fmt.Sprintf("https://gitlab.com/%s", g.options.Repository) +} + +func Pointer[T any](value T) *T { + return &value +} diff --git a/git.go b/git.go new file mode 100644 index 0000000..9131570 --- /dev/null +++ b/git.go @@ -0,0 +1,52 @@ +package rp + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport" +) + +const ( + GitRemoteName = "origin" +) + +type Tag struct { + Hash string + Name string +} + +func CloneRepo(ctx context.Context, cloneURL, branch string, auth transport.AuthMethod) (*git.Repository, error) { + dir, err := os.MkdirTemp("", "releaser-pleaser.*") + if err != nil { + return nil, fmt.Errorf("failed to create temporary directory for repo clone: %w", err) + } + + // TODO: Log tmpdir + fmt.Printf("Clone tmpdir: %s\n", dir) + repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{ + URL: cloneURL, + RemoteName: GitRemoteName, + ReferenceName: plumbing.NewBranchReferenceName(branch), + SingleBranch: false, + Auth: auth, + }) + if err != nil { + return nil, fmt.Errorf("failed to clone repository: %w", err) + } + + return repo, nil +} + +func GitSignature() *object.Signature { + return &object.Signature{ + Name: "releaser-pleaser", + Email: "", + When: time.Now(), + } +} diff --git a/git_test.go b/git_test.go new file mode 100644 index 0000000..0c37ea1 --- /dev/null +++ b/git_test.go @@ -0,0 +1 @@ +package rp diff --git a/go.mod b/go.mod index 9add194..76b6fcb 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23.0 require ( github.com/blang/semver/v4 v4.0.0 + github.com/go-git/go-billy/v5 v5.5.0 github.com/go-git/go-git/v5 v5.12.0 github.com/google/go-github/v63 v63.0.0 github.com/leodido/go-conventionalcommits v0.12.0 @@ -21,7 +22,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/internal/commitparser/commitparser.go b/internal/commitparser/commitparser.go deleted file mode 100644 index 484d733..0000000 --- a/internal/commitparser/commitparser.go +++ /dev/null @@ -1,17 +0,0 @@ -package commitparser - -import ( - "github.com/apricote/releaser-pleaser/internal/git" -) - -type CommitParser interface { - Analyze(commits []git.Commit) ([]AnalyzedCommit, error) -} - -type AnalyzedCommit struct { - git.Commit - Type string - Description string - Scope *string - BreakingChange bool -} diff --git a/internal/forge/forge.go b/internal/forge/forge.go deleted file mode 100644 index a5f418d..0000000 --- a/internal/forge/forge.go +++ /dev/null @@ -1,61 +0,0 @@ -package forge - -import ( - "context" - - "github.com/go-git/go-git/v5/plumbing/transport" - - "github.com/apricote/releaser-pleaser/internal/git" - "github.com/apricote/releaser-pleaser/internal/releasepr" -) - -type Forge interface { - RepoURL() string - CloneURL() string - ReleaseURL(version string) string - - GitAuth() transport.AuthMethod - - // 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) (git.Releases, 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. - CommitsSince(context.Context, *git.Tag) ([]git.Commit, error) - - // EnsureLabelsExist verifies that all desired labels are available on the repository. If labels are missing, they - // are created them. - EnsureLabelsExist(context.Context, []releasepr.Label) error - - // PullRequestForBranch returns the open pull request between the branch and Options.BaseBranch. If no open PR - // exists, it returns nil. - PullRequestForBranch(context.Context, string) (*releasepr.ReleasePullRequest, error) - - // CreatePullRequest opens a new pull/merge request for the ReleasePullRequest. - CreatePullRequest(context.Context, *releasepr.ReleasePullRequest) error - - // UpdatePullRequest updates the pull/merge request identified through the ID of - // the ReleasePullRequest to the current description and title. - UpdatePullRequest(context.Context, *releasepr.ReleasePullRequest) error - - // SetPullRequestLabels updates the pull/merge request identified through the ID of - // the ReleasePullRequest to the current labels. - SetPullRequestLabels(ctx context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error - - // ClosePullRequest closes the pull/merge request identified through the ID of - // the ReleasePullRequest, as it is no longer required. - ClosePullRequest(context.Context, *releasepr.ReleasePullRequest) error - - // PendingReleases returns a list of ReleasePullRequest. The list should contain all pull/merge requests that are - // merged and have the matching label. - PendingReleases(context.Context, releasepr.Label) ([]*releasepr.ReleasePullRequest, error) - - // CreateRelease creates a release on the Forge, pointing at the commit with the passed in details. - CreateRelease(ctx context.Context, commit git.Commit, title, changelog string, prerelease, latest bool) error -} - -type Options struct { - Repository string - BaseBranch string -} diff --git a/internal/forge/gitlab/gitlab.go b/internal/forge/gitlab/gitlab.go deleted file mode 100644 index 98a4252..0000000 --- a/internal/forge/gitlab/gitlab.go +++ /dev/null @@ -1,31 +0,0 @@ -package gitlab - -import ( - "fmt" - - "github.com/apricote/releaser-pleaser/internal/forge" -) - -// var _ forge.Forge = &GitLab{} - -type GitLab struct { - options forge.Options -} - -func (g *GitLab) autodiscover() { - // Read settings from GitLab-CI env vars -} - -func New(options forge.Options) *GitLab { - gl := &GitLab{ - options: options, - } - - gl.autodiscover() - - return gl -} - -func (g *GitLab) RepoURL() string { - return fmt.Sprintf("https://gitlab.com/%s", g.options.Repository) -} diff --git a/internal/git/git.go b/internal/git/git.go deleted file mode 100644 index 09fd5c9..0000000 --- a/internal/git/git.go +++ /dev/null @@ -1,227 +0,0 @@ -package git - -import ( - "context" - "fmt" - "io" - "log/slog" - "os" - "time" - - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/config" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/go-git/go-git/v5/plumbing/transport" - - "github.com/apricote/releaser-pleaser/internal/updater" -) - -const ( - remoteName = "origin" -) - -type Commit struct { - Hash string - Message string - - PullRequest *PullRequest -} - -type PullRequest struct { - ID int - Title string - Description string -} - -type Tag struct { - Hash string - Name string -} - -type Releases struct { - Latest *Tag - Stable *Tag -} - -func CloneRepo(ctx context.Context, logger *slog.Logger, cloneURL, branch string, auth transport.AuthMethod) (*Repository, error) { - dir, err := os.MkdirTemp("", "releaser-pleaser.*") - if err != nil { - return nil, fmt.Errorf("failed to create temporary directory for repo clone: %w", err) - } - - repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{ - URL: cloneURL, - RemoteName: remoteName, - ReferenceName: plumbing.NewBranchReferenceName(branch), - SingleBranch: false, - Auth: auth, - }) - if err != nil { - return nil, fmt.Errorf("failed to clone repository: %w", err) - } - - return &Repository{r: repo, logger: logger, auth: auth}, nil -} - -type Repository struct { - r *git.Repository - logger *slog.Logger - auth transport.AuthMethod -} - -func (r *Repository) DeleteBranch(ctx context.Context, branch string) error { - if b, _ := r.r.Branch(branch); b != nil { - r.logger.DebugContext(ctx, "deleting local branch", "branch.name", branch) - if err := r.r.DeleteBranch(branch); err != nil { - return err - } - } - - return nil -} - -func (r *Repository) Checkout(_ context.Context, branch string) error { - worktree, err := r.r.Worktree() - if err != nil { - return err - } - - if err = worktree.Checkout(&git.CheckoutOptions{ - Branch: plumbing.NewBranchReferenceName(branch), - Create: true, - }); err != nil { - return fmt.Errorf("failed to check out branch: %w", err) - } - - return nil -} - -func (r *Repository) UpdateFile(_ context.Context, path string, updaters []updater.Updater) error { - worktree, err := r.r.Worktree() - if err != nil { - return err - } - - file, err := worktree.Filesystem.OpenFile(path, os.O_RDWR, 0) - if err != nil { - return err - } - defer file.Close() - - content, err := io.ReadAll(file) - if err != nil { - return err - } - - updatedContent := string(content) - - for _, update := range updaters { - updatedContent, err = update(updatedContent) - if err != nil { - return fmt.Errorf("failed to run updater on file %s", path) - } - } - - err = file.Truncate(0) - if err != nil { - return fmt.Errorf("failed to replace file content: %w", err) - } - _, err = file.Seek(0, 0) - if err != nil { - return fmt.Errorf("failed to replace file content: %w", err) - } - _, err = file.Write([]byte(updatedContent)) - if err != nil { - return fmt.Errorf("failed to replace file content: %w", err) - } - - _, err = worktree.Add(path) - if err != nil { - return fmt.Errorf("failed to add updated file to git worktree: %w", err) - } - - return nil -} - -func (r *Repository) Commit(_ context.Context, message string) (Commit, error) { - worktree, err := r.r.Worktree() - if err != nil { - return Commit{}, err - } - - releaseCommitHash, err := worktree.Commit(message, &git.CommitOptions{ - Author: signature(), - Committer: signature(), - }) - if err != nil { - return Commit{}, fmt.Errorf("failed to commit changes: %w", err) - } - - return Commit{ - Hash: releaseCommitHash.String(), - Message: message, - }, nil -} - -func (r *Repository) HasChangesWithRemote(ctx context.Context, branch string) (bool, error) { - remoteRef, err := r.r.Reference(plumbing.NewRemoteReferenceName(remoteName, branch), false) - if err != nil { - if err.Error() == "reference not found" { - // No remote branch means that there are changes - return true, nil - } - - return false, err - } - - remoteCommit, err := r.r.CommitObject(remoteRef.Hash()) - if err != nil { - return false, err - } - - localRef, err := r.r.Reference(plumbing.NewBranchReferenceName(branch), false) - if err != nil { - return false, err - } - - localCommit, err := r.r.CommitObject(localRef.Hash()) - if err != nil { - return false, err - } - - diff, err := localCommit.PatchContext(ctx, remoteCommit) - if err != nil { - return false, err - } - - hasChanges := len(diff.FilePatches()) > 0 - - return hasChanges, nil -} - -func (r *Repository) ForcePush(ctx context.Context, branch string) error { - pushRefSpec := config.RefSpec(fmt.Sprintf( - "+%s:%s", - plumbing.NewBranchReferenceName(branch), - // This needs to be the local branch name, not the remotes/origin ref - // See https://stackoverflow.com/a/75727620 - plumbing.NewBranchReferenceName(branch), - )) - - r.logger.DebugContext(ctx, "pushing branch", "branch.name", branch, "refspec", pushRefSpec.String()) - return r.r.PushContext(ctx, &git.PushOptions{ - RemoteName: remoteName, - RefSpecs: []config.RefSpec{pushRefSpec}, - Force: true, - Auth: r.auth, - }) -} - -func signature() *object.Signature { - return &object.Signature{ - Name: "releaser-pleaser", - Email: "", - When: time.Now(), - } -} diff --git a/internal/pointer/pointer.go b/internal/pointer/pointer.go deleted file mode 100644 index 1c62f74..0000000 --- a/internal/pointer/pointer.go +++ /dev/null @@ -1,5 +0,0 @@ -package pointer - -func Pointer[T any](value T) *T { - return &value -} diff --git a/internal/releasepr/label.go b/internal/releasepr/label.go deleted file mode 100644 index 518eb87..0000000 --- a/internal/releasepr/label.go +++ /dev/null @@ -1 +0,0 @@ -package releasepr diff --git a/internal/testutils/git.go b/internal/testutils/git.go new file mode 100644 index 0000000..f5721b6 --- /dev/null +++ b/internal/testutils/git.go @@ -0,0 +1,126 @@ +package testutils + +import ( + "testing" + "time" + + "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/storage/memory" + "github.com/stretchr/testify/require" +) + +var author = &object.Signature{ + Name: "releaser-pleaser", + When: time.Date(2020, 01, 01, 01, 01, 01, 01, time.UTC), +} + +type CommitOption func(*commitOptions) + +type commitOptions struct { + cleanFiles bool + files []commitFile + tags []string +} + +type commitFile struct { + path string + content string +} + +type Commit func(*testing.T, *git.Repository) error + +type Repo func(*testing.T) *git.Repository + +func WithCommit(message string, options ...CommitOption) Commit { + return func(t *testing.T, repo *git.Repository) error { + t.Helper() + + require.NotEmpty(t, message, "commit message is required") + + opts := &commitOptions{} + for _, opt := range options { + opt(opts) + } + + wt, err := repo.Worktree() + require.NoError(t, err) + + // Yeet all files + if opts.cleanFiles { + files, err := wt.Filesystem.ReadDir(".") + require.NoError(t, err, "failed to get current files") + + for _, fileInfo := range files { + err = wt.Filesystem.Remove(fileInfo.Name()) + require.NoError(t, err, "failed to remove file %q", fileInfo.Name()) + } + } + + // Create new files + for _, fileInfo := range opts.files { + file, err := wt.Filesystem.Create(fileInfo.path) + require.NoError(t, err, "failed to create file %q", fileInfo.path) + + _, err = file.Write([]byte(fileInfo.content)) + file.Close() + require.NoError(t, err, "failed to write content to file %q", fileInfo.path) + } + + // Commit + commitHash, err := wt.Commit(message, &git.CommitOptions{ + All: true, + AllowEmptyCommits: true, + Author: author, + Committer: author, + }) + require.NoError(t, err, "failed to commit") + + // Create tags + for _, tagName := range opts.tags { + _, err = repo.CreateTag(tagName, commitHash, nil) + require.NoError(t, err, "failed to create tag %q", tagName) + } + + return nil + } +} + +func WithFile(path, content string) CommitOption { + return func(opts *commitOptions) { + opts.files = append(opts.files, commitFile{path: path, content: content}) + } +} + +func WithCleanFiles() CommitOption { + return func(opts *commitOptions) { + opts.cleanFiles = true + } +} + +func WithTag(name string) CommitOption { + return func(opts *commitOptions) { + opts.tags = append(opts.tags, name) + } +} + +func WithTestRepo(commits ...Commit) Repo { + return func(t *testing.T) *git.Repository { + t.Helper() + + repo, err := git.Init(memory.NewStorage(), memfs.New()) + require.NoError(t, err, "failed to create in-memory repository") + + // Make initial commit + err = WithCommit("chore: init")(t, repo) + require.NoError(t, err, "failed to create init commit") + + for i, commit := range commits { + err = commit(t, repo) + require.NoError(t, err, "failed to create commit %d", i) + } + + return repo + } +} diff --git a/internal/updater/changelog.go b/internal/updater/changelog.go deleted file mode 100644 index 8bdb9f6..0000000 --- a/internal/updater/changelog.go +++ /dev/null @@ -1,32 +0,0 @@ -package updater - -import ( - "fmt" - "regexp" -) - -const ( - ChangelogHeader = "# Changelog" - ChangelogFile = "CHANGELOG.md" -) - -var ( - ChangelogUpdaterHeaderRegex = regexp.MustCompile(`^# Changelog\n`) -) - -func Changelog(info ReleaseInfo) Updater { - return func(content string) (string, error) { - headerIndex := ChangelogUpdaterHeaderRegex.FindStringIndex(content) - if headerIndex == nil && len(content) != 0 { - return "", fmt.Errorf("unexpected format of CHANGELOG.md, header does not match") - } - if headerIndex != nil { - // Remove the header from the content - content = content[headerIndex[1]:] - } - - content = ChangelogHeader + "\n\n" + info.ChangelogEntry + content - - return content, nil - } -} diff --git a/internal/updater/changelog_test.go b/internal/updater/changelog_test.go deleted file mode 100644 index 917cd14..0000000 --- a/internal/updater/changelog_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package updater - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestChangelogUpdater_UpdateContent(t *testing.T) { - tests := []updaterTestCase{ - { - name: "empty file", - content: "", - info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n"}, - want: "# Changelog\n\n## v1.0.0\n", - wantErr: assert.NoError, - }, - { - name: "well-formatted changelog", - content: `# Changelog - -## v0.0.1 - -- Bazzle - -## v0.1.0 - -### Bazuuum -`, - info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"}, - want: `# Changelog - -## v1.0.0 - -- Version 1, juhu. - -## v0.0.1 - -- Bazzle - -## v0.1.0 - -### Bazuuum -`, - wantErr: assert.NoError, - }, - { - name: "error on invalid header", - content: "What even is this file?", - info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"}, - want: "", - wantErr: assert.Error, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - runUpdaterTest(t, Changelog, tt) - }) - } -} diff --git a/internal/updater/generic.go b/internal/updater/generic.go deleted file mode 100644 index b8d73b0..0000000 --- a/internal/updater/generic.go +++ /dev/null @@ -1,17 +0,0 @@ -package updater - -import ( - "regexp" - "strings" -) - -var GenericUpdaterSemVerRegex = regexp.MustCompile(`\d+\.\d+\.\d+(-[\w.]+)?(.*x-releaser-pleaser-version)`) - -func Generic(info ReleaseInfo) Updater { - return func(content string) (string, error) { - // We strip the "v" prefix to avoid adding/removing it from the users input. - version := strings.TrimPrefix(info.Version, "v") - - return GenericUpdaterSemVerRegex.ReplaceAllString(content, version+"${2}"), nil - } -} diff --git a/internal/updater/generic_test.go b/internal/updater/generic_test.go deleted file mode 100644 index e0a8d1d..0000000 --- a/internal/updater/generic_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package updater - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestGenericUpdater_UpdateContent(t *testing.T) { - tests := []updaterTestCase{ - { - name: "single line", - content: "v1.0.0 // x-releaser-pleaser-version", - info: ReleaseInfo{ - Version: "v1.2.0", - }, - want: "v1.2.0 // x-releaser-pleaser-version", - wantErr: assert.NoError, - }, - { - name: "multiline line", - content: "Foooo\n\v1.2.0\nv1.0.0 // x-releaser-pleaser-version\n", - info: ReleaseInfo{ - Version: "v1.2.0", - }, - want: "Foooo\n\v1.2.0\nv1.2.0 // x-releaser-pleaser-version\n", - wantErr: assert.NoError, - }, - { - name: "invalid existing version", - content: "1.0 // x-releaser-pleaser-version", - info: ReleaseInfo{ - Version: "v1.2.0", - }, - want: "1.0 // x-releaser-pleaser-version", - wantErr: assert.NoError, - }, - { - name: "complicated line", - content: "version: v1.2.0-alpha.1 => Awesome, isnt it? x-releaser-pleaser-version foobar", - info: ReleaseInfo{ - Version: "v1.2.0", - }, - want: "version: v1.2.0 => Awesome, isnt it? x-releaser-pleaser-version foobar", - wantErr: assert.NoError, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - runUpdaterTest(t, Generic, tt) - }) - } -} diff --git a/internal/updater/updater.go b/internal/updater/updater.go deleted file mode 100644 index fb773b4..0000000 --- a/internal/updater/updater.go +++ /dev/null @@ -1,19 +0,0 @@ -package updater - -type ReleaseInfo struct { - Version string - ChangelogEntry string -} - -type Updater func(string) (string, error) - -type NewUpdater func(ReleaseInfo) Updater - -func WithInfo(info ReleaseInfo, constructors ...NewUpdater) []Updater { - updaters := make([]Updater, 0, len(constructors)) - for _, constructor := range constructors { - updaters = append(updaters, constructor(info)) - } - - return updaters -} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go deleted file mode 100644 index 0c0c40e..0000000 --- a/internal/updater/updater_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package updater - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" -) - -type updaterTestCase struct { - name string - content string - info ReleaseInfo - want string - wantErr assert.ErrorAssertionFunc -} - -func runUpdaterTest(t *testing.T, constructor NewUpdater, tt updaterTestCase) { - t.Helper() - - got, err := constructor(tt.info)(tt.content) - if !tt.wantErr(t, err, fmt.Sprintf("Updater(%v, %v)", tt.content, tt.info)) { - return - } - assert.Equalf(t, tt.want, got, "Updater(%v, %v)", tt.content, tt.info) -} diff --git a/internal/versioning/versioning.go b/internal/versioning/versioning.go deleted file mode 100644 index 3bf8138..0000000 --- a/internal/versioning/versioning.go +++ /dev/null @@ -1,56 +0,0 @@ -package versioning - -import ( - "github.com/leodido/go-conventionalcommits" - - "github.com/apricote/releaser-pleaser/internal/git" -) - -type Strategy = func(git.Releases, VersionBump, NextVersionType) (string, error) - -type VersionBump conventionalcommits.VersionBump - -const ( - UnknownVersion VersionBump = iota - PatchVersion - MinorVersion - MajorVersion -) - -type NextVersionType int - -const ( - NextVersionTypeUndefined NextVersionType = iota - NextVersionTypeNormal - NextVersionTypeRC - NextVersionTypeBeta - NextVersionTypeAlpha -) - -func (n NextVersionType) String() string { - switch n { - case NextVersionTypeUndefined: - return "undefined" - case NextVersionTypeNormal: - return "normal" - case NextVersionTypeRC: - return "rc" - case NextVersionTypeBeta: - return "beta" - case NextVersionTypeAlpha: - return "alpha" - default: - return "" - } -} - -func (n NextVersionType) IsPrerelease() bool { - switch n { - case NextVersionTypeRC, NextVersionTypeBeta, NextVersionTypeAlpha: - return true - case NextVersionTypeUndefined, NextVersionTypeNormal: - return false - default: - return false - } -} diff --git a/internal/releasepr/releasepr.go b/releasepr.go similarity index 81% rename from internal/releasepr/releasepr.go rename to releasepr.go index 5bf3649..e177010 100644 --- a/internal/releasepr/releasepr.go +++ b/releasepr.go @@ -1,4 +1,4 @@ -package releasepr +package rp import ( "bytes" @@ -12,10 +12,8 @@ import ( "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/text" - "github.com/apricote/releaser-pleaser/internal/git" "github.com/apricote/releaser-pleaser/internal/markdown" - ast2 "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast" - "github.com/apricote/releaser-pleaser/internal/versioning" + east "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast" ) var ( @@ -35,7 +33,7 @@ func init() { // ReleasePullRequest // -// TODO: Reuse [git.PullRequest] +// TODO: Reuse [PullRequest] type ReleasePullRequest struct { ID int Title string @@ -43,12 +41,9 @@ type ReleasePullRequest struct { Labels []Label Head string - ReleaseCommit *git.Commit + ReleaseCommit *Commit } -// Label is the string identifier of a pull/merge request label on the forge. -type Label string - func NewReleasePullRequest(head, branch, version, changelogEntry string) (*ReleasePullRequest, error) { rp := &ReleasePullRequest{ Head: head, @@ -66,9 +61,50 @@ func NewReleasePullRequest(head, branch, version, changelogEntry string) (*Relea type ReleaseOverrides struct { Prefix string Suffix string - NextVersionType versioning.NextVersionType + NextVersionType NextVersionType } +type NextVersionType int + +const ( + NextVersionTypeUndefined NextVersionType = iota + NextVersionTypeNormal + NextVersionTypeRC + NextVersionTypeBeta + NextVersionTypeAlpha +) + +func (n NextVersionType) String() string { + switch n { + case NextVersionTypeUndefined: + return "undefined" + case NextVersionTypeNormal: + return "normal" + case NextVersionTypeRC: + return "rc" + case NextVersionTypeBeta: + return "beta" + case NextVersionTypeAlpha: + return "alpha" + default: + return "" + } +} + +func (n NextVersionType) IsPrerelease() bool { + switch n { + case NextVersionTypeRC, NextVersionTypeBeta, NextVersionTypeAlpha: + return true + case NextVersionTypeUndefined, NextVersionTypeNormal: + return false + default: + return false + } +} + +// Label is the string identifier of a pull/merge request label on the forge. +type Label string + const ( LabelNextVersionTypeNormal Label = "rp-next-version::normal" LabelNextVersionTypeRC Label = "rp-next-version::rc" @@ -122,13 +158,13 @@ func (pr *ReleasePullRequest) parseVersioningFlags(overrides ReleaseOverrides) R switch label { // Versioning case LabelNextVersionTypeNormal: - overrides.NextVersionType = versioning.NextVersionTypeNormal + overrides.NextVersionType = NextVersionTypeNormal case LabelNextVersionTypeRC: - overrides.NextVersionType = versioning.NextVersionTypeRC + overrides.NextVersionType = NextVersionTypeRC case LabelNextVersionTypeBeta: - overrides.NextVersionType = versioning.NextVersionTypeBeta + overrides.NextVersionType = NextVersionTypeBeta case LabelNextVersionTypeAlpha: - overrides.NextVersionType = versioning.NextVersionTypeAlpha + overrides.NextVersionType = NextVersionTypeAlpha case LabelReleasePending, LabelReleaseTagged: // These labels have no effect on the versioning. break @@ -177,18 +213,18 @@ func (pr *ReleasePullRequest) ChangelogText() (string, error) { gm := markdown.New() descriptionAST := gm.Parser().Parse(text.NewReader(source)) - var section *ast2.Section + var section *east.Section err := ast.Walk(descriptionAST, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } - if n.Type() != ast.TypeBlock || n.Kind() != ast2.KindSection { + if n.Type() != ast.TypeBlock || n.Kind() != east.KindSection { return ast.WalkContinue, nil } - anySection, ok := n.(*ast2.Section) + anySection, ok := n.(*east.Section) if !ok { return ast.WalkStop, fmt.Errorf("node has unexpected type: %T", n) } diff --git a/internal/releasepr/releasepr.md.tpl b/releasepr.md.tpl similarity index 100% rename from internal/releasepr/releasepr.md.tpl rename to releasepr.md.tpl diff --git a/internal/releasepr/releasepr_test.go b/releasepr_test.go similarity index 99% rename from internal/releasepr/releasepr_test.go rename to releasepr_test.go index 92f338d..b1ffbb2 100644 --- a/internal/releasepr/releasepr_test.go +++ b/releasepr_test.go @@ -1,4 +1,4 @@ -package releasepr +package rp import ( "testing" diff --git a/releaserpleaser.go b/releaserpleaser.go index 54064a4..f0bf6cd 100644 --- a/releaserpleaser.go +++ b/releaserpleaser.go @@ -3,15 +3,13 @@ package rp import ( "context" "fmt" + "io" "log/slog" + "os" - "github.com/apricote/releaser-pleaser/internal/changelog" - "github.com/apricote/releaser-pleaser/internal/commitparser" - "github.com/apricote/releaser-pleaser/internal/forge" - "github.com/apricote/releaser-pleaser/internal/git" - "github.com/apricote/releaser-pleaser/internal/releasepr" - "github.com/apricote/releaser-pleaser/internal/updater" - "github.com/apricote/releaser-pleaser/internal/versioning" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" ) const ( @@ -19,16 +17,16 @@ const ( ) type ReleaserPleaser struct { - forge forge.Forge + forge Forge logger *slog.Logger targetBranch string - commitParser commitparser.CommitParser - nextVersion versioning.Strategy + commitParser CommitParser + nextVersion VersioningStrategy extraFiles []string - updaters []updater.NewUpdater + updaters []Updater } -func New(forge forge.Forge, logger *slog.Logger, targetBranch string, commitParser commitparser.CommitParser, versioningStrategy versioning.Strategy, extraFiles []string, updaters []updater.NewUpdater) *ReleaserPleaser { +func New(forge Forge, logger *slog.Logger, targetBranch string, commitParser CommitParser, versioningStrategy VersioningStrategy, extraFiles []string, updaters []Updater) *ReleaserPleaser { return &ReleaserPleaser{ forge: forge, logger: logger, @@ -42,8 +40,7 @@ func New(forge forge.Forge, logger *slog.Logger, targetBranch string, commitPars func (rp *ReleaserPleaser) EnsureLabels(ctx context.Context) error { // TODO: Wrap Error - - return rp.forge.EnsureLabelsExist(ctx, releasepr.KnownLabels) + return rp.forge.EnsureLabelsExist(ctx, KnownLabels) } func (rp *ReleaserPleaser) Run(ctx context.Context) error { @@ -78,7 +75,7 @@ func (rp *ReleaserPleaser) runCreatePendingReleases(ctx context.Context) error { logger := rp.logger.With("method", "runCreatePendingReleases") logger.InfoContext(ctx, "checking for pending releases") - prs, err := rp.forge.PendingReleases(ctx, releasepr.LabelReleasePending) + prs, err := rp.forge.PendingReleases(ctx, LabelReleasePending) if err != nil { return err } @@ -100,7 +97,7 @@ func (rp *ReleaserPleaser) runCreatePendingReleases(ctx context.Context) error { return nil } -func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *releasepr.ReleasePullRequest) error { +func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *ReleasePullRequest) error { logger := rp.logger.With( "method", "createPendingRelease", "pr.id", pr.ID, @@ -132,7 +129,7 @@ func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *release logger.DebugContext(ctx, "created release", "release.title", version, "release.url", rp.forge.ReleaseURL(version)) logger.DebugContext(ctx, "updating pr labels") - err = rp.forge.SetPullRequestLabels(ctx, pr, []releasepr.Label{releasepr.LabelReleasePending}, []releasepr.Label{releasepr.LabelReleaseTagged}) + err = rp.forge.SetPullRequestLabels(ctx, pr, []Label{LabelReleasePending}, []Label{LabelReleaseTagged}) if err != nil { return err } @@ -147,13 +144,14 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { logger := rp.logger.With("method", "runReconcileReleasePR") rpBranch := fmt.Sprintf(PullRequestBranchFormat, rp.targetBranch) + rpBranchRef := plumbing.NewBranchReferenceName(rpBranch) pr, err := rp.forge.PullRequestForBranch(ctx, rpBranch) if err != nil { return err } - var releaseOverrides releasepr.ReleaseOverrides + var releaseOverrides ReleaseOverrides if pr != nil { logger = logger.With("pr.id", pr.ID, "pr.title", pr.Title) @@ -217,7 +215,7 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { return nil } - versionBump := versioning.BumpFromCommits(analyzedCommits) + versionBump := VersionBumpFromCommits(analyzedCommits) // TODO: Set version in release pr nextVersion, err := rp.nextVersion(releases, versionBump, releaseOverrides.NextVersionType) if err != nil { @@ -226,68 +224,161 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { logger.InfoContext(ctx, "next version", "version", nextVersion) logger.DebugContext(ctx, "cloning repository", "clone.url", rp.forge.CloneURL()) - repo, err := git.CloneRepo(ctx, logger, rp.forge.CloneURL(), rp.targetBranch, rp.forge.GitAuth()) + repo, err := CloneRepo(ctx, rp.forge.CloneURL(), rp.targetBranch, rp.forge.GitAuth()) if err != nil { return fmt.Errorf("failed to clone repository: %w", err) } - - if err = repo.DeleteBranch(ctx, rpBranch); err != nil { + worktree, err := repo.Worktree() + if err != nil { return err } - if err = repo.Checkout(ctx, rpBranch); err != nil { - return err + if branch, _ := repo.Branch(rpBranch); branch != nil { + logger.DebugContext(ctx, "deleting previous releaser-pleaser branch locally", "branch.name", rpBranch) + if err = repo.DeleteBranch(rpBranch); err != nil { + return err + } } - changelogEntry, err := changelog.NewChangelogEntry(analyzedCommits, nextVersion, rp.forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix) + if err = worktree.Checkout(&git.CheckoutOptions{ + Branch: rpBranchRef, + Create: true, + }); err != nil { + return fmt.Errorf("failed to check out branch: %w", err) + } + + changelogEntry, err := NewChangelogEntry(analyzedCommits, nextVersion, rp.forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix) if err != nil { return fmt.Errorf("failed to build changelog entry: %w", err) } // Info for updaters - info := updater.ReleaseInfo{Version: nextVersion, ChangelogEntry: changelogEntry} + info := ReleaseInfo{Version: nextVersion, ChangelogEntry: changelogEntry} - err = repo.UpdateFile(ctx, updater.ChangelogFile, updater.WithInfo(info, updater.Changelog)) + updateFile := func(path string, updaters []Updater) error { + file, err := worktree.Filesystem.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return err + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return err + } + + updatedContent := string(content) + + for _, updater := range updaters { + updatedContent, err = updater.UpdateContent(updatedContent, info) + if err != nil { + return fmt.Errorf("failed to run updater %T on file %s", updater, path) + } + } + + err = file.Truncate(0) + if err != nil { + return fmt.Errorf("failed to replace file content: %w", err) + } + _, err = file.Seek(0, 0) + if err != nil { + return fmt.Errorf("failed to replace file content: %w", err) + } + _, err = file.Write([]byte(updatedContent)) + if err != nil { + return fmt.Errorf("failed to replace file content: %w", err) + } + + _, err = worktree.Add(path) + if err != nil { + return fmt.Errorf("failed to add updated file to git worktree: %w", err) + } + + return nil + } + + err = updateFile(ChangelogFile, []Updater{&ChangelogUpdater{}}) if err != nil { return fmt.Errorf("failed to update changelog file: %w", err) } for _, path := range rp.extraFiles { - // TODO: Check for missing files - err = repo.UpdateFile(ctx, path, updater.WithInfo(info, rp.updaters...)) + _, err = worktree.Filesystem.Stat(path) + if err != nil { + // TODO: Check for non existing file or dirs + return fmt.Errorf("failed to run file updater because the file %s does not exist: %w", path, err) + } + + err = updateFile(path, rp.updaters) if err != nil { return fmt.Errorf("failed to run file updater: %w", err) } } releaseCommitMessage := fmt.Sprintf("chore(%s): release %s", rp.targetBranch, nextVersion) - releaseCommit, err := repo.Commit(ctx, releaseCommitMessage) + releaseCommitHash, err := worktree.Commit(releaseCommitMessage, &git.CommitOptions{ + Author: GitSignature(), + Committer: GitSignature(), + }) if err != nil { return fmt.Errorf("failed to commit changes: %w", err) } - logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommit.Hash, "commit.message", releaseCommit.Message) + logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommitHash.String(), "commit.message", releaseCommitMessage) + + newReleasePRChanges := true // Check if anything changed in comparison to the remote branch (if exists) - newReleasePRChanges, err := repo.HasChangesWithRemote(ctx, rpBranch) - if err != nil { - return err + if remoteRef, err := repo.Reference(plumbing.NewRemoteReferenceName(GitRemoteName, rpBranch), false); err != nil { + if err.Error() != "reference not found" { + // "reference not found" is expected and we should always push + return err + } + } else { + remoteCommit, err := repo.CommitObject(remoteRef.Hash()) + if err != nil { + return err + } + + localCommit, err := repo.CommitObject(releaseCommitHash) + if err != nil { + return err + } + + diff, err := localCommit.PatchContext(ctx, remoteCommit) + if err != nil { + return err + } + + newReleasePRChanges = len(diff.FilePatches()) > 0 } if newReleasePRChanges { - err = repo.ForcePush(ctx, rpBranch) - if err != nil { + pushRefSpec := config.RefSpec(fmt.Sprintf( + "+%s:%s", + rpBranchRef, + // This needs to be the local branch name, not the remotes/origin ref + // See https://stackoverflow.com/a/75727620 + rpBranchRef, + )) + logger.DebugContext(ctx, "pushing branch", "commit.hash", releaseCommitHash.String(), "branch.name", rpBranch, "refspec", pushRefSpec.String()) + if err = repo.PushContext(ctx, &git.PushOptions{ + RemoteName: GitRemoteName, + RefSpecs: []config.RefSpec{pushRefSpec}, + Force: true, + Auth: rp.forge.GitAuth(), + }); err != nil { return fmt.Errorf("failed to push branch: %w", err) } - logger.InfoContext(ctx, "pushed branch", "commit.hash", releaseCommit.Hash, "branch.name", rpBranch) + logger.InfoContext(ctx, "pushed branch", "commit.hash", releaseCommitHash.String(), "branch.name", rpBranch, "refspec", pushRefSpec.String()) } else { logger.InfoContext(ctx, "file content is already up-to-date in remote branch, skipping push") } // Open/Update PR if pr == nil { - pr, err = releasepr.NewReleasePullRequest(rpBranch, rp.targetBranch, nextVersion, changelogEntry) + pr, err = NewReleasePullRequest(rpBranch, rp.targetBranch, nextVersion, changelogEntry) if err != nil { return err } diff --git a/updater.go b/updater.go new file mode 100644 index 0000000..3aaedae --- /dev/null +++ b/updater.go @@ -0,0 +1,47 @@ +package rp + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + GenericUpdaterSemVerRegex = regexp.MustCompile(`\d+\.\d+\.\d+(-[\w.]+)?(.*x-releaser-pleaser-version)`) + ChangelogUpdaterHeaderRegex = regexp.MustCompile(`^# Changelog\n`) +) + +type ReleaseInfo struct { + Version string + ChangelogEntry string +} + +type Updater interface { + UpdateContent(content string, info ReleaseInfo) (string, error) +} + +type GenericUpdater struct{} + +func (u *GenericUpdater) UpdateContent(content string, info ReleaseInfo) (string, error) { + // We strip the "v" prefix to avoid adding/removing it from the users input. + version := strings.TrimPrefix(info.Version, "v") + + return GenericUpdaterSemVerRegex.ReplaceAllString(content, version+"${2}"), nil +} + +type ChangelogUpdater struct{} + +func (u *ChangelogUpdater) UpdateContent(content string, info ReleaseInfo) (string, error) { + headerIndex := ChangelogUpdaterHeaderRegex.FindStringIndex(content) + if headerIndex == nil && len(content) != 0 { + return "", fmt.Errorf("unexpected format of CHANGELOG.md, header does not match") + } + if headerIndex != nil { + // Remove the header from the content + content = content[headerIndex[1]:] + } + + content = ChangelogHeader + "\n\n" + info.ChangelogEntry + content + + return content, nil +} diff --git a/updater_test.go b/updater_test.go new file mode 100644 index 0000000..c0e1419 --- /dev/null +++ b/updater_test.go @@ -0,0 +1,129 @@ +package rp + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +type updaterTestCase struct { + name string + content string + info ReleaseInfo + want string + wantErr assert.ErrorAssertionFunc +} + +func runUpdaterTest(t *testing.T, updater Updater, tt updaterTestCase) { + t.Helper() + + got, err := updater.UpdateContent(tt.content, tt.info) + if !tt.wantErr(t, err, fmt.Sprintf("UpdateContent(%v, %v)", tt.content, tt.info)) { + return + } + assert.Equalf(t, tt.want, got, "UpdateContent(%v, %v)", tt.content, tt.info) +} + +func TestGenericUpdater_UpdateContent(t *testing.T) { + updater := &GenericUpdater{} + + tests := []updaterTestCase{ + { + name: "single line", + content: "v1.0.0 // x-releaser-pleaser-version", + info: ReleaseInfo{ + Version: "v1.2.0", + }, + want: "v1.2.0 // x-releaser-pleaser-version", + wantErr: assert.NoError, + }, + { + name: "multiline line", + content: "Foooo\n\v1.2.0\nv1.0.0 // x-releaser-pleaser-version\n", + info: ReleaseInfo{ + Version: "v1.2.0", + }, + want: "Foooo\n\v1.2.0\nv1.2.0 // x-releaser-pleaser-version\n", + wantErr: assert.NoError, + }, + { + name: "invalid existing version", + content: "1.0 // x-releaser-pleaser-version", + info: ReleaseInfo{ + Version: "v1.2.0", + }, + want: "1.0 // x-releaser-pleaser-version", + wantErr: assert.NoError, + }, + { + name: "complicated line", + content: "version: v1.2.0-alpha.1 => Awesome, isnt it? x-releaser-pleaser-version foobar", + info: ReleaseInfo{ + Version: "v1.2.0", + }, + want: "version: v1.2.0 => Awesome, isnt it? x-releaser-pleaser-version foobar", + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runUpdaterTest(t, updater, tt) + }) + } +} + +func TestChangelogUpdater_UpdateContent(t *testing.T) { + updater := &ChangelogUpdater{} + + tests := []updaterTestCase{ + { + name: "empty file", + content: "", + info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n"}, + want: "# Changelog\n\n## v1.0.0\n", + wantErr: assert.NoError, + }, + { + name: "well-formatted changelog", + content: `# Changelog + +## v0.0.1 + +- Bazzle + +## v0.1.0 + +### Bazuuum +`, + info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"}, + want: `# Changelog + +## v1.0.0 + +- Version 1, juhu. + +## v0.0.1 + +- Bazzle + +## v0.1.0 + +### Bazuuum +`, + wantErr: assert.NoError, + }, + { + name: "error on invalid header", + content: "What even is this file?", + info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"}, + want: "", + wantErr: assert.Error, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runUpdaterTest(t, updater, tt) + }) + } +} diff --git a/internal/versioning/semver.go b/versioning.go similarity index 68% rename from internal/versioning/semver.go rename to versioning.go index 49dc019..d18d480 100644 --- a/internal/versioning/semver.go +++ b/versioning.go @@ -1,18 +1,23 @@ -package versioning +package rp import ( "fmt" "strings" "github.com/blang/semver/v4" - - "github.com/apricote/releaser-pleaser/internal/commitparser" - "github.com/apricote/releaser-pleaser/internal/git" + "github.com/leodido/go-conventionalcommits" ) -var _ Strategy = SemVerNextVersion +type Releases struct { + Latest *Tag + Stable *Tag +} -func SemVerNextVersion(r git.Releases, versionBump VersionBump, nextVersionType NextVersionType) (string, error) { +type VersioningStrategy = func(Releases, conventionalcommits.VersionBump, NextVersionType) (string, error) + +var _ VersioningStrategy = SemVerNextVersion + +func SemVerNextVersion(r Releases, versionBump conventionalcommits.VersionBump, nextVersionType NextVersionType) (string, error) { latest, err := parseSemverWithDefault(r.Latest) if err != nil { return "", fmt.Errorf("failed to parse latest version: %w", err) @@ -31,13 +36,13 @@ func SemVerNextVersion(r git.Releases, versionBump VersionBump, nextVersionType } switch versionBump { - case UnknownVersion: + case conventionalcommits.UnknownVersion: return "", fmt.Errorf("invalid latest bump (unknown)") - case PatchVersion: + case conventionalcommits.PatchVersion: err = next.IncrementPatch() - case MinorVersion: + case conventionalcommits.MinorVersion: err = next.IncrementMinor() - case MajorVersion: + case conventionalcommits.MajorVersion: err = next.IncrementMajor() } if err != nil { @@ -63,18 +68,18 @@ func SemVerNextVersion(r git.Releases, versionBump VersionBump, nextVersionType return "v" + next.String(), nil } -func BumpFromCommits(commits []commitparser.AnalyzedCommit) VersionBump { - bump := UnknownVersion +func VersionBumpFromCommits(commits []AnalyzedCommit) conventionalcommits.VersionBump { + bump := conventionalcommits.UnknownVersion for _, commit := range commits { - entryBump := UnknownVersion + entryBump := conventionalcommits.UnknownVersion switch { case commit.BreakingChange: - entryBump = MajorVersion + entryBump = conventionalcommits.MajorVersion case commit.Type == "feat": - entryBump = MinorVersion + entryBump = conventionalcommits.MinorVersion case commit.Type == "fix": - entryBump = PatchVersion + entryBump = conventionalcommits.PatchVersion } if entryBump > bump { @@ -92,7 +97,7 @@ func setPRVersion(version *semver.Version, prType string, count uint64) { } } -func parseSemverWithDefault(tag *git.Tag) (semver.Version, error) { +func parseSemverWithDefault(tag *Tag) (semver.Version, error) { version := "v0.0.0" if tag != nil { version = tag.Name diff --git a/internal/versioning/semver_test.go b/versioning_test.go similarity index 55% rename from internal/versioning/semver_test.go rename to versioning_test.go index db22c88..b6a0995 100644 --- a/internal/versioning/semver_test.go +++ b/versioning_test.go @@ -1,19 +1,17 @@ -package versioning +package rp import ( "fmt" "testing" + "github.com/leodido/go-conventionalcommits" "github.com/stretchr/testify/assert" - - "github.com/apricote/releaser-pleaser/internal/commitparser" - "github.com/apricote/releaser-pleaser/internal/git" ) func TestReleases_NextVersion(t *testing.T) { type args struct { - releases git.Releases - versionBump VersionBump + releases Releases + versionBump conventionalcommits.VersionBump nextVersionType NextVersionType } tests := []struct { @@ -25,11 +23,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "simple bump (major)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1"}, - Stable: &git.Tag{Name: "v1.1.1"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, }, - versionBump: MajorVersion, + versionBump: conventionalcommits.MajorVersion, nextVersionType: NextVersionTypeUndefined, }, want: "v2.0.0", @@ -38,11 +36,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "simple bump (minor)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1"}, - Stable: &git.Tag{Name: "v1.1.1"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, }, - versionBump: MinorVersion, + versionBump: conventionalcommits.MinorVersion, nextVersionType: NextVersionTypeUndefined, }, want: "v1.2.0", @@ -51,11 +49,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "simple bump (patch)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1"}, - Stable: &git.Tag{Name: "v1.1.1"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, }, - versionBump: PatchVersion, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeUndefined, }, want: "v1.1.2", @@ -64,11 +62,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "normal to prerelease (major)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1"}, - Stable: &git.Tag{Name: "v1.1.1"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, }, - versionBump: MajorVersion, + versionBump: conventionalcommits.MajorVersion, nextVersionType: NextVersionTypeRC, }, want: "v2.0.0-rc.0", @@ -77,11 +75,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "normal to prerelease (minor)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1"}, - Stable: &git.Tag{Name: "v1.1.1"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, }, - versionBump: MinorVersion, + versionBump: conventionalcommits.MinorVersion, nextVersionType: NextVersionTypeRC, }, want: "v1.2.0-rc.0", @@ -90,11 +88,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "normal to prerelease (patch)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1"}, - Stable: &git.Tag{Name: "v1.1.1"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, }, - versionBump: PatchVersion, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeRC, }, want: "v1.1.2-rc.0", @@ -103,11 +101,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "prerelease bump (major)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v2.0.0-rc.0"}, - Stable: &git.Tag{Name: "v1.1.1"}, + releases: Releases{ + Latest: &Tag{Name: "v2.0.0-rc.0"}, + Stable: &Tag{Name: "v1.1.1"}, }, - versionBump: MajorVersion, + versionBump: conventionalcommits.MajorVersion, nextVersionType: NextVersionTypeRC, }, want: "v2.0.0-rc.1", @@ -116,11 +114,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "prerelease bump (minor)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.2.0-rc.0"}, - Stable: &git.Tag{Name: "v1.1.1"}, + releases: Releases{ + Latest: &Tag{Name: "v1.2.0-rc.0"}, + Stable: &Tag{Name: "v1.1.1"}, }, - versionBump: MinorVersion, + versionBump: conventionalcommits.MinorVersion, nextVersionType: NextVersionTypeRC, }, want: "v1.2.0-rc.1", @@ -129,11 +127,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "prerelease bump (patch)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.2-rc.0"}, - Stable: &git.Tag{Name: "v1.1.1"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.2-rc.0"}, + Stable: &Tag{Name: "v1.1.1"}, }, - versionBump: PatchVersion, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeRC, }, want: "v1.1.2-rc.1", @@ -142,11 +140,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "prerelease different bump (major)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.2.0-rc.0"}, - Stable: &git.Tag{Name: "v1.1.1"}, + releases: Releases{ + Latest: &Tag{Name: "v1.2.0-rc.0"}, + Stable: &Tag{Name: "v1.1.1"}, }, - versionBump: MajorVersion, + versionBump: conventionalcommits.MajorVersion, nextVersionType: NextVersionTypeRC, }, want: "v2.0.0-rc.1", @@ -155,11 +153,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "prerelease different bump (minor)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.2-rc.0"}, - Stable: &git.Tag{Name: "v1.1.1"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.2-rc.0"}, + Stable: &Tag{Name: "v1.1.1"}, }, - versionBump: MinorVersion, + versionBump: conventionalcommits.MinorVersion, nextVersionType: NextVersionTypeRC, }, want: "v1.2.0-rc.1", @@ -168,11 +166,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "prerelease to prerelease", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1-alpha.2"}, - Stable: &git.Tag{Name: "v1.1.0"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-alpha.2"}, + Stable: &Tag{Name: "v1.1.0"}, }, - versionBump: PatchVersion, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeRC, }, want: "v1.1.1-rc.0", @@ -181,11 +179,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "prerelease to normal (explicit)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1-alpha.2"}, - Stable: &git.Tag{Name: "v1.1.0"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-alpha.2"}, + Stable: &Tag{Name: "v1.1.0"}, }, - versionBump: PatchVersion, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeNormal, }, want: "v1.1.1", @@ -194,11 +192,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "prerelease to normal (implicit)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1-alpha.2"}, - Stable: &git.Tag{Name: "v1.1.0"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-alpha.2"}, + Stable: &Tag{Name: "v1.1.0"}, }, - versionBump: PatchVersion, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeUndefined, }, want: "v1.1.1", @@ -207,11 +205,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "nil tag (major)", args: args{ - releases: git.Releases{ + releases: Releases{ Latest: nil, Stable: nil, }, - versionBump: MajorVersion, + versionBump: conventionalcommits.MajorVersion, nextVersionType: NextVersionTypeUndefined, }, want: "v1.0.0", @@ -220,11 +218,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "nil tag (minor)", args: args{ - releases: git.Releases{ + releases: Releases{ Latest: nil, Stable: nil, }, - versionBump: MinorVersion, + versionBump: conventionalcommits.MinorVersion, nextVersionType: NextVersionTypeUndefined, }, want: "v0.1.0", @@ -233,11 +231,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "nil tag (patch)", args: args{ - releases: git.Releases{ + releases: Releases{ Latest: nil, Stable: nil, }, - versionBump: PatchVersion, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeUndefined, }, want: "v0.0.1", @@ -246,11 +244,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "nil stable release (major)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1-rc.0"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-rc.0"}, Stable: nil, }, - versionBump: MajorVersion, + versionBump: conventionalcommits.MajorVersion, nextVersionType: NextVersionTypeUndefined, }, want: "v2.0.0", @@ -259,11 +257,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "nil stable release (minor)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1-rc.0"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-rc.0"}, Stable: nil, }, - versionBump: MinorVersion, + versionBump: conventionalcommits.MinorVersion, nextVersionType: NextVersionTypeUndefined, }, want: "v1.2.0", @@ -272,11 +270,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "nil stable release (patch)", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1-rc.0"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-rc.0"}, Stable: nil, }, - versionBump: PatchVersion, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeUndefined, }, // TODO: Is this actually correct our should it be v1.1.1? @@ -286,11 +284,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "error on invalid tag semver", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "foodazzle"}, - Stable: &git.Tag{Name: "foodazzle"}, + releases: Releases{ + Latest: &Tag{Name: "foodazzle"}, + Stable: &Tag{Name: "foodazzle"}, }, - versionBump: PatchVersion, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeRC, }, want: "", @@ -299,11 +297,11 @@ func TestReleases_NextVersion(t *testing.T) { { name: "error on invalid tag prerelease", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1-rc.foo"}, - Stable: &git.Tag{Name: "v1.1.1-rc.foo"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-rc.foo"}, + Stable: &Tag{Name: "v1.1.1-rc.foo"}, }, - versionBump: PatchVersion, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeRC, }, want: "", @@ -312,12 +310,12 @@ func TestReleases_NextVersion(t *testing.T) { { name: "error on invalid bump", args: args{ - releases: git.Releases{ - Latest: &git.Tag{Name: "v1.1.1"}, - Stable: &git.Tag{Name: "v1.1.1"}, + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, }, - versionBump: UnknownVersion, + versionBump: conventionalcommits.UnknownVersion, nextVersionType: NextVersionTypeUndefined, }, want: "", @@ -338,53 +336,53 @@ func TestReleases_NextVersion(t *testing.T) { func TestVersionBumpFromCommits(t *testing.T) { tests := []struct { name string - analyzedCommits []commitparser.AnalyzedCommit - want VersionBump + analyzedCommits []AnalyzedCommit + want conventionalcommits.VersionBump }{ { name: "no entries (unknown)", - analyzedCommits: []commitparser.AnalyzedCommit{}, - want: UnknownVersion, + analyzedCommits: []AnalyzedCommit{}, + want: conventionalcommits.UnknownVersion, }, { name: "non-release type (unknown)", - analyzedCommits: []commitparser.AnalyzedCommit{{Type: "docs"}}, - want: UnknownVersion, + analyzedCommits: []AnalyzedCommit{{Type: "docs"}}, + want: conventionalcommits.UnknownVersion, }, { name: "single breaking (major)", - analyzedCommits: []commitparser.AnalyzedCommit{{BreakingChange: true}}, - want: MajorVersion, + analyzedCommits: []AnalyzedCommit{{BreakingChange: true}}, + want: conventionalcommits.MajorVersion, }, { name: "single feat (minor)", - analyzedCommits: []commitparser.AnalyzedCommit{{Type: "feat"}}, - want: MinorVersion, + analyzedCommits: []AnalyzedCommit{{Type: "feat"}}, + want: conventionalcommits.MinorVersion, }, { name: "single fix (patch)", - analyzedCommits: []commitparser.AnalyzedCommit{{Type: "fix"}}, - want: PatchVersion, + analyzedCommits: []AnalyzedCommit{{Type: "fix"}}, + want: conventionalcommits.PatchVersion, }, { name: "multiple entries (major)", - analyzedCommits: []commitparser.AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}}, - want: MajorVersion, + analyzedCommits: []AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}}, + want: conventionalcommits.MajorVersion, }, { name: "multiple entries (minor)", - analyzedCommits: []commitparser.AnalyzedCommit{{Type: "fix"}, {Type: "feat"}}, - want: MinorVersion, + analyzedCommits: []AnalyzedCommit{{Type: "fix"}, {Type: "feat"}}, + want: conventionalcommits.MinorVersion, }, { name: "multiple entries (patch)", - analyzedCommits: []commitparser.AnalyzedCommit{{Type: "docs"}, {Type: "fix"}}, - want: PatchVersion, + analyzedCommits: []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, BumpFromCommits(tt.analyzedCommits), "BumpFromCommits(%v)", tt.analyzedCommits) + assert.Equalf(t, tt.want, VersionBumpFromCommits(tt.analyzedCommits), "VersionBumpFromCommits(%v)", tt.analyzedCommits) }) } }