feat: tag releases on merged prs

This commit is contained in:
Julian Tölle 2024-08-04 21:22:22 +02:00
parent fe871a0213
commit 6120821631
5 changed files with 337 additions and 33 deletions

View file

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

176
forge.go
View file

@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"os"
"slices"
"strings"
"github.com/blang/semver/v4"
@ -17,6 +18,7 @@ import (
const (
GitHubPerPageMax = 100
GitHubPRStateOpen = "open"
GitHubPRStateClosed = "closed"
GitHubEnvAPIToken = "GITHUB_TOKEN"
GitHubEnvUsername = "GITHUB_USER"
)
@ -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
}

View file

@ -5,6 +5,7 @@ import (
_ "embed"
"fmt"
"log"
"regexp"
"text/template"
"github.com/yuin/goldmark/ast"
@ -36,6 +37,7 @@ type ReleasePullRequest struct {
Labels []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
}

View file

@ -1,4 +1,8 @@
---
<!-- section-start changelog -->
{{ .Changelog }}
<!-- section-end changelog -->
---

View file

@ -58,7 +58,11 @@ func TestReleasePullRequest_SetDescription(t *testing.T) {
name: "empty description",
pr: &ReleasePullRequest{},
changelogEntry: `## v1.0.0`,
want: `## v1.0.0
want: `---
<!-- section-start changelog -->
## v1.0.0
<!-- section-end changelog -->
---
@ -87,11 +91,15 @@ func TestReleasePullRequest_SetDescription(t *testing.T) {
{
name: "existing overrides",
pr: &ReleasePullRequest{
Description: `## v0.1.0
Description: `---
<!-- section-start changelog -->
## v0.1.0
### Features
- bedazzle
<!-- section-end changelog -->
---
@ -117,7 +125,11 @@ This release is awesome!
`,
},
changelogEntry: `## v1.0.0`,
want: `## v1.0.0
want: `---
<!-- section-start changelog -->
## v1.0.0
<!-- section-end changelog -->
---