refactor: move things to packages (#39)

This commit is contained in:
Julian Tölle 2024-08-31 15:23:21 +02:00 committed by GitHub
parent 44184a77f9
commit a0a064d387
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 923 additions and 892 deletions

View file

@ -6,6 +6,11 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
rp "github.com/apricote/releaser-pleaser" 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{ var runCmd = &cobra.Command{
@ -41,9 +46,9 @@ func run(cmd *cobra.Command, _ []string) error {
"repo", flagRepo, "repo", flagRepo,
) )
var forge rp.Forge var f forge.Forge
forgeOptions := rp.ForgeOptions{ forgeOptions := forge.Options{
Repository: flagRepo, Repository: flagRepo,
BaseBranch: flagBranch, BaseBranch: flagBranch,
} }
@ -53,23 +58,23 @@ func run(cmd *cobra.Command, _ []string) error {
// f = rp.NewGitLab(forgeOptions) // f = rp.NewGitLab(forgeOptions)
case "github": case "github":
logger.DebugContext(ctx, "using forge GitHub") logger.DebugContext(ctx, "using forge GitHub")
forge = rp.NewGitHub(logger, &rp.GitHubOptions{ f = github.New(logger, &github.Options{
ForgeOptions: forgeOptions, Options: forgeOptions,
Owner: flagOwner, Owner: flagOwner,
Repo: flagRepo, Repo: flagRepo,
}) })
} }
extraFiles := parseExtraFiles(flagExtraFiles) extraFiles := parseExtraFiles(flagExtraFiles)
releaserPleaser := rp.New( releaserPleaser := rp.New(
forge, f,
logger, logger,
flagBranch, flagBranch,
rp.NewConventionalCommitsParser(), conventionalcommits.NewParser(),
rp.SemVerNextVersion, versioning.SemVerNextVersion,
extraFiles, extraFiles,
[]rp.Updater{&rp.GenericUpdater{}}, []updater.NewUpdater{updater.Generic},
) )
return releaserPleaser.Run(ctx) return releaserPleaser.Run(ctx)

52
git.go
View file

@ -1,52 +0,0 @@
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(),
}
}

View file

@ -1 +0,0 @@
package rp

2
go.mod
View file

@ -4,7 +4,6 @@ go 1.23.0
require ( require (
github.com/blang/semver/v4 v4.0.0 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/go-git/go-git/v5 v5.12.0
github.com/google/go-github/v63 v63.0.0 github.com/google/go-github/v63 v63.0.0
github.com/leodido/go-conventionalcommits v0.12.0 github.com/leodido/go-conventionalcommits v0.12.0
@ -22,6 +21,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.18.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/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/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect

View file

@ -1,15 +1,12 @@
package rp package changelog
import ( import (
"bytes" "bytes"
_ "embed" _ "embed"
"html/template" "html/template"
"log" "log"
)
const ( "github.com/apricote/releaser-pleaser/internal/commitparser"
ChangelogFile = "CHANGELOG.md"
ChangelogHeader = "# Changelog"
) )
var ( var (
@ -27,9 +24,9 @@ func init() {
} }
} }
func NewChangelogEntry(commits []AnalyzedCommit, version, link, prefix, suffix string) (string, error) { func NewChangelogEntry(commits []commitparser.AnalyzedCommit, version, link, prefix, suffix string) (string, error) {
features := make([]AnalyzedCommit, 0) features := make([]commitparser.AnalyzedCommit, 0)
fixes := make([]AnalyzedCommit, 0) fixes := make([]commitparser.AnalyzedCommit, 0)
for _, commit := range commits { for _, commit := range commits {
switch commit.Type { switch commit.Type {
@ -54,5 +51,4 @@ func NewChangelogEntry(commits []AnalyzedCommit, version, link, prefix, suffix s
} }
return changelog.String(), nil return changelog.String(), nil
} }

View file

@ -1,9 +1,12 @@
package rp package changelog
import ( import (
"testing" "testing"
"github.com/stretchr/testify/assert" "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 { func ptr[T any](input T) *T {
@ -12,7 +15,7 @@ func ptr[T any](input T) *T {
func Test_NewChangelogEntry(t *testing.T) { func Test_NewChangelogEntry(t *testing.T) {
type args struct { type args struct {
analyzedCommits []AnalyzedCommit analyzedCommits []commitparser.AnalyzedCommit
version string version string
link string link string
prefix string prefix string
@ -27,7 +30,7 @@ func Test_NewChangelogEntry(t *testing.T) {
{ {
name: "empty", name: "empty",
args: args{ args: args{
analyzedCommits: []AnalyzedCommit{}, analyzedCommits: []commitparser.AnalyzedCommit{},
version: "1.0.0", version: "1.0.0",
link: "https://example.com/1.0.0", link: "https://example.com/1.0.0",
}, },
@ -37,9 +40,9 @@ func Test_NewChangelogEntry(t *testing.T) {
{ {
name: "single feature", name: "single feature",
args: args{ args: args{
analyzedCommits: []AnalyzedCommit{ analyzedCommits: []commitparser.AnalyzedCommit{
{ {
Commit: Commit{}, Commit: git.Commit{},
Type: "feat", Type: "feat",
Description: "Foobar!", Description: "Foobar!",
}, },
@ -53,9 +56,9 @@ func Test_NewChangelogEntry(t *testing.T) {
{ {
name: "single fix", name: "single fix",
args: args{ args: args{
analyzedCommits: []AnalyzedCommit{ analyzedCommits: []commitparser.AnalyzedCommit{
{ {
Commit: Commit{}, Commit: git.Commit{},
Type: "fix", Type: "fix",
Description: "Foobar!", Description: "Foobar!",
}, },
@ -69,25 +72,25 @@ func Test_NewChangelogEntry(t *testing.T) {
{ {
name: "multiple commits with scopes", name: "multiple commits with scopes",
args: args{ args: args{
analyzedCommits: []AnalyzedCommit{ analyzedCommits: []commitparser.AnalyzedCommit{
{ {
Commit: Commit{}, Commit: git.Commit{},
Type: "feat", Type: "feat",
Description: "Blabla!", Description: "Blabla!",
}, },
{ {
Commit: Commit{}, Commit: git.Commit{},
Type: "feat", Type: "feat",
Description: "So awesome!", Description: "So awesome!",
Scope: ptr("awesome"), Scope: ptr("awesome"),
}, },
{ {
Commit: Commit{}, Commit: git.Commit{},
Type: "fix", Type: "fix",
Description: "Foobar!", Description: "Foobar!",
}, },
{ {
Commit: Commit{}, Commit: git.Commit{},
Type: "fix", Type: "fix",
Description: "So sad!", Description: "So sad!",
Scope: ptr("sad"), Scope: ptr("sad"),
@ -112,9 +115,9 @@ func Test_NewChangelogEntry(t *testing.T) {
{ {
name: "prefix", name: "prefix",
args: args{ args: args{
analyzedCommits: []AnalyzedCommit{ analyzedCommits: []commitparser.AnalyzedCommit{
{ {
Commit: Commit{}, Commit: git.Commit{},
Type: "fix", Type: "fix",
Description: "Foobar!", Description: "Foobar!",
}, },
@ -135,9 +138,9 @@ func Test_NewChangelogEntry(t *testing.T) {
{ {
name: "suffix", name: "suffix",
args: args{ args: args{
analyzedCommits: []AnalyzedCommit{ analyzedCommits: []commitparser.AnalyzedCommit{
{ {
Commit: Commit{}, Commit: git.Commit{},
Type: "fix", Type: "fix",
Description: "Foobar!", Description: "Foobar!",
}, },

View file

@ -0,0 +1,17 @@
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
}

View file

@ -1,54 +1,32 @@
package rp package conventionalcommits
import ( import (
"fmt" "fmt"
"github.com/leodido/go-conventionalcommits" "github.com/leodido/go-conventionalcommits"
"github.com/leodido/go-conventionalcommits/parser" "github.com/leodido/go-conventionalcommits/parser"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/git"
) )
type Commit struct { type Parser 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 machine conventionalcommits.Machine
} }
func NewConventionalCommitsParser() *ConventionalCommitsParser { func NewParser() *Parser {
parserMachine := parser.NewMachine( parserMachine := parser.NewMachine(
parser.WithBestEffort(), parser.WithBestEffort(),
parser.WithTypes(conventionalcommits.TypesConventional), parser.WithTypes(conventionalcommits.TypesConventional),
) )
return &ConventionalCommitsParser{ return &Parser{
machine: parserMachine, machine: parserMachine,
} }
} }
func (c *ConventionalCommitsParser) Analyze(commits []Commit) ([]AnalyzedCommit, error) { func (c *Parser) Analyze(commits []git.Commit) ([]commitparser.AnalyzedCommit, error) {
analyzedCommits := make([]AnalyzedCommit, 0, len(commits)) analyzedCommits := make([]commitparser.AnalyzedCommit, 0, len(commits))
for _, commit := range commits { for _, commit := range commits {
msg, err := c.machine.Parse([]byte(commit.Message)) msg, err := c.machine.Parse([]byte(commit.Message))
@ -63,7 +41,7 @@ func (c *ConventionalCommitsParser) Analyze(commits []Commit) ([]AnalyzedCommit,
commitVersionBump := conventionalCommit.VersionBump(conventionalcommits.DefaultStrategy) commitVersionBump := conventionalCommit.VersionBump(conventionalcommits.DefaultStrategy)
if commitVersionBump > conventionalcommits.UnknownVersion { if commitVersionBump > conventionalcommits.UnknownVersion {
// We only care about releasable commits // We only care about releasable commits
analyzedCommits = append(analyzedCommits, AnalyzedCommit{ analyzedCommits = append(analyzedCommits, commitparser.AnalyzedCommit{
Commit: commit, Commit: commit,
Type: conventionalCommit.Type, Type: conventionalCommit.Type,
Description: conventionalCommit.Description, Description: conventionalCommit.Description,

View file

@ -1,27 +1,30 @@
package rp package conventionalcommits
import ( import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/git"
) )
func TestAnalyzeCommits(t *testing.T) { func TestAnalyzeCommits(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
commits []Commit commits []git.Commit
expectedCommits []AnalyzedCommit expectedCommits []commitparser.AnalyzedCommit
wantErr assert.ErrorAssertionFunc wantErr assert.ErrorAssertionFunc
}{ }{
{ {
name: "empty commits", name: "empty commits",
commits: []Commit{}, commits: []git.Commit{},
expectedCommits: []AnalyzedCommit{}, expectedCommits: []commitparser.AnalyzedCommit{},
wantErr: assert.NoError, wantErr: assert.NoError,
}, },
{ {
name: "malformed commit message", name: "malformed commit message",
commits: []Commit{ commits: []git.Commit{
{ {
Message: "aksdjaklsdjka", Message: "aksdjaklsdjka",
}, },
@ -31,17 +34,17 @@ func TestAnalyzeCommits(t *testing.T) {
}, },
{ {
name: "drops unreleasable", name: "drops unreleasable",
commits: []Commit{ commits: []git.Commit{
{ {
Message: "chore: foobar", Message: "chore: foobar",
}, },
}, },
expectedCommits: []AnalyzedCommit{}, expectedCommits: []commitparser.AnalyzedCommit{},
wantErr: assert.NoError, wantErr: assert.NoError,
}, },
{ {
name: "highest bump (patch)", name: "highest bump (patch)",
commits: []Commit{ commits: []git.Commit{
{ {
Message: "chore: foobar", Message: "chore: foobar",
}, },
@ -49,9 +52,9 @@ func TestAnalyzeCommits(t *testing.T) {
Message: "fix: blabla", Message: "fix: blabla",
}, },
}, },
expectedCommits: []AnalyzedCommit{ expectedCommits: []commitparser.AnalyzedCommit{
{ {
Commit: Commit{Message: "fix: blabla"}, Commit: git.Commit{Message: "fix: blabla"},
Type: "fix", Type: "fix",
Description: "blabla", Description: "blabla",
}, },
@ -60,7 +63,7 @@ func TestAnalyzeCommits(t *testing.T) {
}, },
{ {
name: "highest bump (minor)", name: "highest bump (minor)",
commits: []Commit{ commits: []git.Commit{
{ {
Message: "fix: blabla", Message: "fix: blabla",
}, },
@ -68,14 +71,14 @@ func TestAnalyzeCommits(t *testing.T) {
Message: "feat: foobar", Message: "feat: foobar",
}, },
}, },
expectedCommits: []AnalyzedCommit{ expectedCommits: []commitparser.AnalyzedCommit{
{ {
Commit: Commit{Message: "fix: blabla"}, Commit: git.Commit{Message: "fix: blabla"},
Type: "fix", Type: "fix",
Description: "blabla", Description: "blabla",
}, },
{ {
Commit: Commit{Message: "feat: foobar"}, Commit: git.Commit{Message: "feat: foobar"},
Type: "feat", Type: "feat",
Description: "foobar", Description: "foobar",
}, },
@ -85,7 +88,7 @@ func TestAnalyzeCommits(t *testing.T) {
{ {
name: "highest bump (major)", name: "highest bump (major)",
commits: []Commit{ commits: []git.Commit{
{ {
Message: "fix: blabla", Message: "fix: blabla",
}, },
@ -93,14 +96,14 @@ func TestAnalyzeCommits(t *testing.T) {
Message: "feat!: foobar", Message: "feat!: foobar",
}, },
}, },
expectedCommits: []AnalyzedCommit{ expectedCommits: []commitparser.AnalyzedCommit{
{ {
Commit: Commit{Message: "fix: blabla"}, Commit: git.Commit{Message: "fix: blabla"},
Type: "fix", Type: "fix",
Description: "blabla", Description: "blabla",
}, },
{ {
Commit: Commit{Message: "feat!: foobar"}, Commit: git.Commit{Message: "feat!: foobar"},
Type: "feat", Type: "feat",
Description: "foobar", Description: "foobar",
BreakingChange: true, BreakingChange: true,
@ -111,7 +114,7 @@ func TestAnalyzeCommits(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
analyzedCommits, err := NewConventionalCommitsParser().Analyze(tt.commits) analyzedCommits, err := NewParser().Analyze(tt.commits)
if !tt.wantErr(t, err) { if !tt.wantErr(t, err) {
return return
} }

61
internal/forge/forge.go Normal file
View file

@ -0,0 +1,61 @@
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
}

View file

@ -1,4 +1,4 @@
package rp package github
import ( import (
"context" "context"
@ -13,75 +13,27 @@ import (
"github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/google/go-github/v63/github" "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 ( const (
GitHubPerPageMax = 100 PerPageMax = 100
GitHubPRStateOpen = "open" PRStateOpen = "open"
GitHubPRStateClosed = "closed" PRStateClosed = "closed"
GitHubEnvAPIToken = "GITHUB_TOKEN" // nolint:gosec // Not actually a hardcoded credential EnvAPIToken = "GITHUB_TOKEN" // nolint:gosec // Not actually a hardcoded credential
GitHubEnvUsername = "GITHUB_USER" EnvUsername = "GITHUB_USER"
GitHubEnvRepository = "GITHUB_REPOSITORY" EnvRepository = "GITHUB_REPOSITORY"
GitHubLabelColor = "dedede" LabelColor = "dedede"
) )
type Forge interface { var _ forge.Forge = &GitHub{}
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 { type GitHub struct {
options *GitHubOptions options *Options
client *github.Client client *github.Client
log *slog.Logger log *slog.Logger
@ -106,24 +58,24 @@ func (g *GitHub) GitAuth() transport.AuthMethod {
} }
} }
func (g *GitHub) LatestTags(ctx context.Context) (Releases, error) { func (g *GitHub) LatestTags(ctx context.Context) (git.Releases, error) {
g.log.DebugContext(ctx, "listing all tags in github repository") g.log.DebugContext(ctx, "listing all tags in github repository")
page := 1 page := 1
var releases Releases var releases git.Releases
for { for {
tags, resp, err := g.client.Repositories.ListTags( tags, resp, err := g.client.Repositories.ListTags(
ctx, g.options.Owner, g.options.Repo, ctx, g.options.Owner, g.options.Repo,
&github.ListOptions{Page: page, PerPage: GitHubPerPageMax}, &github.ListOptions{Page: page, PerPage: PerPageMax},
) )
if err != nil { if err != nil {
return Releases{}, err return git.Releases{}, err
} }
for _, ghTag := range tags { for _, ghTag := range tags {
tag := &Tag{ tag := &git.Tag{
Hash: ghTag.GetCommit().GetSHA(), Hash: ghTag.GetCommit().GetSHA(),
Name: ghTag.GetName(), Name: ghTag.GetName(),
} }
@ -160,7 +112,7 @@ func (g *GitHub) LatestTags(ctx context.Context) (Releases, error) {
return releases, nil return releases, nil
} }
func (g *GitHub) CommitsSince(ctx context.Context, tag *Tag) ([]Commit, error) { func (g *GitHub) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) {
var repositoryCommits []*github.RepositoryCommit var repositoryCommits []*github.RepositoryCommit
var err error var err error
if tag != nil { if tag != nil {
@ -173,9 +125,9 @@ func (g *GitHub) CommitsSince(ctx context.Context, tag *Tag) ([]Commit, error) {
return nil, err return nil, err
} }
var commits = make([]Commit, 0, len(repositoryCommits)) var commits = make([]git.Commit, 0, len(repositoryCommits))
for _, ghCommit := range repositoryCommits { for _, ghCommit := range repositoryCommits {
commit := Commit{ commit := git.Commit{
Hash: ghCommit.GetSHA(), Hash: ghCommit.GetSHA(),
Message: ghCommit.GetCommit().GetMessage(), Message: ghCommit.GetCommit().GetMessage(),
} }
@ -190,7 +142,7 @@ func (g *GitHub) CommitsSince(ctx context.Context, tag *Tag) ([]Commit, error) {
return commits, nil return commits, nil
} }
func (g *GitHub) commitsSinceTag(ctx context.Context, tag *Tag) ([]*github.RepositoryCommit, error) { func (g *GitHub) commitsSinceTag(ctx context.Context, tag *git.Tag) ([]*github.RepositoryCommit, error) {
head := g.options.BaseBranch head := g.options.BaseBranch
log := g.log.With("base", tag.Hash, "head", head) log := g.log.With("base", tag.Hash, "head", head)
log.Debug("comparing commits", "base", tag.Hash, "head", head) log.Debug("comparing commits", "base", tag.Hash, "head", head)
@ -204,7 +156,7 @@ func (g *GitHub) commitsSinceTag(ctx context.Context, tag *Tag) ([]*github.Repos
ctx, g.options.Owner, g.options.Repo, ctx, g.options.Owner, g.options.Repo,
tag.Hash, head, &github.ListOptions{ tag.Hash, head, &github.ListOptions{
Page: page, Page: page,
PerPage: GitHubPerPageMax, PerPage: PerPageMax,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -244,7 +196,7 @@ func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryComm
SHA: head, SHA: head,
ListOptions: github.ListOptions{ ListOptions: github.ListOptions{
Page: page, Page: page,
PerPage: GitHubPerPageMax, PerPage: PerPageMax,
}, },
}) })
if err != nil { if err != nil {
@ -254,7 +206,7 @@ func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryComm
if repositoryCommits == nil && resp.LastPage > 0 { if repositoryCommits == nil && resp.LastPage > 0 {
// Pre-initialize slice on first request // Pre-initialize slice on first request
log.Debug("found commits", "pages", resp.LastPage) log.Debug("found commits", "pages", resp.LastPage)
repositoryCommits = make([]*github.RepositoryCommit, 0, resp.LastPage*GitHubPerPageMax) repositoryCommits = make([]*github.RepositoryCommit, 0, resp.LastPage*PerPageMax)
} }
repositoryCommits = append(repositoryCommits, commits...) repositoryCommits = append(repositoryCommits, commits...)
@ -269,7 +221,7 @@ func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryComm
return repositoryCommits, nil return repositoryCommits, nil
} }
func (g *GitHub) prForCommit(ctx context.Context, commit Commit) (*PullRequest, error) { func (g *GitHub) prForCommit(ctx context.Context, commit git.Commit) (*git.PullRequest, error) {
// We naively look up the associated PR for each commit through the "List pull requests associated with a commit" // 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. // 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, // Using the "List pull requests" endpoint might be faster, as it allows us to fetch 100 arbitrary PRs per request,
@ -285,7 +237,7 @@ func (g *GitHub) prForCommit(ctx context.Context, commit Commit) (*PullRequest,
ctx, g.options.Owner, g.options.Repo, ctx, g.options.Owner, g.options.Repo,
commit.Hash, &github.ListOptions{ commit.Hash, &github.ListOptions{
Page: page, Page: page,
PerPage: GitHubPerPageMax, PerPage: PerPageMax,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -314,7 +266,7 @@ func (g *GitHub) prForCommit(ctx context.Context, commit Commit) (*PullRequest,
return gitHubPRToPullRequest(pullrequest), nil return gitHubPRToPullRequest(pullrequest), nil
} }
func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []Label) error { func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []releasepr.Label) error {
existingLabels := make([]string, 0, len(labels)) existingLabels := make([]string, 0, len(labels))
page := 1 page := 1
@ -325,7 +277,7 @@ func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []Label) error {
ctx, g.options.Owner, g.options.Repo, ctx, g.options.Owner, g.options.Repo,
&github.ListOptions{ &github.ListOptions{
Page: page, Page: page,
PerPage: GitHubPerPageMax, PerPage: PerPageMax,
}) })
if err != nil { if err != nil {
return err return err
@ -347,8 +299,8 @@ func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []Label) error {
_, _, err := g.client.Issues.CreateLabel( _, _, err := g.client.Issues.CreateLabel(
ctx, g.options.Owner, g.options.Repo, ctx, g.options.Owner, g.options.Repo,
&github.Label{ &github.Label{
Name: Pointer(string(label)), Name: pointer.Pointer(string(label)),
Color: Pointer(GitHubLabelColor), Color: pointer.Pointer(LabelColor),
}, },
) )
if err != nil { if err != nil {
@ -360,13 +312,13 @@ func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []Label) error {
return nil return nil
} }
func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*ReleasePullRequest, error) { func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*releasepr.ReleasePullRequest, error) {
page := 1 page := 1
for { for {
prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit(ctx, g.options.Owner, g.options.Repo, branch, &github.ListOptions{ prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit(ctx, g.options.Owner, g.options.Repo, branch, &github.ListOptions{
Page: page, Page: page,
PerPage: GitHubPerPageMax, PerPage: PerPageMax,
}) })
if err != nil { if err != nil {
var ghErr *github.ErrorResponse var ghErr *github.ErrorResponse
@ -379,7 +331,7 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*Rele
} }
for _, pr := range prs { for _, pr := range prs {
if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == GitHubPRStateOpen { if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == PRStateOpen {
return gitHubPRToReleasePullRequest(pr), nil return gitHubPRToReleasePullRequest(pr), nil
} }
} }
@ -393,7 +345,7 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*Rele
return nil, nil return nil, nil
} }
func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest) error { func (g *GitHub) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
ghPR, _, err := g.client.PullRequests.Create( ghPR, _, err := g.client.PullRequests.Create(
ctx, g.options.Owner, g.options.Repo, ctx, g.options.Owner, g.options.Repo,
&github.NewPullRequest{ &github.NewPullRequest{
@ -410,7 +362,7 @@ func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest)
// TODO: String ID? // TODO: String ID?
pr.ID = ghPR.GetNumber() pr.ID = ghPR.GetNumber()
err = g.SetPullRequestLabels(ctx, pr, []Label{}, pr.Labels) err = g.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels)
if err != nil { if err != nil {
return err return err
} }
@ -418,7 +370,7 @@ func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest)
return nil return nil
} }
func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *ReleasePullRequest) error { func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
_, _, err := g.client.PullRequests.Edit( _, _, err := g.client.PullRequests.Edit(
ctx, g.options.Owner, g.options.Repo, ctx, g.options.Owner, g.options.Repo,
pr.ID, &github.PullRequest{ pr.ID, &github.PullRequest{
@ -433,7 +385,7 @@ func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *ReleasePullRequest)
return nil return nil
} }
func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []Label) error { func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error {
for _, label := range remove { for _, label := range remove {
_, err := g.client.Issues.RemoveLabelForIssue( _, err := g.client.Issues.RemoveLabelForIssue(
ctx, g.options.Owner, g.options.Repo, ctx, g.options.Owner, g.options.Repo,
@ -460,11 +412,11 @@ func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *ReleasePullReques
return nil return nil
} }
func (g *GitHub) ClosePullRequest(ctx context.Context, pr *ReleasePullRequest) error { func (g *GitHub) ClosePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
_, _, err := g.client.PullRequests.Edit( _, _, err := g.client.PullRequests.Edit(
ctx, g.options.Owner, g.options.Repo, ctx, g.options.Owner, g.options.Repo,
pr.ID, &github.PullRequest{ pr.ID, &github.PullRequest{
State: Pointer(GitHubPRStateClosed), State: pointer.Pointer(PRStateClosed),
}, },
) )
if err != nil { if err != nil {
@ -474,20 +426,20 @@ func (g *GitHub) ClosePullRequest(ctx context.Context, pr *ReleasePullRequest) e
return nil return nil
} }
func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel Label) ([]*ReleasePullRequest, error) { func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) {
page := 1 page := 1
var prs []*ReleasePullRequest var prs []*releasepr.ReleasePullRequest
for { for {
ghPRs, resp, err := g.client.PullRequests.List( ghPRs, resp, err := g.client.PullRequests.List(
ctx, g.options.Owner, g.options.Repo, ctx, g.options.Owner, g.options.Repo,
&github.PullRequestListOptions{ &github.PullRequestListOptions{
State: GitHubPRStateClosed, State: PRStateClosed,
Base: g.options.BaseBranch, Base: g.options.BaseBranch,
ListOptions: github.ListOptions{ ListOptions: github.ListOptions{
Page: page, Page: page,
PerPage: GitHubPerPageMax, PerPage: PerPageMax,
}, },
}) })
if err != nil { if err != nil {
@ -497,7 +449,7 @@ func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel Label) ([]*Re
if prs == nil && resp.LastPage > 0 { if prs == nil && resp.LastPage > 0 {
// Pre-initialize slice on first request // Pre-initialize slice on first request
g.log.Debug("found pending releases", "pages", resp.LastPage) g.log.Debug("found pending releases", "pages", resp.LastPage)
prs = make([]*ReleasePullRequest, 0, (resp.LastPage-1)*GitHubPerPageMax) prs = make([]*releasepr.ReleasePullRequest, 0, (resp.LastPage-1)*PerPageMax)
} }
for _, pr := range ghPRs { for _, pr := range ghPRs {
@ -526,7 +478,7 @@ func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel Label) ([]*Re
return prs, nil return prs, nil
} }
func (g *GitHub) CreateRelease(ctx context.Context, commit Commit, title, changelog string, preRelease, latest bool) error { func (g *GitHub) CreateRelease(ctx context.Context, commit git.Commit, title, changelog string, preRelease, latest bool) error {
makeLatest := "" makeLatest := ""
if latest { if latest {
makeLatest = "true" makeLatest = "true"
@ -551,29 +503,29 @@ func (g *GitHub) CreateRelease(ctx context.Context, commit Commit, title, change
return nil return nil
} }
func gitHubPRToPullRequest(pr *github.PullRequest) *PullRequest { func gitHubPRToPullRequest(pr *github.PullRequest) *git.PullRequest {
return &PullRequest{ return &git.PullRequest{
ID: pr.GetNumber(), ID: pr.GetNumber(),
Title: pr.GetTitle(), Title: pr.GetTitle(),
Description: pr.GetBody(), Description: pr.GetBody(),
} }
} }
func gitHubPRToReleasePullRequest(pr *github.PullRequest) *ReleasePullRequest { func gitHubPRToReleasePullRequest(pr *github.PullRequest) *releasepr.ReleasePullRequest {
labels := make([]Label, 0, len(pr.Labels)) labels := make([]releasepr.Label, 0, len(pr.Labels))
for _, label := range pr.Labels { for _, label := range pr.Labels {
labelName := Label(label.GetName()) labelName := releasepr.Label(label.GetName())
if slices.Contains(KnownLabels, Label(label.GetName())) { if slices.Contains(releasepr.KnownLabels, releasepr.Label(label.GetName())) {
labels = append(labels, labelName) labels = append(labels, labelName)
} }
} }
var releaseCommit *Commit var releaseCommit *git.Commit
if pr.MergeCommitSHA != nil { if pr.MergeCommitSHA != nil {
releaseCommit = &Commit{Hash: pr.GetMergeCommitSHA()} releaseCommit = &git.Commit{Hash: pr.GetMergeCommitSHA()}
} }
return &ReleasePullRequest{ return &releasepr.ReleasePullRequest{
ID: pr.GetNumber(), ID: pr.GetNumber(),
Title: pr.GetTitle(), Title: pr.GetTitle(),
Description: pr.GetBody(), Description: pr.GetBody(),
@ -584,16 +536,16 @@ func gitHubPRToReleasePullRequest(pr *github.PullRequest) *ReleasePullRequest {
} }
} }
func (g *GitHubOptions) autodiscover() { func (g *Options) autodiscover() {
if apiToken := os.Getenv(GitHubEnvAPIToken); apiToken != "" { if apiToken := os.Getenv(EnvAPIToken); apiToken != "" {
g.APIToken = apiToken g.APIToken = apiToken
} }
// TODO: Check if there is a better solution for cloning/pushing locally // TODO: Check if there is a better solution for cloning/pushing locally
if username := os.Getenv(GitHubEnvUsername); username != "" { if username := os.Getenv(EnvUsername); username != "" {
g.Username = username g.Username = username
} }
if envRepository := os.Getenv(GitHubEnvRepository); envRepository != "" { if envRepository := os.Getenv(EnvRepository); envRepository != "" {
// GITHUB_REPOSITORY=apricote/releaser-pleaser // GITHUB_REPOSITORY=apricote/releaser-pleaser
parts := strings.Split(envRepository, "/") parts := strings.Split(envRepository, "/")
if len(parts) == 2 { if len(parts) == 2 {
@ -604,8 +556,8 @@ func (g *GitHubOptions) autodiscover() {
} }
} }
type GitHubOptions struct { type Options struct {
ForgeOptions forge.Options
Owner string Owner string
Repo string Repo string
@ -614,7 +566,7 @@ type GitHubOptions struct {
Username string Username string
} }
func NewGitHub(log *slog.Logger, options *GitHubOptions) *GitHub { func New(log *slog.Logger, options *Options) *GitHub {
options.autodiscover() options.autodiscover()
client := github.NewClient(nil) client := github.NewClient(nil)
@ -631,29 +583,3 @@ func NewGitHub(log *slog.Logger, options *GitHubOptions) *GitHub {
return gh 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
}

View file

@ -0,0 +1,31 @@
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)
}

227
internal/git/git.go Normal file
View file

@ -0,0 +1,227 @@
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(),
}
}

View file

@ -0,0 +1,5 @@
package pointer
func Pointer[T any](value T) *T {
return &value
}

View file

@ -0,0 +1 @@
package releasepr

View file

@ -1,4 +1,4 @@
package rp package releasepr
import ( import (
"bytes" "bytes"
@ -12,8 +12,10 @@ import (
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text" "github.com/yuin/goldmark/text"
"github.com/apricote/releaser-pleaser/internal/git"
"github.com/apricote/releaser-pleaser/internal/markdown" "github.com/apricote/releaser-pleaser/internal/markdown"
east "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast" ast2 "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast"
"github.com/apricote/releaser-pleaser/internal/versioning"
) )
var ( var (
@ -33,7 +35,7 @@ func init() {
// ReleasePullRequest // ReleasePullRequest
// //
// TODO: Reuse [PullRequest] // TODO: Reuse [git.PullRequest]
type ReleasePullRequest struct { type ReleasePullRequest struct {
ID int ID int
Title string Title string
@ -41,9 +43,12 @@ type ReleasePullRequest struct {
Labels []Label Labels []Label
Head string Head string
ReleaseCommit *Commit ReleaseCommit *git.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) { func NewReleasePullRequest(head, branch, version, changelogEntry string) (*ReleasePullRequest, error) {
rp := &ReleasePullRequest{ rp := &ReleasePullRequest{
Head: head, Head: head,
@ -61,50 +66,9 @@ func NewReleasePullRequest(head, branch, version, changelogEntry string) (*Relea
type ReleaseOverrides struct { type ReleaseOverrides struct {
Prefix string Prefix string
Suffix string Suffix string
NextVersionType NextVersionType NextVersionType versioning.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 ( const (
LabelNextVersionTypeNormal Label = "rp-next-version::normal" LabelNextVersionTypeNormal Label = "rp-next-version::normal"
LabelNextVersionTypeRC Label = "rp-next-version::rc" LabelNextVersionTypeRC Label = "rp-next-version::rc"
@ -158,13 +122,13 @@ func (pr *ReleasePullRequest) parseVersioningFlags(overrides ReleaseOverrides) R
switch label { switch label {
// Versioning // Versioning
case LabelNextVersionTypeNormal: case LabelNextVersionTypeNormal:
overrides.NextVersionType = NextVersionTypeNormal overrides.NextVersionType = versioning.NextVersionTypeNormal
case LabelNextVersionTypeRC: case LabelNextVersionTypeRC:
overrides.NextVersionType = NextVersionTypeRC overrides.NextVersionType = versioning.NextVersionTypeRC
case LabelNextVersionTypeBeta: case LabelNextVersionTypeBeta:
overrides.NextVersionType = NextVersionTypeBeta overrides.NextVersionType = versioning.NextVersionTypeBeta
case LabelNextVersionTypeAlpha: case LabelNextVersionTypeAlpha:
overrides.NextVersionType = NextVersionTypeAlpha overrides.NextVersionType = versioning.NextVersionTypeAlpha
case LabelReleasePending, LabelReleaseTagged: case LabelReleasePending, LabelReleaseTagged:
// These labels have no effect on the versioning. // These labels have no effect on the versioning.
break break
@ -213,18 +177,18 @@ func (pr *ReleasePullRequest) ChangelogText() (string, error) {
gm := markdown.New() gm := markdown.New()
descriptionAST := gm.Parser().Parse(text.NewReader(source)) descriptionAST := gm.Parser().Parse(text.NewReader(source))
var section *east.Section var section *ast2.Section
err := ast.Walk(descriptionAST, func(n ast.Node, entering bool) (ast.WalkStatus, error) { err := ast.Walk(descriptionAST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering { if !entering {
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
if n.Type() != ast.TypeBlock || n.Kind() != east.KindSection { if n.Type() != ast.TypeBlock || n.Kind() != ast2.KindSection {
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
anySection, ok := n.(*east.Section) anySection, ok := n.(*ast2.Section)
if !ok { if !ok {
return ast.WalkStop, fmt.Errorf("node has unexpected type: %T", n) return ast.WalkStop, fmt.Errorf("node has unexpected type: %T", n)
} }

View file

@ -1,4 +1,4 @@
package rp package releasepr
import ( import (
"testing" "testing"

View file

@ -1,126 +0,0 @@
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
}
}

View file

@ -0,0 +1,32 @@
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
}
}

View file

@ -0,0 +1,60 @@
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)
})
}
}

View file

@ -0,0 +1,17 @@
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
}
}

View file

@ -0,0 +1,53 @@
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)
})
}
}

View file

@ -0,0 +1,19 @@
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
}

View file

@ -0,0 +1,26 @@
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)
}

View file

@ -1,23 +1,18 @@
package rp package versioning
import ( import (
"fmt" "fmt"
"strings" "strings"
"github.com/blang/semver/v4" "github.com/blang/semver/v4"
"github.com/leodido/go-conventionalcommits"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/git"
) )
type Releases struct { var _ Strategy = SemVerNextVersion
Latest *Tag
Stable *Tag
}
type VersioningStrategy = func(Releases, conventionalcommits.VersionBump, NextVersionType) (string, error) func SemVerNextVersion(r git.Releases, versionBump VersionBump, nextVersionType NextVersionType) (string, error) {
var _ VersioningStrategy = SemVerNextVersion
func SemVerNextVersion(r Releases, versionBump conventionalcommits.VersionBump, nextVersionType NextVersionType) (string, error) {
latest, err := parseSemverWithDefault(r.Latest) latest, err := parseSemverWithDefault(r.Latest)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to parse latest version: %w", err) return "", fmt.Errorf("failed to parse latest version: %w", err)
@ -36,13 +31,13 @@ func SemVerNextVersion(r Releases, versionBump conventionalcommits.VersionBump,
} }
switch versionBump { switch versionBump {
case conventionalcommits.UnknownVersion: case UnknownVersion:
return "", fmt.Errorf("invalid latest bump (unknown)") return "", fmt.Errorf("invalid latest bump (unknown)")
case conventionalcommits.PatchVersion: case PatchVersion:
err = next.IncrementPatch() err = next.IncrementPatch()
case conventionalcommits.MinorVersion: case MinorVersion:
err = next.IncrementMinor() err = next.IncrementMinor()
case conventionalcommits.MajorVersion: case MajorVersion:
err = next.IncrementMajor() err = next.IncrementMajor()
} }
if err != nil { if err != nil {
@ -68,18 +63,18 @@ func SemVerNextVersion(r Releases, versionBump conventionalcommits.VersionBump,
return "v" + next.String(), nil return "v" + next.String(), nil
} }
func VersionBumpFromCommits(commits []AnalyzedCommit) conventionalcommits.VersionBump { func BumpFromCommits(commits []commitparser.AnalyzedCommit) VersionBump {
bump := conventionalcommits.UnknownVersion bump := UnknownVersion
for _, commit := range commits { for _, commit := range commits {
entryBump := conventionalcommits.UnknownVersion entryBump := UnknownVersion
switch { switch {
case commit.BreakingChange: case commit.BreakingChange:
entryBump = conventionalcommits.MajorVersion entryBump = MajorVersion
case commit.Type == "feat": case commit.Type == "feat":
entryBump = conventionalcommits.MinorVersion entryBump = MinorVersion
case commit.Type == "fix": case commit.Type == "fix":
entryBump = conventionalcommits.PatchVersion entryBump = PatchVersion
} }
if entryBump > bump { if entryBump > bump {
@ -97,7 +92,7 @@ func setPRVersion(version *semver.Version, prType string, count uint64) {
} }
} }
func parseSemverWithDefault(tag *Tag) (semver.Version, error) { func parseSemverWithDefault(tag *git.Tag) (semver.Version, error) {
version := "v0.0.0" version := "v0.0.0"
if tag != nil { if tag != nil {
version = tag.Name version = tag.Name

View file

@ -1,17 +1,19 @@
package rp package versioning
import ( import (
"fmt" "fmt"
"testing" "testing"
"github.com/leodido/go-conventionalcommits"
"github.com/stretchr/testify/assert" "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) { func TestReleases_NextVersion(t *testing.T) {
type args struct { type args struct {
releases Releases releases git.Releases
versionBump conventionalcommits.VersionBump versionBump VersionBump
nextVersionType NextVersionType nextVersionType NextVersionType
} }
tests := []struct { tests := []struct {
@ -23,11 +25,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "simple bump (major)", name: "simple bump (major)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1"}, Latest: &git.Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"}, Stable: &git.Tag{Name: "v1.1.1"},
}, },
versionBump: conventionalcommits.MajorVersion, versionBump: MajorVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
want: "v2.0.0", want: "v2.0.0",
@ -36,11 +38,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "simple bump (minor)", name: "simple bump (minor)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1"}, Latest: &git.Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"}, Stable: &git.Tag{Name: "v1.1.1"},
}, },
versionBump: conventionalcommits.MinorVersion, versionBump: MinorVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
want: "v1.2.0", want: "v1.2.0",
@ -49,11 +51,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "simple bump (patch)", name: "simple bump (patch)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1"}, Latest: &git.Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"}, Stable: &git.Tag{Name: "v1.1.1"},
}, },
versionBump: conventionalcommits.PatchVersion, versionBump: PatchVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
want: "v1.1.2", want: "v1.1.2",
@ -62,11 +64,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "normal to prerelease (major)", name: "normal to prerelease (major)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1"}, Latest: &git.Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"}, Stable: &git.Tag{Name: "v1.1.1"},
}, },
versionBump: conventionalcommits.MajorVersion, versionBump: MajorVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
want: "v2.0.0-rc.0", want: "v2.0.0-rc.0",
@ -75,11 +77,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "normal to prerelease (minor)", name: "normal to prerelease (minor)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1"}, Latest: &git.Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"}, Stable: &git.Tag{Name: "v1.1.1"},
}, },
versionBump: conventionalcommits.MinorVersion, versionBump: MinorVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
want: "v1.2.0-rc.0", want: "v1.2.0-rc.0",
@ -88,11 +90,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "normal to prerelease (patch)", name: "normal to prerelease (patch)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1"}, Latest: &git.Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"}, Stable: &git.Tag{Name: "v1.1.1"},
}, },
versionBump: conventionalcommits.PatchVersion, versionBump: PatchVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
want: "v1.1.2-rc.0", want: "v1.1.2-rc.0",
@ -101,11 +103,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "prerelease bump (major)", name: "prerelease bump (major)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v2.0.0-rc.0"}, Latest: &git.Tag{Name: "v2.0.0-rc.0"},
Stable: &Tag{Name: "v1.1.1"}, Stable: &git.Tag{Name: "v1.1.1"},
}, },
versionBump: conventionalcommits.MajorVersion, versionBump: MajorVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
want: "v2.0.0-rc.1", want: "v2.0.0-rc.1",
@ -114,11 +116,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "prerelease bump (minor)", name: "prerelease bump (minor)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.2.0-rc.0"}, Latest: &git.Tag{Name: "v1.2.0-rc.0"},
Stable: &Tag{Name: "v1.1.1"}, Stable: &git.Tag{Name: "v1.1.1"},
}, },
versionBump: conventionalcommits.MinorVersion, versionBump: MinorVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
want: "v1.2.0-rc.1", want: "v1.2.0-rc.1",
@ -127,11 +129,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "prerelease bump (patch)", name: "prerelease bump (patch)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.2-rc.0"}, Latest: &git.Tag{Name: "v1.1.2-rc.0"},
Stable: &Tag{Name: "v1.1.1"}, Stable: &git.Tag{Name: "v1.1.1"},
}, },
versionBump: conventionalcommits.PatchVersion, versionBump: PatchVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
want: "v1.1.2-rc.1", want: "v1.1.2-rc.1",
@ -140,11 +142,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "prerelease different bump (major)", name: "prerelease different bump (major)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.2.0-rc.0"}, Latest: &git.Tag{Name: "v1.2.0-rc.0"},
Stable: &Tag{Name: "v1.1.1"}, Stable: &git.Tag{Name: "v1.1.1"},
}, },
versionBump: conventionalcommits.MajorVersion, versionBump: MajorVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
want: "v2.0.0-rc.1", want: "v2.0.0-rc.1",
@ -153,11 +155,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "prerelease different bump (minor)", name: "prerelease different bump (minor)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.2-rc.0"}, Latest: &git.Tag{Name: "v1.1.2-rc.0"},
Stable: &Tag{Name: "v1.1.1"}, Stable: &git.Tag{Name: "v1.1.1"},
}, },
versionBump: conventionalcommits.MinorVersion, versionBump: MinorVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
want: "v1.2.0-rc.1", want: "v1.2.0-rc.1",
@ -166,11 +168,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "prerelease to prerelease", name: "prerelease to prerelease",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1-alpha.2"}, Latest: &git.Tag{Name: "v1.1.1-alpha.2"},
Stable: &Tag{Name: "v1.1.0"}, Stable: &git.Tag{Name: "v1.1.0"},
}, },
versionBump: conventionalcommits.PatchVersion, versionBump: PatchVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
want: "v1.1.1-rc.0", want: "v1.1.1-rc.0",
@ -179,11 +181,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "prerelease to normal (explicit)", name: "prerelease to normal (explicit)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1-alpha.2"}, Latest: &git.Tag{Name: "v1.1.1-alpha.2"},
Stable: &Tag{Name: "v1.1.0"}, Stable: &git.Tag{Name: "v1.1.0"},
}, },
versionBump: conventionalcommits.PatchVersion, versionBump: PatchVersion,
nextVersionType: NextVersionTypeNormal, nextVersionType: NextVersionTypeNormal,
}, },
want: "v1.1.1", want: "v1.1.1",
@ -192,11 +194,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "prerelease to normal (implicit)", name: "prerelease to normal (implicit)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1-alpha.2"}, Latest: &git.Tag{Name: "v1.1.1-alpha.2"},
Stable: &Tag{Name: "v1.1.0"}, Stable: &git.Tag{Name: "v1.1.0"},
}, },
versionBump: conventionalcommits.PatchVersion, versionBump: PatchVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
want: "v1.1.1", want: "v1.1.1",
@ -205,11 +207,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "nil tag (major)", name: "nil tag (major)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: nil, Latest: nil,
Stable: nil, Stable: nil,
}, },
versionBump: conventionalcommits.MajorVersion, versionBump: MajorVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
want: "v1.0.0", want: "v1.0.0",
@ -218,11 +220,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "nil tag (minor)", name: "nil tag (minor)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: nil, Latest: nil,
Stable: nil, Stable: nil,
}, },
versionBump: conventionalcommits.MinorVersion, versionBump: MinorVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
want: "v0.1.0", want: "v0.1.0",
@ -231,11 +233,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "nil tag (patch)", name: "nil tag (patch)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: nil, Latest: nil,
Stable: nil, Stable: nil,
}, },
versionBump: conventionalcommits.PatchVersion, versionBump: PatchVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
want: "v0.0.1", want: "v0.0.1",
@ -244,11 +246,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "nil stable release (major)", name: "nil stable release (major)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1-rc.0"}, Latest: &git.Tag{Name: "v1.1.1-rc.0"},
Stable: nil, Stable: nil,
}, },
versionBump: conventionalcommits.MajorVersion, versionBump: MajorVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
want: "v2.0.0", want: "v2.0.0",
@ -257,11 +259,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "nil stable release (minor)", name: "nil stable release (minor)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1-rc.0"}, Latest: &git.Tag{Name: "v1.1.1-rc.0"},
Stable: nil, Stable: nil,
}, },
versionBump: conventionalcommits.MinorVersion, versionBump: MinorVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
want: "v1.2.0", want: "v1.2.0",
@ -270,11 +272,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "nil stable release (patch)", name: "nil stable release (patch)",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1-rc.0"}, Latest: &git.Tag{Name: "v1.1.1-rc.0"},
Stable: nil, Stable: nil,
}, },
versionBump: conventionalcommits.PatchVersion, versionBump: PatchVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
// TODO: Is this actually correct our should it be v1.1.1? // TODO: Is this actually correct our should it be v1.1.1?
@ -284,11 +286,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "error on invalid tag semver", name: "error on invalid tag semver",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "foodazzle"}, Latest: &git.Tag{Name: "foodazzle"},
Stable: &Tag{Name: "foodazzle"}, Stable: &git.Tag{Name: "foodazzle"},
}, },
versionBump: conventionalcommits.PatchVersion, versionBump: PatchVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
want: "", want: "",
@ -297,11 +299,11 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "error on invalid tag prerelease", name: "error on invalid tag prerelease",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1-rc.foo"}, Latest: &git.Tag{Name: "v1.1.1-rc.foo"},
Stable: &Tag{Name: "v1.1.1-rc.foo"}, Stable: &git.Tag{Name: "v1.1.1-rc.foo"},
}, },
versionBump: conventionalcommits.PatchVersion, versionBump: PatchVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
want: "", want: "",
@ -310,12 +312,12 @@ func TestReleases_NextVersion(t *testing.T) {
{ {
name: "error on invalid bump", name: "error on invalid bump",
args: args{ args: args{
releases: Releases{ releases: git.Releases{
Latest: &Tag{Name: "v1.1.1"}, Latest: &git.Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"}, Stable: &git.Tag{Name: "v1.1.1"},
}, },
versionBump: conventionalcommits.UnknownVersion, versionBump: UnknownVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
want: "", want: "",
@ -336,53 +338,53 @@ func TestReleases_NextVersion(t *testing.T) {
func TestVersionBumpFromCommits(t *testing.T) { func TestVersionBumpFromCommits(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
analyzedCommits []AnalyzedCommit analyzedCommits []commitparser.AnalyzedCommit
want conventionalcommits.VersionBump want VersionBump
}{ }{
{ {
name: "no entries (unknown)", name: "no entries (unknown)",
analyzedCommits: []AnalyzedCommit{}, analyzedCommits: []commitparser.AnalyzedCommit{},
want: conventionalcommits.UnknownVersion, want: UnknownVersion,
}, },
{ {
name: "non-release type (unknown)", name: "non-release type (unknown)",
analyzedCommits: []AnalyzedCommit{{Type: "docs"}}, analyzedCommits: []commitparser.AnalyzedCommit{{Type: "docs"}},
want: conventionalcommits.UnknownVersion, want: UnknownVersion,
}, },
{ {
name: "single breaking (major)", name: "single breaking (major)",
analyzedCommits: []AnalyzedCommit{{BreakingChange: true}}, analyzedCommits: []commitparser.AnalyzedCommit{{BreakingChange: true}},
want: conventionalcommits.MajorVersion, want: MajorVersion,
}, },
{ {
name: "single feat (minor)", name: "single feat (minor)",
analyzedCommits: []AnalyzedCommit{{Type: "feat"}}, analyzedCommits: []commitparser.AnalyzedCommit{{Type: "feat"}},
want: conventionalcommits.MinorVersion, want: MinorVersion,
}, },
{ {
name: "single fix (patch)", name: "single fix (patch)",
analyzedCommits: []AnalyzedCommit{{Type: "fix"}}, analyzedCommits: []commitparser.AnalyzedCommit{{Type: "fix"}},
want: conventionalcommits.PatchVersion, want: PatchVersion,
}, },
{ {
name: "multiple entries (major)", name: "multiple entries (major)",
analyzedCommits: []AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}}, analyzedCommits: []commitparser.AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}},
want: conventionalcommits.MajorVersion, want: MajorVersion,
}, },
{ {
name: "multiple entries (minor)", name: "multiple entries (minor)",
analyzedCommits: []AnalyzedCommit{{Type: "fix"}, {Type: "feat"}}, analyzedCommits: []commitparser.AnalyzedCommit{{Type: "fix"}, {Type: "feat"}},
want: conventionalcommits.MinorVersion, want: MinorVersion,
}, },
{ {
name: "multiple entries (patch)", name: "multiple entries (patch)",
analyzedCommits: []AnalyzedCommit{{Type: "docs"}, {Type: "fix"}}, analyzedCommits: []commitparser.AnalyzedCommit{{Type: "docs"}, {Type: "fix"}},
want: conventionalcommits.PatchVersion, want: PatchVersion,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, VersionBumpFromCommits(tt.analyzedCommits), "VersionBumpFromCommits(%v)", tt.analyzedCommits) assert.Equalf(t, tt.want, BumpFromCommits(tt.analyzedCommits), "BumpFromCommits(%v)", tt.analyzedCommits)
}) })
} }
} }

View file

@ -0,0 +1,56 @@
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
}
}

View file

@ -3,13 +3,15 @@ package rp
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os"
"github.com/go-git/go-git/v5" "github.com/apricote/releaser-pleaser/internal/changelog"
"github.com/go-git/go-git/v5/config" "github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/go-git/go-git/v5/plumbing" "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"
) )
const ( const (
@ -17,16 +19,16 @@ const (
) )
type ReleaserPleaser struct { type ReleaserPleaser struct {
forge Forge forge forge.Forge
logger *slog.Logger logger *slog.Logger
targetBranch string targetBranch string
commitParser CommitParser commitParser commitparser.CommitParser
nextVersion VersioningStrategy nextVersion versioning.Strategy
extraFiles []string extraFiles []string
updaters []Updater updaters []updater.NewUpdater
} }
func New(forge Forge, logger *slog.Logger, targetBranch string, commitParser CommitParser, versioningStrategy VersioningStrategy, extraFiles []string, updaters []Updater) *ReleaserPleaser { func New(forge forge.Forge, logger *slog.Logger, targetBranch string, commitParser commitparser.CommitParser, versioningStrategy versioning.Strategy, extraFiles []string, updaters []updater.NewUpdater) *ReleaserPleaser {
return &ReleaserPleaser{ return &ReleaserPleaser{
forge: forge, forge: forge,
logger: logger, logger: logger,
@ -40,7 +42,8 @@ func New(forge Forge, logger *slog.Logger, targetBranch string, commitParser Com
func (rp *ReleaserPleaser) EnsureLabels(ctx context.Context) error { func (rp *ReleaserPleaser) EnsureLabels(ctx context.Context) error {
// TODO: Wrap Error // TODO: Wrap Error
return rp.forge.EnsureLabelsExist(ctx, KnownLabels)
return rp.forge.EnsureLabelsExist(ctx, releasepr.KnownLabels)
} }
func (rp *ReleaserPleaser) Run(ctx context.Context) error { func (rp *ReleaserPleaser) Run(ctx context.Context) error {
@ -75,7 +78,7 @@ func (rp *ReleaserPleaser) runCreatePendingReleases(ctx context.Context) error {
logger := rp.logger.With("method", "runCreatePendingReleases") logger := rp.logger.With("method", "runCreatePendingReleases")
logger.InfoContext(ctx, "checking for pending releases") logger.InfoContext(ctx, "checking for pending releases")
prs, err := rp.forge.PendingReleases(ctx, LabelReleasePending) prs, err := rp.forge.PendingReleases(ctx, releasepr.LabelReleasePending)
if err != nil { if err != nil {
return err return err
} }
@ -97,7 +100,7 @@ func (rp *ReleaserPleaser) runCreatePendingReleases(ctx context.Context) error {
return nil return nil
} }
func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *ReleasePullRequest) error { func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
logger := rp.logger.With( logger := rp.logger.With(
"method", "createPendingRelease", "method", "createPendingRelease",
"pr.id", pr.ID, "pr.id", pr.ID,
@ -129,7 +132,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, "created release", "release.title", version, "release.url", rp.forge.ReleaseURL(version))
logger.DebugContext(ctx, "updating pr labels") logger.DebugContext(ctx, "updating pr labels")
err = rp.forge.SetPullRequestLabels(ctx, pr, []Label{LabelReleasePending}, []Label{LabelReleaseTagged}) err = rp.forge.SetPullRequestLabels(ctx, pr, []releasepr.Label{releasepr.LabelReleasePending}, []releasepr.Label{releasepr.LabelReleaseTagged})
if err != nil { if err != nil {
return err return err
} }
@ -144,14 +147,13 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error {
logger := rp.logger.With("method", "runReconcileReleasePR") logger := rp.logger.With("method", "runReconcileReleasePR")
rpBranch := fmt.Sprintf(PullRequestBranchFormat, rp.targetBranch) rpBranch := fmt.Sprintf(PullRequestBranchFormat, rp.targetBranch)
rpBranchRef := plumbing.NewBranchReferenceName(rpBranch)
pr, err := rp.forge.PullRequestForBranch(ctx, rpBranch) pr, err := rp.forge.PullRequestForBranch(ctx, rpBranch)
if err != nil { if err != nil {
return err return err
} }
var releaseOverrides ReleaseOverrides var releaseOverrides releasepr.ReleaseOverrides
if pr != nil { if pr != nil {
logger = logger.With("pr.id", pr.ID, "pr.title", pr.Title) logger = logger.With("pr.id", pr.ID, "pr.title", pr.Title)
@ -215,7 +217,7 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error {
return nil return nil
} }
versionBump := VersionBumpFromCommits(analyzedCommits) versionBump := versioning.BumpFromCommits(analyzedCommits)
// TODO: Set version in release pr // TODO: Set version in release pr
nextVersion, err := rp.nextVersion(releases, versionBump, releaseOverrides.NextVersionType) nextVersion, err := rp.nextVersion(releases, versionBump, releaseOverrides.NextVersionType)
if err != nil { if err != nil {
@ -224,161 +226,68 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error {
logger.InfoContext(ctx, "next version", "version", nextVersion) logger.InfoContext(ctx, "next version", "version", nextVersion)
logger.DebugContext(ctx, "cloning repository", "clone.url", rp.forge.CloneURL()) logger.DebugContext(ctx, "cloning repository", "clone.url", rp.forge.CloneURL())
repo, err := CloneRepo(ctx, rp.forge.CloneURL(), rp.targetBranch, rp.forge.GitAuth()) repo, err := git.CloneRepo(ctx, logger, rp.forge.CloneURL(), rp.targetBranch, rp.forge.GitAuth())
if err != nil { if err != nil {
return fmt.Errorf("failed to clone repository: %w", err) return fmt.Errorf("failed to clone repository: %w", err)
} }
worktree, err := repo.Worktree()
if err != nil { if err = repo.DeleteBranch(ctx, rpBranch); err != nil {
return err return err
} }
if branch, _ := repo.Branch(rpBranch); branch != nil { if err = repo.Checkout(ctx, rpBranch); err != nil {
logger.DebugContext(ctx, "deleting previous releaser-pleaser branch locally", "branch.name", rpBranch) return err
if err = repo.DeleteBranch(rpBranch); err != nil {
return err
}
} }
if err = worktree.Checkout(&git.CheckoutOptions{ changelogEntry, err := changelog.NewChangelogEntry(analyzedCommits, nextVersion, rp.forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix)
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 { if err != nil {
return fmt.Errorf("failed to build changelog entry: %w", err) return fmt.Errorf("failed to build changelog entry: %w", err)
} }
// Info for updaters // Info for updaters
info := ReleaseInfo{Version: nextVersion, ChangelogEntry: changelogEntry} info := updater.ReleaseInfo{Version: nextVersion, ChangelogEntry: changelogEntry}
updateFile := func(path string, updaters []Updater) error { err = repo.UpdateFile(ctx, updater.ChangelogFile, updater.WithInfo(info, updater.Changelog))
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 { if err != nil {
return fmt.Errorf("failed to update changelog file: %w", err) return fmt.Errorf("failed to update changelog file: %w", err)
} }
for _, path := range rp.extraFiles { for _, path := range rp.extraFiles {
_, err = worktree.Filesystem.Stat(path) // TODO: Check for missing files
if err != nil { err = repo.UpdateFile(ctx, path, updater.WithInfo(info, rp.updaters...))
// 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 { if err != nil {
return fmt.Errorf("failed to run file updater: %w", err) return fmt.Errorf("failed to run file updater: %w", err)
} }
} }
releaseCommitMessage := fmt.Sprintf("chore(%s): release %s", rp.targetBranch, nextVersion) releaseCommitMessage := fmt.Sprintf("chore(%s): release %s", rp.targetBranch, nextVersion)
releaseCommitHash, err := worktree.Commit(releaseCommitMessage, &git.CommitOptions{ releaseCommit, err := repo.Commit(ctx, releaseCommitMessage)
Author: GitSignature(),
Committer: GitSignature(),
})
if err != nil { if err != nil {
return fmt.Errorf("failed to commit changes: %w", err) return fmt.Errorf("failed to commit changes: %w", err)
} }
logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommitHash.String(), "commit.message", releaseCommitMessage) logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommit.Hash, "commit.message", releaseCommit.Message)
newReleasePRChanges := true
// Check if anything changed in comparison to the remote branch (if exists) // Check if anything changed in comparison to the remote branch (if exists)
if remoteRef, err := repo.Reference(plumbing.NewRemoteReferenceName(GitRemoteName, rpBranch), false); err != nil { newReleasePRChanges, err := repo.HasChangesWithRemote(ctx, rpBranch)
if err.Error() != "reference not found" { if err != nil {
// "reference not found" is expected and we should always push return err
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 { if newReleasePRChanges {
pushRefSpec := config.RefSpec(fmt.Sprintf( err = repo.ForcePush(ctx, rpBranch)
"+%s:%s", if err != nil {
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) return fmt.Errorf("failed to push branch: %w", err)
} }
logger.InfoContext(ctx, "pushed branch", "commit.hash", releaseCommitHash.String(), "branch.name", rpBranch, "refspec", pushRefSpec.String()) logger.InfoContext(ctx, "pushed branch", "commit.hash", releaseCommit.Hash, "branch.name", rpBranch)
} else { } else {
logger.InfoContext(ctx, "file content is already up-to-date in remote branch, skipping push") logger.InfoContext(ctx, "file content is already up-to-date in remote branch, skipping push")
} }
// Open/Update PR // Open/Update PR
if pr == nil { if pr == nil {
pr, err = NewReleasePullRequest(rpBranch, rp.targetBranch, nextVersion, changelogEntry) pr, err = releasepr.NewReleasePullRequest(rpBranch, rp.targetBranch, nextVersion, changelogEntry)
if err != nil { if err != nil {
return err return err
} }

View file

@ -1,47 +0,0 @@
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
}

View file

@ -1,129 +0,0 @@
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)
})
}
}