feat: push branch with changelog

This commit is contained in:
Julian Tölle 2024-08-01 23:00:56 +02:00
parent 8199918903
commit c7743e0a80
10 changed files with 313 additions and 36 deletions

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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(),
})
}

View file

@ -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 {

27
git.go
View file

@ -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()

5
go.mod
View file

@ -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

5
go.sum
View file

@ -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=

View file

@ -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"

12
updater.go Normal file
View file

@ -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
}

89
versioning.go Normal file
View file

@ -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},
}
}