diff --git a/cmd/rp/cmd/root.go b/cmd/rp/cmd/root.go index a0262cf..fc23cea 100644 --- a/cmd/rp/cmd/root.go +++ b/cmd/rp/cmd/root.go @@ -17,11 +17,14 @@ along with this program. If not, see . package cmd import ( + "log/slog" "os" "github.com/spf13/cobra" ) +var logger *slog.Logger + // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "releaser-pleaser", @@ -47,6 +50,10 @@ func Execute() { } func init() { + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + // Here you will define your flags and configuration settings. // Cobra supports persistent flags, which, if defined here, // will be global for your application. diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index 3d6baf7..0ef07a7 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -5,10 +5,7 @@ package cmd import ( "fmt" - "log" - "strings" - "github.com/go-git/go-git/v5" "github.com/spf13/cobra" rp "github.com/apricote/releaser-pleaser" @@ -21,8 +18,10 @@ var runCmd = &cobra.Command{ } var ( - flagForge string - flagRepo string + flagForge string + flagBranch string + flagOwner string + flagRepo string ) func init() { @@ -31,51 +30,60 @@ func init() { // Here you will define your flags and configuration settings. runCmd.PersistentFlags().StringVar(&flagForge, "forge", "", "") + runCmd.PersistentFlags().StringVar(&flagBranch, "branch", "main", "") + runCmd.PersistentFlags().StringVar(&flagOwner, "owner", "", "") runCmd.PersistentFlags().StringVar(&flagRepo, "repo", "", "") } func run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + var f rp.Forge forgeOptions := rp.ForgeOptions{ Repository: flagRepo, + BaseBranch: flagBranch, } switch flagForge { - case "gitlab": - f = rp.NewGitLab(forgeOptions) + //case "gitlab": + //f = rp.NewGitLab(forgeOptions) case "github": - f = rp.NewGitHub(forgeOptions) + f = rp.NewGitHub(logger, &rp.GitHubOptions{ + ForgeOptions: forgeOptions, + Owner: flagOwner, + Repo: flagRepo, + }) } - log.Println("Repo URL: " + f.RepoURL()) - - //repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{ - // URL: .RepoURL(), - // SingleBranch: true, - // Depth: CommitSearchDepth, - //}) - repo, err := git.PlainOpen("~/git/listory") + tag, err := f.LatestTag(ctx) if err != nil { return err } - commits, previousTag, err := rp.ReleasableCommits(repo) + logger.InfoContext(ctx, "Latest Tag", "tag.hash", tag.Hash, "tag.name", tag.Name) + + releaseableCommits, err := f.CommitsSince(ctx, tag) if err != nil { return err } - analyzedCommits, versionBump, err := rp.AnalyzeCommits(commits) + logger.InfoContext(ctx, "Found releasable commits", "length", len(releaseableCommits)) + + changesets, err := f.Changesets(ctx, releaseableCommits) if err != nil { return err } - for _, commit := range analyzedCommits { - title, _, _ := strings.Cut(commit.Message, "\n") - fmt.Printf("%s %s\n", commit.Hash, title) + logger.InfoContext(ctx, "Found changesets", "length", len(changesets)) + + for _, changeset := range changesets { + fmt.Printf("%s %s\n", changeset.Identifier, changeset.URL) + for _, entry := range changeset.ChangelogEntries { + fmt.Printf(" - %s %s\n", entry.Hash, entry.Description) + } } - fmt.Printf("Previous Tag: %s\n", previousTag.Name) - fmt.Printf("Recommended Bump: %v\n", versionBump) + fmt.Printf("Previous Tag: %s\n", tag.Name) return nil } diff --git a/forge.go b/forge.go index 54d64b8..364df34 100644 --- a/forge.go +++ b/forge.go @@ -1,38 +1,231 @@ package rp import ( + "context" "fmt" + "log/slog" + + "github.com/google/go-github/v63/github" ) +type Changeset struct { + URL string + Identifier string + ChangelogEntries []AnalyzedCommit +} + type Forge interface { RepoURL() string + + // LatestTag returns the last tag created on the main branch. If no tag is found, it returns nil. + LatestTag(context.Context) (*Tag, error) + + // CommitsSince returns all commits to main branch after the Tag. The tag can be `nil`, in which case this + // function should return all commits. + CommitsSince(context.Context, *Tag) ([]Commit, error) + + // Changesets looks up the Pull/Merge Requests for each commit, returning its parsed metadata. + Changesets(context.Context, []Commit) ([]Changeset, error) } type ForgeOptions struct { Repository string + BaseBranch string } var _ Forge = &GitHub{} -var _ Forge = &GitLab{} + +// var _ Forge = &GitLab{} type GitHub struct { - options ForgeOptions + options *GitHubOptions + + client *github.Client + log *slog.Logger } func (g *GitHub) RepoURL() string { return fmt.Sprintf("https://github.com/%s", g.options.Repository) } -func (g *GitHub) autodiscover() { - // Read settings from GitHub Actions env vars -} - -func NewGitHub(options ForgeOptions) *GitHub { - gh := &GitHub{ - options: options, +func (g *GitHub) LatestTag(ctx context.Context) (*Tag, error) { + g.log.Debug("listing all tags in github repository") + // We only get the first page because the latest tag is returned as the first item + tags, _, err := g.client.Repositories.ListTags(ctx, g.options.Owner, g.options.Repo, nil) + if err != nil { + return nil, err } - gh.autodiscover() + if len(tags) > 0 { + // TODO: Is tags sorted? + tag := tags[0] + return &Tag{ + Hash: tag.GetCommit().GetSHA(), + Name: tag.GetName(), + }, nil + } + + return nil, nil +} + +func (g *GitHub) CommitsSince(ctx context.Context, tag *Tag) ([]Commit, error) { + var repositoryCommits []*github.RepositoryCommit + var err error + if tag != nil { + repositoryCommits, err = g.commitsSinceTag(ctx, tag) + } else { + return nil, fmt.Errorf("not implemented") + } + + if err != nil { + return nil, err + } + + var commits = make([]Commit, 0, len(repositoryCommits)) + for _, ghCommit := range repositoryCommits { + commits = append(commits, Commit{ + Hash: ghCommit.GetSHA(), + Message: ghCommit.GetCommit().GetMessage(), + }) + } + + return commits, nil +} + +func (g *GitHub) commitsSinceTag(ctx context.Context, tag *Tag) ([]*github.RepositoryCommit, error) { + head := g.options.BaseBranch + log := g.log.With("base", tag.Hash, "head", head) + log.Debug("comparing commits", "base", tag.Hash, "head", head) + + page := 1 + + var repositoryCommits []*github.RepositoryCommit + for { + log.Debug("fetching page", "page", page) + comparison, resp, err := g.client.Repositories.CompareCommits( + ctx, g.options.Owner, g.options.Repo, + tag.Hash, head, &github.ListOptions{ + Page: page, + PerPage: 100, + }) + if err != nil { + return nil, err + } + + if repositoryCommits == nil { + // Pre-initialize slice on first request + log.Debug("found commits", "length", comparison.GetTotalCommits()) + repositoryCommits = make([]*github.RepositoryCommit, 0, comparison.GetTotalCommits()) + } + + repositoryCommits = append(repositoryCommits, comparison.Commits...) + + if page == resp.LastPage || resp.LastPage == 0 { + break + } + + page = resp.NextPage + } + + return repositoryCommits, nil +} + +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. + + changesets := make([]Changeset, 0, len(commits)) + + 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: 100, + }) + 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 := AnalyzeCommits([]Commit{commit}) + if err != nil { + log.Warn("unable to parse changelog entries", "error", err) + continue + } + + if len(changelogEntries) > 0 { + changesets = append(changesets, Changeset{ + URL: pullrequest.GetHTMLURL(), + Identifier: fmt.Sprintf("#%d", pullrequest.GetNumber()), + ChangelogEntries: changelogEntries, + }) + } + } + + return changesets, nil +} + +func (g *GitHubOptions) autodiscover() { + // TODO: Read settings from GitHub Actions env vars +} + +type GitHubOptions struct { + ForgeOptions + + Owner string + Repo string + + APIToken string +} + +func NewGitHub(log *slog.Logger, options *GitHubOptions) *GitHub { + options.autodiscover() + + client := github.NewClient(nil) + if options.APIToken != "" { + client = client.WithAuthToken(options.APIToken) + } + + gh := &GitHub{ + options: options, + + client: client, + log: log.With("forge", "github"), + } return gh } diff --git a/go.mod b/go.mod index 2ee14e9..1457526 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.22.4 require ( github.com/go-git/go-billy/v5 v5.5.0 github.com/go-git/go-git/v5 v5.12.0 + github.com/leodido/go-conventionalcommits v0.12.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 ) @@ -19,12 +20,14 @@ 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/leodido/go-conventionalcommits v0.12.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 731977e..9627246 100644 --- a/go.sum +++ b/go.sum @@ -35,8 +35,13 @@ github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZt github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v63 v63.0.0 h1:13xwK/wk9alSokujB9lJkuzdmQuVn2QCPeck76wR3nE= +github.com/google/go-github/v63 v63.0.0/go.mod h1:IqbcrgUmIcEaioWrGYei/09o+ge5vhffGOcxrO0AfmA= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -63,6 +68,8 @@ 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= @@ -146,6 +153,7 @@ 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= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=