From 32734d9aa12fdbe11947c0fabb44b0971cf9e936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 17 Aug 2024 15:28:25 +0200 Subject: [PATCH] refactor: move run logic outside of cli code --- cmd/rp/cmd/run.go | 316 +---------------------------------------- releaserpleaser.go | 341 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+), 312 deletions(-) create mode 100644 releaserpleaser.go diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index 04bf933..ea1e940 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -1,21 +1,11 @@ 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", @@ -50,7 +40,7 @@ func run(cmd *cobra.Command, _ []string) error { "repo", flagRepo, ) - var f rp.Forge + var forge rp.Forge forgeOptions := rp.ForgeOptions{ Repository: flagRepo, @@ -62,312 +52,14 @@ func run(cmd *cobra.Command, _ []string) error { //f = rp.NewGitLab(forgeOptions) case "github": logger.DebugContext(ctx, "using forge GitHub") - f = rp.NewGitHub(logger, &rp.GitHubOptions{ + forge = rp.NewGitHub(logger, &rp.GitHubOptions{ ForgeOptions: forgeOptions, Owner: flagOwner, Repo: flagRepo, }) } - err := ensureLabels(ctx, f) - if err != nil { - return fmt.Errorf("failed to ensure all labels exist: %w", err) - } + releaserPleaser := rp.New(forge, logger, flagBranch) - 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 + return releaserPleaser.Run(ctx) } diff --git a/releaserpleaser.go b/releaserpleaser.go new file mode 100644 index 0000000..2970ba5 --- /dev/null +++ b/releaserpleaser.go @@ -0,0 +1,341 @@ +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 +} + +func New(forge Forge, logger *slog.Logger, targetBranch string) *ReleaserPleaser { + return &ReleaserPleaser{ + forge: forge, + logger: logger, + targetBranch: targetBranch, + } +} + +func (rp *ReleaserPleaser) EnsureLabels(ctx context.Context) error { + // TODO: Wrap Error + return rp.forge.EnsureLabelsExist(ctx, Labels) +} + +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) + 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, []string{LabelReleasePending}, []string{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)) + + changesets, err := rp.forge.Changesets(ctx, releasableCommits) + if err != nil { + return err + } + + logger.InfoContext(ctx, "Found changesets", "length", len(changesets)) + + 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(changesets) == 0 { + if pr != nil { + logger.InfoContext(ctx, "closing existing pull requests, no changesets 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 changesets available for release") + } + + return nil + } + + var releaseOverrides ReleaseOverrides + if pr != nil { + releaseOverrides, err = pr.GetOverrides() + if err != nil { + return err + } + } + + versionBump := VersionBumpFromChangesets(changesets) + nextVersion, err := SemVerNextVersion(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(changesets, 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 +}