mirror of
https://github.com/apricote/releaser-pleaser.git
synced 2026-01-13 21:21:03 +00:00
Previously all commits were authored and committed by
releaser-pleaser <>
This looked weird when looking at the commit. We now check with the
Forge API for details on the currently authenticated user, and use that
name and email as the commit author. The commit committer stays the same
for now.
In GitHub, the default `$GITHUB_TOKEN` does not allow access to the
required endpoint, so for github the user `github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>` is hardcoded
when the request fails.
352 lines
11 KiB
Go
352 lines
11 KiB
Go
package rp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/apricote/releaser-pleaser/internal/changelog"
|
|
"github.com/apricote/releaser-pleaser/internal/commitparser"
|
|
"github.com/apricote/releaser-pleaser/internal/forge"
|
|
"github.com/apricote/releaser-pleaser/internal/git"
|
|
"github.com/apricote/releaser-pleaser/internal/releasepr"
|
|
"github.com/apricote/releaser-pleaser/internal/updater"
|
|
"github.com/apricote/releaser-pleaser/internal/versioning"
|
|
)
|
|
|
|
const (
|
|
PullRequestBranchFormat = "releaser-pleaser--branches--%s"
|
|
)
|
|
|
|
type ReleaserPleaser struct {
|
|
forge forge.Forge
|
|
logger *slog.Logger
|
|
targetBranch string
|
|
commitParser commitparser.CommitParser
|
|
versioning versioning.Strategy
|
|
extraFiles []string
|
|
updaters []updater.NewUpdater
|
|
}
|
|
|
|
func New(forge forge.Forge, logger *slog.Logger, targetBranch string, commitParser commitparser.CommitParser, versioningStrategy versioning.Strategy, extraFiles []string, updaters []updater.NewUpdater) *ReleaserPleaser {
|
|
return &ReleaserPleaser{
|
|
forge: forge,
|
|
logger: logger,
|
|
targetBranch: targetBranch,
|
|
commitParser: commitParser,
|
|
versioning: versioningStrategy,
|
|
extraFiles: extraFiles,
|
|
updaters: updaters,
|
|
}
|
|
}
|
|
|
|
func (rp *ReleaserPleaser) EnsureLabels(ctx context.Context) error {
|
|
// TODO: Wrap Error
|
|
|
|
return rp.forge.EnsureLabelsExist(ctx, releasepr.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, releasepr.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 *releasepr.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
|
|
}
|
|
|
|
changelogText, err := pr.ChangelogText()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// TODO: Check if version should be marked latest
|
|
|
|
logger.DebugContext(ctx, "Creating release on forge")
|
|
err = rp.forge.CreateRelease(ctx, *pr.ReleaseCommit, version, changelogText, rp.versioning.IsPrerelease(version), 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, []releasepr.Label{releasepr.LabelReleasePending}, []releasepr.Label{releasepr.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")
|
|
|
|
rpBranch := fmt.Sprintf(PullRequestBranchFormat, rp.targetBranch)
|
|
|
|
pr, err := rp.forge.PullRequestForBranch(ctx, rpBranch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var releaseOverrides releasepr.ReleaseOverrides
|
|
|
|
if pr != nil {
|
|
logger = logger.With("pr.id", pr.ID, "pr.title", pr.Title)
|
|
logger.InfoContext(ctx, "found existing release pull request")
|
|
|
|
releaseOverrides, err = pr.GetOverrides()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// For stable releases, we want to consider all changes since the last stable release for version and changelog.
|
|
// For prereleases, we want to consider all changes...
|
|
// - since the last stable release for the version
|
|
// - since the latest release (stable or prerelease) for the changelog
|
|
analyzedCommitsForVersioning, err := rp.analyzedCommitsSince(ctx, releases.Stable)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(analyzedCommitsForVersioning) == 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
|
|
}
|
|
|
|
versionBump := versioning.BumpFromCommits(analyzedCommitsForVersioning)
|
|
// TODO: Set version in release pr
|
|
nextVersion, err := rp.versioning.NextVersion(releases, versionBump, releaseOverrides.NextVersionType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.InfoContext(ctx, "next version", "version", nextVersion)
|
|
|
|
analyzedCommitsForChangelog := analyzedCommitsForVersioning
|
|
if releaseOverrides.NextVersionType.IsPrerelease() && releases.Latest != releases.Stable {
|
|
analyzedCommitsForChangelog, err = rp.analyzedCommitsSince(ctx, releases.Latest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
logger.DebugContext(ctx, "cloning repository", "clone.url", rp.forge.CloneURL())
|
|
repo, err := git.CloneRepo(ctx, logger, rp.forge.CloneURL(), rp.targetBranch, rp.forge.GitAuth())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to clone repository: %w", err)
|
|
}
|
|
|
|
if err = repo.DeleteBranch(ctx, rpBranch); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = repo.Checkout(ctx, rpBranch); err != nil {
|
|
return err
|
|
}
|
|
|
|
changelogData := changelog.New(commitparser.ByType(analyzedCommitsForChangelog), nextVersion, rp.forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix)
|
|
|
|
changelogEntry, err := changelog.Entry(logger, changelog.DefaultTemplate(), changelogData, changelog.Formatting{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build changelog entry: %w", err)
|
|
}
|
|
|
|
// Info for updaters
|
|
info := updater.ReleaseInfo{Version: nextVersion, ChangelogEntry: changelogEntry}
|
|
|
|
err = repo.UpdateFile(ctx, updater.ChangelogFile, true, updater.WithInfo(info, updater.Changelog))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update changelog file: %w", err)
|
|
}
|
|
|
|
for _, path := range rp.extraFiles {
|
|
// TODO: Check for missing files
|
|
err = repo.UpdateFile(ctx, path, false, updater.WithInfo(info, rp.updaters...))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to run file updater: %w", err)
|
|
}
|
|
}
|
|
|
|
releaseCommitAuthor, err := rp.forge.CommitAuthor(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get commit author: %w", err)
|
|
}
|
|
|
|
releaseCommitMessage := fmt.Sprintf("chore(%s): release %s", rp.targetBranch, nextVersion)
|
|
releaseCommit, err := repo.Commit(ctx, releaseCommitMessage, releaseCommitAuthor)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to commit changes: %w", err)
|
|
}
|
|
|
|
logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommit.Hash, "commit.message", releaseCommit.Message, "commit.author", releaseCommitAuthor)
|
|
|
|
// Check if anything changed in comparison to the remote branch (if exists)
|
|
newReleasePRChanges, err := repo.HasChangesWithRemote(ctx, rpBranch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if newReleasePRChanges {
|
|
err = repo.ForcePush(ctx, rpBranch)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to push branch: %w", err)
|
|
}
|
|
|
|
logger.InfoContext(ctx, "pushed branch", "commit.hash", releaseCommit.Hash, "branch.name", rpBranch)
|
|
} else {
|
|
logger.InfoContext(ctx, "file content is already up-to-date in remote branch, skipping push")
|
|
}
|
|
|
|
// We do not need the version title here. In the pull request the version is available from the title, and in the
|
|
// release on the Forge its usually in a heading somewhere above the text.
|
|
changelogEntryPullRequest, err := changelog.Entry(logger, changelog.DefaultTemplate(), changelogData, changelog.Formatting{HideVersionTitle: true})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build pull request changelog entry: %w", err)
|
|
}
|
|
|
|
// Open/Update PR
|
|
if pr == nil {
|
|
pr, err = releasepr.NewReleasePullRequest(rpBranch, rp.targetBranch, nextVersion, changelogEntryPullRequest)
|
|
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, "pr.url", rp.forge.PullRequestURL(pr.ID))
|
|
} else {
|
|
pr.SetTitle(rp.targetBranch, nextVersion)
|
|
|
|
overrides, err := pr.GetOverrides()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = pr.SetDescription(changelogEntryPullRequest, 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, "pr.url", rp.forge.PullRequestURL(pr.ID))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (rp *ReleaserPleaser) analyzedCommitsSince(ctx context.Context, since *git.Tag) ([]commitparser.AnalyzedCommit, error) {
|
|
logger := rp.logger.With("method", "analyzedCommitsSince", "tag.hash", since.Hash, "tag.name", since.Name)
|
|
|
|
commits, err := rp.forge.CommitsSince(ctx, since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
commits, err = parsePRBodyForCommitOverrides(commits)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
logger.InfoContext(ctx, "Found releasable commits", "length", len(commits))
|
|
|
|
analyzedCommits, err := rp.commitParser.Analyze(commits)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
logger.InfoContext(ctx, "Analyzed commits", "length", len(analyzedCommits))
|
|
|
|
return analyzedCommits, nil
|
|
}
|