mirror of
https://github.com/apricote/releaser-pleaser.git
synced 2026-01-13 13:21:00 +00:00
refactor: move things to packages (#39)
This commit is contained in:
parent
44184a77f9
commit
a0a064d387
32 changed files with 923 additions and 892 deletions
54
internal/changelog/changelog.go
Normal file
54
internal/changelog/changelog.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package changelog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
|
||||
"github.com/apricote/releaser-pleaser/internal/commitparser"
|
||||
)
|
||||
|
||||
var (
|
||||
changelogTemplate *template.Template
|
||||
)
|
||||
|
||||
//go:embed changelog.md.tpl
|
||||
var rawChangelogTemplate string
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
changelogTemplate, err = template.New("changelog").Parse(rawChangelogTemplate)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to parse changelog template: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func NewChangelogEntry(commits []commitparser.AnalyzedCommit, version, link, prefix, suffix string) (string, error) {
|
||||
features := make([]commitparser.AnalyzedCommit, 0)
|
||||
fixes := make([]commitparser.AnalyzedCommit, 0)
|
||||
|
||||
for _, commit := range commits {
|
||||
switch commit.Type {
|
||||
case "feat":
|
||||
features = append(features, commit)
|
||||
case "fix":
|
||||
fixes = append(fixes, commit)
|
||||
}
|
||||
}
|
||||
|
||||
var changelog bytes.Buffer
|
||||
err := changelogTemplate.Execute(&changelog, map[string]any{
|
||||
"Features": features,
|
||||
"Fixes": fixes,
|
||||
"Version": version,
|
||||
"VersionLink": link,
|
||||
"Prefix": prefix,
|
||||
"Suffix": suffix,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return changelog.String(), nil
|
||||
}
|
||||
22
internal/changelog/changelog.md.tpl
Normal file
22
internal/changelog/changelog.md.tpl
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
## [{{.Version}}]({{.VersionLink}})
|
||||
{{- if .Prefix }}
|
||||
{{ .Prefix }}
|
||||
{{ end -}}
|
||||
{{- if (gt (len .Features) 0) }}
|
||||
### Features
|
||||
|
||||
{{ range .Features -}}
|
||||
- {{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}}
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
{{- if (gt (len .Fixes) 0) }}
|
||||
### Bug Fixes
|
||||
|
||||
{{ range .Fixes -}}
|
||||
- {{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}}
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if .Suffix }}
|
||||
{{ .Suffix }}
|
||||
{{ end -}}
|
||||
174
internal/changelog/changelog_test.go
Normal file
174
internal/changelog/changelog_test.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package changelog
|
||||
|
||||
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 {
|
||||
return &input
|
||||
}
|
||||
|
||||
func Test_NewChangelogEntry(t *testing.T) {
|
||||
type args struct {
|
||||
analyzedCommits []commitparser.AnalyzedCommit
|
||||
version string
|
||||
link string
|
||||
prefix string
|
||||
suffix string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
args: args{
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{},
|
||||
version: "1.0.0",
|
||||
link: "https://example.com/1.0.0",
|
||||
},
|
||||
want: "## [1.0.0](https://example.com/1.0.0)",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "single feature",
|
||||
args: args{
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: git.Commit{},
|
||||
Type: "feat",
|
||||
Description: "Foobar!",
|
||||
},
|
||||
},
|
||||
version: "1.0.0",
|
||||
link: "https://example.com/1.0.0",
|
||||
},
|
||||
want: "## [1.0.0](https://example.com/1.0.0)\n### Features\n\n- Foobar!\n",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "single fix",
|
||||
args: args{
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: git.Commit{},
|
||||
Type: "fix",
|
||||
Description: "Foobar!",
|
||||
},
|
||||
},
|
||||
version: "1.0.0",
|
||||
link: "https://example.com/1.0.0",
|
||||
},
|
||||
want: "## [1.0.0](https://example.com/1.0.0)\n### Bug Fixes\n\n- Foobar!\n",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "multiple commits with scopes",
|
||||
args: args{
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: git.Commit{},
|
||||
Type: "feat",
|
||||
Description: "Blabla!",
|
||||
},
|
||||
{
|
||||
Commit: git.Commit{},
|
||||
Type: "feat",
|
||||
Description: "So awesome!",
|
||||
Scope: ptr("awesome"),
|
||||
},
|
||||
{
|
||||
Commit: git.Commit{},
|
||||
Type: "fix",
|
||||
Description: "Foobar!",
|
||||
},
|
||||
{
|
||||
Commit: git.Commit{},
|
||||
Type: "fix",
|
||||
Description: "So sad!",
|
||||
Scope: ptr("sad"),
|
||||
},
|
||||
},
|
||||
version: "1.0.0",
|
||||
link: "https://example.com/1.0.0",
|
||||
},
|
||||
want: `## [1.0.0](https://example.com/1.0.0)
|
||||
### Features
|
||||
|
||||
- Blabla!
|
||||
- **awesome**: So awesome!
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Foobar!
|
||||
- **sad**: So sad!
|
||||
`,
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "prefix",
|
||||
args: args{
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: git.Commit{},
|
||||
Type: "fix",
|
||||
Description: "Foobar!",
|
||||
},
|
||||
},
|
||||
version: "1.0.0",
|
||||
link: "https://example.com/1.0.0",
|
||||
prefix: "### Breaking Changes",
|
||||
},
|
||||
want: `## [1.0.0](https://example.com/1.0.0)
|
||||
### Breaking Changes
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Foobar!
|
||||
`,
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "suffix",
|
||||
args: args{
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: git.Commit{},
|
||||
Type: "fix",
|
||||
Description: "Foobar!",
|
||||
},
|
||||
},
|
||||
version: "1.0.0",
|
||||
link: "https://example.com/1.0.0",
|
||||
suffix: "### Compatibility\n\nThis version is compatible with flux-compensator v2.2 - v2.9.",
|
||||
},
|
||||
want: `## [1.0.0](https://example.com/1.0.0)
|
||||
### Bug Fixes
|
||||
|
||||
- Foobar!
|
||||
|
||||
### Compatibility
|
||||
|
||||
This version is compatible with flux-compensator v2.2 - v2.9.
|
||||
`,
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NewChangelogEntry(tt.args.analyzedCommits, tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix)
|
||||
if !tt.wantErr(t, err) {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
17
internal/commitparser/commitparser.go
Normal file
17
internal/commitparser/commitparser.go
Normal 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
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package conventionalcommits
|
||||
|
||||
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 {
|
||||
machine conventionalcommits.Machine
|
||||
}
|
||||
|
||||
func NewParser() *Parser {
|
||||
parserMachine := parser.NewMachine(
|
||||
parser.WithBestEffort(),
|
||||
parser.WithTypes(conventionalcommits.TypesConventional),
|
||||
)
|
||||
|
||||
return &Parser{
|
||||
machine: parserMachine,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Parser) Analyze(commits []git.Commit) ([]commitparser.AnalyzedCommit, error) {
|
||||
analyzedCommits := make([]commitparser.AnalyzedCommit, 0, len(commits))
|
||||
|
||||
for _, commit := range commits {
|
||||
msg, err := c.machine.Parse([]byte(commit.Message))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse message of commit %q: %w", commit.Hash, err)
|
||||
}
|
||||
conventionalCommit, ok := msg.(*conventionalcommits.ConventionalCommit)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unable to get ConventionalCommit from parser result: %T", msg)
|
||||
}
|
||||
|
||||
commitVersionBump := conventionalCommit.VersionBump(conventionalcommits.DefaultStrategy)
|
||||
if commitVersionBump > conventionalcommits.UnknownVersion {
|
||||
// We only care about releasable commits
|
||||
analyzedCommits = append(analyzedCommits, commitparser.AnalyzedCommit{
|
||||
Commit: commit,
|
||||
Type: conventionalCommit.Type,
|
||||
Description: conventionalCommit.Description,
|
||||
Scope: conventionalCommit.Scope,
|
||||
BreakingChange: conventionalCommit.IsBreakingChange(),
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return analyzedCommits, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package conventionalcommits
|
||||
|
||||
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
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "empty commits",
|
||||
commits: []git.Commit{},
|
||||
expectedCommits: []commitparser.AnalyzedCommit{},
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "malformed commit message",
|
||||
commits: []git.Commit{
|
||||
{
|
||||
Message: "aksdjaklsdjka",
|
||||
},
|
||||
},
|
||||
expectedCommits: nil,
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "drops unreleasable",
|
||||
commits: []git.Commit{
|
||||
{
|
||||
Message: "chore: foobar",
|
||||
},
|
||||
},
|
||||
expectedCommits: []commitparser.AnalyzedCommit{},
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "highest bump (patch)",
|
||||
commits: []git.Commit{
|
||||
{
|
||||
Message: "chore: foobar",
|
||||
},
|
||||
{
|
||||
Message: "fix: blabla",
|
||||
},
|
||||
},
|
||||
expectedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: git.Commit{Message: "fix: blabla"},
|
||||
Type: "fix",
|
||||
Description: "blabla",
|
||||
},
|
||||
},
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "highest bump (minor)",
|
||||
commits: []git.Commit{
|
||||
{
|
||||
Message: "fix: blabla",
|
||||
},
|
||||
{
|
||||
Message: "feat: foobar",
|
||||
},
|
||||
},
|
||||
expectedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: git.Commit{Message: "fix: blabla"},
|
||||
Type: "fix",
|
||||
Description: "blabla",
|
||||
},
|
||||
{
|
||||
Commit: git.Commit{Message: "feat: foobar"},
|
||||
Type: "feat",
|
||||
Description: "foobar",
|
||||
},
|
||||
},
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
|
||||
{
|
||||
name: "highest bump (major)",
|
||||
commits: []git.Commit{
|
||||
{
|
||||
Message: "fix: blabla",
|
||||
},
|
||||
{
|
||||
Message: "feat!: foobar",
|
||||
},
|
||||
},
|
||||
expectedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: git.Commit{Message: "fix: blabla"},
|
||||
Type: "fix",
|
||||
Description: "blabla",
|
||||
},
|
||||
{
|
||||
Commit: git.Commit{Message: "feat!: foobar"},
|
||||
Type: "feat",
|
||||
Description: "foobar",
|
||||
BreakingChange: true,
|
||||
},
|
||||
},
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
analyzedCommits, err := NewParser().Analyze(tt.commits)
|
||||
if !tt.wantErr(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expectedCommits, analyzedCommits)
|
||||
})
|
||||
}
|
||||
}
|
||||
61
internal/forge/forge.go
Normal file
61
internal/forge/forge.go
Normal 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
|
||||
}
|
||||
585
internal/forge/github/github.go
Normal file
585
internal/forge/github/github.go
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/blang/semver/v4"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
"github.com/google/go-github/v63/github"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var _ forge.Forge = &GitHub{}
|
||||
|
||||
type GitHub struct {
|
||||
options *Options
|
||||
|
||||
client *github.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func (g *GitHub) RepoURL() string {
|
||||
return fmt.Sprintf("https://github.com/%s/%s", g.options.Owner, g.options.Repo)
|
||||
}
|
||||
|
||||
func (g *GitHub) CloneURL() string {
|
||||
return fmt.Sprintf("https://github.com/%s/%s.git", g.options.Owner, g.options.Repo)
|
||||
}
|
||||
|
||||
func (g *GitHub) ReleaseURL(version string) string {
|
||||
return fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", g.options.Owner, g.options.Repo, version)
|
||||
}
|
||||
|
||||
func (g *GitHub) GitAuth() transport.AuthMethod {
|
||||
return &http.BasicAuth{
|
||||
Username: g.options.Username,
|
||||
Password: g.options.APIToken,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GitHub) LatestTags(ctx context.Context) (git.Releases, error) {
|
||||
g.log.DebugContext(ctx, "listing all tags in github repository")
|
||||
|
||||
page := 1
|
||||
|
||||
var releases git.Releases
|
||||
|
||||
for {
|
||||
tags, resp, err := g.client.Repositories.ListTags(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
&github.ListOptions{Page: page, PerPage: PerPageMax},
|
||||
)
|
||||
if err != nil {
|
||||
return git.Releases{}, err
|
||||
}
|
||||
|
||||
for _, ghTag := range tags {
|
||||
tag := &git.Tag{
|
||||
Hash: ghTag.GetCommit().GetSHA(),
|
||||
Name: ghTag.GetName(),
|
||||
}
|
||||
|
||||
version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v"))
|
||||
if err != nil {
|
||||
g.log.WarnContext(
|
||||
ctx, "unable to parse tag as semver, skipping",
|
||||
"tag.name", tag.Name,
|
||||
"tag.hash", tag.Hash,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if releases.Latest == nil {
|
||||
releases.Latest = tag
|
||||
}
|
||||
if len(version.Pre) == 0 {
|
||||
// Stable version tag
|
||||
// We return once we have found the latest stable tag, not needed to look at every single tag.
|
||||
releases.Stable = tag
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if page == resp.LastPage || resp.LastPage == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
page = resp.NextPage
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
func (g *GitHub) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) {
|
||||
var repositoryCommits []*github.RepositoryCommit
|
||||
var err error
|
||||
if tag != nil {
|
||||
repositoryCommits, err = g.commitsSinceTag(ctx, tag)
|
||||
} else {
|
||||
repositoryCommits, err = g.commitsSinceInit(ctx)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var commits = make([]git.Commit, 0, len(repositoryCommits))
|
||||
for _, ghCommit := range repositoryCommits {
|
||||
commit := git.Commit{
|
||||
Hash: ghCommit.GetSHA(),
|
||||
Message: ghCommit.GetCommit().GetMessage(),
|
||||
}
|
||||
commit.PullRequest, err = g.prForCommit(ctx, commit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check for commit pull request: %w", err)
|
||||
}
|
||||
|
||||
commits = append(commits, commit)
|
||||
}
|
||||
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
func (g *GitHub) commitsSinceTag(ctx context.Context, tag *git.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)
|
||||
|
||||
page := 1
|
||||
|
||||
var repositoryCommits []*github.RepositoryCommit
|
||||
for {
|
||||
log.Debug("fetching page", "page", page)
|
||||
comparison, resp, err := g.client.Repositories.CompareCommits(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
tag.Hash, head, &github.ListOptions{
|
||||
Page: page,
|
||||
PerPage: PerPageMax,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if repositoryCommits == nil {
|
||||
// Pre-initialize slice on first request
|
||||
log.Debug("found commits", "length", comparison.GetTotalCommits())
|
||||
repositoryCommits = make([]*github.RepositoryCommit, 0, comparison.GetTotalCommits())
|
||||
}
|
||||
|
||||
repositoryCommits = append(repositoryCommits, comparison.Commits...)
|
||||
|
||||
if page == resp.LastPage || resp.LastPage == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
page = resp.NextPage
|
||||
}
|
||||
|
||||
return repositoryCommits, nil
|
||||
}
|
||||
|
||||
func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryCommit, error) {
|
||||
head := g.options.BaseBranch
|
||||
log := g.log.With("head", head)
|
||||
log.Debug("listing all commits")
|
||||
|
||||
page := 1
|
||||
|
||||
var repositoryCommits []*github.RepositoryCommit
|
||||
for {
|
||||
log.Debug("fetching page", "page", page)
|
||||
commits, resp, err := g.client.Repositories.ListCommits(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
&github.CommitsListOptions{
|
||||
SHA: head,
|
||||
ListOptions: github.ListOptions{
|
||||
Page: page,
|
||||
PerPage: PerPageMax,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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 = append(repositoryCommits, commits...)
|
||||
|
||||
if page == resp.LastPage || resp.LastPage == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
page = resp.NextPage
|
||||
}
|
||||
|
||||
return repositoryCommits, nil
|
||||
}
|
||||
|
||||
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"
|
||||
// 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,
|
||||
// but worst case we need to look up all PRs made in the repository ever.
|
||||
|
||||
log := g.log.With("commit.hash", commit.Hash)
|
||||
page := 1
|
||||
var associatedPRs []*github.PullRequest
|
||||
|
||||
for {
|
||||
log.Debug("fetching pull requests associated with commit", "page", page)
|
||||
prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
commit.Hash, &github.ListOptions{
|
||||
Page: page,
|
||||
PerPage: PerPageMax,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
associatedPRs = append(associatedPRs, prs...)
|
||||
|
||||
if page == resp.LastPage || resp.LastPage == 0 {
|
||||
break
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
|
||||
var pullrequest *github.PullRequest
|
||||
for _, pr := range associatedPRs {
|
||||
// We only look for the PR that has this commit set as the "merge commit" => The result of squashing this branch onto main
|
||||
if pr.GetMergeCommitSHA() == commit.Hash {
|
||||
pullrequest = pr
|
||||
break
|
||||
}
|
||||
}
|
||||
if pullrequest == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return gitHubPRToPullRequest(pullrequest), nil
|
||||
}
|
||||
|
||||
func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []releasepr.Label) error {
|
||||
existingLabels := make([]string, 0, len(labels))
|
||||
|
||||
page := 1
|
||||
|
||||
for {
|
||||
g.log.Debug("fetching labels on repo", "page", page)
|
||||
ghLabels, resp, err := g.client.Issues.ListLabels(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
&github.ListOptions{
|
||||
Page: page,
|
||||
PerPage: PerPageMax,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, label := range ghLabels {
|
||||
existingLabels = append(existingLabels, label.GetName())
|
||||
}
|
||||
|
||||
if page == resp.LastPage || resp.LastPage == 0 {
|
||||
break
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
|
||||
for _, label := range labels {
|
||||
if !slices.Contains(existingLabels, string(label)) {
|
||||
g.log.Info("creating label in repository", "label.name", label)
|
||||
_, _, err := g.client.Issues.CreateLabel(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
&github.Label{
|
||||
Name: pointer.Pointer(string(label)),
|
||||
Color: pointer.Pointer(LabelColor),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*releasepr.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,
|
||||
})
|
||||
if err != nil {
|
||||
var ghErr *github.ErrorResponse
|
||||
if errors.As(err, &ghErr) {
|
||||
if ghErr.Message == fmt.Sprintf("No commit found for SHA: %s", branch) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, pr := range prs {
|
||||
if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == PRStateOpen {
|
||||
return gitHubPRToReleasePullRequest(pr), nil
|
||||
}
|
||||
}
|
||||
|
||||
if page == resp.LastPage || resp.LastPage == 0 {
|
||||
break
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (g *GitHub) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
|
||||
ghPR, _, err := g.client.PullRequests.Create(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
&github.NewPullRequest{
|
||||
Title: &pr.Title,
|
||||
Head: &pr.Head,
|
||||
Base: &g.options.BaseBranch,
|
||||
Body: &pr.Description,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: String ID?
|
||||
pr.ID = ghPR.GetNumber()
|
||||
|
||||
err = g.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
|
||||
_, _, err := g.client.PullRequests.Edit(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
pr.ID, &github.PullRequest{
|
||||
Title: &pr.Title,
|
||||
Body: &pr.Description,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error {
|
||||
for _, label := range remove {
|
||||
_, err := g.client.Issues.RemoveLabelForIssue(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
pr.ID, string(label),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
addString := make([]string, 0, len(add))
|
||||
for _, label := range add {
|
||||
addString = append(addString, string(label))
|
||||
}
|
||||
|
||||
_, _, err := g.client.Issues.AddLabelsToIssue(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
pr.ID, addString,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitHub) ClosePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
|
||||
_, _, err := g.client.PullRequests.Edit(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
pr.ID, &github.PullRequest{
|
||||
State: pointer.Pointer(PRStateClosed),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) {
|
||||
page := 1
|
||||
|
||||
var prs []*releasepr.ReleasePullRequest
|
||||
|
||||
for {
|
||||
ghPRs, resp, err := g.client.PullRequests.List(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
&github.PullRequestListOptions{
|
||||
State: PRStateClosed,
|
||||
Base: g.options.BaseBranch,
|
||||
ListOptions: github.ListOptions{
|
||||
Page: page,
|
||||
PerPage: PerPageMax,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
for _, pr := range ghPRs {
|
||||
pending := slices.ContainsFunc(pr.Labels, func(l *github.Label) bool {
|
||||
return l.GetName() == string(pendingLabel)
|
||||
})
|
||||
if !pending {
|
||||
continue
|
||||
}
|
||||
|
||||
// pr.Merged is always nil :(
|
||||
if pr.MergedAt == nil {
|
||||
// Closed and not merged
|
||||
continue
|
||||
}
|
||||
|
||||
prs = append(prs, gitHubPRToReleasePullRequest(pr))
|
||||
}
|
||||
|
||||
if page == resp.LastPage || resp.LastPage == 0 {
|
||||
break
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
|
||||
return prs, nil
|
||||
}
|
||||
|
||||
func (g *GitHub) CreateRelease(ctx context.Context, commit git.Commit, title, changelog string, preRelease, latest bool) error {
|
||||
makeLatest := ""
|
||||
if latest {
|
||||
makeLatest = "true"
|
||||
} else {
|
||||
makeLatest = "false"
|
||||
}
|
||||
_, _, err := g.client.Repositories.CreateRelease(
|
||||
ctx, g.options.Owner, g.options.Repo,
|
||||
&github.RepositoryRelease{
|
||||
TagName: &title,
|
||||
TargetCommitish: &commit.Hash,
|
||||
Name: &title,
|
||||
Body: &changelog,
|
||||
Prerelease: &preRelease,
|
||||
MakeLatest: &makeLatest,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func gitHubPRToPullRequest(pr *github.PullRequest) *git.PullRequest {
|
||||
return &git.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))
|
||||
for _, label := range pr.Labels {
|
||||
labelName := releasepr.Label(label.GetName())
|
||||
if slices.Contains(releasepr.KnownLabels, releasepr.Label(label.GetName())) {
|
||||
labels = append(labels, labelName)
|
||||
}
|
||||
}
|
||||
|
||||
var releaseCommit *git.Commit
|
||||
if pr.MergeCommitSHA != nil {
|
||||
releaseCommit = &git.Commit{Hash: pr.GetMergeCommitSHA()}
|
||||
}
|
||||
|
||||
return &releasepr.ReleasePullRequest{
|
||||
ID: pr.GetNumber(),
|
||||
Title: pr.GetTitle(),
|
||||
Description: pr.GetBody(),
|
||||
Labels: labels,
|
||||
|
||||
Head: pr.GetHead().GetRef(),
|
||||
ReleaseCommit: releaseCommit,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Options) autodiscover() {
|
||||
if apiToken := os.Getenv(EnvAPIToken); apiToken != "" {
|
||||
g.APIToken = apiToken
|
||||
}
|
||||
// TODO: Check if there is a better solution for cloning/pushing locally
|
||||
if username := os.Getenv(EnvUsername); username != "" {
|
||||
g.Username = username
|
||||
}
|
||||
|
||||
if envRepository := os.Getenv(EnvRepository); envRepository != "" {
|
||||
// GITHUB_REPOSITORY=apricote/releaser-pleaser
|
||||
parts := strings.Split(envRepository, "/")
|
||||
if len(parts) == 2 {
|
||||
g.Owner = parts[0]
|
||||
g.Repo = parts[1]
|
||||
g.Repository = envRepository
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
forge.Options
|
||||
|
||||
Owner string
|
||||
Repo string
|
||||
|
||||
APIToken string
|
||||
Username string
|
||||
}
|
||||
|
||||
func New(log *slog.Logger, options *Options) *GitHub {
|
||||
options.autodiscover()
|
||||
|
||||
client := github.NewClient(nil)
|
||||
if options.APIToken != "" {
|
||||
client = client.WithAuthToken(options.APIToken)
|
||||
}
|
||||
|
||||
gh := &GitHub{
|
||||
options: options,
|
||||
|
||||
client: client,
|
||||
log: log.With("forge", "github"),
|
||||
}
|
||||
|
||||
return gh
|
||||
}
|
||||
31
internal/forge/gitlab/gitlab.go
Normal file
31
internal/forge/gitlab/gitlab.go
Normal 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
227
internal/git/git.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
5
internal/pointer/pointer.go
Normal file
5
internal/pointer/pointer.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package pointer
|
||||
|
||||
func Pointer[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
1
internal/releasepr/label.go
Normal file
1
internal/releasepr/label.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package releasepr
|
||||
258
internal/releasepr/releasepr.go
Normal file
258
internal/releasepr/releasepr.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
package releasepr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
releasePRTemplate *template.Template
|
||||
)
|
||||
|
||||
//go:embed releasepr.md.tpl
|
||||
var rawReleasePRTemplate string
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
releasePRTemplate, err = template.New("releasepr").Parse(rawReleasePRTemplate)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to parse release pr template: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ReleasePullRequest
|
||||
//
|
||||
// TODO: Reuse [git.PullRequest]
|
||||
type ReleasePullRequest struct {
|
||||
ID int
|
||||
Title string
|
||||
Description string
|
||||
Labels []Label
|
||||
|
||||
Head string
|
||||
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) {
|
||||
rp := &ReleasePullRequest{
|
||||
Head: head,
|
||||
Labels: []Label{LabelReleasePending},
|
||||
}
|
||||
|
||||
rp.SetTitle(branch, version)
|
||||
if err := rp.SetDescription(changelogEntry, ReleaseOverrides{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rp, nil
|
||||
}
|
||||
|
||||
type ReleaseOverrides struct {
|
||||
Prefix string
|
||||
Suffix string
|
||||
NextVersionType versioning.NextVersionType
|
||||
}
|
||||
|
||||
const (
|
||||
LabelNextVersionTypeNormal Label = "rp-next-version::normal"
|
||||
LabelNextVersionTypeRC Label = "rp-next-version::rc"
|
||||
LabelNextVersionTypeBeta Label = "rp-next-version::beta"
|
||||
LabelNextVersionTypeAlpha Label = "rp-next-version::alpha"
|
||||
|
||||
LabelReleasePending Label = "rp-release::pending"
|
||||
LabelReleaseTagged Label = "rp-release::tagged"
|
||||
)
|
||||
|
||||
var KnownLabels = []Label{
|
||||
LabelNextVersionTypeNormal,
|
||||
LabelNextVersionTypeRC,
|
||||
LabelNextVersionTypeBeta,
|
||||
LabelNextVersionTypeAlpha,
|
||||
|
||||
LabelReleasePending,
|
||||
LabelReleaseTagged,
|
||||
}
|
||||
|
||||
const (
|
||||
DescriptionLanguagePrefix = "rp-prefix"
|
||||
DescriptionLanguageSuffix = "rp-suffix"
|
||||
)
|
||||
|
||||
const (
|
||||
MarkdownSectionChangelog = "changelog"
|
||||
)
|
||||
|
||||
const (
|
||||
TitleFormat = "chore(%s): release %s"
|
||||
)
|
||||
|
||||
var (
|
||||
TitleRegex = regexp.MustCompile("chore(.*): release (.*)")
|
||||
)
|
||||
|
||||
func (pr *ReleasePullRequest) GetOverrides() (ReleaseOverrides, error) {
|
||||
overrides := ReleaseOverrides{}
|
||||
overrides = pr.parseVersioningFlags(overrides)
|
||||
overrides, err := pr.parseDescription(overrides)
|
||||
if err != nil {
|
||||
return ReleaseOverrides{}, err
|
||||
}
|
||||
|
||||
return overrides, nil
|
||||
}
|
||||
|
||||
func (pr *ReleasePullRequest) parseVersioningFlags(overrides ReleaseOverrides) ReleaseOverrides {
|
||||
for _, label := range pr.Labels {
|
||||
switch label {
|
||||
// Versioning
|
||||
case LabelNextVersionTypeNormal:
|
||||
overrides.NextVersionType = versioning.NextVersionTypeNormal
|
||||
case LabelNextVersionTypeRC:
|
||||
overrides.NextVersionType = versioning.NextVersionTypeRC
|
||||
case LabelNextVersionTypeBeta:
|
||||
overrides.NextVersionType = versioning.NextVersionTypeBeta
|
||||
case LabelNextVersionTypeAlpha:
|
||||
overrides.NextVersionType = versioning.NextVersionTypeAlpha
|
||||
case LabelReleasePending, LabelReleaseTagged:
|
||||
// These labels have no effect on the versioning.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return overrides
|
||||
}
|
||||
|
||||
func (pr *ReleasePullRequest) parseDescription(overrides ReleaseOverrides) (ReleaseOverrides, error) {
|
||||
source := []byte(pr.Description)
|
||||
descriptionAST := markdown.New().Parser().Parse(text.NewReader(source))
|
||||
|
||||
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() != ast.KindFencedCodeBlock {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
codeBlock, ok := n.(*ast.FencedCodeBlock)
|
||||
if !ok {
|
||||
return ast.WalkStop, fmt.Errorf("node has unexpected type: %T", n)
|
||||
}
|
||||
|
||||
switch string(codeBlock.Language(source)) {
|
||||
case DescriptionLanguagePrefix:
|
||||
overrides.Prefix = textFromLines(source, codeBlock)
|
||||
case DescriptionLanguageSuffix:
|
||||
overrides.Suffix = textFromLines(source, codeBlock)
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
if err != nil {
|
||||
return ReleaseOverrides{}, err
|
||||
}
|
||||
|
||||
return overrides, nil
|
||||
}
|
||||
|
||||
func (pr *ReleasePullRequest) ChangelogText() (string, error) {
|
||||
source := []byte(pr.Description)
|
||||
gm := markdown.New()
|
||||
descriptionAST := gm.Parser().Parse(text.NewReader(source))
|
||||
|
||||
var section *ast2.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 {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
anySection, ok := n.(*ast2.Section)
|
||||
if !ok {
|
||||
return ast.WalkStop, fmt.Errorf("node has unexpected type: %T", n)
|
||||
}
|
||||
|
||||
if anySection.Name != MarkdownSectionChangelog {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
section = anySection
|
||||
return ast.WalkStop, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if section == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
outputBuffer := new(bytes.Buffer)
|
||||
err = gm.Renderer().Render(outputBuffer, source, section)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return outputBuffer.String(), nil
|
||||
}
|
||||
|
||||
func textFromLines(source []byte, n ast.Node) string {
|
||||
content := make([]byte, 0)
|
||||
|
||||
l := n.Lines().Len()
|
||||
for i := 0; i < l; i++ {
|
||||
line := n.Lines().At(i)
|
||||
content = append(content, line.Value(source)...)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(content))
|
||||
}
|
||||
|
||||
func (pr *ReleasePullRequest) SetTitle(branch, version string) {
|
||||
pr.Title = fmt.Sprintf(TitleFormat, branch, version)
|
||||
}
|
||||
|
||||
func (pr *ReleasePullRequest) Version() (string, error) {
|
||||
matches := TitleRegex.FindStringSubmatch(pr.Title)
|
||||
if len(matches) != 3 {
|
||||
return "", fmt.Errorf("title has unexpected format")
|
||||
}
|
||||
|
||||
return matches[2], nil
|
||||
}
|
||||
|
||||
func (pr *ReleasePullRequest) SetDescription(changelogEntry string, overrides ReleaseOverrides) error {
|
||||
var description bytes.Buffer
|
||||
err := releasePRTemplate.Execute(&description, map[string]any{
|
||||
"Changelog": changelogEntry,
|
||||
"Overrides": overrides,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pr.Description = description.String()
|
||||
|
||||
return nil
|
||||
}
|
||||
32
internal/releasepr/releasepr.md.tpl
Normal file
32
internal/releasepr/releasepr.md.tpl
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<!-- section-start changelog -->
|
||||
{{ .Changelog }}
|
||||
<!-- section-end changelog -->
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><h4>PR by <a href="https://github.com/apricote/releaser-pleaser">releaser-pleaser</a> 🤖</h4></summary>
|
||||
|
||||
If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs.
|
||||
|
||||
## Release Notes
|
||||
|
||||
### Prefix / Start
|
||||
|
||||
This will be added to the start of the release notes.
|
||||
|
||||
```rp-prefix
|
||||
{{- if .Overrides.Prefix }}
|
||||
{{ .Overrides.Prefix }}{{ end }}
|
||||
```
|
||||
|
||||
### Suffix / End
|
||||
|
||||
This will be added to the end of the release notes.
|
||||
|
||||
```rp-suffix
|
||||
{{- if .Overrides.Suffix }}
|
||||
{{ .Overrides.Suffix }}{{ end }}
|
||||
```
|
||||
|
||||
</details>
|
||||
144
internal/releasepr/releasepr_test.go
Normal file
144
internal/releasepr/releasepr_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package releasepr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestReleasePullRequest_SetTitle(t *testing.T) {
|
||||
type args struct {
|
||||
branch string
|
||||
version string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
pr *ReleasePullRequest
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "simple update",
|
||||
pr: &ReleasePullRequest{Title: "foo: bar"},
|
||||
args: args{
|
||||
branch: "main",
|
||||
version: "v1.0.0",
|
||||
},
|
||||
want: "chore(main): release v1.0.0",
|
||||
},
|
||||
{
|
||||
name: "no previous title",
|
||||
pr: &ReleasePullRequest{},
|
||||
args: args{
|
||||
branch: "release-1.x",
|
||||
version: "v1.1.1-rc.0",
|
||||
},
|
||||
want: "chore(release-1.x): release v1.1.1-rc.0",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.pr.SetTitle(tt.args.branch, tt.args.version)
|
||||
|
||||
assert.Equal(t, tt.want, tt.pr.Title)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePullRequest_SetDescription(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
changelogEntry string
|
||||
overrides ReleaseOverrides
|
||||
want string
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "no overrides",
|
||||
changelogEntry: `## v1.0.0`,
|
||||
overrides: ReleaseOverrides{},
|
||||
want: `<!-- section-start changelog -->
|
||||
## v1.0.0
|
||||
<!-- section-end changelog -->
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><h4>PR by <a href="https://github.com/apricote/releaser-pleaser">releaser-pleaser</a> 🤖</h4></summary>
|
||||
|
||||
If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs.
|
||||
|
||||
## Release Notes
|
||||
|
||||
### Prefix / Start
|
||||
|
||||
This will be added to the start of the release notes.
|
||||
|
||||
` + "```" + `rp-prefix
|
||||
` + "```" + `
|
||||
|
||||
### Suffix / End
|
||||
|
||||
This will be added to the end of the release notes.
|
||||
|
||||
` + "```" + `rp-suffix
|
||||
` + "```" + `
|
||||
|
||||
</details>
|
||||
`,
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "existing overrides",
|
||||
changelogEntry: `## v1.0.0`,
|
||||
overrides: ReleaseOverrides{
|
||||
Prefix: "This release is awesome!",
|
||||
Suffix: "Fooo",
|
||||
},
|
||||
want: `<!-- section-start changelog -->
|
||||
## v1.0.0
|
||||
<!-- section-end changelog -->
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><h4>PR by <a href="https://github.com/apricote/releaser-pleaser">releaser-pleaser</a> 🤖</h4></summary>
|
||||
|
||||
If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs.
|
||||
|
||||
## Release Notes
|
||||
|
||||
### Prefix / Start
|
||||
|
||||
This will be added to the start of the release notes.
|
||||
|
||||
` + "```" + `rp-prefix
|
||||
This release is awesome!
|
||||
` + "```" + `
|
||||
|
||||
### Suffix / End
|
||||
|
||||
This will be added to the end of the release notes.
|
||||
|
||||
` + "```" + `rp-suffix
|
||||
Fooo
|
||||
` + "```" + `
|
||||
|
||||
</details>
|
||||
`,
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pr := &ReleasePullRequest{}
|
||||
err := pr.SetDescription(tt.changelogEntry, tt.overrides)
|
||||
if !tt.wantErr(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.want, pr.Description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
32
internal/updater/changelog.go
Normal file
32
internal/updater/changelog.go
Normal 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
|
||||
}
|
||||
}
|
||||
60
internal/updater/changelog_test.go
Normal file
60
internal/updater/changelog_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
17
internal/updater/generic.go
Normal file
17
internal/updater/generic.go
Normal 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
|
||||
}
|
||||
}
|
||||
53
internal/updater/generic_test.go
Normal file
53
internal/updater/generic_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
19
internal/updater/updater.go
Normal file
19
internal/updater/updater.go
Normal 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
|
||||
}
|
||||
26
internal/updater/updater_test.go
Normal file
26
internal/updater/updater_test.go
Normal 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)
|
||||
}
|
||||
110
internal/versioning/semver.go
Normal file
110
internal/versioning/semver.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package versioning
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/blang/semver/v4"
|
||||
|
||||
"github.com/apricote/releaser-pleaser/internal/commitparser"
|
||||
"github.com/apricote/releaser-pleaser/internal/git"
|
||||
)
|
||||
|
||||
var _ Strategy = SemVerNextVersion
|
||||
|
||||
func SemVerNextVersion(r git.Releases, versionBump VersionBump, nextVersionType NextVersionType) (string, error) {
|
||||
latest, err := parseSemverWithDefault(r.Latest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse latest version: %w", err)
|
||||
}
|
||||
|
||||
stable, err := parseSemverWithDefault(r.Stable)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse stable version: %w", err)
|
||||
}
|
||||
|
||||
// If there is a previous stable release, we use that as the version anchor. Falling back to any pre-releases
|
||||
// if they are the only tags in the repo.
|
||||
next := latest
|
||||
if r.Stable != nil {
|
||||
next = stable
|
||||
}
|
||||
|
||||
switch versionBump {
|
||||
case UnknownVersion:
|
||||
return "", fmt.Errorf("invalid latest bump (unknown)")
|
||||
case PatchVersion:
|
||||
err = next.IncrementPatch()
|
||||
case MinorVersion:
|
||||
err = next.IncrementMinor()
|
||||
case MajorVersion:
|
||||
err = next.IncrementMajor()
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch nextVersionType {
|
||||
case NextVersionTypeUndefined, NextVersionTypeNormal:
|
||||
next.Pre = make([]semver.PRVersion, 0)
|
||||
case NextVersionTypeAlpha, NextVersionTypeBeta, NextVersionTypeRC:
|
||||
id := uint64(0)
|
||||
|
||||
if len(latest.Pre) >= 2 && latest.Pre[0].String() == nextVersionType.String() {
|
||||
if latest.Pre[1].String() == "" || !latest.Pre[1].IsNumeric() {
|
||||
return "", fmt.Errorf("invalid format of previous tag")
|
||||
}
|
||||
id = latest.Pre[1].VersionNum + 1
|
||||
}
|
||||
|
||||
setPRVersion(&next, nextVersionType.String(), id)
|
||||
}
|
||||
|
||||
return "v" + next.String(), nil
|
||||
}
|
||||
|
||||
func BumpFromCommits(commits []commitparser.AnalyzedCommit) VersionBump {
|
||||
bump := UnknownVersion
|
||||
|
||||
for _, commit := range commits {
|
||||
entryBump := UnknownVersion
|
||||
switch {
|
||||
case commit.BreakingChange:
|
||||
entryBump = MajorVersion
|
||||
case commit.Type == "feat":
|
||||
entryBump = MinorVersion
|
||||
case commit.Type == "fix":
|
||||
entryBump = PatchVersion
|
||||
}
|
||||
|
||||
if entryBump > bump {
|
||||
bump = entryBump
|
||||
}
|
||||
}
|
||||
|
||||
return bump
|
||||
}
|
||||
|
||||
func setPRVersion(version *semver.Version, prType string, count uint64) {
|
||||
version.Pre = []semver.PRVersion{
|
||||
{VersionStr: prType},
|
||||
{VersionNum: count, IsNum: true},
|
||||
}
|
||||
}
|
||||
|
||||
func parseSemverWithDefault(tag *git.Tag) (semver.Version, error) {
|
||||
version := "v0.0.0"
|
||||
if tag != nil {
|
||||
version = tag.Name
|
||||
}
|
||||
|
||||
// The lib can not handle v prefixes
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
|
||||
parsedVersion, err := semver.Parse(version)
|
||||
if err != nil {
|
||||
return semver.Version{}, fmt.Errorf("failed to parse version %q: %w", version, err)
|
||||
}
|
||||
|
||||
return parsedVersion, nil
|
||||
}
|
||||
390
internal/versioning/semver_test.go
Normal file
390
internal/versioning/semver_test.go
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
package versioning
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"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
|
||||
nextVersionType NextVersionType
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "simple bump (major)",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: &git.Tag{Name: "v1.1.1"},
|
||||
Stable: &git.Tag{Name: "v1.1.1"},
|
||||
},
|
||||
versionBump: MajorVersion,
|
||||
nextVersionType: NextVersionTypeUndefined,
|
||||
},
|
||||
want: "v2.0.0",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "simple bump (minor)",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: &git.Tag{Name: "v1.1.1"},
|
||||
Stable: &git.Tag{Name: "v1.1.1"},
|
||||
},
|
||||
versionBump: MinorVersion,
|
||||
nextVersionType: NextVersionTypeUndefined,
|
||||
},
|
||||
want: "v1.2.0",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "simple bump (patch)",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: &git.Tag{Name: "v1.1.1"},
|
||||
Stable: &git.Tag{Name: "v1.1.1"},
|
||||
},
|
||||
versionBump: PatchVersion,
|
||||
nextVersionType: NextVersionTypeUndefined,
|
||||
},
|
||||
want: "v1.1.2",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "normal to prerelease (major)",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: &git.Tag{Name: "v1.1.1"},
|
||||
Stable: &git.Tag{Name: "v1.1.1"},
|
||||
},
|
||||
versionBump: MajorVersion,
|
||||
nextVersionType: NextVersionTypeRC,
|
||||
},
|
||||
want: "v2.0.0-rc.0",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "normal to prerelease (minor)",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: &git.Tag{Name: "v1.1.1"},
|
||||
Stable: &git.Tag{Name: "v1.1.1"},
|
||||
},
|
||||
versionBump: MinorVersion,
|
||||
nextVersionType: NextVersionTypeRC,
|
||||
},
|
||||
want: "v1.2.0-rc.0",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "normal to prerelease (patch)",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: &git.Tag{Name: "v1.1.1"},
|
||||
Stable: &git.Tag{Name: "v1.1.1"},
|
||||
},
|
||||
versionBump: PatchVersion,
|
||||
nextVersionType: NextVersionTypeRC,
|
||||
},
|
||||
want: "v1.1.2-rc.0",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
versionBump: MajorVersion,
|
||||
nextVersionType: NextVersionTypeRC,
|
||||
},
|
||||
want: "v2.0.0-rc.1",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
versionBump: MinorVersion,
|
||||
nextVersionType: NextVersionTypeRC,
|
||||
},
|
||||
want: "v1.2.0-rc.1",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
versionBump: PatchVersion,
|
||||
nextVersionType: NextVersionTypeRC,
|
||||
},
|
||||
want: "v1.1.2-rc.1",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
versionBump: MajorVersion,
|
||||
nextVersionType: NextVersionTypeRC,
|
||||
},
|
||||
want: "v2.0.0-rc.1",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
versionBump: MinorVersion,
|
||||
nextVersionType: NextVersionTypeRC,
|
||||
},
|
||||
want: "v1.2.0-rc.1",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
versionBump: PatchVersion,
|
||||
nextVersionType: NextVersionTypeRC,
|
||||
},
|
||||
want: "v1.1.1-rc.0",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
versionBump: PatchVersion,
|
||||
nextVersionType: NextVersionTypeNormal,
|
||||
},
|
||||
want: "v1.1.1",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
versionBump: PatchVersion,
|
||||
nextVersionType: NextVersionTypeUndefined,
|
||||
},
|
||||
want: "v1.1.1",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "nil tag (major)",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: nil,
|
||||
Stable: nil,
|
||||
},
|
||||
versionBump: MajorVersion,
|
||||
nextVersionType: NextVersionTypeUndefined,
|
||||
},
|
||||
want: "v1.0.0",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "nil tag (minor)",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: nil,
|
||||
Stable: nil,
|
||||
},
|
||||
versionBump: MinorVersion,
|
||||
nextVersionType: NextVersionTypeUndefined,
|
||||
},
|
||||
want: "v0.1.0",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "nil tag (patch)",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: nil,
|
||||
Stable: nil,
|
||||
},
|
||||
versionBump: PatchVersion,
|
||||
nextVersionType: NextVersionTypeUndefined,
|
||||
},
|
||||
want: "v0.0.1",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "nil stable release (major)",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: &git.Tag{Name: "v1.1.1-rc.0"},
|
||||
Stable: nil,
|
||||
},
|
||||
versionBump: MajorVersion,
|
||||
nextVersionType: NextVersionTypeUndefined,
|
||||
},
|
||||
want: "v2.0.0",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "nil stable release (minor)",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: &git.Tag{Name: "v1.1.1-rc.0"},
|
||||
Stable: nil,
|
||||
},
|
||||
versionBump: MinorVersion,
|
||||
nextVersionType: NextVersionTypeUndefined,
|
||||
},
|
||||
want: "v1.2.0",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "nil stable release (patch)",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: &git.Tag{Name: "v1.1.1-rc.0"},
|
||||
Stable: nil,
|
||||
},
|
||||
versionBump: PatchVersion,
|
||||
nextVersionType: NextVersionTypeUndefined,
|
||||
},
|
||||
// TODO: Is this actually correct our should it be v1.1.1?
|
||||
want: "v1.1.2",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "error on invalid tag semver",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: &git.Tag{Name: "foodazzle"},
|
||||
Stable: &git.Tag{Name: "foodazzle"},
|
||||
},
|
||||
versionBump: PatchVersion,
|
||||
nextVersionType: NextVersionTypeRC,
|
||||
},
|
||||
want: "",
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
versionBump: PatchVersion,
|
||||
nextVersionType: NextVersionTypeRC,
|
||||
},
|
||||
want: "",
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "error on invalid bump",
|
||||
args: args{
|
||||
releases: git.Releases{
|
||||
Latest: &git.Tag{Name: "v1.1.1"},
|
||||
Stable: &git.Tag{Name: "v1.1.1"},
|
||||
},
|
||||
|
||||
versionBump: UnknownVersion,
|
||||
nextVersionType: NextVersionTypeUndefined,
|
||||
},
|
||||
want: "",
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := SemVerNextVersion(tt.args.releases, tt.args.versionBump, tt.args.nextVersionType)
|
||||
if !tt.wantErr(t, err, fmt.Sprintf("SemVerNextVersion(Releases(%v, %v), %v, %v)", tt.args.releases.Latest, tt.args.releases.Stable, tt.args.versionBump, tt.args.nextVersionType)) {
|
||||
return
|
||||
}
|
||||
assert.Equalf(t, tt.want, got, "SemVerNextVersion(Releases(%v, %v), %v, %v)", tt.args.releases.Latest, tt.args.releases.Stable, tt.args.versionBump, tt.args.nextVersionType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionBumpFromCommits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
analyzedCommits []commitparser.AnalyzedCommit
|
||||
want VersionBump
|
||||
}{
|
||||
{
|
||||
name: "no entries (unknown)",
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{},
|
||||
want: UnknownVersion,
|
||||
},
|
||||
{
|
||||
name: "non-release type (unknown)",
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{{Type: "docs"}},
|
||||
want: UnknownVersion,
|
||||
},
|
||||
{
|
||||
name: "single breaking (major)",
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{{BreakingChange: true}},
|
||||
want: MajorVersion,
|
||||
},
|
||||
{
|
||||
name: "single feat (minor)",
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{{Type: "feat"}},
|
||||
want: MinorVersion,
|
||||
},
|
||||
{
|
||||
name: "single fix (patch)",
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{{Type: "fix"}},
|
||||
want: PatchVersion,
|
||||
},
|
||||
{
|
||||
name: "multiple entries (major)",
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}},
|
||||
want: MajorVersion,
|
||||
},
|
||||
{
|
||||
name: "multiple entries (minor)",
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{{Type: "fix"}, {Type: "feat"}},
|
||||
want: MinorVersion,
|
||||
},
|
||||
{
|
||||
name: "multiple entries (patch)",
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{{Type: "docs"}, {Type: "fix"}},
|
||||
want: 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
56
internal/versioning/versioning.go
Normal file
56
internal/versioning/versioning.go
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue