mirror of
https://github.com/apricote/releaser-pleaser.git
synced 2026-01-13 21:21:03 +00:00
feat: tag releases on merged prs
This commit is contained in:
parent
fe871a0213
commit
6120821631
5 changed files with 337 additions and 33 deletions
|
|
@ -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()
|
||||
|
|
|
|||
184
forge.go
184
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
|
||||
}
|
||||
|
|
|
|||
71
releasepr.go
71
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
---
|
||||
|
||||
<!-- section-start changelog -->
|
||||
{{ .Changelog }}
|
||||
<!-- section-end changelog -->
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue