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/changelog.go b/changelog.go index 43568d5..40c65d4 100644 --- a/changelog.go +++ b/changelog.go @@ -14,8 +14,9 @@ import ( ) const ( - ChangelogFile = "CHANGELOG.md" - ChangelogHeader = "# Changelog" + ChangelogFile = "CHANGELOG.md" + ChangelogFileBuffer = "CHANGELOG.md.tmp" + ChangelogHeader = "# Changelog" ) var ( @@ -89,16 +90,18 @@ func UpdateChangelogFile(wt *git.Worktree, newEntry string) error { return nil } -func NewChangelogEntry(commits []AnalyzedCommit, version, link, prefix, suffix string) (string, error) { +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 3d1612b..91ffc85 100644 --- a/changelog_test.go +++ b/changelog_test.go @@ -96,11 +96,11 @@ func TestUpdateChangelogFile(t *testing.T) { 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 @@ -111,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, @@ -121,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", }, @@ -137,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", }, @@ -153,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", @@ -176,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", }, @@ -196,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", @@ -219,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.", @@ -245,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 34db06f..04bf933 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -1,11 +1,21 @@ package cmd import ( + "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", @@ -40,7 +50,7 @@ func run(cmd *cobra.Command, _ []string) error { "repo", flagRepo, ) - var forge rp.Forge + var f rp.Forge forgeOptions := rp.ForgeOptions{ Repository: flagRepo, @@ -52,14 +62,312 @@ func run(cmd *cobra.Command, _ []string) error { //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, }) } - releaserPleaser := rp.New(forge, logger, flagBranch, rp.NewConventionalCommitsParser(), rp.SemVerNextVersion) + err := ensureLabels(ctx, f) + if err != nil { + return fmt.Errorf("failed to ensure all labels exist: %w", err) + } - return releaserPleaser.Run(ctx) + err = createPendingReleases(ctx, f) + if err != nil { + return fmt.Errorf("failed to create pending releases: %w", err) + } + + 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 ensureLabels(ctx context.Context, forge rp.Forge) error { + return forge.EnsureLabelsExist(ctx, rp.Labels) +} + +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 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 := rp.SemVerNextVersion(releases, 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) + + overrides, err := pr.GetOverrides() + if err != nil { + return err + } + err = pr.SetDescription(changelogEntry, overrides) + 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..28f9f4a 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 @@ -47,7 +34,7 @@ func NewConventionalCommitsParser() *ConventionalCommitsParser { } } -func (c *ConventionalCommitsParser) Analyze(commits []Commit) ([]AnalyzedCommit, error) { +func (c *ConventionalCommitsParser) AnalyzeCommits(commits []Commit) ([]AnalyzedCommit, error) { analyzedCommits := make([]AnalyzedCommit, 0, len(commits)) for _, commit := range commits { diff --git a/commits_test.go b/commits_test.go index e58a718..0725b32 100644 --- a/commits_test.go +++ b/commits_test.go @@ -111,7 +111,7 @@ func TestAnalyzeCommits(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - analyzedCommits, err := NewConventionalCommitsParser().Analyze(tt.commits) + analyzedCommits, err := NewConventionalCommitsParser().AnalyzeCommits(tt.commits) if !tt.wantErr(t, err) { return } diff --git a/forge.go b/forge.go index c244a0c..99e65a3 100644 --- a/forge.go +++ b/forge.go @@ -25,6 +25,12 @@ const ( 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 := NewConventionalCommitsParser().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/renderer/markdown/renderer.go b/internal/markdown/renderer/markdown/renderer.go index e0d4ecb..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 } @@ -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 14737a0..3bfd053 100644 --- a/internal/testutils/git.go +++ b/internal/testutils/git.go @@ -61,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) } diff --git a/releasepr.go b/releasepr.go index 1d41325..a0a6c0b 100644 --- a/releasepr.go +++ b/releasepr.go @@ -31,14 +31,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,7 +44,7 @@ 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) @@ -61,7 +58,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 +89,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, diff --git a/releaserpleaser.go b/releaserpleaser.go deleted file mode 100644 index 7c3ab16..0000000 --- a/releaserpleaser.go +++ /dev/null @@ -1,347 +0,0 @@ -package rp - -import ( - "context" - "fmt" - "log/slog" - - "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 -} - -func New(forge Forge, logger *slog.Logger, targetBranch string, commitParser CommitParser, versioningStrategy VersioningStrategy) *ReleaserPleaser { - return &ReleaserPleaser{ - forge: forge, - logger: logger, - targetBranch: targetBranch, - commitParser: commitParser, - nextVersion: versioningStrategy, - } -} - -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) - } - - err = RunUpdater(ctx, nextVersion, worktree) - if err != nil { - return fmt.Errorf("failed to update files with new version: %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) - } - - err = UpdateChangelogFile(worktree, changelogEntry) - if err != nil { - return fmt.Errorf("failed to update changelog file: %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/versioning.go b/versioning.go index 176a28d..77bb96f 100644 --- a/versioning.go +++ b/versioning.go @@ -69,22 +69,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..d1846d8 100644 --- a/versioning_test.go +++ b/versioning_test.go @@ -333,56 +333,86 @@ func TestReleases_NextVersion(t *testing.T) { } } -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) }) } }