Compare commits

..

1 commit

Author SHA1 Message Date
releaser-pleaser
4eb0fd7253 chore(main): release v0.1.0 2024-08-05 22:10:51 +00:00
25 changed files with 1001 additions and 1090 deletions

View file

@ -21,7 +21,7 @@ jobs:
- name: Run golangci-lint - name: Run golangci-lint
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v6
with: with:
version: v1.60.1 # renovate: datasource=github-releases depName=golangci/golangci-lint version: v1.59.1 # renovate: datasource=github-releases depName=golangci/golangci-lint
args: --timeout 5m args: --timeout 5m
@ -37,12 +37,8 @@ jobs:
go-version-file: go.mod go-version-file: go.mod
- name: Run tests - name: Run tests
run: go test -v -race -coverpkg=./... -coverprofile=coverage.txt ./... run: go test -v -race -coverpkg=./... ./...
- name: Upload results to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
go-mod-tidy: go-mod-tidy:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -9,7 +9,10 @@ on:
- labeled - labeled
- unlabeled - unlabeled
permissions: {} permissions:
contents: write
issues: write
pull-requests: write
jobs: jobs:
releaser-pleaser: releaser-pleaser:
@ -18,7 +21,5 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: releaser-pleaser - run: env
uses: ./ - uses: ./
with:
token: ${{ secrets.RELEASER_PLEASER_TOKEN }}

View file

@ -1,27 +0,0 @@
linters:
presets:
- bugs
- error
- import
- metalinter
- module
- unused
enable:
- testifylint
disable:
# preset error
# These should probably be cleaned up at some point if we want to publish part of this as a library.
- err113 # Very annoying to define static errors everywhere
- wrapcheck # Very annoying to wrap errors everywhere
# preset import
- depguard
linters-settings:
gci:
sections:
- standard
- default
- localmodule

View file

@ -1,10 +1,5 @@
# Changelog # Changelog
## [v0.2.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.2.0)
### Features
- update version references in any files (#14)
## [v0.1.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.1.0) ## [v0.1.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.1.0)
### This is the first release ever, so it also includes a lot of other functionality. ### This is the first release ever, so it also includes a lot of other functionality.

View file

@ -12,19 +12,14 @@ inputs:
description: 'GitHub token for creating and grooming release PRs, defaults to using secrets.GITHUB_TOKEN' description: 'GitHub token for creating and grooming release PRs, defaults to using secrets.GITHUB_TOKEN'
required: false required: false
default: ${{ github.token }} default: ${{ github.token }}
extra-files:
description: 'List of files that are scanned for version references.'
required: false
default: ""
outputs: {} outputs: {}
runs: runs:
using: 'docker' using: 'docker'
image: ghcr.io/apricote/releaser-pleaser:v0.2.0 # x-releaser-pleaser-version image: ghcr.io/apricote/releaser-pleaser:v0.1.0
args: args:
- run - run
- --forge=github - --forge=github
- --branch=${{ inputs.branch }} - --branch=${{ inputs.branch }}
- --extra-files="${{ inputs.extra-files }}"
env: env:
GITHUB_TOKEN: ${{ inputs.token }} GITHUB_TOKEN: ${{ inputs.token }}
GITHUB_USER: "oauth2" GITHUB_USER: "oauth2"

View file

@ -3,17 +3,26 @@ package rp
import ( import (
"bytes" "bytes"
_ "embed" _ "embed"
"fmt"
"html/template" "html/template"
"io"
"log" "log"
"os"
"regexp"
"github.com/go-git/go-git/v5"
) )
const ( const (
ChangelogFile = "CHANGELOG.md" ChangelogFile = "CHANGELOG.md"
ChangelogHeader = "# Changelog" ChangelogFileBuffer = "CHANGELOG.md.tmp"
ChangelogHeader = "# Changelog"
) )
var ( var (
changelogTemplate *template.Template changelogTemplate *template.Template
headerRegex = regexp.MustCompile(`^# Changelog\n`)
) )
//go:embed changelog.md.tpl //go:embed changelog.md.tpl
@ -27,16 +36,72 @@ func init() {
} }
} }
func NewChangelogEntry(commits []AnalyzedCommit, version, link, prefix, suffix string) (string, error) { func UpdateChangelogFile(wt *git.Worktree, newEntry string) error {
file, err := wt.Filesystem.OpenFile(ChangelogFile, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
return err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return err
}
headerIndex := headerRegex.FindIndex(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]:]
}
err = file.Truncate(0)
if err != nil {
return err
}
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return err
}
_, err = file.Write([]byte(ChangelogHeader + "\n\n" + newEntry))
if err != nil {
return err
}
_, err = file.Write(content)
if err != nil {
return err
}
// Close file to make sure it is written to disk.
err = file.Close()
if err != nil {
return err
}
_, err = wt.Add(ChangelogFile)
if err != nil {
return err
}
return nil
}
func NewChangelogEntry(changesets []Changeset, version, link, prefix, suffix string) (string, error) {
features := make([]AnalyzedCommit, 0) features := make([]AnalyzedCommit, 0)
fixes := make([]AnalyzedCommit, 0) fixes := make([]AnalyzedCommit, 0)
for _, commit := range commits { for _, changeset := range changesets {
switch commit.Type { for _, commit := range changeset.ChangelogEntries {
case "feat": switch commit.Type {
features = append(features, commit) case "feat":
case "fix": features = append(features, commit)
fixes = append(fixes, commit) case "fix":
fixes = append(fixes, commit)
}
} }
} }

View file

@ -1,22 +1,106 @@
package rp package rp
import ( import (
"io"
"testing" "testing"
"github.com/go-git/go-git/v5"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/apricote/releaser-pleaser/internal/testutils"
) )
func ptr[T any](input T) *T { func ptr[T any](input T) *T {
return &input return &input
} }
func TestUpdateChangelogFile(t *testing.T) {
tests := []struct {
name string
repoFn testutils.Repo
entry string
expectedContent string
wantErr assert.ErrorAssertionFunc
}{
{
name: "empty repo",
repoFn: testutils.WithTestRepo(),
entry: "## v1.0.0\n",
expectedContent: "# Changelog\n\n## v1.0.0\n",
wantErr: assert.NoError,
},
{
name: "repo with well-formatted changelog",
repoFn: testutils.WithTestRepo(testutils.WithCommit("feat: add changelog", testutils.WithFile(ChangelogFile, `# Changelog
## v0.0.1
- Bazzle
## v0.1.0
### Bazuuum
`))),
entry: "## v1.0.0\n\n- Version 1, juhu.\n",
expectedContent: `# Changelog
## v1.0.0
- Version 1, juhu.
## v0.0.1
- Bazzle
## v0.1.0
### Bazuuum
`,
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := tt.repoFn(t)
wt, err := repo.Worktree()
require.NoError(t, err, "failed to get worktree")
err = UpdateChangelogFile(wt, tt.entry)
if !tt.wantErr(t, err) {
return
}
wtStatus, err := wt.Status()
require.NoError(t, err, "failed to get worktree status")
assert.Len(t, wtStatus, 1, "worktree status does not have the expected entry number")
changelogFileStatus := wtStatus.File(ChangelogFile)
assert.Equal(t, git.Unmodified, changelogFileStatus.Worktree, "unexpected file status in worktree")
assert.Equal(t, git.Added, changelogFileStatus.Staging, "unexpected file status in staging")
changelogFile, err := wt.Filesystem.Open(ChangelogFile)
require.NoError(t, err)
defer changelogFile.Close()
changelogFileContent, err := io.ReadAll(changelogFile)
require.NoError(t, err)
assert.Equal(t, tt.expectedContent, string(changelogFileContent))
})
}
}
func Test_NewChangelogEntry(t *testing.T) { func Test_NewChangelogEntry(t *testing.T) {
type args struct { type args struct {
analyzedCommits []AnalyzedCommit changesets []Changeset
version string version string
link string link string
prefix string prefix string
suffix string suffix string
} }
tests := []struct { tests := []struct {
name string name string
@ -27,9 +111,9 @@ func Test_NewChangelogEntry(t *testing.T) {
{ {
name: "empty", name: "empty",
args: args{ args: args{
analyzedCommits: []AnalyzedCommit{}, changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{}}},
version: "1.0.0", version: "1.0.0",
link: "https://example.com/1.0.0", link: "https://example.com/1.0.0",
}, },
want: "## [1.0.0](https://example.com/1.0.0)", want: "## [1.0.0](https://example.com/1.0.0)",
wantErr: assert.NoError, wantErr: assert.NoError,
@ -37,13 +121,13 @@ func Test_NewChangelogEntry(t *testing.T) {
{ {
name: "single feature", name: "single feature",
args: args{ args: args{
analyzedCommits: []AnalyzedCommit{ changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{
{ {
Commit: Commit{}, Commit: Commit{},
Type: "feat", Type: "feat",
Description: "Foobar!", Description: "Foobar!",
}, },
}, }}},
version: "1.0.0", version: "1.0.0",
link: "https://example.com/1.0.0", link: "https://example.com/1.0.0",
}, },
@ -53,13 +137,13 @@ func Test_NewChangelogEntry(t *testing.T) {
{ {
name: "single fix", name: "single fix",
args: args{ args: args{
analyzedCommits: []AnalyzedCommit{ changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{
{ {
Commit: Commit{}, Commit: Commit{},
Type: "fix", Type: "fix",
Description: "Foobar!", Description: "Foobar!",
}, },
}, }}},
version: "1.0.0", version: "1.0.0",
link: "https://example.com/1.0.0", link: "https://example.com/1.0.0",
}, },
@ -69,7 +153,7 @@ func Test_NewChangelogEntry(t *testing.T) {
{ {
name: "multiple commits with scopes", name: "multiple commits with scopes",
args: args{ args: args{
analyzedCommits: []AnalyzedCommit{ changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{
{ {
Commit: Commit{}, Commit: Commit{},
Type: "feat", Type: "feat",
@ -92,7 +176,7 @@ func Test_NewChangelogEntry(t *testing.T) {
Description: "So sad!", Description: "So sad!",
Scope: ptr("sad"), Scope: ptr("sad"),
}, },
}, }}},
version: "1.0.0", version: "1.0.0",
link: "https://example.com/1.0.0", link: "https://example.com/1.0.0",
}, },
@ -112,13 +196,13 @@ func Test_NewChangelogEntry(t *testing.T) {
{ {
name: "prefix", name: "prefix",
args: args{ args: args{
analyzedCommits: []AnalyzedCommit{ changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{
{ {
Commit: Commit{}, Commit: Commit{},
Type: "fix", Type: "fix",
Description: "Foobar!", Description: "Foobar!",
}, },
}, }}},
version: "1.0.0", version: "1.0.0",
link: "https://example.com/1.0.0", link: "https://example.com/1.0.0",
prefix: "### Breaking Changes", prefix: "### Breaking Changes",
@ -135,13 +219,13 @@ func Test_NewChangelogEntry(t *testing.T) {
{ {
name: "suffix", name: "suffix",
args: args{ args: args{
analyzedCommits: []AnalyzedCommit{ changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{
{ {
Commit: Commit{}, Commit: Commit{},
Type: "fix", Type: "fix",
Description: "Foobar!", Description: "Foobar!",
}, },
}, }}},
version: "1.0.0", version: "1.0.0",
link: "https://example.com/1.0.0", link: "https://example.com/1.0.0",
suffix: "### Compatibility\n\nThis version is compatible with flux-compensator v2.2 - v2.9.", suffix: "### Compatibility\n\nThis version is compatible with flux-compensator v2.2 - v2.9.",
@ -161,7 +245,7 @@ This version is compatible with flux-compensator v2.2 - v2.9.
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) got, err := NewChangelogEntry(tt.args.changesets, tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix)
if !tt.wantErr(t, err) { if !tt.wantErr(t, err) {
return return
} }

View file

@ -1,13 +1,21 @@
package cmd package cmd
import ( import (
"strings" "context"
"fmt"
"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/spf13/cobra" "github.com/spf13/cobra"
rp "github.com/apricote/releaser-pleaser" rp "github.com/apricote/releaser-pleaser"
) )
const (
RELEASER_PLEASER_BRANCH = "releaser-pleaser--branches--%s"
)
// runCmd represents the run command // runCmd represents the run command
var runCmd = &cobra.Command{ var runCmd = &cobra.Command{
Use: "run", Use: "run",
@ -15,11 +23,10 @@ var runCmd = &cobra.Command{
} }
var ( var (
flagForge string flagForge string
flagBranch string flagBranch string
flagOwner string flagOwner string
flagRepo string flagRepo string
flagExtraFiles string
) )
func init() { func init() {
@ -31,7 +38,6 @@ func init() {
runCmd.PersistentFlags().StringVar(&flagBranch, "branch", "main", "") runCmd.PersistentFlags().StringVar(&flagBranch, "branch", "main", "")
runCmd.PersistentFlags().StringVar(&flagOwner, "owner", "", "") runCmd.PersistentFlags().StringVar(&flagOwner, "owner", "", "")
runCmd.PersistentFlags().StringVar(&flagRepo, "repo", "", "") runCmd.PersistentFlags().StringVar(&flagRepo, "repo", "", "")
runCmd.PersistentFlags().StringVar(&flagExtraFiles, "extra-files", "", "")
} }
func run(cmd *cobra.Command, _ []string) error { func run(cmd *cobra.Command, _ []string) error {
@ -44,50 +50,319 @@ func run(cmd *cobra.Command, _ []string) error {
"repo", flagRepo, "repo", flagRepo,
) )
var forge rp.Forge var f rp.Forge
forgeOptions := rp.ForgeOptions{ forgeOptions := rp.ForgeOptions{
Repository: flagRepo, Repository: flagRepo,
BaseBranch: flagBranch, BaseBranch: flagBranch,
} }
switch flagForge { // nolint:gocritic // Will become a proper switch once gitlab is added switch flagForge {
// case "gitlab": //case "gitlab":
// 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 = rp.NewGitHub(logger, &rp.GitHubOptions{
ForgeOptions: forgeOptions, ForgeOptions: forgeOptions,
Owner: flagOwner, Owner: flagOwner,
Repo: flagRepo, Repo: flagRepo,
}) })
} }
extraFiles := parseExtraFiles(flagExtraFiles) err := ensureLabels(ctx, f)
if err != nil {
return fmt.Errorf("failed to ensure all labels exist: %w", err)
}
releaserPleaser := rp.New( err = createPendingReleases(ctx, f)
forge, if err != nil {
logger, return fmt.Errorf("failed to create pending releases: %w", err)
flagBranch, }
rp.NewConventionalCommitsParser(),
rp.SemVerNextVersion,
extraFiles,
[]rp.Updater{&rp.GenericUpdater{}},
)
return releaserPleaser.Run(ctx) changesets, releases, err := getChangesetsFromForge(ctx, f)
if err != nil {
return fmt.Errorf("failed to get changesets: %w", err)
}
err = reconcileReleasePR(ctx, f, changesets, releases)
if err != nil {
return fmt.Errorf("failed to reconcile release pr: %w", err)
}
return nil
} }
func parseExtraFiles(input string) []string { func ensureLabels(ctx context.Context, forge rp.Forge) error {
lines := strings.Split(input, "\n") return forge.EnsureLabelsExist(ctx, rp.Labels)
}
extraFiles := make([]string, 0, len(lines)) func createPendingReleases(ctx context.Context, forge rp.Forge) error {
for _, line := range lines { logger.InfoContext(ctx, "checking for pending releases")
line = strings.TrimSpace(line) prs, err := forge.PendingReleases(ctx)
if len(line) > 0 { if err != nil {
extraFiles = append(extraFiles, line) return err
}
if len(prs) == 0 {
logger.InfoContext(ctx, "No pending releases found")
return nil
}
logger.InfoContext(ctx, "Found pending releases", "length", len(prs))
for _, pr := range prs {
err = createPendingRelease(ctx, forge, pr)
if err != nil {
return err
} }
} }
return extraFiles return nil
}
func createPendingRelease(ctx context.Context, forge rp.Forge, pr *rp.ReleasePullRequest) error {
logger := logger.With("pr.id", pr.ID, "pr.title", pr.Title)
if pr.ReleaseCommit == nil {
return fmt.Errorf("pull request is missing the merge commit")
}
logger.Info("Creating release", "commit.hash", pr.ReleaseCommit.Hash)
version, err := pr.Version()
if err != nil {
return err
}
changelog, err := pr.ChangelogText()
if err != nil {
return err
}
// TODO: pre-release & latest
logger.DebugContext(ctx, "Creating release on forge")
err = forge.CreateRelease(ctx, *pr.ReleaseCommit, version, changelog, false, true)
if err != nil {
return fmt.Errorf("failed to create release on forge: %w", err)
}
logger.DebugContext(ctx, "created release", "release.title", version, "release.url", forge.ReleaseURL(version))
logger.DebugContext(ctx, "updating pr labels")
err = forge.SetPullRequestLabels(ctx, pr, []string{rp.LabelReleasePending}, []string{rp.LabelReleaseTagged})
if err != nil {
return err
}
logger.DebugContext(ctx, "updated pr labels")
logger.InfoContext(ctx, "Created release", "release.title", version, "release.url", forge.ReleaseURL(version))
return nil
}
func getChangesetsFromForge(ctx context.Context, forge rp.Forge) ([]rp.Changeset, rp.Releases, error) {
releases, err := forge.LatestTags(ctx)
if err != nil {
return nil, rp.Releases{}, err
}
if releases.Latest != nil {
logger.InfoContext(ctx, "found latest tag", "tag.hash", releases.Latest.Hash, "tag.name", releases.Latest.Name)
if releases.Stable != nil && releases.Latest.Hash != releases.Stable.Hash {
logger.InfoContext(ctx, "found stable tag", "tag.hash", releases.Stable.Hash, "tag.name", releases.Stable.Name)
}
} else {
logger.InfoContext(ctx, "no latest tag found")
}
releasableCommits, err := forge.CommitsSince(ctx, releases.Stable)
if err != nil {
return nil, rp.Releases{}, err
}
logger.InfoContext(ctx, "Found releasable commits", "length", len(releasableCommits))
changesets, err := forge.Changesets(ctx, releasableCommits)
if err != nil {
return nil, rp.Releases{}, err
}
logger.InfoContext(ctx, "Found changesets", "length", len(changesets))
return changesets, releases, nil
}
func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Changeset, releases rp.Releases) error {
rpBranch := fmt.Sprintf(RELEASER_PLEASER_BRANCH, flagBranch)
rpBranchRef := plumbing.NewBranchReferenceName(rpBranch)
// Check Forge for open PR
// Get any modifications from open PR
// Clone Repo
// Run Updaters + Changelog
// Upsert PR
pr, err := forge.PullRequestForBranch(ctx, fmt.Sprintf(RELEASER_PLEASER_BRANCH, flagBranch))
if err != nil {
return err
}
if pr != nil {
logger.InfoContext(ctx, "found existing release pull request", "pr.id", pr.ID, "pr.title", pr.Title)
}
if len(changesets) == 0 {
if pr != nil {
logger.InfoContext(ctx, "closing existing pull requests, no changesets available", "pr.id", pr.ID, "pr.title", pr.Title)
err = forge.ClosePullRequest(ctx, pr)
if err != nil {
return err
}
} else {
logger.InfoContext(ctx, "No changesets available for release")
}
return nil
}
var releaseOverrides rp.ReleaseOverrides
if pr != nil {
releaseOverrides, err = pr.GetOverrides()
if err != nil {
return err
}
}
versionBump := rp.VersionBumpFromChangesets(changesets)
nextVersion, err := releases.NextVersion(versionBump, releaseOverrides.NextVersionType)
if err != nil {
return err
}
logger.InfoContext(ctx, "next version", "version", nextVersion)
logger.DebugContext(ctx, "cloning repository", "clone.url", forge.CloneURL())
repo, err := rp.CloneRepo(ctx, forge.CloneURL(), flagBranch, forge.GitAuth())
if err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
if branch, _ := repo.Branch(rpBranch); branch != nil {
logger.DebugContext(ctx, "deleting previous releaser-pleaser branch locally", "branch.name", rpBranch)
if err = repo.DeleteBranch(rpBranch); err != nil {
return err
}
}
if err = worktree.Checkout(&git.CheckoutOptions{
Branch: rpBranchRef,
Create: true,
}); err != nil {
return fmt.Errorf("failed to check out branch: %w", err)
}
err = rp.RunUpdater(ctx, nextVersion, worktree)
if err != nil {
return fmt.Errorf("failed to update files with new version: %w", err)
}
changelogEntry, err := rp.NewChangelogEntry(changesets, nextVersion, forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix)
if err != nil {
return fmt.Errorf("failed to build changelog entry: %w", err)
}
err = rp.UpdateChangelogFile(worktree, changelogEntry)
if err != nil {
return fmt.Errorf("failed to update changelog file: %w", err)
}
releaseCommitMessage := fmt.Sprintf("chore(%s): release %s", flagBranch, nextVersion)
releaseCommitHash, err := worktree.Commit(releaseCommitMessage, &git.CommitOptions{
Author: rp.GitSignature(),
Committer: rp.GitSignature(),
})
if err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
}
logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommitHash.String(), "commit.message", releaseCommitMessage)
newReleasePRChanges := true
// Check if anything changed in comparison to the remote branch (if exists)
if remoteRef, err := repo.Reference(plumbing.NewRemoteReferenceName(rp.GitRemoteName, rpBranch), false); err != nil {
if err.Error() != "reference not found" {
// "reference not found" is expected and we should always push
return err
}
} else {
remoteCommit, err := repo.CommitObject(remoteRef.Hash())
if err != nil {
return err
}
localCommit, err := repo.CommitObject(releaseCommitHash)
if err != nil {
return err
}
diff, err := localCommit.PatchContext(ctx, remoteCommit)
if err != nil {
return err
}
newReleasePRChanges = len(diff.FilePatches()) > 0
}
if newReleasePRChanges {
pushRefSpec := config.RefSpec(fmt.Sprintf(
"+%s:%s",
rpBranchRef,
// This needs to be the local branch name, not the remotes/origin ref
// See https://stackoverflow.com/a/75727620
rpBranchRef,
))
logger.DebugContext(ctx, "pushing branch", "commit.hash", releaseCommitHash.String(), "branch.name", rpBranch, "refspec", pushRefSpec.String())
if err = repo.PushContext(ctx, &git.PushOptions{
RemoteName: rp.GitRemoteName,
RefSpecs: []config.RefSpec{pushRefSpec},
Force: true,
Auth: forge.GitAuth(),
}); err != nil {
return fmt.Errorf("failed to push branch: %w", err)
}
logger.InfoContext(ctx, "pushed branch", "commit.hash", releaseCommitHash.String(), "branch.name", rpBranch, "refspec", pushRefSpec.String())
} else {
logger.InfoContext(ctx, "file content is already up-to-date in remote branch, skipping push")
}
// Open/Update PR
if pr == nil {
pr, err = rp.NewReleasePullRequest(rpBranch, flagBranch, nextVersion, changelogEntry)
if err != nil {
return err
}
err = forge.CreatePullRequest(ctx, pr)
if err != nil {
return err
}
logger.InfoContext(ctx, "opened pull request", "pr.title", pr.Title, "pr.id", pr.ID)
} else {
pr.SetTitle(flagBranch, nextVersion)
err = pr.SetDescription(changelogEntry)
if err != nil {
return err
}
err = forge.UpdatePullRequest(ctx, pr)
if err != nil {
return err
}
logger.InfoContext(ctx, "updated pull request", "pr.title", pr.Title, "pr.id", pr.ID)
}
return nil
} }

View file

@ -7,19 +7,6 @@ import (
"github.com/leodido/go-conventionalcommits/parser" "github.com/leodido/go-conventionalcommits/parser"
) )
type Commit struct {
Hash string
Message string
PullRequest *PullRequest
}
type PullRequest struct {
ID int
Title string
Description string
}
type AnalyzedCommit struct { type AnalyzedCommit struct {
Commit Commit
Type string Type string
@ -28,36 +15,24 @@ type AnalyzedCommit struct {
BreakingChange bool BreakingChange bool
} }
type CommitParser interface { func AnalyzeCommits(commits []Commit) ([]AnalyzedCommit, conventionalcommits.VersionBump, error) {
Analyze(commits []Commit) ([]AnalyzedCommit, error)
}
type ConventionalCommitsParser struct {
machine conventionalcommits.Machine
}
func NewConventionalCommitsParser() *ConventionalCommitsParser {
parserMachine := parser.NewMachine( parserMachine := parser.NewMachine(
parser.WithBestEffort(), parser.WithBestEffort(),
parser.WithTypes(conventionalcommits.TypesConventional), parser.WithTypes(conventionalcommits.TypesConventional),
) )
return &ConventionalCommitsParser{
machine: parserMachine,
}
}
func (c *ConventionalCommitsParser) Analyze(commits []Commit) ([]AnalyzedCommit, error) {
analyzedCommits := make([]AnalyzedCommit, 0, len(commits)) analyzedCommits := make([]AnalyzedCommit, 0, len(commits))
highestVersionBump := conventionalcommits.UnknownVersion
for _, commit := range commits { for _, commit := range commits {
msg, err := c.machine.Parse([]byte(commit.Message)) msg, err := parserMachine.Parse([]byte(commit.Message))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse message of commit %q: %w", commit.Hash, err) return nil, conventionalcommits.UnknownVersion, fmt.Errorf("failed to parse message of commit %q: %w", commit.Hash, err)
} }
conventionalCommit, ok := msg.(*conventionalcommits.ConventionalCommit) conventionalCommit, ok := msg.(*conventionalcommits.ConventionalCommit)
if !ok { if !ok {
return nil, fmt.Errorf("unable to get ConventionalCommit from parser result: %T", msg) return nil, conventionalcommits.UnknownVersion, fmt.Errorf("unable to get ConventionalCommit from parser result: %T", msg)
} }
commitVersionBump := conventionalCommit.VersionBump(conventionalcommits.DefaultStrategy) commitVersionBump := conventionalCommit.VersionBump(conventionalcommits.DefaultStrategy)
@ -72,7 +47,11 @@ func (c *ConventionalCommitsParser) Analyze(commits []Commit) ([]AnalyzedCommit,
}) })
} }
if commitVersionBump > highestVersionBump {
// Get max version bump from all releasable commits
highestVersionBump = commitVersionBump
}
} }
return analyzedCommits, nil return analyzedCommits, highestVersionBump, nil
} }

View file

@ -3,6 +3,7 @@ package rp
import ( import (
"testing" "testing"
"github.com/leodido/go-conventionalcommits"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -11,12 +12,14 @@ func TestAnalyzeCommits(t *testing.T) {
name string name string
commits []Commit commits []Commit
expectedCommits []AnalyzedCommit expectedCommits []AnalyzedCommit
expectedBump conventionalcommits.VersionBump
wantErr assert.ErrorAssertionFunc wantErr assert.ErrorAssertionFunc
}{ }{
{ {
name: "empty commits", name: "empty commits",
commits: []Commit{}, commits: []Commit{},
expectedCommits: []AnalyzedCommit{}, expectedCommits: []AnalyzedCommit{},
expectedBump: conventionalcommits.UnknownVersion,
wantErr: assert.NoError, wantErr: assert.NoError,
}, },
{ {
@ -27,6 +30,7 @@ func TestAnalyzeCommits(t *testing.T) {
}, },
}, },
expectedCommits: nil, expectedCommits: nil,
expectedBump: conventionalcommits.UnknownVersion,
wantErr: assert.Error, wantErr: assert.Error,
}, },
{ {
@ -37,6 +41,7 @@ func TestAnalyzeCommits(t *testing.T) {
}, },
}, },
expectedCommits: []AnalyzedCommit{}, expectedCommits: []AnalyzedCommit{},
expectedBump: conventionalcommits.UnknownVersion,
wantErr: assert.NoError, wantErr: assert.NoError,
}, },
{ {
@ -56,7 +61,8 @@ func TestAnalyzeCommits(t *testing.T) {
Description: "blabla", Description: "blabla",
}, },
}, },
wantErr: assert.NoError, expectedBump: conventionalcommits.PatchVersion,
wantErr: assert.NoError,
}, },
{ {
name: "highest bump (minor)", name: "highest bump (minor)",
@ -80,7 +86,8 @@ func TestAnalyzeCommits(t *testing.T) {
Description: "foobar", Description: "foobar",
}, },
}, },
wantErr: assert.NoError, expectedBump: conventionalcommits.MinorVersion,
wantErr: assert.NoError,
}, },
{ {
@ -106,17 +113,19 @@ func TestAnalyzeCommits(t *testing.T) {
BreakingChange: true, BreakingChange: true,
}, },
}, },
wantErr: assert.NoError, expectedBump: conventionalcommits.MajorVersion,
wantErr: assert.NoError,
}, },
} }
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, versionBump, err := AnalyzeCommits(tt.commits)
if !tt.wantErr(t, err) { if !tt.wantErr(t, err) {
return return
} }
assert.Equal(t, tt.expectedCommits, analyzedCommits) assert.Equal(t, tt.expectedCommits, analyzedCommits)
assert.Equal(t, tt.expectedBump, versionBump)
}) })
} }
} }

168
forge.go
View file

@ -19,12 +19,18 @@ const (
GitHubPerPageMax = 100 GitHubPerPageMax = 100
GitHubPRStateOpen = "open" GitHubPRStateOpen = "open"
GitHubPRStateClosed = "closed" GitHubPRStateClosed = "closed"
GitHubEnvAPIToken = "GITHUB_TOKEN" // nolint:gosec // Not actually a hardcoded credential GitHubEnvAPIToken = "GITHUB_TOKEN"
GitHubEnvUsername = "GITHUB_USER" GitHubEnvUsername = "GITHUB_USER"
GitHubEnvRepository = "GITHUB_REPOSITORY" GitHubEnvRepository = "GITHUB_REPOSITORY"
GitHubLabelColor = "dedede" GitHubLabelColor = "dedede"
) )
type Changeset struct {
URL string
Identifier string
ChangelogEntries []AnalyzedCommit
}
type Forge interface { type Forge interface {
RepoURL() string RepoURL() string
CloneURL() string CloneURL() string
@ -40,35 +46,23 @@ type Forge interface {
// function should return all commits. // function should return all commits.
CommitsSince(context.Context, *Tag) ([]Commit, error) CommitsSince(context.Context, *Tag) ([]Commit, error)
// EnsureLabelsExist verifies that all desired labels are available on the repository. If labels are missing, they // Changesets looks up the Pull/Merge Requests for each commit, returning its parsed metadata.
// are created them. Changesets(context.Context, []Commit) ([]Changeset, error)
EnsureLabelsExist(context.Context, []Label) error
EnsureLabelsExist(context.Context, []string) error
// PullRequestForBranch returns the open pull request between the branch and ForgeOptions.BaseBranch. If no open PR // PullRequestForBranch returns the open pull request between the branch and ForgeOptions.BaseBranch. If no open PR
// exists, it returns nil. // exists, it returns nil.
PullRequestForBranch(context.Context, string) (*ReleasePullRequest, error) PullRequestForBranch(context.Context, string) (*ReleasePullRequest, error)
// CreatePullRequest opens a new pull/merge request for the ReleasePullRequest.
CreatePullRequest(context.Context, *ReleasePullRequest) error 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 UpdatePullRequest(context.Context, *ReleasePullRequest) error
SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []string) 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 ClosePullRequest(context.Context, *ReleasePullRequest) error
// PendingReleases returns a list of ReleasePullRequest. The list should contain all pull/merge requests that are PendingReleases(context.Context) ([]*ReleasePullRequest, error)
// 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, prelease, latest bool) error
CreateRelease(ctx context.Context, commit Commit, title, changelog string, prerelease, latest bool) error
} }
type ForgeOptions struct { type ForgeOptions struct {
@ -175,16 +169,10 @@ func (g *GitHub) CommitsSince(ctx context.Context, tag *Tag) ([]Commit, error) {
var commits = make([]Commit, 0, len(repositoryCommits)) var commits = make([]Commit, 0, len(repositoryCommits))
for _, ghCommit := range repositoryCommits { for _, ghCommit := range repositoryCommits {
commit := Commit{ commits = append(commits, Commit{
Hash: ghCommit.GetSHA(), Hash: ghCommit.GetSHA(),
Message: ghCommit.GetCommit().GetMessage(), 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 return commits, nil
@ -269,52 +257,76 @@ 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) Changesets(ctx context.Context, commits []Commit) ([]Changeset, 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,
// but worst case we need to look up all PRs made in the repository ever. // but worst case we need to look up all PRs made in the repository ever.
log := g.log.With("commit.hash", commit.Hash) changesets := make([]Changeset, 0, len(commits))
page := 1
var associatedPRs []*github.PullRequest
for { for _, commit := range commits {
log.Debug("fetching pull requests associated with commit", "page", page) log := g.log.With("commit.hash", commit.Hash)
prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit( page := 1
ctx, g.options.Owner, g.options.Repo, var associatedPRs []*github.PullRequest
commit.Hash, &github.ListOptions{
Page: page, for {
PerPage: GitHubPerPageMax, 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: GitHubPerPageMax,
})
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 {
log.Warn("did not find associated pull request, not considering it for changesets")
// No pull request was found for this commit, nothing to do here
// TODO: We could also return the minimal changeset for this commit, so at least it would come up in the changelog.
continue
}
log = log.With("pullrequest.id", pullrequest.GetID())
// TODO: Parse PR description for overrides
changelogEntries, _, err := AnalyzeCommits([]Commit{commit})
if err != nil { if err != nil {
return nil, err log.Warn("unable to parse changelog entries", "error", err)
continue
} }
associatedPRs = append(associatedPRs, prs...) if len(changelogEntries) > 0 {
changesets = append(changesets, Changeset{
if page == resp.LastPage || resp.LastPage == 0 { URL: pullrequest.GetHTMLURL(),
break Identifier: fmt.Sprintf("#%d", pullrequest.GetNumber()),
} ChangelogEntries: changelogEntries,
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 return changesets, nil
} }
func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []Label) error { func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []string) error {
existingLabels := make([]string, 0, len(labels)) existingLabels := make([]string, 0, len(labels))
page := 1 page := 1
@ -342,12 +354,12 @@ func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []Label) error {
} }
for _, label := range labels { for _, label := range labels {
if !slices.Contains(existingLabels, string(label)) { if !slices.Contains(existingLabels, label) {
g.log.Info("creating label in repository", "label.name", label) g.log.Info("creating label in repository", "label.name", label)
_, _, 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: &label,
Color: Pointer(GitHubLabelColor), Color: Pointer(GitHubLabelColor),
}, },
) )
@ -410,7 +422,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, []string{}, pr.Labels)
if err != nil { if err != nil {
return err return err
} }
@ -433,25 +445,20 @@ 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 *ReleasePullRequest, remove, add []string) 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,
pr.ID, string(label), pr.ID, label,
) )
if err != nil { if err != nil {
return err return err
} }
} }
addString := make([]string, 0, len(add))
for _, label := range add {
addString = append(addString, string(label))
}
_, _, err := g.client.Issues.AddLabelsToIssue( _, _, err := g.client.Issues.AddLabelsToIssue(
ctx, g.options.Owner, g.options.Repo, ctx, g.options.Owner, g.options.Repo,
pr.ID, addString, pr.ID, add,
) )
if err != nil { if err != nil {
return err return err
@ -474,7 +481,7 @@ 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) ([]*ReleasePullRequest, error) {
page := 1 page := 1
var prs []*ReleasePullRequest var prs []*ReleasePullRequest
@ -502,7 +509,7 @@ func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel Label) ([]*Re
for _, pr := range ghPRs { for _, pr := range ghPRs {
pending := slices.ContainsFunc(pr.Labels, func(l *github.Label) bool { pending := slices.ContainsFunc(pr.Labels, func(l *github.Label) bool {
return l.GetName() == string(pendingLabel) return l.GetName() == LabelReleasePending
}) })
if !pending { if !pending {
continue continue
@ -551,21 +558,10 @@ func (g *GitHub) CreateRelease(ctx context.Context, commit Commit, title, change
return nil return nil
} }
func gitHubPRToPullRequest(pr *github.PullRequest) *PullRequest {
return &PullRequest{
ID: pr.GetNumber(),
Title: pr.GetTitle(),
Description: pr.GetBody(),
}
}
func gitHubPRToReleasePullRequest(pr *github.PullRequest) *ReleasePullRequest { func gitHubPRToReleasePullRequest(pr *github.PullRequest) *ReleasePullRequest {
labels := make([]Label, 0, len(pr.Labels)) labels := make([]string, 0, len(pr.Labels))
for _, label := range pr.Labels { for _, label := range pr.Labels {
labelName := Label(label.GetName()) labels = append(labels, label.GetName())
if slices.Contains(KnownLabels, Label(label.GetName())) {
labels = append(labels, labelName)
}
} }
var releaseCommit *Commit var releaseCommit *Commit

8
git.go
View file

@ -13,9 +13,15 @@ import (
) )
const ( const (
GitRemoteName = "origin" CommitSearchDepth = 50 // TODO: Increase
GitRemoteName = "origin"
) )
type Commit struct {
Hash string
Message string
}
type Tag struct { type Tag struct {
Hash string Hash string
Name string Name string

20
go.mod
View file

@ -1,6 +1,6 @@
module github.com/apricote/releaser-pleaser module github.com/apricote/releaser-pleaser
go 1.23.0 go 1.22.4
require ( require (
github.com/blang/semver/v4 v4.0.0 github.com/blang/semver/v4 v4.0.0
@ -14,11 +14,11 @@ require (
) )
require ( require (
dario.cat/mergo v1.0.1 // indirect dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/cloudflare/circl v1.3.9 // indirect github.com/cloudflare/circl v1.3.7 // indirect
github.com/cyphar/filepath-securejoin v0.3.1 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect
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
@ -31,12 +31,14 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skeema/knownhosts v1.3.0 // indirect github.com/skeema/knownhosts v1.2.2 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.26.0 // indirect golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.28.0 // indirect golang.org/x/mod v0.12.0 // indirect
golang.org/x/sys v0.24.0 // indirect golang.org/x/net v0.22.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/tools v0.13.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

46
go.sum
View file

@ -1,8 +1,8 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@ -13,11 +13,11 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.9 h1:QFrlgFYf2Qpi8bSpVPK1HBvWpx16v/1TZivyo7pGuBE= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.9/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cyphar/filepath-securejoin v0.3.1 h1:1V7cHiaW+C+39wEfpH6XlLBQo3j/PciWFrgfCLS8XrE= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.3.1/go.mod h1:F7i41x/9cBF7lzCrVsYs9fuzwRZm4NQsGTBdpp6mETc= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -75,8 +75,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@ -97,10 +97,12 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@ -108,11 +110,13 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -126,15 +130,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -142,12 +146,14 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -12,10 +12,8 @@ import (
"github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast" "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast"
) )
var ( var sectionStartRegex = regexp.MustCompile(`^<!-- section-start (.+) -->`)
sectionStartRegex = regexp.MustCompile(`^<!-- section-start (.+) -->`) var sectionEndRegex = regexp.MustCompile(`^<!-- section-end (.+) -->`)
sectionEndRegex = regexp.MustCompile(`^<!-- section-end (.+) -->`)
)
const ( const (
sectionTrigger = "<!--" sectionTrigger = "<!--"
@ -23,7 +21,8 @@ const (
SectionEndFormat = "<!-- section-end %s -->" SectionEndFormat = "<!-- section-end %s -->"
) )
type sectionParser struct{} type sectionParser struct {
}
func (s *sectionParser) Open(_ gast.Node, reader text.Reader, _ parser.Context) (gast.Node, parser.State) { func (s *sectionParser) Open(_ gast.Node, reader text.Reader, _ parser.Context) (gast.Node, parser.State) {
line, _ := reader.PeekLine() line, _ := reader.PeekLine()
@ -76,7 +75,8 @@ func (s *sectionParser) Trigger() []byte {
return []byte(sectionTrigger) return []byte(sectionTrigger)
} }
type section struct{} type section struct {
}
// Section is an extension that allow you to use group content under a shared parent ast node. // Section is an extension that allow you to use group content under a shared parent ast node.
var Section = &section{} var Section = &section{}

View file

@ -149,7 +149,7 @@ func (r *Renderer) writeByte(w io.Writer, c byte) error {
return nil return nil
} }
// WriteString writes a string to an io.Writer, ensuring that appropriate indentation and prefixes are added at the // WriteString writes a string to an io.Writer, ensuring that appropriate indentation and prefices are added at the
// beginning of each line. // beginning of each line.
func (r *Renderer) writeString(w io.Writer, s string) (int, error) { func (r *Renderer) writeString(w io.Writer, s string) (int, error) {
n, err := r.write(w, []byte(s)) n, err := r.write(w, []byte(s))
@ -178,7 +178,7 @@ func (r *Renderer) popPrefix() {
// OpenBlock ensures that each block begins on a new line, and that blank lines are inserted before blocks as // OpenBlock ensures that each block begins on a new line, and that blank lines are inserted before blocks as
// indicated by node.HasPreviousBlankLines. // indicated by node.HasPreviousBlankLines.
func (r *Renderer) openBlock(w util.BufWriter, _ []byte, node ast.Node) error { func (r *Renderer) openBlock(w util.BufWriter, source []byte, node ast.Node) error {
r.openBlocks = append(r.openBlocks, blockState{ r.openBlocks = append(r.openBlocks, blockState{
node: node, node: node,
fresh: true, fresh: true,
@ -222,7 +222,7 @@ func (r *Renderer) closeBlock(w io.Writer) error {
} }
// RenderDocument renders an *ast.Document node to the given BufWriter. // RenderDocument renders an *ast.Document node to the given BufWriter.
func (r *Renderer) renderDocument(_ util.BufWriter, _ []byte, _ ast.Node, _ bool) (ast.WalkStatus, error) { func (r *Renderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
r.listStack, r.prefixStack, r.prefix, r.atNewline = nil, nil, nil, false r.listStack, r.prefixStack, r.prefix, r.atNewline = nil, nil, nil, false
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
@ -331,7 +331,7 @@ func (r *Renderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node a
return ast.WalkStop, fmt.Errorf(": %w", err) return ast.WalkStop, fmt.Errorf(": %w", err)
} }
if err := r.writeByte(w, '\n'); err != nil { if err := r.writeByte(w, '\n'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err) return ast.WalkStop, nil
} }
// Write the contents of the fenced code block. // Write the contents of the fenced code block.
@ -594,7 +594,7 @@ func (r *Renderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node
} }
// RenderEmphasis renders an *ast.Emphasis node to the given BufWriter. // RenderEmphasis renders an *ast.Emphasis node to the given BufWriter.
func (r *Renderer) renderEmphasis(w util.BufWriter, _ []byte, node ast.Node, _ bool) (ast.WalkStatus, error) { func (r *Renderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
em := node.(*ast.Emphasis) em := node.(*ast.Emphasis)
if _, err := r.writeString(w, strings.Repeat("*", em.Level)); err != nil { if _, err := r.writeString(w, strings.Repeat("*", em.Level)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err) return ast.WalkStop, fmt.Errorf(": %w", err)
@ -663,7 +663,7 @@ func (r *Renderer) renderLinkOrImage(w util.BufWriter, open string, dest, title
} }
// RenderImage renders an *ast.Image node to the given BufWriter. // RenderImage renders an *ast.Image node to the given BufWriter.
func (r *Renderer) renderImage(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { func (r *Renderer) renderImage(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
img := node.(*ast.Image) img := node.(*ast.Image)
if err := r.renderLinkOrImage(w, "![", img.Destination, img.Title, enter); err != nil { if err := r.renderLinkOrImage(w, "![", img.Destination, img.Title, enter); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err) return ast.WalkStop, fmt.Errorf(": %w", err)
@ -672,7 +672,7 @@ func (r *Renderer) renderImage(w util.BufWriter, _ []byte, node ast.Node, enter
} }
// RenderLink renders an *ast.Link node to the given BufWriter. // RenderLink renders an *ast.Link node to the given BufWriter.
func (r *Renderer) renderLink(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { func (r *Renderer) renderLink(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
link := node.(*ast.Link) link := node.(*ast.Link)
if err := r.renderLinkOrImage(w, "[", link.Destination, link.Title, enter); err != nil { if err := r.renderLinkOrImage(w, "[", link.Destination, link.Title, enter); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err) return ast.WalkStop, fmt.Errorf(": %w", err)
@ -724,7 +724,7 @@ func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, en
} }
// RenderString renders an *ast.String node to the given BufWriter. // RenderString renders an *ast.String node to the given BufWriter.
func (r *Renderer) renderString(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { func (r *Renderer) renderString(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter { if !enter {
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
@ -801,7 +801,7 @@ func (r *Renderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
func (r *Renderer) renderTableCell(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { func (r *Renderer) renderTableCell(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter { if !enter {
if node.NextSibling() != nil { if node.NextSibling() != nil {
if _, err := r.writeString(w, " | "); err != nil { if _, err := r.writeString(w, " | "); err != nil {
@ -813,14 +813,14 @@ func (r *Renderer) renderTableCell(w util.BufWriter, _ []byte, node ast.Node, en
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
func (r *Renderer) renderStrikethrough(w util.BufWriter, _ []byte, _ ast.Node, _ bool) (ast.WalkStatus, error) { func (r *Renderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if _, err := r.writeString(w, "~~"); err != nil { if _, err := r.writeString(w, "~~"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err) return ast.WalkStop, fmt.Errorf(": %w", err)
} }
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
func (r *Renderer) renderTaskCheckBox(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { func (r *Renderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter { if enter {
var fill byte = ' ' var fill byte = ' '
if task := node.(*exast.TaskCheckBox); task.IsChecked { if task := node.(*exast.TaskCheckBox); task.IsChecked {

View file

@ -11,26 +11,25 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var author = &object.Signature{ var (
Name: "releaser-pleaser", author = &object.Signature{
When: time.Date(2020, 01, 01, 01, 01, 01, 01, time.UTC), Name: "releaser-pleaser",
} When: time.Date(2020, 01, 01, 01, 01, 01, 01, time.UTC),
}
)
type CommitOption func(*commitOptions) type CommitOption func(*commitOptions)
type commitOptions struct { type commitOptions struct {
cleanFiles bool cleanFiles bool
files []commitFile files []commitFile
tags []string tags []string
} }
type commitFile struct { type commitFile struct {
path string path string
content string content string
} }
type Commit func(*testing.T, *git.Repository) error type Commit func(*testing.T, *git.Repository) error
type Repo func(*testing.T) *git.Repository type Repo func(*testing.T) *git.Repository
func WithCommit(message string, options ...CommitOption) Commit { func WithCommit(message string, options ...CommitOption) Commit {
@ -62,9 +61,9 @@ func WithCommit(message string, options ...CommitOption) Commit {
for _, fileInfo := range opts.files { for _, fileInfo := range opts.files {
file, err := wt.Filesystem.Create(fileInfo.path) file, err := wt.Filesystem.Create(fileInfo.path)
require.NoError(t, err, "failed to create file %q", fileInfo.path) require.NoError(t, err, "failed to create file %q", fileInfo.path)
defer file.Close()
_, err = file.Write([]byte(fileInfo.content)) _, err = file.Write([]byte(fileInfo.content))
file.Close()
require.NoError(t, err, "failed to write content to file %q", fileInfo.path) require.NoError(t, err, "failed to write content to file %q", fileInfo.path)
} }
@ -84,6 +83,7 @@ func WithCommit(message string, options ...CommitOption) Commit {
} }
return nil return nil
} }
} }

View file

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"log" "log"
"regexp" "regexp"
"strings"
"text/template" "text/template"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
@ -31,14 +30,11 @@ func init() {
} }
} }
// ReleasePullRequest
//
// TODO: Reuse [PullRequest]
type ReleasePullRequest struct { type ReleasePullRequest struct {
ID int ID int
Title string Title string
Description string Description string
Labels []Label Labels []string
Head string Head string
ReleaseCommit *Commit ReleaseCommit *Commit
@ -47,11 +43,11 @@ type ReleasePullRequest struct {
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,
Labels: []Label{LabelReleasePending}, Labels: []string{LabelReleasePending},
} }
rp.SetTitle(branch, version) rp.SetTitle(branch, version)
if err := rp.SetDescription(changelogEntry, ReleaseOverrides{}); err != nil { if err := rp.SetDescription(changelogEntry); err != nil {
return nil, err return nil, err
} }
@ -61,7 +57,7 @@ func NewReleasePullRequest(head, branch, version, changelogEntry string) (*Relea
type ReleaseOverrides struct { type ReleaseOverrides struct {
Prefix string Prefix string
Suffix string Suffix string
// TODO: Doing the changelog for normal releases after previews requires to know about this while fetching the commits // TODO: Doing the changelog for normal releases after previews requires to know about this while fetching the changesets
NextVersionType NextVersionType NextVersionType NextVersionType
} }
@ -92,20 +88,18 @@ func (n NextVersionType) String() string {
} }
} }
// Label is the string identifier of a pull/merge request label on the forge. // PR Labels
type Label string
const ( const (
LabelNextVersionTypeNormal Label = "rp-next-version::normal" LabelNextVersionTypeNormal = "rp-next-version::normal"
LabelNextVersionTypeRC Label = "rp-next-version::rc" LabelNextVersionTypeRC = "rp-next-version::rc"
LabelNextVersionTypeBeta Label = "rp-next-version::beta" LabelNextVersionTypeBeta = "rp-next-version::beta"
LabelNextVersionTypeAlpha Label = "rp-next-version::alpha" LabelNextVersionTypeAlpha = "rp-next-version::alpha"
LabelReleasePending Label = "rp-release::pending" LabelReleasePending = "rp-release::pending"
LabelReleaseTagged Label = "rp-release::tagged" LabelReleaseTagged = "rp-release::tagged"
) )
var KnownLabels = []Label{ var Labels = []string{
LabelNextVersionTypeNormal, LabelNextVersionTypeNormal,
LabelNextVersionTypeRC, LabelNextVersionTypeRC,
LabelNextVersionTypeBeta, LabelNextVersionTypeBeta,
@ -121,6 +115,7 @@ const (
) )
const ( const (
MarkdownSectionOverrides = "overrides"
MarkdownSectionChangelog = "changelog" MarkdownSectionChangelog = "changelog"
) )
@ -155,9 +150,6 @@ func (pr *ReleasePullRequest) parseVersioningFlags(overrides ReleaseOverrides) R
overrides.NextVersionType = NextVersionTypeBeta overrides.NextVersionType = NextVersionTypeBeta
case LabelNextVersionTypeAlpha: case LabelNextVersionTypeAlpha:
overrides.NextVersionType = NextVersionTypeAlpha overrides.NextVersionType = NextVersionTypeAlpha
case LabelReleasePending, LabelReleaseTagged:
// These labels have no effect on the versioning.
break
} }
} }
@ -198,6 +190,51 @@ func (pr *ReleasePullRequest) parseDescription(overrides ReleaseOverrides) (Rele
return overrides, nil return overrides, nil
} }
func (pr *ReleasePullRequest) overridesText() (string, error) {
source := []byte(pr.Description)
gm := markdown.New()
descriptionAST := gm.Parser().Parse(text.NewReader(source))
var section *east.Section
err := ast.Walk(descriptionAST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if n.Type() != ast.TypeBlock || n.Kind() != east.KindSection {
return ast.WalkContinue, nil
}
anySection, ok := n.(*east.Section)
if !ok {
return ast.WalkStop, fmt.Errorf("node has unexpected type: %T", n)
}
if anySection.Name != MarkdownSectionOverrides {
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 (pr *ReleasePullRequest) ChangelogText() (string, error) { func (pr *ReleasePullRequest) ChangelogText() (string, error) {
source := []byte(pr.Description) source := []byte(pr.Description)
gm := markdown.New() gm := markdown.New()
@ -252,11 +289,11 @@ func textFromLines(source []byte, n ast.Node) string {
content = append(content, line.Value(source)...) content = append(content, line.Value(source)...)
} }
return strings.TrimSpace(string(content)) return string(content)
} }
func (pr *ReleasePullRequest) SetTitle(branch, version string) { func (pr *ReleasePullRequest) SetTitle(branch, version string) {
pr.Title = fmt.Sprintf(TitleFormat, branch, version) pr.Title = fmt.Sprintf("chore(%s): release %s", branch, version)
} }
func (pr *ReleasePullRequest) Version() (string, error) { func (pr *ReleasePullRequest) Version() (string, error) {
@ -268,9 +305,14 @@ func (pr *ReleasePullRequest) Version() (string, error) {
return matches[2], nil return matches[2], nil
} }
func (pr *ReleasePullRequest) SetDescription(changelogEntry string, overrides ReleaseOverrides) error { func (pr *ReleasePullRequest) SetDescription(changelogEntry string) error {
overrides, err := pr.overridesText()
if err != nil {
return err
}
var description bytes.Buffer var description bytes.Buffer
err := releasePRTemplate.Execute(&description, map[string]any{ err = releasePRTemplate.Execute(&description, map[string]any{
"Changelog": changelogEntry, "Changelog": changelogEntry,
"Overrides": overrides, "Overrides": overrides,
}) })

View file

@ -1,32 +1,29 @@
---
<!-- section-start changelog --> <!-- section-start changelog -->
{{ .Changelog }} {{ .Changelog }}
<!-- section-end changelog --> <!-- section-end changelog -->
--- ---
<details> ## releaser-pleaser Instructions
<summary><h4>PR by <a href="https://github.com/apricote/releaser-pleaser">releaser-pleaser</a> 🤖</h4></summary> {{ if .Overrides }}
{{- .Overrides -}}
{{- else }}
<!-- section-start overrides -->
> If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs.
If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. ### Prefix
## Release Notes
### Prefix / Start
This will be added to the start of the release notes.
```rp-prefix ```rp-prefix
{{- if .Overrides.Prefix }}
{{ .Overrides.Prefix }}{{ end }}
``` ```
### Suffix / End ### Suffix
This will be added to the end of the release notes.
```rp-suffix ```rp-suffix
{{- if .Overrides.Suffix }}
{{ .Overrides.Suffix }}{{ end }}
``` ```
</details> <!-- section-end overrides -->
{{ end }}
#### PR by [releaser-pleaser](https://github.com/apricote/releaser-pleaser)

View file

@ -49,96 +49,121 @@ func TestReleasePullRequest_SetDescription(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
pr *ReleasePullRequest
changelogEntry string changelogEntry string
overrides ReleaseOverrides
want string want string
wantErr assert.ErrorAssertionFunc wantErr assert.ErrorAssertionFunc
}{ }{
{ {
name: "no overrides", name: "empty description",
pr: &ReleasePullRequest{},
changelogEntry: `## v1.0.0`, changelogEntry: `## v1.0.0`,
overrides: ReleaseOverrides{}, want: `---
want: `<!-- section-start changelog -->
<!-- section-start changelog -->
## v1.0.0 ## v1.0.0
<!-- section-end changelog --> <!-- section-end changelog -->
--- ---
<details> ## releaser-pleaser Instructions
<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. <!-- section-start overrides -->
> 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
### Prefix / Start
This will be added to the start of the release notes.
` + "```" + `rp-prefix ` + "```" + `rp-prefix
` + "```" + ` ` + "```" + `
### Suffix / End ### Suffix
This will be added to the end of the release notes.
` + "```" + `rp-suffix ` + "```" + `rp-suffix
` + "```" + ` ` + "```" + `
</details> <!-- section-end overrides -->
#### PR by [releaser-pleaser](https://github.com/apricote/releaser-pleaser)
`, `,
wantErr: assert.NoError, wantErr: assert.NoError,
}, },
{ {
name: "existing overrides", name: "existing overrides",
changelogEntry: `## v1.0.0`, pr: &ReleasePullRequest{
overrides: ReleaseOverrides{ Description: `---
Prefix: "This release is awesome!",
Suffix: "Fooo", <!-- section-start changelog -->
}, ## v0.1.0
want: `<!-- section-start changelog -->
## v1.0.0 ### Features
- bedazzle
<!-- section-end changelog --> <!-- section-end changelog -->
--- ---
<details> ## releaser-pleaser Instructions
<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. <!-- section-start overrides -->
> 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
### Prefix / Start
This will be added to the start of the release notes.
` + "```" + `rp-prefix ` + "```" + `rp-prefix
This release is awesome! This release is awesome!
` + "```" + ` ` + "```" + `
### Suffix / End ### Suffix
This will be added to the end of the release notes.
` + "```" + `rp-suffix ` + "```" + `rp-suffix
Fooo
` + "```" + ` ` + "```" + `
</details> <!-- section-end overrides -->
#### PR by [releaser-pleaser](https://github.com/apricote/releaser-pleaser)
`,
},
changelogEntry: `## v1.0.0`,
want: `---
<!-- section-start changelog -->
## v1.0.0
<!-- section-end changelog -->
---
## releaser-pleaser Instructions
<!-- section-start overrides -->
> If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs.
### Prefix
` + "```" + `rp-prefix
This release is awesome!
` + "```" + `
### Suffix
` + "```" + `rp-suffix
` + "```" + `
<!-- section-end overrides -->
#### PR by [releaser-pleaser](https://github.com/apricote/releaser-pleaser)
`, `,
wantErr: assert.NoError, wantErr: assert.NoError,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
pr := &ReleasePullRequest{} err := tt.pr.SetDescription(tt.changelogEntry)
err := pr.SetDescription(tt.changelogEntry, tt.overrides)
if !tt.wantErr(t, err) { if !tt.wantErr(t, err) {
return return
} }
assert.Equal(t, tt.want, pr.Description) assert.Equal(t, tt.want, tt.pr.Description)
}) })
} }
} }

View file

@ -1,406 +0,0 @@
package rp
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
)
const (
PullRequestBranchFormat = "releaser-pleaser--branches--%s"
)
type ReleaserPleaser struct {
forge Forge
logger *slog.Logger
targetBranch string
commitParser CommitParser
nextVersion VersioningStrategy
extraFiles []string
updaters []Updater
}
func New(forge Forge, logger *slog.Logger, targetBranch string, commitParser CommitParser, versioningStrategy VersioningStrategy, extraFiles []string, updaters []Updater) *ReleaserPleaser {
return &ReleaserPleaser{
forge: forge,
logger: logger,
targetBranch: targetBranch,
commitParser: commitParser,
nextVersion: versioningStrategy,
extraFiles: extraFiles,
updaters: updaters,
}
}
func (rp *ReleaserPleaser) EnsureLabels(ctx context.Context) error {
// TODO: Wrap Error
return rp.forge.EnsureLabelsExist(ctx, KnownLabels)
}
func (rp *ReleaserPleaser) Run(ctx context.Context) error {
err := rp.runOnboarding(ctx)
if err != nil {
return fmt.Errorf("failed to onboard repository: %w", err)
}
err = rp.runCreatePendingReleases(ctx)
if err != nil {
return fmt.Errorf("failed to create pending releases: %w", err)
}
err = rp.runReconcileReleasePR(ctx)
if err != nil {
return fmt.Errorf("failed to reconcile release pull request: %w", err)
}
return nil
}
func (rp *ReleaserPleaser) runOnboarding(ctx context.Context) error {
err := rp.EnsureLabels(ctx)
if err != nil {
return fmt.Errorf("failed to ensure all labels exist: %w", err)
}
return nil
}
func (rp *ReleaserPleaser) runCreatePendingReleases(ctx context.Context) error {
logger := rp.logger.With("method", "runCreatePendingReleases")
logger.InfoContext(ctx, "checking for pending releases")
prs, err := rp.forge.PendingReleases(ctx, LabelReleasePending)
if err != nil {
return err
}
if len(prs) == 0 {
logger.InfoContext(ctx, "No pending releases found")
return nil
}
logger.InfoContext(ctx, "Found pending releases", "length", len(prs))
for _, pr := range prs {
err = rp.createPendingRelease(ctx, pr)
if err != nil {
return err
}
}
return nil
}
func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *ReleasePullRequest) error {
logger := rp.logger.With(
"method", "createPendingRelease",
"pr.id", pr.ID,
"pr.title", pr.Title)
if pr.ReleaseCommit == nil {
return fmt.Errorf("pull request is missing the merge commit")
}
logger.Info("Creating release", "commit.hash", pr.ReleaseCommit.Hash)
version, err := pr.Version()
if err != nil {
return err
}
changelog, err := pr.ChangelogText()
if err != nil {
return err
}
// TODO: pre-release & latest
logger.DebugContext(ctx, "Creating release on forge")
err = rp.forge.CreateRelease(ctx, *pr.ReleaseCommit, version, changelog, false, true)
if err != nil {
return fmt.Errorf("failed to create release on forge: %w", err)
}
logger.DebugContext(ctx, "created release", "release.title", version, "release.url", rp.forge.ReleaseURL(version))
logger.DebugContext(ctx, "updating pr labels")
err = rp.forge.SetPullRequestLabels(ctx, pr, []Label{LabelReleasePending}, []Label{LabelReleaseTagged})
if err != nil {
return err
}
logger.DebugContext(ctx, "updated pr labels")
logger.InfoContext(ctx, "Created release", "release.title", version, "release.url", rp.forge.ReleaseURL(version))
return nil
}
func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error {
logger := rp.logger.With("method", "runReconcileReleasePR")
releases, err := rp.forge.LatestTags(ctx)
if err != nil {
return err
}
if releases.Latest != nil {
logger.InfoContext(ctx, "found latest tag", "tag.hash", releases.Latest.Hash, "tag.name", releases.Latest.Name)
if releases.Stable != nil && releases.Latest.Hash != releases.Stable.Hash {
logger.InfoContext(ctx, "found stable tag", "tag.hash", releases.Stable.Hash, "tag.name", releases.Stable.Name)
}
} else {
logger.InfoContext(ctx, "no latest tag found")
}
releasableCommits, err := rp.forge.CommitsSince(ctx, releases.Stable)
if err != nil {
return err
}
logger.InfoContext(ctx, "Found releasable commits", "length", len(releasableCommits))
// TODO: Handle commit overrides
analyzedCommits, err := rp.commitParser.Analyze(releasableCommits)
if err != nil {
return err
}
logger.InfoContext(ctx, "Analyzed commits", "length", len(analyzedCommits))
rpBranch := fmt.Sprintf(PullRequestBranchFormat, rp.targetBranch)
rpBranchRef := plumbing.NewBranchReferenceName(rpBranch)
// Check Forge for open PR
// Get any modifications from open PR
// Clone Repo
// Run Updaters + Changelog
// Upsert PR
pr, err := rp.forge.PullRequestForBranch(ctx, rpBranch)
if err != nil {
return err
}
if pr != nil {
logger.InfoContext(ctx, "found existing release pull request", "pr.id", pr.ID, "pr.title", pr.Title)
}
if len(analyzedCommits) == 0 {
if pr != nil {
logger.InfoContext(ctx, "closing existing pull requests, no commits available", "pr.id", pr.ID, "pr.title", pr.Title)
err = rp.forge.ClosePullRequest(ctx, pr)
if err != nil {
return err
}
} else {
logger.InfoContext(ctx, "No commits available for release")
}
return nil
}
var releaseOverrides ReleaseOverrides
if pr != nil {
releaseOverrides, err = pr.GetOverrides()
if err != nil {
return err
}
}
versionBump := VersionBumpFromCommits(analyzedCommits)
// TODO: Set version in release pr
nextVersion, err := rp.nextVersion(releases, versionBump, releaseOverrides.NextVersionType)
if err != nil {
return err
}
logger.InfoContext(ctx, "next version", "version", nextVersion)
logger.DebugContext(ctx, "cloning repository", "clone.url", rp.forge.CloneURL())
repo, err := CloneRepo(ctx, rp.forge.CloneURL(), rp.targetBranch, rp.forge.GitAuth())
if err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
if branch, _ := repo.Branch(rpBranch); branch != nil {
logger.DebugContext(ctx, "deleting previous releaser-pleaser branch locally", "branch.name", rpBranch)
if err = repo.DeleteBranch(rpBranch); err != nil {
return err
}
}
if err = worktree.Checkout(&git.CheckoutOptions{
Branch: rpBranchRef,
Create: true,
}); err != nil {
return fmt.Errorf("failed to check out branch: %w", err)
}
changelogEntry, err := NewChangelogEntry(analyzedCommits, nextVersion, rp.forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix)
if err != nil {
return fmt.Errorf("failed to build changelog entry: %w", err)
}
// Info for updaters
info := ReleaseInfo{Version: nextVersion, ChangelogEntry: changelogEntry}
updateFile := func(path string, updaters []Updater) error {
file, err := worktree.Filesystem.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return err
}
updatedContent := string(content)
for _, updater := range updaters {
updatedContent, err = updater.UpdateContent(updatedContent, info)
if err != nil {
return fmt.Errorf("failed to run updater %T on file %s", updater, path)
}
}
err = file.Truncate(0)
if err != nil {
return fmt.Errorf("failed to replace file content: %w", err)
}
_, err = file.Seek(0, 0)
if err != nil {
return fmt.Errorf("failed to replace file content: %w", err)
}
_, err = file.Write([]byte(updatedContent))
if err != nil {
return fmt.Errorf("failed to replace file content: %w", err)
}
_, err = worktree.Add(path)
if err != nil {
return fmt.Errorf("failed to add updated file to git worktree: %w", err)
}
return nil
}
err = updateFile(ChangelogFile, []Updater{&ChangelogUpdater{}})
if err != nil {
return fmt.Errorf("failed to update changelog file: %w", err)
}
for _, path := range rp.extraFiles {
_, err = worktree.Filesystem.Stat(path)
if err != nil {
// TODO: Check for non existing file or dirs
return fmt.Errorf("failed to run file updater because the file %s does not exist: %w", path, err)
}
err = updateFile(path, rp.updaters)
if err != nil {
return fmt.Errorf("failed to run file updater: %w", err)
}
}
releaseCommitMessage := fmt.Sprintf("chore(%s): release %s", rp.targetBranch, nextVersion)
releaseCommitHash, err := worktree.Commit(releaseCommitMessage, &git.CommitOptions{
Author: GitSignature(),
Committer: GitSignature(),
})
if err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
}
logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommitHash.String(), "commit.message", releaseCommitMessage)
newReleasePRChanges := true
// Check if anything changed in comparison to the remote branch (if exists)
if remoteRef, err := repo.Reference(plumbing.NewRemoteReferenceName(GitRemoteName, rpBranch), false); err != nil {
if err.Error() != "reference not found" {
// "reference not found" is expected and we should always push
return err
}
} else {
remoteCommit, err := repo.CommitObject(remoteRef.Hash())
if err != nil {
return err
}
localCommit, err := repo.CommitObject(releaseCommitHash)
if err != nil {
return err
}
diff, err := localCommit.PatchContext(ctx, remoteCommit)
if err != nil {
return err
}
newReleasePRChanges = len(diff.FilePatches()) > 0
}
if newReleasePRChanges {
pushRefSpec := config.RefSpec(fmt.Sprintf(
"+%s:%s",
rpBranchRef,
// This needs to be the local branch name, not the remotes/origin ref
// See https://stackoverflow.com/a/75727620
rpBranchRef,
))
logger.DebugContext(ctx, "pushing branch", "commit.hash", releaseCommitHash.String(), "branch.name", rpBranch, "refspec", pushRefSpec.String())
if err = repo.PushContext(ctx, &git.PushOptions{
RemoteName: GitRemoteName,
RefSpecs: []config.RefSpec{pushRefSpec},
Force: true,
Auth: rp.forge.GitAuth(),
}); err != nil {
return fmt.Errorf("failed to push branch: %w", err)
}
logger.InfoContext(ctx, "pushed branch", "commit.hash", releaseCommitHash.String(), "branch.name", rpBranch, "refspec", pushRefSpec.String())
} else {
logger.InfoContext(ctx, "file content is already up-to-date in remote branch, skipping push")
}
// Open/Update PR
if pr == nil {
pr, err = NewReleasePullRequest(rpBranch, rp.targetBranch, nextVersion, changelogEntry)
if err != nil {
return err
}
err = rp.forge.CreatePullRequest(ctx, pr)
if err != nil {
return err
}
logger.InfoContext(ctx, "opened pull request", "pr.title", pr.Title, "pr.id", pr.ID)
} else {
pr.SetTitle(rp.targetBranch, nextVersion)
overrides, err := pr.GetOverrides()
if err != nil {
return err
}
err = pr.SetDescription(changelogEntry, overrides)
if err != nil {
return err
}
err = rp.forge.UpdatePullRequest(ctx, pr)
if err != nil {
return err
}
logger.InfoContext(ctx, "updated pull request", "pr.title", pr.Title, "pr.id", pr.ID)
}
return nil
}

View file

@ -1,47 +1,12 @@
package rp package rp
import ( import (
"fmt" "context"
"regexp"
"strings" "github.com/go-git/go-git/v5"
) )
var ( func RunUpdater(ctx context.Context, version string, worktree *git.Worktree) error {
GenericUpdaterSemVerRegex = regexp.MustCompile(`\d+\.\d+\.\d+(-[\w.]+)?(.*x-releaser-pleaser-version)`) // TODO: Implement updater for Go,Python,ExtraFilesMarkers
ChangelogUpdaterHeaderRegex = regexp.MustCompile(`^# Changelog\n`) return nil
)
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)
})
}
}

View file

@ -13,11 +13,7 @@ type Releases struct {
Stable *Tag Stable *Tag
} }
type VersioningStrategy = func(Releases, conventionalcommits.VersionBump, NextVersionType) (string, error) func (r Releases) NextVersion(versionBump conventionalcommits.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)
@ -69,22 +65,24 @@ func SemVerNextVersion(r Releases, versionBump conventionalcommits.VersionBump,
return "v" + next.String(), nil return "v" + next.String(), nil
} }
func VersionBumpFromCommits(commits []AnalyzedCommit) conventionalcommits.VersionBump { func VersionBumpFromChangesets(changesets []Changeset) conventionalcommits.VersionBump {
bump := conventionalcommits.UnknownVersion bump := conventionalcommits.UnknownVersion
for _, commit := range commits { for _, changeset := range changesets {
entryBump := conventionalcommits.UnknownVersion for _, entry := range changeset.ChangelogEntries {
switch { entryBump := conventionalcommits.UnknownVersion
case commit.BreakingChange: switch {
entryBump = conventionalcommits.MajorVersion case entry.BreakingChange:
case commit.Type == "feat": entryBump = conventionalcommits.MajorVersion
entryBump = conventionalcommits.MinorVersion case entry.Type == "feat":
case commit.Type == "fix": entryBump = conventionalcommits.MinorVersion
entryBump = conventionalcommits.PatchVersion case entry.Type == "fix":
} entryBump = conventionalcommits.PatchVersion
}
if entryBump > bump { if entryBump > bump {
bump = entryBump bump = entryBump
}
} }
} }

View file

@ -10,23 +10,23 @@ import (
func TestReleases_NextVersion(t *testing.T) { func TestReleases_NextVersion(t *testing.T) {
type args struct { type args struct {
releases Releases
versionBump conventionalcommits.VersionBump versionBump conventionalcommits.VersionBump
nextVersionType NextVersionType nextVersionType NextVersionType
} }
tests := []struct { tests := []struct {
name string name string
args args releases Releases
want string args args
wantErr assert.ErrorAssertionFunc want string
wantErr assert.ErrorAssertionFunc
}{ }{
{ {
name: "simple bump (major)", name: "simple bump (major)",
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MajorVersion, versionBump: conventionalcommits.MajorVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
@ -35,11 +35,12 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "simple bump (minor)", name: "simple bump (minor)",
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MinorVersion, versionBump: conventionalcommits.MinorVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
@ -48,11 +49,12 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "simple bump (patch)", name: "simple bump (patch)",
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.PatchVersion, versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
@ -61,11 +63,12 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "normal to prerelease (major)", name: "normal to prerelease (major)",
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MajorVersion, versionBump: conventionalcommits.MajorVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
@ -74,11 +77,12 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "normal to prerelease (minor)", name: "normal to prerelease (minor)",
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MinorVersion, versionBump: conventionalcommits.MinorVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
@ -87,11 +91,12 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "normal to prerelease (patch)", name: "normal to prerelease (patch)",
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.PatchVersion, versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
@ -100,11 +105,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "prerelease bump (major)", name: "prerelease bump (major)",
releases: Releases{
Latest: &Tag{Name: "v2.0.0-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v2.0.0-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MajorVersion, versionBump: conventionalcommits.MajorVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
@ -113,11 +118,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "prerelease bump (minor)", name: "prerelease bump (minor)",
releases: Releases{
Latest: &Tag{Name: "v1.2.0-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.2.0-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MinorVersion, versionBump: conventionalcommits.MinorVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
@ -126,11 +131,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "prerelease bump (patch)", name: "prerelease bump (patch)",
releases: Releases{
Latest: &Tag{Name: "v1.1.2-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.2-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.PatchVersion, versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
@ -139,11 +144,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "prerelease different bump (major)", name: "prerelease different bump (major)",
releases: Releases{
Latest: &Tag{Name: "v1.2.0-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.2.0-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MajorVersion, versionBump: conventionalcommits.MajorVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
@ -152,11 +157,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "prerelease different bump (minor)", name: "prerelease different bump (minor)",
releases: Releases{
Latest: &Tag{Name: "v1.1.2-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.2-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MinorVersion, versionBump: conventionalcommits.MinorVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
@ -165,11 +170,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "prerelease to prerelease", name: "prerelease to prerelease",
releases: Releases{
Latest: &Tag{Name: "v1.1.1-alpha.2"},
Stable: &Tag{Name: "v1.1.0"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-alpha.2"},
Stable: &Tag{Name: "v1.1.0"},
},
versionBump: conventionalcommits.PatchVersion, versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
@ -178,11 +183,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "prerelease to normal (explicit)", name: "prerelease to normal (explicit)",
releases: Releases{
Latest: &Tag{Name: "v1.1.1-alpha.2"},
Stable: &Tag{Name: "v1.1.0"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-alpha.2"},
Stable: &Tag{Name: "v1.1.0"},
},
versionBump: conventionalcommits.PatchVersion, versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeNormal, nextVersionType: NextVersionTypeNormal,
}, },
@ -191,11 +196,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "prerelease to normal (implicit)", name: "prerelease to normal (implicit)",
releases: Releases{
Latest: &Tag{Name: "v1.1.1-alpha.2"},
Stable: &Tag{Name: "v1.1.0"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-alpha.2"},
Stable: &Tag{Name: "v1.1.0"},
},
versionBump: conventionalcommits.PatchVersion, versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
@ -204,11 +209,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "nil tag (major)", name: "nil tag (major)",
releases: Releases{
Latest: nil,
Stable: nil,
},
args: args{ args: args{
releases: Releases{
Latest: nil,
Stable: nil,
},
versionBump: conventionalcommits.MajorVersion, versionBump: conventionalcommits.MajorVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
@ -217,11 +222,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "nil tag (minor)", name: "nil tag (minor)",
releases: Releases{
Latest: nil,
Stable: nil,
},
args: args{ args: args{
releases: Releases{
Latest: nil,
Stable: nil,
},
versionBump: conventionalcommits.MinorVersion, versionBump: conventionalcommits.MinorVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
@ -230,11 +235,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "nil tag (patch)", name: "nil tag (patch)",
releases: Releases{
Latest: nil,
Stable: nil,
},
args: args{ args: args{
releases: Releases{
Latest: nil,
Stable: nil,
},
versionBump: conventionalcommits.PatchVersion, versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
@ -243,11 +248,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "nil stable release (major)", name: "nil stable release (major)",
releases: Releases{
Latest: &Tag{Name: "v1.1.1-rc.0"},
Stable: nil,
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-rc.0"},
Stable: nil,
},
versionBump: conventionalcommits.MajorVersion, versionBump: conventionalcommits.MajorVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
@ -256,11 +261,12 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "nil stable release (minor)", name: "nil stable release (minor)",
releases: Releases{
Latest: &Tag{Name: "v1.1.1-rc.0"},
Stable: nil,
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-rc.0"},
Stable: nil,
},
versionBump: conventionalcommits.MinorVersion, versionBump: conventionalcommits.MinorVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
@ -269,11 +275,12 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "nil stable release (patch)", name: "nil stable release (patch)",
releases: Releases{
Latest: &Tag{Name: "v1.1.1-rc.0"},
Stable: nil,
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-rc.0"},
Stable: nil,
},
versionBump: conventionalcommits.PatchVersion, versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
}, },
@ -283,11 +290,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "error on invalid tag semver", name: "error on invalid tag semver",
releases: Releases{
Latest: &Tag{Name: "foodazzle"},
Stable: &Tag{Name: "foodazzle"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "foodazzle"},
Stable: &Tag{Name: "foodazzle"},
},
versionBump: conventionalcommits.PatchVersion, versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
@ -296,11 +303,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "error on invalid tag prerelease", name: "error on invalid tag prerelease",
releases: Releases{
Latest: &Tag{Name: "v1.1.1-rc.foo"},
Stable: &Tag{Name: "v1.1.1-rc.foo"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-rc.foo"},
Stable: &Tag{Name: "v1.1.1-rc.foo"},
},
versionBump: conventionalcommits.PatchVersion, versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeRC, nextVersionType: NextVersionTypeRC,
}, },
@ -309,11 +316,11 @@ func TestReleases_NextVersion(t *testing.T) {
}, },
{ {
name: "error on invalid bump", name: "error on invalid bump",
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
args: args{ args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.UnknownVersion, versionBump: conventionalcommits.UnknownVersion,
nextVersionType: NextVersionTypeUndefined, nextVersionType: NextVersionTypeUndefined,
@ -324,65 +331,95 @@ func TestReleases_NextVersion(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) {
got, err := SemVerNextVersion(tt.args.releases, tt.args.versionBump, tt.args.nextVersionType) got, err := tt.releases.NextVersion(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)) { if !tt.wantErr(t, err, fmt.Sprintf("Releases(%v, %v).NextVersion(%v, %v)", tt.releases.Latest, tt.releases.Stable, tt.args.versionBump, tt.args.nextVersionType)) {
return 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) assert.Equalf(t, tt.want, got, "Releases(%v, %v).NextVersion(%v, %v)", tt.releases.Latest, tt.releases.Stable, tt.args.versionBump, tt.args.nextVersionType)
}) })
} }
} }
func TestVersionBumpFromCommits(t *testing.T) { func TestVersionBumpFromChangesets(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
analyzedCommits []AnalyzedCommit changesets []Changeset
want conventionalcommits.VersionBump want conventionalcommits.VersionBump
}{ }{
{ {
name: "no entries (unknown)", name: "no entries (unknown)",
analyzedCommits: []AnalyzedCommit{}, changesets: []Changeset{},
want: conventionalcommits.UnknownVersion, want: conventionalcommits.UnknownVersion,
}, },
{ {
name: "non-release type (unknown)", name: "non-release type (unknown)",
analyzedCommits: []AnalyzedCommit{{Type: "docs"}}, changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{Type: "docs"}}}},
want: conventionalcommits.UnknownVersion, want: conventionalcommits.UnknownVersion,
}, },
{ {
name: "single breaking (major)", name: "single breaking (major)",
analyzedCommits: []AnalyzedCommit{{BreakingChange: true}}, changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{BreakingChange: true}}}},
want: conventionalcommits.MajorVersion, want: conventionalcommits.MajorVersion,
}, },
{ {
name: "single feat (minor)", name: "single feat (minor)",
analyzedCommits: []AnalyzedCommit{{Type: "feat"}}, changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{Type: "feat"}}}},
want: conventionalcommits.MinorVersion, want: conventionalcommits.MinorVersion,
}, },
{ {
name: "single fix (patch)", name: "single fix (patch)",
analyzedCommits: []AnalyzedCommit{{Type: "fix"}}, changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}}},
want: conventionalcommits.PatchVersion, want: conventionalcommits.PatchVersion,
}, },
{ {
name: "multiple entries (major)", name: "multiple changesets (major)",
analyzedCommits: []AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}}, changesets: []Changeset{
want: conventionalcommits.MajorVersion, {ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}},
{ChangelogEntries: []AnalyzedCommit{{BreakingChange: true}}},
},
want: conventionalcommits.MajorVersion,
}, },
{ {
name: "multiple entries (minor)", name: "multiple changesets (minor)",
analyzedCommits: []AnalyzedCommit{{Type: "fix"}, {Type: "feat"}}, changesets: []Changeset{
want: conventionalcommits.MinorVersion, {ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}},
{ChangelogEntries: []AnalyzedCommit{{Type: "feat"}}},
},
want: conventionalcommits.MinorVersion,
}, },
{ {
name: "multiple entries (patch)", name: "multiple changesets (patch)",
analyzedCommits: []AnalyzedCommit{{Type: "docs"}, {Type: "fix"}}, changesets: []Changeset{
want: conventionalcommits.PatchVersion, {ChangelogEntries: []AnalyzedCommit{{Type: "docs"}}},
{ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}},
},
want: conventionalcommits.PatchVersion,
},
{
name: "multiple entries in one changeset (major)",
changesets: []Changeset{
{ChangelogEntries: []AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}}},
},
want: conventionalcommits.MajorVersion,
},
{
name: "multiple entries in one changeset (minor)",
changesets: []Changeset{
{ChangelogEntries: []AnalyzedCommit{{Type: "fix"}, {Type: "feat"}}},
},
want: conventionalcommits.MinorVersion,
},
{
name: "multiple entries in one changeset (patch)",
changesets: []Changeset{
{ChangelogEntries: []AnalyzedCommit{{Type: "docs"}, {Type: "fix"}}},
},
want: conventionalcommits.PatchVersion,
}, },
} }
for _, tt := range tests { 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, VersionBumpFromChangesets(tt.changesets), "VersionBumpFromChangesets(%v)", tt.changesets)
}) })
} }
} }