1
0
mirror of https://github.com/goreleaser/goreleaser.git synced 2025-07-15 01:34:21 +02:00

feat: support closing milestones (#1657)

* feat: support closing milestones

Reference: https://github.com/goreleaser/goreleaser/issues/1415

* refactor: Adjust milestone handling for code simplification, add ErrNoMilestoneFound, and fix milestone documentation close default

Reference: https://github.com/goreleaser/goreleaser/pull/1657#pullrequestreview-445025743

* refactor: Use single repo config in milestones instead of each VCS

* fix: Ensure milestone Pipe is included in Defaulters

* feat: Add fail_on_error configuration to milestone configuration

Co-authored-by: Radek Simko <radek.simko@gmail.com>
This commit is contained in:
Brian Flad
2020-07-09 16:40:37 -04:00
committed by GitHub
parent 608e4008b6
commit 01fd3e8c7b
19 changed files with 581 additions and 21 deletions

View File

@ -32,6 +32,7 @@ func (r Repo) String() string {
// Client interface.
type Client interface {
CloseMilestone(ctx *context.Context, repo Repo, title string) (err error)
CreateRelease(ctx *context.Context, body string) (releaseID string, err error)
ReleaseURLTemplate(ctx *context.Context) (string, error)
CreateFile(ctx *context.Context, commitAuthor config.CommitAuthor, repo Repo, content []byte, path, message string) (err error)
@ -66,6 +67,15 @@ func NewWithToken(ctx *context.Context, token string) (Client, error) {
return nil, nil
}
// ErrNoMilestoneFound is an error when no milestone is found.
type ErrNoMilestoneFound struct {
Title string
}
func (e ErrNoMilestoneFound) Error() string {
return fmt.Sprintf("no milestone found: %s", e.Title)
}
// RetriableError is an error that will cause the action to be retried.
type RetriableError struct {
Err error

View File

@ -51,6 +51,32 @@ func NewGitea(ctx *context.Context, token string) (Client, error) {
return &giteaClient{client: client}, nil
}
// CloseMilestone closes a given milestone.
func (c *giteaClient) CloseMilestone(ctx *context.Context, repo Repo, title string) error {
milestone, err := c.getMilestoneByTitle(repo, title)
if err != nil {
return err
}
if milestone == nil {
return ErrNoMilestoneFound{Title: title}
}
closedState := string(gitea.StateClosed)
opts := gitea.EditMilestoneOption{
Deadline: milestone.Deadline,
Description: &milestone.Description,
State: &closedState,
Title: milestone.Title,
}
_, err = c.client.EditMilestone(repo.Owner, repo.Name, milestone.ID, opts)
return err
}
// CreateFile creates a file in the repository at a given path
// or updates the file if it exists.
func (c *giteaClient) CreateFile(
@ -193,3 +219,21 @@ func (c *giteaClient) Upload(
}
return nil
}
// getMilestoneByTitle returns a milestone by title.
func (c *giteaClient) getMilestoneByTitle(repo Repo, title string) (*gitea.Milestone, error) {
// The Gitea API/SDK does not provide lookup by title functionality currently.
milestones, err := c.client.ListRepoMilestones(repo.Owner, repo.Name, gitea.ListMilestoneOption{})
if err != nil {
return nil, err
}
for _, milestone := range milestones {
if milestone.Title == title {
return milestone, nil
}
}
return nil, nil
}

View File

@ -56,6 +56,32 @@ func NewGitHub(ctx *context.Context, token string) (Client, error) {
return &githubClient{client: client}, nil
}
// CloseMilestone closes a given milestone.
func (c *githubClient) CloseMilestone(ctx *context.Context, repo Repo, title string) error {
milestone, err := c.getMilestoneByTitle(ctx, repo, title)
if err != nil {
return err
}
if milestone == nil {
return ErrNoMilestoneFound{Title: title}
}
closedState := "closed"
milestone.State = &closedState
_, _, err = c.client.Issues.EditMilestone(
ctx,
repo.Owner,
repo.Name,
*milestone.Number,
milestone,
)
return err
}
func (c *githubClient) CreateFile(
ctx *context.Context,
commitAuthor config.CommitAuthor,
@ -187,3 +213,38 @@ func (c *githubClient) Upload(
}
return RetriableError{err}
}
// getMilestoneByTitle returns a milestone by title.
func (c *githubClient) getMilestoneByTitle(ctx *context.Context, repo Repo, title string) (*github.Milestone, error) {
// The GitHub API/SDK does not provide lookup by title functionality currently.
opts := &github.MilestoneListOptions{
ListOptions: github.ListOptions{PerPage: 100},
}
for {
milestones, resp, err := c.client.Issues.ListMilestones(
ctx,
repo.Owner,
repo.Name,
opts,
)
if err != nil {
return nil, err
}
for _, m := range milestones {
if m != nil && m.Title != nil && *m.Title == title {
return m, nil
}
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return nil, nil
}

View File

@ -48,6 +48,37 @@ func NewGitLab(ctx *context.Context, token string) (Client, error) {
return &gitlabClient{client: client}, nil
}
// CloseMilestone closes a given milestone.
func (c *gitlabClient) CloseMilestone(ctx *context.Context, repo Repo, title string) error {
milestone, err := c.getMilestoneByTitle(repo, title)
if err != nil {
return err
}
if milestone == nil {
return ErrNoMilestoneFound{Title: title}
}
closeStateEvent := "close"
opts := &gitlab.UpdateMilestoneOptions{
Description: &milestone.Description,
DueDate: milestone.DueDate,
StartDate: milestone.StartDate,
StateEvent: &closeStateEvent,
Title: &milestone.Title,
}
_, _, err = c.client.Milestones.UpdateMilestone(
repo.String(),
milestone.ID,
opts,
)
return err
}
// CreateFile gets a file in the repository at a given path
// and updates if it exists or creates it for later pipes in the pipeline.
func (c *gitlabClient) CreateFile(
@ -319,3 +350,32 @@ func extractProjectFileHashFrom(projectFileURL string) (string, error) {
}).Debug("extracted file hash")
return fileHash, nil
}
// getMilestoneByTitle returns a milestone by title.
func (c *gitlabClient) getMilestoneByTitle(repo Repo, title string) (*gitlab.Milestone, error) {
opts := &gitlab.ListMilestonesOptions{
Title: &title,
}
for {
milestones, resp, err := c.client.Milestones.ListMilestones(repo.String(), opts)
if err != nil {
return nil, err
}
for _, milestone := range milestones {
if milestone != nil && milestone.Title == title {
return milestone, nil
}
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return nil, nil
}

View File

@ -1,27 +1,26 @@
package release
package git
import (
"fmt"
"strings"
"github.com/goreleaser/goreleaser/internal/git"
"github.com/goreleaser/goreleaser/pkg/config"
"github.com/pkg/errors"
)
// remoteRepo gets the repo name from the Git config.
func remoteRepo() (result config.Repo, err error) {
if !git.IsRepo() {
// ExtractRepoFromConfig gets the repo name from the Git config.
func ExtractRepoFromConfig() (result config.Repo, err error) {
if !IsRepo() {
return result, errors.New("current folder is not a git repository")
}
out, err := git.Run("config", "--get", "remote.origin.url")
out, err := Run("config", "--get", "remote.origin.url")
if err != nil {
return result, fmt.Errorf("repository doesn't have an `origin` remote")
}
return extractRepoFromURL(out), nil
return ExtractRepoFromURL(out), nil
}
func extractRepoFromURL(s string) config.Repo {
func ExtractRepoFromURL(s string) config.Repo {
// removes the .git suffix and any new lines
s = strings.NewReplacer(
".git", "",

View File

@ -1,8 +1,9 @@
package release
package git_test
import (
"testing"
"github.com/goreleaser/goreleaser/internal/git"
"github.com/goreleaser/goreleaser/internal/testlib"
"github.com/stretchr/testify/assert"
)
@ -12,7 +13,7 @@ func TestRepoName(t *testing.T) {
defer back()
testlib.GitInit(t)
testlib.GitRemoteAdd(t, "git@github.com:goreleaser/goreleaser.git")
repo, err := remoteRepo()
repo, err := git.ExtractRepoFromConfig()
assert.NoError(t, err)
assert.Equal(t, "goreleaser/goreleaser", repo.String())
}
@ -27,7 +28,7 @@ func TestExtractRepoFromURL(t *testing.T) {
"https://github.enterprise.com/crazy/url/goreleaser/goreleaser.git",
} {
t.Run(url, func(t *testing.T) {
repo := extractRepoFromURL(url)
repo := git.ExtractRepoFromURL(url)
assert.Equal(t, "goreleaser/goreleaser", repo.String())
})
}

View File

@ -1,18 +1,19 @@
package git
package git_test
import (
"os"
"testing"
"github.com/goreleaser/goreleaser/internal/git"
"github.com/stretchr/testify/assert"
)
func TestGit(t *testing.T) {
out, err := Run("status")
out, err := git.Run("status")
assert.NoError(t, err)
assert.NotEmpty(t, out)
out, err = Run("command-that-dont-exist")
out, err = git.Run("command-that-dont-exist")
assert.Error(t, err)
assert.Empty(t, out)
assert.Equal(
@ -23,18 +24,18 @@ func TestGit(t *testing.T) {
}
func TestRepo(t *testing.T) {
assert.True(t, IsRepo(), "goreleaser folder should be a git repo")
assert.True(t, git.IsRepo(), "goreleaser folder should be a git repo")
assert.NoError(t, os.Chdir(os.TempDir()))
assert.False(t, IsRepo(), os.TempDir()+" folder should be a git repo")
assert.False(t, git.IsRepo(), os.TempDir()+" folder should be a git repo")
}
func TestClean(t *testing.T) {
out, err := Clean("asdasd 'ssadas'\nadasd", nil)
out, err := git.Clean("asdasd 'ssadas'\nadasd", nil)
assert.NoError(t, err)
assert.Equal(t, "asdasd ssadas", out)
out, err = Clean(Run("command-that-dont-exist"))
out, err = git.Clean(git.Run("command-that-dont-exist"))
assert.Error(t, err)
assert.Empty(t, out)
assert.Equal(

View File

@ -731,6 +731,10 @@ type DummyClient struct {
NotImplemented bool
}
func (dc *DummyClient) CloseMilestone(ctx *context.Context, repo client.Repo, title string) error {
return nil
}
func (dc *DummyClient) CreateRelease(ctx *context.Context, body string) (releaseID string, err error) {
return
}

View File

@ -0,0 +1,2 @@
// Package milestone implements Pipe and manages VCS milestones.
package milestone

View File

@ -0,0 +1,98 @@
package milestone
import (
"github.com/apex/log"
"github.com/goreleaser/goreleaser/internal/client"
"github.com/goreleaser/goreleaser/internal/git"
"github.com/goreleaser/goreleaser/internal/pipe"
"github.com/goreleaser/goreleaser/internal/tmpl"
"github.com/goreleaser/goreleaser/pkg/config"
"github.com/goreleaser/goreleaser/pkg/context"
)
const defaultNameTemplate = "{{ .Tag }}"
// Pipe for milestone.
type Pipe struct{}
func (Pipe) String() string {
return "milestones"
}
// Default sets the pipe defaults.
func (Pipe) Default(ctx *context.Context) error {
if len(ctx.Config.Milestones) == 0 {
ctx.Config.Milestones = append(ctx.Config.Milestones, config.Milestone{})
}
for i := range ctx.Config.Milestones {
milestone := &ctx.Config.Milestones[i]
if milestone.NameTemplate == "" {
milestone.NameTemplate = defaultNameTemplate
}
if milestone.Repo.Name == "" {
repo, err := git.ExtractRepoFromConfig()
if err != nil && !ctx.Snapshot {
return err
}
milestone.Repo = repo
}
}
return nil
}
// Publish the release.
func (Pipe) Publish(ctx *context.Context) error {
if ctx.SkipPublish {
return pipe.ErrSkipPublishEnabled
}
c, err := client.New(ctx)
if err != nil {
return err
}
return doPublish(ctx, c)
}
func doPublish(ctx *context.Context, vcsClient client.Client) error {
for i := range ctx.Config.Milestones {
milestone := &ctx.Config.Milestones[i]
if !milestone.Close {
return pipe.Skip("milestone pipe is disabled")
}
name, err := tmpl.New(ctx).Apply(milestone.NameTemplate)
if err != nil {
return err
}
repo := client.Repo{
Name: milestone.Repo.Name,
Owner: milestone.Repo.Owner,
}
log.WithField("milestone", name).
WithField("repo", repo.String()).
Info("closing milestone")
err = vcsClient.CloseMilestone(ctx, repo, name)
if err != nil {
if milestone.FailOnError {
return err
}
log.WithField("milestone", name).
WithField("repo", repo.String()).
Warnf("error closing milestone: %s", err)
}
}
return nil
}

View File

@ -0,0 +1,222 @@
package milestone
import (
"errors"
"os"
"testing"
"github.com/goreleaser/goreleaser/internal/artifact"
"github.com/goreleaser/goreleaser/internal/client"
"github.com/goreleaser/goreleaser/internal/testlib"
"github.com/goreleaser/goreleaser/pkg/config"
"github.com/goreleaser/goreleaser/pkg/context"
"github.com/stretchr/testify/assert"
)
func TestDefaultWithRepoConfig(t *testing.T) {
_, back := testlib.Mktmp(t)
defer back()
testlib.GitInit(t)
testlib.GitRemoteAdd(t, "git@github.com:githubowner/githubrepo.git")
var ctx = &context.Context{
Config: config.Project{
Milestones: []config.Milestone{
{
Repo: config.Repo{
Name: "configrepo",
Owner: "configowner",
},
},
},
},
}
ctx.TokenType = context.TokenTypeGitHub
assert.NoError(t, Pipe{}.Default(ctx))
assert.Equal(t, "configrepo", ctx.Config.Milestones[0].Repo.Name)
assert.Equal(t, "configowner", ctx.Config.Milestones[0].Repo.Owner)
}
func TestDefaultWithRepoRemote(t *testing.T) {
_, back := testlib.Mktmp(t)
defer back()
testlib.GitInit(t)
testlib.GitRemoteAdd(t, "git@github.com:githubowner/githubrepo.git")
var ctx = context.New(config.Project{})
ctx.TokenType = context.TokenTypeGitHub
assert.NoError(t, Pipe{}.Default(ctx))
assert.Equal(t, "githubrepo", ctx.Config.Milestones[0].Repo.Name)
assert.Equal(t, "githubowner", ctx.Config.Milestones[0].Repo.Owner)
}
func TestDefaultWithNameTemplate(t *testing.T) {
var ctx = &context.Context{
Config: config.Project{
Milestones: []config.Milestone{
{
NameTemplate: "confignametemplate",
},
},
},
}
assert.NoError(t, Pipe{}.Default(ctx))
assert.Equal(t, "confignametemplate", ctx.Config.Milestones[0].NameTemplate)
}
func TestDefaultWithoutGitRepo(t *testing.T) {
_, back := testlib.Mktmp(t)
defer back()
var ctx = &context.Context{
Config: config.Project{},
}
ctx.TokenType = context.TokenTypeGitHub
assert.EqualError(t, Pipe{}.Default(ctx), "current folder is not a git repository")
assert.Empty(t, ctx.Config.Milestones[0].Repo.String())
}
func TestDefaultWithoutGitRepoOrigin(t *testing.T) {
_, back := testlib.Mktmp(t)
defer back()
var ctx = &context.Context{
Config: config.Project{},
}
ctx.TokenType = context.TokenTypeGitHub
testlib.GitInit(t)
assert.EqualError(t, Pipe{}.Default(ctx), "repository doesn't have an `origin` remote")
assert.Empty(t, ctx.Config.Milestones[0].Repo.String())
}
func TestDefaultWithoutGitRepoSnapshot(t *testing.T) {
_, back := testlib.Mktmp(t)
defer back()
var ctx = &context.Context{
Config: config.Project{},
}
ctx.TokenType = context.TokenTypeGitHub
ctx.Snapshot = true
assert.NoError(t, Pipe{}.Default(ctx))
assert.Empty(t, ctx.Config.Milestones[0].Repo.String())
}
func TestDefaultWithoutNameTemplate(t *testing.T) {
var ctx = &context.Context{
Config: config.Project{
Milestones: []config.Milestone{},
},
}
assert.NoError(t, Pipe{}.Default(ctx))
assert.Equal(t, "{{ .Tag }}", ctx.Config.Milestones[0].NameTemplate)
}
func TestString(t *testing.T) {
assert.NotEmpty(t, Pipe{}.String())
}
func TestPublishCloseDisabled(t *testing.T) {
var ctx = context.New(config.Project{
Milestones: []config.Milestone{
{
Close: false,
},
},
})
client := &DummyClient{}
testlib.AssertSkipped(t, doPublish(ctx, client))
assert.Equal(t, "", client.ClosedMilestone)
}
func TestPublishCloseEnabled(t *testing.T) {
var ctx = context.New(config.Project{
Milestones: []config.Milestone{
{
Close: true,
NameTemplate: defaultNameTemplate,
Repo: config.Repo{
Name: "configrepo",
Owner: "configowner",
},
},
},
})
ctx.Git.CurrentTag = "v1.0.0"
client := &DummyClient{}
assert.NoError(t, doPublish(ctx, client))
assert.Equal(t, "v1.0.0", client.ClosedMilestone)
}
func TestPublishCloseError(t *testing.T) {
var config = config.Project{
Milestones: []config.Milestone{
{
Close: true,
NameTemplate: defaultNameTemplate,
Repo: config.Repo{
Name: "configrepo",
Owner: "configowner",
},
},
},
}
var ctx = context.New(config)
ctx.Git.CurrentTag = "v1.0.0"
client := &DummyClient{
FailToCloseMilestone: true,
}
assert.NoError(t, doPublish(ctx, client))
assert.Equal(t, "", client.ClosedMilestone)
}
func TestPublishCloseFailOnError(t *testing.T) {
var config = config.Project{
Milestones: []config.Milestone{
{
Close: true,
FailOnError: true,
NameTemplate: defaultNameTemplate,
Repo: config.Repo{
Name: "configrepo",
Owner: "configowner",
},
},
},
}
var ctx = context.New(config)
ctx.Git.CurrentTag = "v1.0.0"
client := &DummyClient{
FailToCloseMilestone: true,
}
assert.Error(t, doPublish(ctx, client))
assert.Equal(t, "", client.ClosedMilestone)
}
type DummyClient struct {
ClosedMilestone string
FailToCloseMilestone bool
}
func (c *DummyClient) CloseMilestone(ctx *context.Context, repo client.Repo, title string) error {
if c.FailToCloseMilestone {
return errors.New("milestone failed")
}
c.ClosedMilestone = title
return nil
}
func (c *DummyClient) CreateRelease(ctx *context.Context, body string) (string, error) {
return "", nil
}
func (c *DummyClient) ReleaseURLTemplate(ctx *context.Context) (string, error) {
return "", nil
}
func (c *DummyClient) CreateFile(ctx *context.Context, commitAuthor config.CommitAuthor, repo client.Repo, content []byte, path, msg string) error {
return nil
}
func (c *DummyClient) Upload(ctx *context.Context, releaseID string, artifact *artifact.Artifact, file *os.File) error {
return nil
}

View File

@ -10,6 +10,7 @@ import (
"github.com/goreleaser/goreleaser/internal/pipe/brew"
"github.com/goreleaser/goreleaser/internal/pipe/custompublishers"
"github.com/goreleaser/goreleaser/internal/pipe/docker"
"github.com/goreleaser/goreleaser/internal/pipe/milestone"
"github.com/goreleaser/goreleaser/internal/pipe/release"
"github.com/goreleaser/goreleaser/internal/pipe/scoop"
"github.com/goreleaser/goreleaser/internal/pipe/snapcraft"
@ -46,6 +47,7 @@ var publishers = []Publisher{
// brew and scoop use the release URL, so, they should be last
brew.Pipe{},
scoop.Pipe{},
milestone.Pipe{},
}
// Run the pipe.

View File

@ -8,6 +8,7 @@ import (
"github.com/goreleaser/goreleaser/internal/artifact"
"github.com/goreleaser/goreleaser/internal/client"
"github.com/goreleaser/goreleaser/internal/extrafiles"
"github.com/goreleaser/goreleaser/internal/git"
"github.com/goreleaser/goreleaser/internal/pipe"
"github.com/goreleaser/goreleaser/internal/semerrgroup"
"github.com/goreleaser/goreleaser/pkg/context"
@ -50,7 +51,7 @@ func (Pipe) Default(ctx *context.Context) error {
case context.TokenTypeGitLab:
{
if ctx.Config.Release.GitLab.Name == "" {
repo, err := remoteRepo()
repo, err := git.ExtractRepoFromConfig()
if err != nil {
return err
}
@ -62,7 +63,7 @@ func (Pipe) Default(ctx *context.Context) error {
case context.TokenTypeGitea:
{
if ctx.Config.Release.Gitea.Name == "" {
repo, err := remoteRepo()
repo, err := git.ExtractRepoFromConfig()
if err != nil {
return err
}
@ -75,7 +76,7 @@ func (Pipe) Default(ctx *context.Context) error {
// We keep github as default for now
if ctx.Config.Release.GitHub.Name == "" {
repo, err := remoteRepo()
repo, err := git.ExtractRepoFromConfig()
if err != nil && !ctx.Snapshot {
return err
}

View File

@ -538,6 +538,10 @@ type DummyClient struct {
Lock sync.Mutex
}
func (c *DummyClient) CloseMilestone(ctx *context.Context, repo client.Repo, title string) error {
return nil
}
func (c *DummyClient) CreateRelease(ctx *context.Context, body string) (releaseID string, err error) {
if c.FailToCreateRelease {
return "", errors.New("release failed")

View File

@ -1023,6 +1023,10 @@ type DummyClient struct {
NotImplemented bool
}
func (dc *DummyClient) CloseMilestone(ctx *context.Context, repo client.Repo, title string) error {
return nil
}
func (dc *DummyClient) CreateRelease(ctx *context.Context, body string) (releaseID string, err error) {
return
}

View File

@ -282,6 +282,14 @@ type Release struct {
ExtraFiles []ExtraFile `yaml:"extra_files,omitempty"`
}
// Milestone config used for VCS milestone.
type Milestone struct {
Repo Repo `yaml:",omitempty"`
Close bool `yaml:",omitempty"`
FailOnError bool `yaml:"fail_on_error,omitempty"`
NameTemplate string `yaml:"name_template,omitempty"`
}
// ExtraFile on a release.
type ExtraFile struct {
Glob string `yaml:"glob,omitempty"`
@ -477,6 +485,7 @@ type Project struct {
ProjectName string `yaml:"project_name,omitempty"`
Env []string `yaml:",omitempty"`
Release Release `yaml:",omitempty"`
Milestones []Milestone `yaml:",omitempty"`
Brews []Homebrew `yaml:",omitempty"`
Scoop Scoop `yaml:",omitempty"`
Builds []Build `yaml:",omitempty"`

View File

@ -12,6 +12,7 @@ import (
"github.com/goreleaser/goreleaser/internal/pipe/build"
"github.com/goreleaser/goreleaser/internal/pipe/checksums"
"github.com/goreleaser/goreleaser/internal/pipe/docker"
"github.com/goreleaser/goreleaser/internal/pipe/milestone"
"github.com/goreleaser/goreleaser/internal/pipe/nfpm"
"github.com/goreleaser/goreleaser/internal/pipe/project"
"github.com/goreleaser/goreleaser/internal/pipe/release"
@ -50,4 +51,5 @@ var Defaulters = []Defaulter{
blob.Pipe{},
brew.Pipe{},
scoop.Pipe{},
milestone.Pipe{},
}

View File

@ -0,0 +1,35 @@
---
title: Milestone
---
GoReleaser can close repository milestones after successfully
publishing all artifacts.
Let's see what can be customized in the `milestones` section:
```yaml
# .goreleaser.yml
milestones:
# You can have multiple milestone configs
-
# Repository for the milestone
# Default is extracted from the origin remote URL
repo:
owner: user
name: repo
# Whether to close the milestone
# Default is false
close: true
# Fail release on errors, such as missing milestone on close
# Default is false
fail_on_error: true
# Name of the milestone
# Default is `{{ .Tag }}`
name_template: "Current Release"
```
!!! tip
Learn more about the [name template engine](/customization/templates).

View File

@ -70,6 +70,7 @@ nav:
- customization/homebrew.md
- customization/upload.md
- customization/templates.md
- customization/milestone.md
- customization/nfpm.md
- customization/project.md
- customization/release.md