diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 38a1f4c..8fbca23 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,7 +21,7 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v6 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 @@ -37,12 +37,8 @@ jobs: go-version-file: go.mod - 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: runs-on: ubuntu-latest diff --git a/.github/workflows/releaser-pleaser.yaml b/.github/workflows/releaser-pleaser.yaml index e0ce818..44690a0 100644 --- a/.github/workflows/releaser-pleaser.yaml +++ b/.github/workflows/releaser-pleaser.yaml @@ -9,7 +9,10 @@ on: - labeled - unlabeled -permissions: {} +permissions: + contents: write + issues: write + pull-requests: write jobs: releaser-pleaser: @@ -18,7 +21,5 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: releaser-pleaser - uses: ./ - with: - token: ${{ secrets.RELEASER_PLEASER_TOKEN }} + - run: env + - uses: ./ diff --git a/.golangci.yaml b/.golangci.yaml deleted file mode 100644 index b3e717d..0000000 --- a/.golangci.yaml +++ /dev/null @@ -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 - diff --git a/CHANGELOG.md b/CHANGELOG.md index 19df7c2..01ed7bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,5 @@ # 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) ### This is the first release ever, so it also includes a lot of other functionality. diff --git a/action.yml b/action.yml index 9d68042..c84aa28 100644 --- a/action.yml +++ b/action.yml @@ -12,19 +12,14 @@ inputs: description: 'GitHub token for creating and grooming release PRs, defaults to using secrets.GITHUB_TOKEN' required: false default: ${{ github.token }} - extra-files: - description: 'List of files that are scanned for version references.' - required: false - default: "" outputs: {} runs: 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: - run - --forge=github - --branch=${{ inputs.branch }} - - --extra-files="${{ inputs.extra-files }}" env: GITHUB_TOKEN: ${{ inputs.token }} GITHUB_USER: "oauth2" diff --git a/changelog.go b/changelog.go index 286faf4..40c65d4 100644 --- a/changelog.go +++ b/changelog.go @@ -3,17 +3,26 @@ package rp import ( "bytes" _ "embed" + "fmt" "html/template" + "io" "log" + "os" + "regexp" + + "github.com/go-git/go-git/v5" ) const ( - ChangelogFile = "CHANGELOG.md" - ChangelogHeader = "# Changelog" + ChangelogFile = "CHANGELOG.md" + ChangelogFileBuffer = "CHANGELOG.md.tmp" + ChangelogHeader = "# Changelog" ) var ( changelogTemplate *template.Template + + headerRegex = regexp.MustCompile(`^# Changelog\n`) ) //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) fixes := make([]AnalyzedCommit, 0) - for _, commit := range commits { - switch commit.Type { - case "feat": - features = append(features, commit) - case "fix": - fixes = append(fixes, commit) + for _, changeset := range changesets { + for _, commit := range changeset.ChangelogEntries { + switch commit.Type { + case "feat": + features = append(features, commit) + case "fix": + fixes = append(fixes, commit) + } } } diff --git a/changelog_test.go b/changelog_test.go index 3fabff8..91ffc85 100644 --- a/changelog_test.go +++ b/changelog_test.go @@ -1,22 +1,106 @@ package rp import ( + "io" "testing" + "github.com/go-git/go-git/v5" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apricote/releaser-pleaser/internal/testutils" ) func ptr[T any](input T) *T { 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) { type args struct { - analyzedCommits []AnalyzedCommit - version string - link string - prefix string - suffix string + changesets []Changeset + version string + link string + prefix string + suffix string } tests := []struct { name string @@ -27,9 +111,9 @@ func Test_NewChangelogEntry(t *testing.T) { { name: "empty", args: args{ - analyzedCommits: []AnalyzedCommit{}, - version: "1.0.0", - link: "https://example.com/1.0.0", + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{}}}, + version: "1.0.0", + link: "https://example.com/1.0.0", }, want: "## [1.0.0](https://example.com/1.0.0)", wantErr: assert.NoError, @@ -37,13 +121,13 @@ func Test_NewChangelogEntry(t *testing.T) { { name: "single feature", args: args{ - analyzedCommits: []AnalyzedCommit{ + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{ { Commit: Commit{}, Type: "feat", Description: "Foobar!", }, - }, + }}}, version: "1.0.0", link: "https://example.com/1.0.0", }, @@ -53,13 +137,13 @@ func Test_NewChangelogEntry(t *testing.T) { { name: "single fix", args: args{ - analyzedCommits: []AnalyzedCommit{ + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{ { Commit: Commit{}, Type: "fix", Description: "Foobar!", }, - }, + }}}, version: "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", args: args{ - analyzedCommits: []AnalyzedCommit{ + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{ { Commit: Commit{}, Type: "feat", @@ -92,7 +176,7 @@ func Test_NewChangelogEntry(t *testing.T) { Description: "So sad!", Scope: ptr("sad"), }, - }, + }}}, version: "1.0.0", link: "https://example.com/1.0.0", }, @@ -112,13 +196,13 @@ func Test_NewChangelogEntry(t *testing.T) { { name: "prefix", args: args{ - analyzedCommits: []AnalyzedCommit{ + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{ { Commit: Commit{}, Type: "fix", Description: "Foobar!", }, - }, + }}}, version: "1.0.0", link: "https://example.com/1.0.0", prefix: "### Breaking Changes", @@ -135,13 +219,13 @@ func Test_NewChangelogEntry(t *testing.T) { { name: "suffix", args: args{ - analyzedCommits: []AnalyzedCommit{ + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{ { Commit: Commit{}, Type: "fix", Description: "Foobar!", }, - }, + }}}, version: "1.0.0", link: "https://example.com/1.0.0", suffix: "### Compatibility\n\nThis version is compatible with flux-compensator v2.2 - v2.9.", @@ -161,7 +245,7 @@ This version is compatible with flux-compensator v2.2 - v2.9. for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := NewChangelogEntry(tt.args.analyzedCommits, tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix) + got, err := NewChangelogEntry(tt.args.changesets, tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix) if !tt.wantErr(t, err) { return } diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index 7661af5..4b7c9e2 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -1,13 +1,21 @@ package cmd 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" rp "github.com/apricote/releaser-pleaser" ) +const ( + RELEASER_PLEASER_BRANCH = "releaser-pleaser--branches--%s" +) + // runCmd represents the run command var runCmd = &cobra.Command{ Use: "run", @@ -15,11 +23,10 @@ var runCmd = &cobra.Command{ } var ( - flagForge string - flagBranch string - flagOwner string - flagRepo string - flagExtraFiles string + flagForge string + flagBranch string + flagOwner string + flagRepo string ) func init() { @@ -31,7 +38,6 @@ func init() { runCmd.PersistentFlags().StringVar(&flagBranch, "branch", "main", "") runCmd.PersistentFlags().StringVar(&flagOwner, "owner", "", "") runCmd.PersistentFlags().StringVar(&flagRepo, "repo", "", "") - runCmd.PersistentFlags().StringVar(&flagExtraFiles, "extra-files", "", "") } func run(cmd *cobra.Command, _ []string) error { @@ -44,50 +50,319 @@ func run(cmd *cobra.Command, _ []string) error { "repo", flagRepo, ) - var forge rp.Forge + var f rp.Forge forgeOptions := rp.ForgeOptions{ Repository: flagRepo, BaseBranch: flagBranch, } - switch flagForge { // nolint:gocritic // Will become a proper switch once gitlab is added - // case "gitlab": - // f = rp.NewGitLab(forgeOptions) + switch flagForge { + //case "gitlab": + //f = rp.NewGitLab(forgeOptions) case "github": logger.DebugContext(ctx, "using forge GitHub") - forge = rp.NewGitHub(logger, &rp.GitHubOptions{ + f = rp.NewGitHub(logger, &rp.GitHubOptions{ ForgeOptions: forgeOptions, Owner: flagOwner, 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( - forge, - logger, - flagBranch, - rp.NewConventionalCommitsParser(), - rp.SemVerNextVersion, - extraFiles, - []rp.Updater{&rp.GenericUpdater{}}, - ) + err = createPendingReleases(ctx, f) + if err != nil { + return fmt.Errorf("failed to create pending releases: %w", err) + } - 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 { - lines := strings.Split(input, "\n") +func ensureLabels(ctx context.Context, forge rp.Forge) error { + return forge.EnsureLabelsExist(ctx, rp.Labels) +} - extraFiles := make([]string, 0, len(lines)) - for _, line := range lines { - line = strings.TrimSpace(line) - if len(line) > 0 { - extraFiles = append(extraFiles, line) +func createPendingReleases(ctx context.Context, forge rp.Forge) error { + logger.InfoContext(ctx, "checking for pending releases") + prs, err := forge.PendingReleases(ctx) + 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 = 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 } diff --git a/commits.go b/commits.go index f0c64e9..565deb7 100644 --- a/commits.go +++ b/commits.go @@ -7,19 +7,6 @@ import ( "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 { Commit Type string @@ -28,36 +15,24 @@ type AnalyzedCommit struct { BreakingChange bool } -type CommitParser interface { - Analyze(commits []Commit) ([]AnalyzedCommit, error) -} - -type ConventionalCommitsParser struct { - machine conventionalcommits.Machine -} - -func NewConventionalCommitsParser() *ConventionalCommitsParser { +func AnalyzeCommits(commits []Commit) ([]AnalyzedCommit, conventionalcommits.VersionBump, error) { parserMachine := parser.NewMachine( parser.WithBestEffort(), parser.WithTypes(conventionalcommits.TypesConventional), ) - return &ConventionalCommitsParser{ - machine: parserMachine, - } -} - -func (c *ConventionalCommitsParser) Analyze(commits []Commit) ([]AnalyzedCommit, error) { analyzedCommits := make([]AnalyzedCommit, 0, len(commits)) + highestVersionBump := conventionalcommits.UnknownVersion + for _, commit := range commits { - msg, err := c.machine.Parse([]byte(commit.Message)) + msg, err := parserMachine.Parse([]byte(commit.Message)) 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) 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) @@ -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 } diff --git a/commits_test.go b/commits_test.go index e58a718..0e686e6 100644 --- a/commits_test.go +++ b/commits_test.go @@ -3,6 +3,7 @@ package rp import ( "testing" + "github.com/leodido/go-conventionalcommits" "github.com/stretchr/testify/assert" ) @@ -11,12 +12,14 @@ func TestAnalyzeCommits(t *testing.T) { name string commits []Commit expectedCommits []AnalyzedCommit + expectedBump conventionalcommits.VersionBump wantErr assert.ErrorAssertionFunc }{ { name: "empty commits", commits: []Commit{}, expectedCommits: []AnalyzedCommit{}, + expectedBump: conventionalcommits.UnknownVersion, wantErr: assert.NoError, }, { @@ -27,6 +30,7 @@ func TestAnalyzeCommits(t *testing.T) { }, }, expectedCommits: nil, + expectedBump: conventionalcommits.UnknownVersion, wantErr: assert.Error, }, { @@ -37,6 +41,7 @@ func TestAnalyzeCommits(t *testing.T) { }, }, expectedCommits: []AnalyzedCommit{}, + expectedBump: conventionalcommits.UnknownVersion, wantErr: assert.NoError, }, { @@ -56,7 +61,8 @@ func TestAnalyzeCommits(t *testing.T) { Description: "blabla", }, }, - wantErr: assert.NoError, + expectedBump: conventionalcommits.PatchVersion, + wantErr: assert.NoError, }, { name: "highest bump (minor)", @@ -80,7 +86,8 @@ func TestAnalyzeCommits(t *testing.T) { Description: "foobar", }, }, - wantErr: assert.NoError, + expectedBump: conventionalcommits.MinorVersion, + wantErr: assert.NoError, }, { @@ -106,17 +113,19 @@ func TestAnalyzeCommits(t *testing.T) { BreakingChange: true, }, }, - wantErr: assert.NoError, + expectedBump: conventionalcommits.MajorVersion, + wantErr: assert.NoError, }, } for _, tt := range tests { 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) { return } assert.Equal(t, tt.expectedCommits, analyzedCommits) + assert.Equal(t, tt.expectedBump, versionBump) }) } } diff --git a/forge.go b/forge.go index 1086564..8a3c9bb 100644 --- a/forge.go +++ b/forge.go @@ -19,12 +19,18 @@ const ( GitHubPerPageMax = 100 GitHubPRStateOpen = "open" GitHubPRStateClosed = "closed" - GitHubEnvAPIToken = "GITHUB_TOKEN" // nolint:gosec // Not actually a hardcoded credential + GitHubEnvAPIToken = "GITHUB_TOKEN" GitHubEnvUsername = "GITHUB_USER" GitHubEnvRepository = "GITHUB_REPOSITORY" GitHubLabelColor = "dedede" ) +type Changeset struct { + URL string + Identifier string + ChangelogEntries []AnalyzedCommit +} + type Forge interface { RepoURL() string CloneURL() string @@ -40,35 +46,23 @@ type Forge interface { // function should return all commits. CommitsSince(context.Context, *Tag) ([]Commit, error) - // EnsureLabelsExist verifies that all desired labels are available on the repository. If labels are missing, they - // are created them. - EnsureLabelsExist(context.Context, []Label) error + // Changesets looks up the Pull/Merge Requests for each commit, returning its parsed metadata. + Changesets(context.Context, []Commit) ([]Changeset, error) + + EnsureLabelsExist(context.Context, []string) error // PullRequestForBranch returns the open pull request between the branch and ForgeOptions.BaseBranch. If no open PR // exists, it returns nil. PullRequestForBranch(context.Context, string) (*ReleasePullRequest, error) - // CreatePullRequest opens a new pull/merge request for the ReleasePullRequest. CreatePullRequest(context.Context, *ReleasePullRequest) error - - // UpdatePullRequest updates the pull/merge request identified through the ID of - // the ReleasePullRequest to the current description and title. UpdatePullRequest(context.Context, *ReleasePullRequest) error - - // SetPullRequestLabels updates the pull/merge request identified through the ID of - // the ReleasePullRequest to the current labels. - SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []Label) error - - // ClosePullRequest closes the pull/merge request identified through the ID of - // the ReleasePullRequest, as it is no longer required. + SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []string) error ClosePullRequest(context.Context, *ReleasePullRequest) error - // PendingReleases returns a list of ReleasePullRequest. The list should contain all pull/merge requests that are - // merged and have the matching label. - PendingReleases(context.Context, Label) ([]*ReleasePullRequest, error) + PendingReleases(context.Context) ([]*ReleasePullRequest, error) - // CreateRelease creates a release on the Forge, pointing at the commit with the passed in details. - CreateRelease(ctx context.Context, commit Commit, title, changelog string, prerelease, latest bool) error + CreateRelease(ctx context.Context, commit Commit, title, changelog string, prelease, latest bool) error } 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)) for _, ghCommit := range repositoryCommits { - commit := Commit{ + commits = append(commits, Commit{ Hash: ghCommit.GetSHA(), Message: ghCommit.GetCommit().GetMessage(), - } - commit.PullRequest, err = g.prForCommit(ctx, commit) - if err != nil { - return nil, fmt.Errorf("failed to check for commit pull request: %w", err) - } - - commits = append(commits, commit) + }) } return commits, nil @@ -269,52 +257,76 @@ func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryComm 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" // endpoint. This requires len(commits) requests. // Using the "List pull requests" endpoint might be faster, as it allows us to fetch 100 arbitrary PRs per request, // but worst case we need to look up all PRs made in the repository ever. - log := g.log.With("commit.hash", commit.Hash) - page := 1 - var associatedPRs []*github.PullRequest + changesets := make([]Changeset, 0, len(commits)) - for { - log.Debug("fetching pull requests associated with commit", "page", page) - prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit( - ctx, g.options.Owner, g.options.Repo, - commit.Hash, &github.ListOptions{ - Page: page, - PerPage: GitHubPerPageMax, - }) + for _, commit := range commits { + log := g.log.With("commit.hash", commit.Hash) + page := 1 + var associatedPRs []*github.PullRequest + + for { + log.Debug("fetching pull requests associated with commit", "page", page) + prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit( + ctx, g.options.Owner, g.options.Repo, + commit.Hash, &github.ListOptions{ + Page: page, + PerPage: 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 { - return nil, err + log.Warn("unable to parse changelog entries", "error", err) + continue } - 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 len(changelogEntries) > 0 { + changesets = append(changesets, Changeset{ + URL: pullrequest.GetHTMLURL(), + Identifier: fmt.Sprintf("#%d", pullrequest.GetNumber()), + ChangelogEntries: changelogEntries, + }) } } - 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)) page := 1 @@ -342,12 +354,12 @@ func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []Label) error { } 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) _, _, err := g.client.Issues.CreateLabel( ctx, g.options.Owner, g.options.Repo, &github.Label{ - Name: Pointer(string(label)), + Name: &label, Color: Pointer(GitHubLabelColor), }, ) @@ -410,7 +422,7 @@ func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest) // TODO: String ID? pr.ID = ghPR.GetNumber() - err = g.SetPullRequestLabels(ctx, pr, []Label{}, pr.Labels) + err = g.SetPullRequestLabels(ctx, pr, []string{}, pr.Labels) if err != nil { return err } @@ -433,25 +445,20 @@ func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *ReleasePullRequest) 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 { _, err := g.client.Issues.RemoveLabelForIssue( ctx, g.options.Owner, g.options.Repo, - pr.ID, string(label), + pr.ID, label, ) if err != nil { return err } } - addString := make([]string, 0, len(add)) - for _, label := range add { - addString = append(addString, string(label)) - } - _, _, err := g.client.Issues.AddLabelsToIssue( ctx, g.options.Owner, g.options.Repo, - pr.ID, addString, + pr.ID, add, ) if err != nil { return err @@ -474,7 +481,7 @@ func (g *GitHub) ClosePullRequest(ctx context.Context, pr *ReleasePullRequest) e return nil } -func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel Label) ([]*ReleasePullRequest, error) { +func (g *GitHub) PendingReleases(ctx context.Context) ([]*ReleasePullRequest, error) { page := 1 var prs []*ReleasePullRequest @@ -502,7 +509,7 @@ func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel Label) ([]*Re for _, pr := range ghPRs { pending := slices.ContainsFunc(pr.Labels, func(l *github.Label) bool { - return l.GetName() == string(pendingLabel) + return l.GetName() == LabelReleasePending }) if !pending { continue @@ -551,21 +558,10 @@ func (g *GitHub) CreateRelease(ctx context.Context, commit Commit, title, change 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 { - labels := make([]Label, 0, len(pr.Labels)) + labels := make([]string, 0, len(pr.Labels)) for _, label := range pr.Labels { - labelName := Label(label.GetName()) - if slices.Contains(KnownLabels, Label(label.GetName())) { - labels = append(labels, labelName) - } + labels = append(labels, label.GetName()) } var releaseCommit *Commit diff --git a/git.go b/git.go index 9131570..7df742c 100644 --- a/git.go +++ b/git.go @@ -13,9 +13,15 @@ import ( ) const ( - GitRemoteName = "origin" + CommitSearchDepth = 50 // TODO: Increase + GitRemoteName = "origin" ) +type Commit struct { + Hash string + Message string +} + type Tag struct { Hash string Name string diff --git a/go.mod b/go.mod index 3be3b4e..e55cfa9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/apricote/releaser-pleaser -go 1.23.0 +go 1.22.4 require ( github.com/blang/semver/v4 v4.0.0 @@ -14,11 +14,11 @@ require ( ) require ( - dario.cat/mergo v1.0.1 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v1.0.0 // indirect - github.com/cloudflare/circl v1.3.9 // indirect - github.com/cyphar/filepath-securejoin v0.3.1 // indirect + github.com/cloudflare/circl v1.3.7 // indirect + github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect @@ -31,12 +31,14 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // 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/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.26.0 // indirect - golang.org/x/net v0.28.0 // indirect - golang.org/x/sys v0.24.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/mod v0.12.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/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c43a8f2..385a5a2 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +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.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +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/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= 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/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.9 h1:QFrlgFYf2Qpi8bSpVPK1HBvWpx16v/1TZivyo7pGuBE= -github.com/cloudflare/circl v1.3.9/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +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/cyphar/filepath-securejoin v0.3.1 h1:1V7cHiaW+C+39wEfpH6XlLBQo3j/PciWFrgfCLS8XrE= -github.com/cyphar/filepath-securejoin v0.3.1/go.mod h1:F7i41x/9cBF7lzCrVsYs9fuzwRZm4NQsGTBdpp6mETc= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 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.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= +github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= +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/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 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.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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +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.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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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.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.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +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-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.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-20191026070338-33540a1f6037/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.5.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.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 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.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= -golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +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.3/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.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.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +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-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.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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/markdown/extensions/section.go b/internal/markdown/extensions/section.go index dcca37d..e85808a 100644 --- a/internal/markdown/extensions/section.go +++ b/internal/markdown/extensions/section.go @@ -12,10 +12,8 @@ import ( "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast" ) -var ( - sectionStartRegex = regexp.MustCompile(`^`) - sectionEndRegex = regexp.MustCompile(`^`) -) +var sectionStartRegex = regexp.MustCompile(`^`) +var sectionEndRegex = regexp.MustCompile(`^`) const ( sectionTrigger = "" ) -type sectionParser struct{} +type sectionParser struct { +} func (s *sectionParser) Open(_ gast.Node, reader text.Reader, _ parser.Context) (gast.Node, parser.State) { line, _ := reader.PeekLine() @@ -76,7 +75,8 @@ func (s *sectionParser) Trigger() []byte { 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. var Section = §ion{} diff --git a/internal/markdown/renderer/markdown/renderer.go b/internal/markdown/renderer/markdown/renderer.go index 69b2883..7a369e7 100644 --- a/internal/markdown/renderer/markdown/renderer.go +++ b/internal/markdown/renderer/markdown/renderer.go @@ -149,7 +149,7 @@ func (r *Renderer) writeByte(w io.Writer, c byte) error { 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. func (r *Renderer) writeString(w io.Writer, s string) (int, error) { 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 // 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{ node: node, fresh: true, @@ -222,7 +222,7 @@ func (r *Renderer) closeBlock(w io.Writer) error { } // 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 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) } 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. @@ -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. -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) if _, err := r.writeString(w, strings.Repeat("*", em.Level)); err != nil { 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. -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) if err := r.renderLinkOrImage(w, "![", img.Destination, img.Title, enter); err != nil { 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. -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) if err := r.renderLinkOrImage(w, "[", link.Destination, link.Title, enter); err != nil { 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. -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 { return ast.WalkContinue, nil } @@ -801,7 +801,7 @@ func (r *Renderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node 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 node.NextSibling() != 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 } -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 { return ast.WalkStop, fmt.Errorf(": %w", err) } 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 { var fill byte = ' ' if task := node.(*exast.TaskCheckBox); task.IsChecked { diff --git a/internal/testutils/git.go b/internal/testutils/git.go index f5721b6..3bfd053 100644 --- a/internal/testutils/git.go +++ b/internal/testutils/git.go @@ -11,26 +11,25 @@ import ( "github.com/stretchr/testify/require" ) -var author = &object.Signature{ - Name: "releaser-pleaser", - When: time.Date(2020, 01, 01, 01, 01, 01, 01, time.UTC), -} +var ( + author = &object.Signature{ + Name: "releaser-pleaser", + When: time.Date(2020, 01, 01, 01, 01, 01, 01, time.UTC), + } +) type CommitOption func(*commitOptions) - type commitOptions struct { cleanFiles bool files []commitFile tags []string } - type commitFile struct { path string content string } type Commit func(*testing.T, *git.Repository) error - type Repo func(*testing.T) *git.Repository func WithCommit(message string, options ...CommitOption) Commit { @@ -62,9 +61,9 @@ func WithCommit(message string, options ...CommitOption) Commit { for _, fileInfo := range opts.files { file, err := wt.Filesystem.Create(fileInfo.path) require.NoError(t, err, "failed to create file %q", fileInfo.path) + defer file.Close() _, err = file.Write([]byte(fileInfo.content)) - file.Close() 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 + } } diff --git a/releasepr.go b/releasepr.go index a6744c4..eadb3b0 100644 --- a/releasepr.go +++ b/releasepr.go @@ -6,7 +6,6 @@ import ( "fmt" "log" "regexp" - "strings" "text/template" "github.com/yuin/goldmark/ast" @@ -31,14 +30,11 @@ func init() { } } -// ReleasePullRequest -// -// TODO: Reuse [PullRequest] type ReleasePullRequest struct { ID int Title string Description string - Labels []Label + Labels []string Head string ReleaseCommit *Commit @@ -47,11 +43,11 @@ type ReleasePullRequest struct { func NewReleasePullRequest(head, branch, version, changelogEntry string) (*ReleasePullRequest, error) { rp := &ReleasePullRequest{ Head: head, - Labels: []Label{LabelReleasePending}, + Labels: []string{LabelReleasePending}, } rp.SetTitle(branch, version) - if err := rp.SetDescription(changelogEntry, ReleaseOverrides{}); err != nil { + if err := rp.SetDescription(changelogEntry); err != nil { return nil, err } @@ -61,7 +57,7 @@ func NewReleasePullRequest(head, branch, version, changelogEntry string) (*Relea type ReleaseOverrides struct { Prefix 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 } @@ -92,20 +88,18 @@ func (n NextVersionType) String() string { } } -// Label is the string identifier of a pull/merge request label on the forge. -type Label string - +// PR Labels const ( - LabelNextVersionTypeNormal Label = "rp-next-version::normal" - LabelNextVersionTypeRC Label = "rp-next-version::rc" - LabelNextVersionTypeBeta Label = "rp-next-version::beta" - LabelNextVersionTypeAlpha Label = "rp-next-version::alpha" + LabelNextVersionTypeNormal = "rp-next-version::normal" + LabelNextVersionTypeRC = "rp-next-version::rc" + LabelNextVersionTypeBeta = "rp-next-version::beta" + LabelNextVersionTypeAlpha = "rp-next-version::alpha" - LabelReleasePending Label = "rp-release::pending" - LabelReleaseTagged Label = "rp-release::tagged" + LabelReleasePending = "rp-release::pending" + LabelReleaseTagged = "rp-release::tagged" ) -var KnownLabels = []Label{ +var Labels = []string{ LabelNextVersionTypeNormal, LabelNextVersionTypeRC, LabelNextVersionTypeBeta, @@ -121,6 +115,7 @@ const ( ) const ( + MarkdownSectionOverrides = "overrides" MarkdownSectionChangelog = "changelog" ) @@ -155,9 +150,6 @@ func (pr *ReleasePullRequest) parseVersioningFlags(overrides ReleaseOverrides) R overrides.NextVersionType = NextVersionTypeBeta case LabelNextVersionTypeAlpha: 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 } +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) { source := []byte(pr.Description) gm := markdown.New() @@ -252,11 +289,11 @@ func textFromLines(source []byte, n ast.Node) string { content = append(content, line.Value(source)...) } - return strings.TrimSpace(string(content)) + return string(content) } 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) { @@ -268,9 +305,14 @@ func (pr *ReleasePullRequest) Version() (string, error) { 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 - err := releasePRTemplate.Execute(&description, map[string]any{ + err = releasePRTemplate.Execute(&description, map[string]any{ "Changelog": changelogEntry, "Overrides": overrides, }) diff --git a/releasepr.md.tpl b/releasepr.md.tpl index 6f74aa0..e48872b 100644 --- a/releasepr.md.tpl +++ b/releasepr.md.tpl @@ -1,32 +1,29 @@ +--- + {{ .Changelog }} --- -
-

PR by releaser-pleaser 🤖

+## releaser-pleaser Instructions +{{ if .Overrides }} +{{- .Overrides -}} +{{- else }} + +> 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. - -## Release Notes - -### Prefix / Start - -This will be added to the start of the release notes. +### Prefix ```rp-prefix -{{- if .Overrides.Prefix }} -{{ .Overrides.Prefix }}{{ end }} ``` -### Suffix / End - -This will be added to the end of the release notes. +### Suffix ```rp-suffix -{{- if .Overrides.Suffix }} -{{ .Overrides.Suffix }}{{ end }} ``` -
+ + +{{ end }} +#### PR by [releaser-pleaser](https://github.com/apricote/releaser-pleaser) diff --git a/releasepr_test.go b/releasepr_test.go index b1ffbb2..124b063 100644 --- a/releasepr_test.go +++ b/releasepr_test.go @@ -49,96 +49,121 @@ func TestReleasePullRequest_SetDescription(t *testing.T) { tests := []struct { name string + pr *ReleasePullRequest changelogEntry string - overrides ReleaseOverrides want string wantErr assert.ErrorAssertionFunc }{ { - name: "no overrides", + name: "empty description", + pr: &ReleasePullRequest{}, changelogEntry: `## v1.0.0`, - overrides: ReleaseOverrides{}, - want: ` + want: `--- + + ## v1.0.0 --- -
-

PR by releaser-pleaser 🤖

+## releaser-pleaser Instructions -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. -## Release Notes - -### Prefix / Start - -This will be added to the start of the release notes. +### Prefix ` + "```" + `rp-prefix ` + "```" + ` -### Suffix / End - -This will be added to the end of the release notes. +### Suffix ` + "```" + `rp-suffix ` + "```" + ` -
+ + + +#### PR by [releaser-pleaser](https://github.com/apricote/releaser-pleaser) `, wantErr: assert.NoError, }, { - name: "existing overrides", - changelogEntry: `## v1.0.0`, - overrides: ReleaseOverrides{ - Prefix: "This release is awesome!", - Suffix: "Fooo", - }, - want: ` -## v1.0.0 + name: "existing overrides", + pr: &ReleasePullRequest{ + Description: `--- + + +## v0.1.0 + +### Features + +- bedazzle --- -
-

PR by releaser-pleaser 🤖

+## releaser-pleaser Instructions -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. -## Release Notes - -### Prefix / Start - -This will be added to the start of the release notes. +### Prefix ` + "```" + `rp-prefix This release is awesome! ` + "```" + ` -### Suffix / End - -This will be added to the end of the release notes. +### Suffix ` + "```" + `rp-suffix -Fooo ` + "```" + ` -
+ + +#### PR by [releaser-pleaser](https://github.com/apricote/releaser-pleaser) +`, + }, + changelogEntry: `## v1.0.0`, + want: `--- + + +## v1.0.0 + + +--- + +## releaser-pleaser Instructions + + +> 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 +` + "```" + ` + + + +#### PR by [releaser-pleaser](https://github.com/apricote/releaser-pleaser) `, wantErr: assert.NoError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - pr := &ReleasePullRequest{} - err := pr.SetDescription(tt.changelogEntry, tt.overrides) + err := tt.pr.SetDescription(tt.changelogEntry) if !tt.wantErr(t, err) { return } - assert.Equal(t, tt.want, pr.Description) + assert.Equal(t, tt.want, tt.pr.Description) }) } } diff --git a/releaserpleaser.go b/releaserpleaser.go deleted file mode 100644 index 5dfcf12..0000000 --- a/releaserpleaser.go +++ /dev/null @@ -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 -} diff --git a/updater.go b/updater.go index 3aaedae..ce01d21 100644 --- a/updater.go +++ b/updater.go @@ -1,47 +1,12 @@ package rp import ( - "fmt" - "regexp" - "strings" + "context" + + "github.com/go-git/go-git/v5" ) -var ( - GenericUpdaterSemVerRegex = regexp.MustCompile(`\d+\.\d+\.\d+(-[\w.]+)?(.*x-releaser-pleaser-version)`) - ChangelogUpdaterHeaderRegex = regexp.MustCompile(`^# Changelog\n`) -) - -type ReleaseInfo struct { - Version string - ChangelogEntry string -} - -type Updater interface { - UpdateContent(content string, info ReleaseInfo) (string, error) -} - -type GenericUpdater struct{} - -func (u *GenericUpdater) UpdateContent(content string, info ReleaseInfo) (string, error) { - // We strip the "v" prefix to avoid adding/removing it from the users input. - version := strings.TrimPrefix(info.Version, "v") - - return GenericUpdaterSemVerRegex.ReplaceAllString(content, version+"${2}"), nil -} - -type ChangelogUpdater struct{} - -func (u *ChangelogUpdater) UpdateContent(content string, info ReleaseInfo) (string, error) { - headerIndex := ChangelogUpdaterHeaderRegex.FindStringIndex(content) - if headerIndex == nil && len(content) != 0 { - return "", fmt.Errorf("unexpected format of CHANGELOG.md, header does not match") - } - if headerIndex != nil { - // Remove the header from the content - content = content[headerIndex[1]:] - } - - content = ChangelogHeader + "\n\n" + info.ChangelogEntry + content - - return content, nil +func RunUpdater(ctx context.Context, version string, worktree *git.Worktree) error { + // TODO: Implement updater for Go,Python,ExtraFilesMarkers + return nil } diff --git a/updater_test.go b/updater_test.go deleted file mode 100644 index c0e1419..0000000 --- a/updater_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/versioning.go b/versioning.go index 176a28d..126e06f 100644 --- a/versioning.go +++ b/versioning.go @@ -13,11 +13,7 @@ type Releases struct { Stable *Tag } -type VersioningStrategy = func(Releases, conventionalcommits.VersionBump, NextVersionType) (string, error) - -var _ VersioningStrategy = SemVerNextVersion - -func SemVerNextVersion(r Releases, versionBump conventionalcommits.VersionBump, nextVersionType NextVersionType) (string, error) { +func (r Releases) NextVersion(versionBump conventionalcommits.VersionBump, nextVersionType NextVersionType) (string, error) { latest, err := parseSemverWithDefault(r.Latest) if err != nil { return "", fmt.Errorf("failed to parse latest version: %w", err) @@ -69,22 +65,24 @@ func SemVerNextVersion(r Releases, versionBump conventionalcommits.VersionBump, return "v" + next.String(), nil } -func VersionBumpFromCommits(commits []AnalyzedCommit) conventionalcommits.VersionBump { +func VersionBumpFromChangesets(changesets []Changeset) conventionalcommits.VersionBump { bump := conventionalcommits.UnknownVersion - for _, commit := range commits { - entryBump := conventionalcommits.UnknownVersion - switch { - case commit.BreakingChange: - entryBump = conventionalcommits.MajorVersion - case commit.Type == "feat": - entryBump = conventionalcommits.MinorVersion - case commit.Type == "fix": - entryBump = conventionalcommits.PatchVersion - } + for _, changeset := range changesets { + for _, entry := range changeset.ChangelogEntries { + entryBump := conventionalcommits.UnknownVersion + switch { + case entry.BreakingChange: + entryBump = conventionalcommits.MajorVersion + case entry.Type == "feat": + entryBump = conventionalcommits.MinorVersion + case entry.Type == "fix": + entryBump = conventionalcommits.PatchVersion + } - if entryBump > bump { - bump = entryBump + if entryBump > bump { + bump = entryBump + } } } diff --git a/versioning_test.go b/versioning_test.go index b6a0995..9319c63 100644 --- a/versioning_test.go +++ b/versioning_test.go @@ -10,23 +10,23 @@ import ( func TestReleases_NextVersion(t *testing.T) { type args struct { - releases Releases versionBump conventionalcommits.VersionBump nextVersionType NextVersionType } tests := []struct { - name string - args args - want string - wantErr assert.ErrorAssertionFunc + name string + releases Releases + args args + want string + wantErr assert.ErrorAssertionFunc }{ { name: "simple bump (major)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1"}, - Stable: &Tag{Name: "v1.1.1"}, - }, versionBump: conventionalcommits.MajorVersion, nextVersionType: NextVersionTypeUndefined, }, @@ -35,11 +35,12 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "simple bump (minor)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1"}, - Stable: &Tag{Name: "v1.1.1"}, - }, + versionBump: conventionalcommits.MinorVersion, nextVersionType: NextVersionTypeUndefined, }, @@ -48,11 +49,12 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "simple bump (patch)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1"}, - Stable: &Tag{Name: "v1.1.1"}, - }, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeUndefined, }, @@ -61,11 +63,12 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "normal to prerelease (major)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1"}, - Stable: &Tag{Name: "v1.1.1"}, - }, + versionBump: conventionalcommits.MajorVersion, nextVersionType: NextVersionTypeRC, }, @@ -74,11 +77,12 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "normal to prerelease (minor)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1"}, - Stable: &Tag{Name: "v1.1.1"}, - }, + versionBump: conventionalcommits.MinorVersion, nextVersionType: NextVersionTypeRC, }, @@ -87,11 +91,12 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "normal to prerelease (patch)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1"}, - Stable: &Tag{Name: "v1.1.1"}, - }, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeRC, }, @@ -100,11 +105,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "prerelease bump (major)", + releases: Releases{ + Latest: &Tag{Name: "v2.0.0-rc.0"}, + Stable: &Tag{Name: "v1.1.1"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v2.0.0-rc.0"}, - Stable: &Tag{Name: "v1.1.1"}, - }, versionBump: conventionalcommits.MajorVersion, nextVersionType: NextVersionTypeRC, }, @@ -113,11 +118,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "prerelease bump (minor)", + releases: Releases{ + Latest: &Tag{Name: "v1.2.0-rc.0"}, + Stable: &Tag{Name: "v1.1.1"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.2.0-rc.0"}, - Stable: &Tag{Name: "v1.1.1"}, - }, versionBump: conventionalcommits.MinorVersion, nextVersionType: NextVersionTypeRC, }, @@ -126,11 +131,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "prerelease bump (patch)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.2-rc.0"}, + Stable: &Tag{Name: "v1.1.1"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.2-rc.0"}, - Stable: &Tag{Name: "v1.1.1"}, - }, versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeRC, }, @@ -139,11 +144,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "prerelease different bump (major)", + releases: Releases{ + Latest: &Tag{Name: "v1.2.0-rc.0"}, + Stable: &Tag{Name: "v1.1.1"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.2.0-rc.0"}, - Stable: &Tag{Name: "v1.1.1"}, - }, versionBump: conventionalcommits.MajorVersion, nextVersionType: NextVersionTypeRC, }, @@ -152,11 +157,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "prerelease different bump (minor)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.2-rc.0"}, + Stable: &Tag{Name: "v1.1.1"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.2-rc.0"}, - Stable: &Tag{Name: "v1.1.1"}, - }, versionBump: conventionalcommits.MinorVersion, nextVersionType: NextVersionTypeRC, }, @@ -165,11 +170,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "prerelease to prerelease", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-alpha.2"}, + Stable: &Tag{Name: "v1.1.0"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1-alpha.2"}, - Stable: &Tag{Name: "v1.1.0"}, - }, versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeRC, }, @@ -178,11 +183,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "prerelease to normal (explicit)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-alpha.2"}, + Stable: &Tag{Name: "v1.1.0"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1-alpha.2"}, - Stable: &Tag{Name: "v1.1.0"}, - }, versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeNormal, }, @@ -191,11 +196,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "prerelease to normal (implicit)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-alpha.2"}, + Stable: &Tag{Name: "v1.1.0"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1-alpha.2"}, - Stable: &Tag{Name: "v1.1.0"}, - }, versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeUndefined, }, @@ -204,11 +209,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "nil tag (major)", + releases: Releases{ + Latest: nil, + Stable: nil, + }, args: args{ - releases: Releases{ - Latest: nil, - Stable: nil, - }, versionBump: conventionalcommits.MajorVersion, nextVersionType: NextVersionTypeUndefined, }, @@ -217,11 +222,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "nil tag (minor)", + releases: Releases{ + Latest: nil, + Stable: nil, + }, args: args{ - releases: Releases{ - Latest: nil, - Stable: nil, - }, versionBump: conventionalcommits.MinorVersion, nextVersionType: NextVersionTypeUndefined, }, @@ -230,11 +235,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "nil tag (patch)", + releases: Releases{ + Latest: nil, + Stable: nil, + }, args: args{ - releases: Releases{ - Latest: nil, - Stable: nil, - }, versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeUndefined, }, @@ -243,11 +248,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "nil stable release (major)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-rc.0"}, + Stable: nil, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1-rc.0"}, - Stable: nil, - }, versionBump: conventionalcommits.MajorVersion, nextVersionType: NextVersionTypeUndefined, }, @@ -256,11 +261,12 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "nil stable release (minor)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-rc.0"}, + Stable: nil, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1-rc.0"}, - Stable: nil, - }, + versionBump: conventionalcommits.MinorVersion, nextVersionType: NextVersionTypeUndefined, }, @@ -269,11 +275,12 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "nil stable release (patch)", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1-rc.0"}, + Stable: nil, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1-rc.0"}, - Stable: nil, - }, + versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeUndefined, }, @@ -283,11 +290,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "error on invalid tag semver", + releases: Releases{ + Latest: &Tag{Name: "foodazzle"}, + Stable: &Tag{Name: "foodazzle"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "foodazzle"}, - Stable: &Tag{Name: "foodazzle"}, - }, versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeRC, }, @@ -296,11 +303,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { 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{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1-rc.foo"}, - Stable: &Tag{Name: "v1.1.1-rc.foo"}, - }, versionBump: conventionalcommits.PatchVersion, nextVersionType: NextVersionTypeRC, }, @@ -309,11 +316,11 @@ func TestReleases_NextVersion(t *testing.T) { }, { name: "error on invalid bump", + releases: Releases{ + Latest: &Tag{Name: "v1.1.1"}, + Stable: &Tag{Name: "v1.1.1"}, + }, args: args{ - releases: Releases{ - Latest: &Tag{Name: "v1.1.1"}, - Stable: &Tag{Name: "v1.1.1"}, - }, versionBump: conventionalcommits.UnknownVersion, nextVersionType: NextVersionTypeUndefined, @@ -324,65 +331,95 @@ func TestReleases_NextVersion(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := SemVerNextVersion(tt.args.releases, tt.args.versionBump, tt.args.nextVersionType) - if !tt.wantErr(t, err, fmt.Sprintf("SemVerNextVersion(Releases(%v, %v), %v, %v)", tt.args.releases.Latest, tt.args.releases.Stable, tt.args.versionBump, tt.args.nextVersionType)) { + got, err := tt.releases.NextVersion(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 } - 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 { - name string - analyzedCommits []AnalyzedCommit - want conventionalcommits.VersionBump + name string + changesets []Changeset + want conventionalcommits.VersionBump }{ { - name: "no entries (unknown)", - analyzedCommits: []AnalyzedCommit{}, - want: conventionalcommits.UnknownVersion, + name: "no entries (unknown)", + changesets: []Changeset{}, + want: conventionalcommits.UnknownVersion, }, { - name: "non-release type (unknown)", - analyzedCommits: []AnalyzedCommit{{Type: "docs"}}, - want: conventionalcommits.UnknownVersion, + name: "non-release type (unknown)", + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{Type: "docs"}}}}, + want: conventionalcommits.UnknownVersion, }, { - name: "single breaking (major)", - analyzedCommits: []AnalyzedCommit{{BreakingChange: true}}, - want: conventionalcommits.MajorVersion, + name: "single breaking (major)", + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{BreakingChange: true}}}}, + want: conventionalcommits.MajorVersion, }, { - name: "single feat (minor)", - analyzedCommits: []AnalyzedCommit{{Type: "feat"}}, - want: conventionalcommits.MinorVersion, + name: "single feat (minor)", + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{Type: "feat"}}}}, + want: conventionalcommits.MinorVersion, }, { - name: "single fix (patch)", - analyzedCommits: []AnalyzedCommit{{Type: "fix"}}, - want: conventionalcommits.PatchVersion, + name: "single fix (patch)", + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}}}, + want: conventionalcommits.PatchVersion, }, { - name: "multiple entries (major)", - analyzedCommits: []AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}}, - want: conventionalcommits.MajorVersion, + name: "multiple changesets (major)", + changesets: []Changeset{ + {ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}}, + {ChangelogEntries: []AnalyzedCommit{{BreakingChange: true}}}, + }, + want: conventionalcommits.MajorVersion, }, { - name: "multiple entries (minor)", - analyzedCommits: []AnalyzedCommit{{Type: "fix"}, {Type: "feat"}}, - want: conventionalcommits.MinorVersion, + name: "multiple changesets (minor)", + changesets: []Changeset{ + {ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}}, + {ChangelogEntries: []AnalyzedCommit{{Type: "feat"}}}, + }, + want: conventionalcommits.MinorVersion, }, { - name: "multiple entries (patch)", - analyzedCommits: []AnalyzedCommit{{Type: "docs"}, {Type: "fix"}}, - want: conventionalcommits.PatchVersion, + name: "multiple changesets (patch)", + changesets: []Changeset{ + {ChangelogEntries: []AnalyzedCommit{{Type: "docs"}}}, + {ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}}, + }, + want: conventionalcommits.PatchVersion, + }, + { + name: "multiple entries in one changeset (major)", + changesets: []Changeset{ + {ChangelogEntries: []AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}}}, + }, + want: conventionalcommits.MajorVersion, + }, + { + name: "multiple entries in one changeset (minor)", + changesets: []Changeset{ + {ChangelogEntries: []AnalyzedCommit{{Type: "fix"}, {Type: "feat"}}}, + }, + want: conventionalcommits.MinorVersion, + }, + { + name: "multiple entries in one changeset (patch)", + changesets: []Changeset{ + {ChangelogEntries: []AnalyzedCommit{{Type: "docs"}, {Type: "fix"}}}, + }, + want: conventionalcommits.PatchVersion, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equalf(t, tt.want, VersionBumpFromCommits(tt.analyzedCommits), "VersionBumpFromCommits(%v)", tt.analyzedCommits) + assert.Equalf(t, tt.want, VersionBumpFromChangesets(tt.changesets), "VersionBumpFromChangesets(%v)", tt.changesets) }) } }