From 6120821631a3ac2b5072cfb5cd127b8da98fd93e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sun, 4 Aug 2024 21:22:22 +0200 Subject: [PATCH] feat: tag releases on merged prs --- cmd/rp/cmd/run.go | 93 ++++++++++++++++++++++- forge.go | 184 +++++++++++++++++++++++++++++++++++++++------- releasepr.go | 71 +++++++++++++++++- releasepr.md.tpl | 4 + releasepr_test.go | 18 ++++- 5 files changed, 337 insertions(+), 33 deletions(-) diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index 0170306..645580e 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -40,9 +40,16 @@ func init() { runCmd.PersistentFlags().StringVar(&flagRepo, "repo", "", "") } -func run(cmd *cobra.Command, args []string) error { +func run(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() + logger.DebugContext(ctx, "run called", + "forge", flagForge, + "branch", flagBranch, + "owner", flagOwner, + "repo", flagRepo, + ) + var f rp.Forge forgeOptions := rp.ForgeOptions{ @@ -54,6 +61,7 @@ func run(cmd *cobra.Command, args []string) error { //case "gitlab": //f = rp.NewGitLab(forgeOptions) case "github": + logger.DebugContext(ctx, "using forge GitHub") f = rp.NewGitHub(logger, &rp.GitHubOptions{ ForgeOptions: forgeOptions, Owner: flagOwner, @@ -61,6 +69,11 @@ func run(cmd *cobra.Command, args []string) error { }) } + 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) @@ -74,6 +87,70 @@ func run(cmd *cobra.Command, args []string) error { return nil } +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 { @@ -123,6 +200,20 @@ func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Cha 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() diff --git a/forge.go b/forge.go index 853133c..dd443c4 100644 --- a/forge.go +++ b/forge.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "os" + "slices" "strings" "github.com/blang/semver/v4" @@ -15,10 +16,11 @@ import ( ) const ( - GitHubPerPageMax = 100 - GitHubPRStateOpen = "open" - GitHubEnvAPIToken = "GITHUB_TOKEN" - GitHubEnvUsername = "GITHUB_USER" + GitHubPerPageMax = 100 + GitHubPRStateOpen = "open" + GitHubPRStateClosed = "closed" + GitHubEnvAPIToken = "GITHUB_TOKEN" + GitHubEnvUsername = "GITHUB_USER" ) type Changeset struct { @@ -51,6 +53,12 @@ type Forge interface { CreatePullRequest(context.Context, *ReleasePullRequest) error UpdatePullRequest(context.Context, *ReleasePullRequest) error + SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []string) error + ClosePullRequest(context.Context, *ReleasePullRequest) error + + PendingReleases(context.Context) ([]*ReleasePullRequest, error) + + CreateRelease(ctx context.Context, commit Commit, title, changelog string, prelease, latest bool) error } type ForgeOptions struct { @@ -334,18 +342,7 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*Rele for _, pr := range prs { if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == GitHubPRStateOpen { - labels := make([]string, 0, len(pr.Labels)) - for _, label := range pr.Labels { - labels = append(labels, label.GetName()) - } - - return &ReleasePullRequest{ - ID: pr.GetNumber(), - Title: pr.GetTitle(), - Description: pr.GetBody(), - Labels: labels, - Head: pr.GetHead().GetRef(), - }, nil + return gitHubPRToReleasePullRequest(pr), nil } } @@ -359,7 +356,6 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*Rele } func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest) error { - // TODO: Labels ghPR, _, err := g.client.PullRequests.Create( ctx, g.options.Owner, g.options.Repo, &github.NewPullRequest{ @@ -373,24 +369,21 @@ func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest) return err } - _, _, err = g.client.Issues.AddLabelsToIssue( - ctx, g.options.Owner, g.options.Repo, - ghPR.GetNumber(), pr.Labels, - ) + // TODO: String ID? + pr.ID = ghPR.GetNumber() + + err = g.SetPullRequestLabels(ctx, pr, []string{}, pr.Labels) if err != nil { return err } - // TODO: String ID? - pr.ID = ghPR.GetNumber() - return nil } func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *ReleasePullRequest) error { _, _, err := g.client.PullRequests.Edit( - ctx, g.options.Owner, g.options.Repo, pr.ID, - &github.PullRequest{ + ctx, g.options.Owner, g.options.Repo, + pr.ID, &github.PullRequest{ Title: &pr.Title, Body: &pr.Description, }, @@ -402,6 +395,141 @@ func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *ReleasePullRequest) return nil } +func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []string) error { + for _, label := range remove { + _, err := g.client.Issues.RemoveLabelForIssue( + ctx, g.options.Owner, g.options.Repo, + pr.ID, label, + ) + if err != nil { + return err + } + } + + _, _, err := g.client.Issues.AddLabelsToIssue( + ctx, g.options.Owner, g.options.Repo, + pr.ID, add, + ) + if err != nil { + return err + } + + return nil +} + +func (g *GitHub) ClosePullRequest(ctx context.Context, pr *ReleasePullRequest) error { + _, _, err := g.client.PullRequests.Edit( + ctx, g.options.Owner, g.options.Repo, + pr.ID, &github.PullRequest{ + State: Pointer(GitHubPRStateClosed), + }, + ) + if err != nil { + return err + } + + return nil +} + +func (g *GitHub) PendingReleases(ctx context.Context) ([]*ReleasePullRequest, error) { + page := 1 + + var prs []*ReleasePullRequest + + for { + ghPRs, resp, err := g.client.PullRequests.List( + ctx, g.options.Owner, g.options.Repo, + &github.PullRequestListOptions{ + State: GitHubPRStateClosed, + Base: g.options.BaseBranch, + ListOptions: github.ListOptions{ + Page: page, + PerPage: GitHubPerPageMax, + }, + }) + if err != nil { + return nil, err + } + + if prs == nil && resp.LastPage > 0 { + // Pre-initialize slice on first request + g.log.Debug("found pending releases", "pages", resp.LastPage) + prs = make([]*ReleasePullRequest, 0, (resp.LastPage-1)*GitHubPerPageMax) + } + + for _, pr := range ghPRs { + pending := slices.ContainsFunc(pr.Labels, func(l *github.Label) bool { + return l.GetName() == LabelReleasePending + }) + if !pending { + continue + } + + // pr.Merged is always nil :( + if pr.MergedAt == nil { + // Closed and not merged + continue + } + + prs = append(prs, gitHubPRToReleasePullRequest(pr)) + } + + if page == resp.LastPage || resp.LastPage == 0 { + break + } + page = resp.NextPage + } + + return prs, nil +} + +func (g *GitHub) CreateRelease(ctx context.Context, commit Commit, title, changelog string, preRelease, latest bool) error { + makeLatest := "" + if latest { + makeLatest = "true" + } else { + makeLatest = "false" + } + _, _, err := g.client.Repositories.CreateRelease( + ctx, g.options.Owner, g.options.Repo, + &github.RepositoryRelease{ + TagName: &title, + TargetCommitish: &commit.Hash, + Name: &title, + Body: &changelog, + Prerelease: &preRelease, + MakeLatest: &makeLatest, + }, + ) + if err != nil { + return err + } + + return nil +} + +func gitHubPRToReleasePullRequest(pr *github.PullRequest) *ReleasePullRequest { + labels := make([]string, 0, len(pr.Labels)) + for _, label := range pr.Labels { + labels = append(labels, label.GetName()) + } + + var releaseCommit *Commit + if pr.MergeCommitSHA != nil { + releaseCommit = &Commit{Hash: pr.GetMergeCommitSHA()} + } + + return &ReleasePullRequest{ + ID: pr.GetNumber(), + Title: pr.GetTitle(), + Description: pr.GetBody(), + Labels: labels, + + Head: pr.GetHead().GetRef(), + ReleaseCommit: releaseCommit, + } +} + func (g *GitHubOptions) autodiscover() { if apiToken := os.Getenv(GitHubEnvAPIToken); apiToken != "" { g.APIToken = apiToken @@ -462,3 +590,7 @@ func NewGitLab(options ForgeOptions) *GitLab { func (g *GitLab) RepoURL() string { return fmt.Sprintf("https://gitlab.com/%s", g.options.Repository) } + +func Pointer[T any](value T) *T { + return &value +} diff --git a/releasepr.go b/releasepr.go index 933e23f..d87e785 100644 --- a/releasepr.go +++ b/releasepr.go @@ -5,6 +5,7 @@ import ( _ "embed" "fmt" "log" + "regexp" "text/template" "github.com/yuin/goldmark/ast" @@ -35,7 +36,8 @@ type ReleasePullRequest struct { Description string Labels []string - Head string + Head string + ReleaseCommit *Commit } func NewReleasePullRequest(head, branch, version, changelogEntry string) (*ReleasePullRequest, error) { @@ -104,6 +106,15 @@ const ( const ( MarkdownSectionOverrides = "overrides" + MarkdownSectionChangelog = "changelog" +) + +const ( + TitleFormat = "chore(%s): release %s" +) + +var ( + TitleRegex = regexp.MustCompile("chore(.*): release (.*)") ) func (pr *ReleasePullRequest) GetOverrides() (ReleaseOverrides, error) { @@ -169,7 +180,7 @@ func (pr *ReleasePullRequest) parseDescription(overrides ReleaseOverrides) (Rele return overrides, nil } -func (pr *ReleasePullRequest) getCurrentOverridesText() (string, error) { +func (pr *ReleasePullRequest) overridesText() (string, error) { source := []byte(pr.Description) gm := markdown.New() descriptionAST := gm.Parser().Parse(text.NewReader(source)) @@ -214,6 +225,51 @@ func (pr *ReleasePullRequest) getCurrentOverridesText() (string, error) { return outputBuffer.String(), nil } +func (pr *ReleasePullRequest) ChangelogText() (string, error) { + source := []byte(pr.Description) + gm := markdown.New() + descriptionAST := gm.Parser().Parse(text.NewReader(source)) + + var section *east.Section + + err := ast.Walk(descriptionAST, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + if n.Type() != ast.TypeBlock || n.Kind() != east.KindSection { + return ast.WalkContinue, nil + } + + anySection, ok := n.(*east.Section) + if !ok { + return ast.WalkStop, fmt.Errorf("node has unexpected type: %T", n) + } + + if anySection.Name != MarkdownSectionChangelog { + return ast.WalkContinue, nil + } + + section = anySection + return ast.WalkStop, nil + }) + if err != nil { + return "", err + } + + if section == nil { + return "", nil + } + + outputBuffer := new(bytes.Buffer) + err = gm.Renderer().Render(outputBuffer, source, section) + if err != nil { + return "", err + } + + return outputBuffer.String(), nil +} + func textFromLines(source []byte, n ast.Node) string { content := make([]byte, 0) @@ -230,8 +286,17 @@ func (pr *ReleasePullRequest) SetTitle(branch, version string) { pr.Title = fmt.Sprintf("chore(%s): release %s", branch, version) } +func (pr *ReleasePullRequest) Version() (string, error) { + matches := TitleRegex.FindStringSubmatch(pr.Title) + if len(matches) != 3 { + return "", fmt.Errorf("title has unexpected format") + } + + return matches[2], nil +} + func (pr *ReleasePullRequest) SetDescription(changelogEntry string) error { - overrides, err := pr.getCurrentOverridesText() + overrides, err := pr.overridesText() if err != nil { return err } diff --git a/releasepr.md.tpl b/releasepr.md.tpl index 03b4bc5..e48872b 100644 --- a/releasepr.md.tpl +++ b/releasepr.md.tpl @@ -1,4 +1,8 @@ +--- + + {{ .Changelog }} + --- diff --git a/releasepr_test.go b/releasepr_test.go index 4bbdf21..124b063 100644 --- a/releasepr_test.go +++ b/releasepr_test.go @@ -58,7 +58,11 @@ func TestReleasePullRequest_SetDescription(t *testing.T) { name: "empty description", pr: &ReleasePullRequest{}, changelogEntry: `## v1.0.0`, - want: `## v1.0.0 + want: `--- + + +## v1.0.0 + --- @@ -87,11 +91,15 @@ func TestReleasePullRequest_SetDescription(t *testing.T) { { name: "existing overrides", pr: &ReleasePullRequest{ - Description: `## v0.1.0 + Description: `--- + + +## v0.1.0 ### Features - bedazzle + --- @@ -117,7 +125,11 @@ This release is awesome! `, }, changelogEntry: `## v1.0.0`, - want: `## v1.0.0 + want: `--- + + +## v1.0.0 + ---