From c7743e0a80119dbba4577624d53f55301bce1c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Thu, 1 Aug 2024 23:00:56 +0200 Subject: [PATCH] feat: push branch with changelog --- changelog.go | 16 ++++--- cmd/rp/cmd/run.go | 109 +++++++++++++++++++++++++++++++++++++++++----- commits.go | 16 ++++--- forge.go | 53 +++++++++++++++++++--- git.go | 27 ++++++++++++ go.mod | 5 ++- go.sum | 5 ++- releasepr.go | 17 ++++++++ updater.go | 12 +++++ versioning.go | 89 +++++++++++++++++++++++++++++++++++++ 10 files changed, 313 insertions(+), 36 deletions(-) create mode 100644 updater.go create mode 100644 versioning.go diff --git a/changelog.go b/changelog.go index 6074f15..7ba61c6 100644 --- a/changelog.go +++ b/changelog.go @@ -90,16 +90,18 @@ func UpdateChangelogFile(wt *git.Worktree, newEntry string) error { return nil } -func NewChangelogEntry(commits []AnalyzedCommit, version, link string) (string, error) { +func NewChangelogEntry(changesets []Changeset, version, link 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/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index 37bbafd..85ea1d6 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -7,6 +7,9 @@ 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" @@ -61,12 +64,12 @@ func run(cmd *cobra.Command, args []string) error { }) } - changesets, err := getChangesetsFromForge(ctx, f) + changesets, tag, err := getChangesetsFromForge(ctx, f) if err != nil { return fmt.Errorf("failed to get changesets: %w", err) } - err = reconcileReleasePR(ctx, f, changesets) + err = reconcileReleasePR(ctx, f, changesets, tag) if err != nil { return fmt.Errorf("failed to reconcile release pr: %w", err) } @@ -74,32 +77,38 @@ func run(cmd *cobra.Command, args []string) error { return nil } -func getChangesetsFromForge(ctx context.Context, forge rp.Forge) ([]rp.Changeset, error) { +func getChangesetsFromForge(ctx context.Context, forge rp.Forge) ([]rp.Changeset, *rp.Tag, error) { tag, err := forge.LatestTag(ctx) if err != nil { - return nil, err + return nil, nil, err } - logger.InfoContext(ctx, "Latest Tag", "tag.hash", tag.Hash, "tag.name", tag.Name) + if tag != nil { + logger.InfoContext(ctx, "found previous tag", "tag.hash", tag.Hash, "tag.name", tag.Name) + } else { + logger.InfoContext(ctx, "no previous tag found") + } releasableCommits, err := forge.CommitsSince(ctx, tag) if err != nil { - return nil, err + return nil, nil, err } logger.InfoContext(ctx, "Found releasable commits", "length", len(releasableCommits)) changesets, err := forge.Changesets(ctx, releasableCommits) if err != nil { - return nil, err + return nil, nil, err } logger.InfoContext(ctx, "Found changesets", "length", len(changesets)) - return changesets, nil + return changesets, tag, nil } -func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Changeset) error { +func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Changeset, tag *rp.Tag) 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 @@ -114,12 +123,90 @@ func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Cha logger.InfoContext(ctx, "found existing release pull request: %d: %s", pr.ID, pr.Title) } - releaseOverrides, err := pr.GetOverrides() + var releaseOverrides rp.ReleaseOverrides + if pr != nil { + releaseOverrides, err = pr.GetOverrides() + if err != nil { + return err + } + } + + nextVersion, err := rp.NextVersion(tag, changesets, 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 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 err + } + + err = rp.RunUpdater(ctx, nextVersion, worktree) + if err != nil { + return err + } + + changelogEntry, err := rp.NewChangelogEntry(changesets, nextVersion, forge.ReleaseURL(nextVersion)) + if err != nil { + return err + } + + err = rp.UpdateChangelogFile(worktree, changelogEntry) + if err != nil { + return err + } + + releaseCommitMessage := fmt.Sprintf("chore(%s): release %s", flagBranch, nextVersion) + releaseCommit, err := worktree.Commit(releaseCommitMessage, &git.CommitOptions{}) + if err != nil { + return err + } + + logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommit.String(), "commit.message", releaseCommitMessage) + + // TODO: Check if there is a diff between forge/rpBranch..rpBranch..forge/rpBranch and only push if there are changes + // To reduce wasted CI cycles + + 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", releaseCommit.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 err + } + + logger.InfoContext(ctx, "pushed branch", "commit.hash", releaseCommit.String(), "branch.name", rpBranch, "refspec", pushRefSpec.String()) + + // TODO Open PR return nil } diff --git a/commits.go b/commits.go index 99c76a2..565deb7 100644 --- a/commits.go +++ b/commits.go @@ -9,9 +9,10 @@ import ( type AnalyzedCommit struct { Commit - Type string - Description string - Scope *string + Type string + Description string + Scope *string + BreakingChange bool } func AnalyzeCommits(commits []Commit) ([]AnalyzedCommit, conventionalcommits.VersionBump, error) { @@ -38,10 +39,11 @@ func AnalyzeCommits(commits []Commit) ([]AnalyzedCommit, conventionalcommits.Ver if commitVersionBump > conventionalcommits.UnknownVersion { // We only care about releasable commits analyzedCommits = append(analyzedCommits, AnalyzedCommit{ - Commit: commit, - Type: conventionalCommit.Type, - Description: conventionalCommit.Description, - Scope: conventionalCommit.Scope, + Commit: commit, + Type: conventionalCommit.Type, + Description: conventionalCommit.Description, + Scope: conventionalCommit.Scope, + BreakingChange: conventionalCommit.IsBreakingChange(), }) } diff --git a/forge.go b/forge.go index 9b839e9..8d99b23 100644 --- a/forge.go +++ b/forge.go @@ -2,15 +2,21 @@ package rp import ( "context" + "errors" "fmt" "log/slog" + "os" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/google/go-github/v63/github" ) const ( - GITHUB_PER_PAGE_MAX = 100 - GITHUB_PR_STATE_OPEN = "open" + GitHubPerPageMax = 100 + GitHubPRStateOpen = "open" + GitHubEnvAPIToken = "GITHUB_TOKEN" + GitHubEnvUsername = "GITHUB_USER" ) type Changeset struct { @@ -21,6 +27,10 @@ type Changeset struct { type Forge interface { RepoURL() string + CloneURL() string + ReleaseURL(version string) string + + GitAuth() transport.AuthMethod // LatestTag returns the last tag created on the main branch. If no tag is found, it returns nil. LatestTag(context.Context) (*Tag, error) @@ -54,7 +64,22 @@ type GitHub struct { } func (g *GitHub) RepoURL() string { - return fmt.Sprintf("https://github.com/%s", g.options.Repository) + return fmt.Sprintf("https://github.com/%s/%s", g.options.Owner, g.options.Repo) +} + +func (g *GitHub) CloneURL() string { + return fmt.Sprintf("https://github.com/%s/%s.git", g.options.Owner, g.options.Repo) +} + +func (g *GitHub) ReleaseURL(version string) string { + return fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", g.options.Owner, g.options.Repo, version) +} + +func (g *GitHub) GitAuth() transport.AuthMethod { + return &http.BasicAuth{ + Username: g.options.Username, + Password: g.options.APIToken, + } } func (g *GitHub) LatestTag(ctx context.Context) (*Tag, error) { @@ -115,7 +140,7 @@ func (g *GitHub) commitsSinceTag(ctx context.Context, tag *Tag) ([]*github.Repos ctx, g.options.Owner, g.options.Repo, tag.Hash, head, &github.ListOptions{ Page: page, - PerPage: GITHUB_PER_PAGE_MAX, + PerPage: GitHubPerPageMax, }) if err != nil { return nil, err @@ -158,7 +183,7 @@ func (g *GitHub) Changesets(ctx context.Context, commits []Commit) ([]Changeset, ctx, g.options.Owner, g.options.Repo, commit.Hash, &github.ListOptions{ Page: page, - PerPage: GITHUB_PER_PAGE_MAX, + PerPage: GitHubPerPageMax, }) if err != nil { return nil, err @@ -214,14 +239,20 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*Rele for { prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit(ctx, g.options.Owner, g.options.Repo, branch, &github.ListOptions{ Page: page, - PerPage: GITHUB_PER_PAGE_MAX, + PerPage: GitHubPerPageMax, }) if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + if ghErr.Message == fmt.Sprintf("No commit found for SHA: %s", branch) { + return nil, nil + } + } return nil, err } for _, pr := range prs { - if pr.Base.GetLabel() == g.options.BaseBranch && pr.Head.GetLabel() == branch && pr.GetState() == GITHUB_PR_STATE_OPEN { + if pr.Base.GetLabel() == g.options.BaseBranch && pr.Head.GetLabel() == branch && pr.GetState() == GitHubPRStateOpen { labels := make([]string, 0, len(pr.Labels)) for _, label := range pr.Labels { labels = append(labels, label.GetName()) @@ -246,6 +277,13 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*Rele } func (g *GitHubOptions) autodiscover() { + if apiToken := os.Getenv(GitHubEnvAPIToken); apiToken != "" { + g.APIToken = apiToken + } + // TODO: Check if there is a better solution for cloning/pushing locally + if username := os.Getenv(GitHubEnvUsername); username != "" { + g.Username = username + } // TODO: Read settings from GitHub Actions env vars } @@ -256,6 +294,7 @@ type GitHubOptions struct { Repo string APIToken string + Username string } func NewGitHub(log *slog.Logger, options *GitHubOptions) *GitHub { diff --git a/git.go b/git.go index ae97cdb..61bc59c 100644 --- a/git.go +++ b/git.go @@ -1,17 +1,22 @@ package rp import ( + "context" "errors" + "fmt" "io" + "os" "slices" "strings" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/transport" ) const ( CommitSearchDepth = 50 // TODO: Increase + GitRemoteName = "origin" ) type Commit struct { @@ -24,6 +29,28 @@ type Tag struct { Name string } +func CloneRepo(ctx context.Context, cloneURL, branch string, auth transport.AuthMethod) (*git.Repository, error) { + dir, err := os.MkdirTemp("", "releaser-pleaser.*") + if err != nil { + return nil, fmt.Errorf("failed to create temporary directory for repo clone: %w", err) + } + + // TODO: Log tmpdir + fmt.Printf("Clone tmpdir: %s\n", dir) + repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{ + URL: cloneURL, + RemoteName: GitRemoteName, + ReferenceName: plumbing.NewBranchReferenceName(branch), + SingleBranch: false, + Auth: auth, + }) + if err != nil { + return nil, fmt.Errorf("failed to clone repository: %w", err) + } + + return repo, nil +} + func ReleasableCommits(repo *git.Repository) ([]Commit, *Tag, error) { ref, err := repo.Head() diff --git a/go.mod b/go.mod index 1457526..aacb703 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,14 @@ module github.com/apricote/releaser-pleaser go 1.22.4 require ( + github.com/blang/semver/v4 v4.0.0 github.com/go-git/go-billy/v5 v5.5.0 github.com/go-git/go-git/v5 v5.12.0 + github.com/google/go-github/v63 v63.0.0 github.com/leodido/go-conventionalcommits v0.12.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 + github.com/yuin/goldmark v1.4.13 ) require ( @@ -20,14 +23,12 @@ require ( github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/google/go-github/v63 v63.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rwtodd/Go.Sed v0.0.0-20230610052213-ba3e9c186f0a // 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.2.2 // indirect diff --git a/go.sum b/go.sum index 9627246..f60c998 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +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.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= @@ -68,8 +70,6 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/rwtodd/Go.Sed v0.0.0-20230610052213-ba3e9c186f0a h1:URwYffGNuBQkfwkcn+1CZhb8IE/mKSXxPXp/zzQsn80= -github.com/rwtodd/Go.Sed v0.0.0-20230610052213-ba3e9c186f0a/go.mod h1:c6qgHcSUeSISur4+Kcf3WYTvpL07S8eAsoP40hDiQ1I= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -89,6 +89,7 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= diff --git a/releasepr.go b/releasepr.go index bd3ecff..7592ca7 100644 --- a/releasepr.go +++ b/releasepr.go @@ -32,6 +32,23 @@ const ( NextVersionTypeAlpha ) +func (n NextVersionType) String() string { + switch n { + case NextVersionTypeUndefined: + return "undefined" + case NextVersionTypeNormal: + return "normal" + case NextVersionTypeRC: + return "rc" + case NextVersionTypeBeta: + return "beta" + case NextVersionTypeAlpha: + return "alpha" + default: + return "" + } +} + // PR Labels const ( LabelNextVersionTypeNormal = "rp-next-version::normal" diff --git a/updater.go b/updater.go new file mode 100644 index 0000000..ce01d21 --- /dev/null +++ b/updater.go @@ -0,0 +1,12 @@ +package rp + +import ( + "context" + + "github.com/go-git/go-git/v5" +) + +func RunUpdater(ctx context.Context, version string, worktree *git.Worktree) error { + // TODO: Implement updater for Go,Python,ExtraFilesMarkers + return nil +} diff --git a/versioning.go b/versioning.go new file mode 100644 index 0000000..93cd2c7 --- /dev/null +++ b/versioning.go @@ -0,0 +1,89 @@ +package rp + +import ( + "fmt" + "strings" + + "github.com/blang/semver/v4" + "github.com/leodido/go-conventionalcommits" +) + +func NextVersion(currentTag *Tag, changesets []Changeset, nextVersionType NextVersionType) (string, error) { + // TODO: Validate for versioning after pre-releases + currentVersion := "v0.0.0" + if currentTag != nil { + currentVersion = currentTag.Name + } + + // The lib can not handle v prefixes + currentVersion = strings.TrimPrefix(currentVersion, "v") + + version, err := semver.Parse(currentVersion) + if err != nil { + return "", err + } + + versionBump := maxVersionBump(changesets) + switch versionBump { + case conventionalcommits.UnknownVersion: + // No new version, TODO: Throw error? + case conventionalcommits.PatchVersion: + err = version.IncrementPatch() + case conventionalcommits.MinorVersion: + err = version.IncrementMinor() + case conventionalcommits.MajorVersion: + err = version.IncrementMajor() + } + if err != nil { + return "", err + } + + switch nextVersionType { + case NextVersionTypeAlpha, NextVersionTypeBeta, NextVersionTypeRC: + id := uint64(0) + + if version.Pre[0].String() == nextVersionType.String() { + if version.Pre[1].String() == "" || !version.Pre[1].IsNumeric() { + return "", fmt.Errorf("invalid format of previous tag") + } + id = version.Pre[1].VersionNum + 1 + } + + setPRVersion(&version, nextVersionType.String(), id) + case NextVersionTypeUndefined, NextVersionTypeNormal: + version.Pre = make([]semver.PRVersion, 0) + } + + return "v" + version.String(), nil +} + +func maxVersionBump(changesets []Changeset) conventionalcommits.VersionBump { + bump := conventionalcommits.UnknownVersion + + 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 + } + } + } + + return bump +} + +func setPRVersion(version *semver.Version, prType string, count uint64) { + version.Pre = []semver.PRVersion{ + {VersionStr: prType}, + {VersionNum: count, IsNum: true}, + } +}