From c7748a809f8f55ceebfaa9ce6243e2795983cc90 Mon Sep 17 00:00:00 2001 From: Simon Pasquier Date: Tue, 3 Sep 2019 10:58:44 +0200 Subject: [PATCH] Add bump command Signed-off-by: Simon Pasquier --- cmd/bump.go | 251 ++++++++++++++++++++++++++++++++ cmd/promu.go | 5 + cmd/release.go | 19 +-- go.sum | 1 + pkg/changelog/changelog.go | 66 ++++----- pkg/changelog/changelog_test.go | 83 ++++++++++- pkg/github/github.go | 66 +++++++++ 7 files changed, 435 insertions(+), 56 deletions(-) create mode 100644 cmd/bump.go create mode 100644 pkg/github/github.go diff --git a/cmd/bump.go b/cmd/bump.go new file mode 100644 index 00000000..ae0baa62 --- /dev/null +++ b/cmd/bump.go @@ -0,0 +1,251 @@ +// Copyright © 2019 Prometheus Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "os" + "sort" + "strings" + "text/template" + "time" + + "github.com/google/go-github/v25/github" + "github.com/pkg/errors" + + "github.com/prometheus/promu/pkg/changelog" + githubUtil "github.com/prometheus/promu/pkg/github" +) + +var ( + bumpcmd = app.Command("bump", "Update CHANGELOG.md and VERSION files to the next version") + + bumpChangelogPath = bumpcmd.Flag("changelog-location", "Path to CHANGELOG.md"). + Default("CHANGELOG.md").String() + bumpVersionPath = bumpcmd.Flag("version-location", "Path to VERSION (set to empty if missing)"). + Default("VERSION").String() + bumpLevel = bumpcmd.Flag("level", "Level of version to increment (should be one of major, minor, patch, pre)").Default("minor").Enum("major", "minor", "patch", "pre") + bumpPreRelease = bumpcmd.Flag("pre-release", "Pre-release identifier").Default("rc.0").String() + bumpBaseBranch = bumpcmd.Flag("base-branch", "Pre-release identifier").Default("master").String() +) + +type pullRequest struct { + Number int + Title string + Kinds changelog.Kinds +} + +var ( + labelPrefix = "changelog/" + skipLabel = labelPrefix + "skip" +) + +type changelogData struct { + Version string + Date string + PullRequests []pullRequest + Skipped []pullRequest + Contributors []string +} + +const changelogTmpl = `## {{ .Version }} / {{ .Date }} +{{ range .PullRequests }} +* [{{ .Kinds.String }}] {{ makeSentence .Title }} #{{ .Number }} +{{- end }} + + +Contributors: +{{ range .Contributors }} +* @{{ . }} +{{- end }} + +` + +func writeChangelog(w io.Writer, version string, prs, skippedPrs []pullRequest, contributors []string) error { + sort.SliceStable(prs, func(i int, j int) bool { return prs[i].Kinds.Before(prs[j].Kinds) }) + sort.SliceStable(skippedPrs, func(i int, j int) bool { return skippedPrs[i].Kinds.Before(skippedPrs[j].Kinds) }) + sort.Strings(contributors) + + tmpl, err := template.New("changelog").Funcs( + template.FuncMap{ + "makeSentence": func(s string) string { + s = strings.TrimRight(s, ".") + return s + "." + }, + }).Parse(changelogTmpl) + if err != nil { + return errors.Wrap(err, "invalid template") + } + + return tmpl.Execute(w, &changelogData{ + Version: version, + Date: time.Now().Format("2006-01-02"), + PullRequests: prs, + Skipped: skippedPrs, + Contributors: contributors, + }) +} + +func runBumpVersion(changelogPath, versionPath string, bumpLevel string, preRelease string, baseBranch string) error { + current, err := projInfo.ToSemver() + if err != nil { + return err + } + + next := *current + switch bumpLevel { + case "major": + next = current.IncMajor() + case "minor": + next = current.IncMinor() + case "patch": + next = current.IncPatch() + } + next, err = next.SetPrerelease(preRelease) + if err != nil { + return err + } + + ctx := context.Background() + if *timeout != time.Duration(0) { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, *timeout) + defer cancel() + } + client, err := githubUtil.NewClient(ctx) + if err != nil { + info(fmt.Sprintf("failed to create authenticated GitHub client: %v", err)) + info("Fallback to client without unauthentication") + client = github.NewClient(nil) + } + + lastTag := "v" + current.String() + commit, _, err := client.Repositories.GetCommit(ctx, projInfo.Owner, projInfo.Name, lastTag) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("Fail to get the GitHub commit for %s", lastTag)) + } + lastTagTime := commit.GetCommit().GetCommitter().GetDate() + lastCommitSHA := commit.GetSHA() + + // Gather all pull requests merged since the last tag. + var ( + prs, skipped []pullRequest + uniqContributors = make(map[string]struct{}) + ) + err = githubUtil.ReadAll( + func(opts *github.ListOptions) (*github.Response, error) { + ghPrs, resp, err := client.PullRequests.List(ctx, projInfo.Owner, projInfo.Name, &github.PullRequestListOptions{ + State: "closed", + Sort: "updated", + Direction: "desc", + ListOptions: *opts, + }) + if err != nil { + return nil, errors.Wrap(err, "Fail to list GitHub pull requests") + } + for _, pr := range ghPrs { + if pr.GetBase().GetRef() != baseBranch { + continue + } + if pr.GetUpdatedAt().Before(lastTagTime) { + // We've reached pull requests that haven't changed since + // the reference tag so we can stop now. + return nil, nil + } + if pr.GetMergedAt().IsZero() || pr.GetMergedAt().Before(lastTagTime) { + continue + } + if pr.GetMergeCommitSHA() == lastCommitSHA { + continue + } + + var ( + kinds []string + skip bool + ) + for _, lbl := range pr.Labels { + if lbl.GetName() == skipLabel { + skip = true + } + if strings.HasPrefix(lbl.GetName(), labelPrefix) { + kinds = append(kinds, strings.ToUpper(strings.TrimPrefix(lbl.GetName(), labelPrefix))) + } + } + p := pullRequest{ + Kinds: changelog.ParseKinds(kinds), + Title: pr.GetTitle(), + Number: pr.GetNumber(), + } + if pr.GetUser() != nil { + uniqContributors[pr.GetUser().GetLogin()] = struct{}{} + } + if skip { + skipped = append(skipped, p) + } else { + prs = append(prs, p) + } + } + return resp, nil + }, + ) + if err != nil { + return err + } + + var contributors []string + for k := range uniqContributors { + contributors = append(contributors, k) + } + + // Update the changelog file. + original, err := ioutil.ReadFile(changelogPath) + if err != nil { + return err + } + f, err := os.Create(changelogPath) + if err != nil { + return err + } + defer f.Close() + err = writeChangelog(f, next.String(), prs, skipped, contributors) + if err != nil { + return err + } + _, err = f.Write(original) + if err != nil { + return err + } + + // Update the version file (if provided). + if versionPath != "" { + f, err := os.Create(versionPath) + if err != nil { + return err + } + defer f.Close() + + _, err = f.WriteString(next.String()) + if err != nil { + return err + } + } + + return nil +} diff --git a/cmd/promu.go b/cmd/promu.go index 264b3274..a1da827a 100644 --- a/cmd/promu.go +++ b/cmd/promu.go @@ -125,6 +125,11 @@ func Execute() { runBuild(optArg(*binariesArg, 0, "all")) case checkLicensescmd.FullCommand(): runCheckLicenses(optArg(*checkLicLocation, 0, "."), *headerLength, *sourceExtensions) + case bumpcmd.FullCommand(): + err = runBumpVersion(*bumpChangelogPath, *bumpVersionPath, *bumpLevel, *bumpPreRelease, *bumpBaseBranch) + if err != nil { + fatal(err) + } case checkChangelogcmd.FullCommand(): if err := runCheckChangelog(*checkChangelogPath, *checkChangelogVersion); err != nil { fatal(err) diff --git a/cmd/release.go b/cmd/release.go index 993f7ff1..d846ad3c 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -24,9 +24,9 @@ import ( "github.com/google/go-github/v25/github" "github.com/pkg/errors" - "golang.org/x/oauth2" "github.com/prometheus/promu/pkg/changelog" + githubUtil "github.com/prometheus/promu/pkg/github" "github.com/prometheus/promu/util/retry" ) @@ -39,25 +39,16 @@ var ( ) func runRelease(location string) { - token := os.Getenv("GITHUB_TOKEN") - if len(token) == 0 { - fatal(errors.New("GITHUB_TOKEN not defined")) - } - ctx := context.Background() if *timeout != time.Duration(0) { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, *timeout) defer cancel() } - client := github.NewClient( - oauth2.NewClient( - ctx, - oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: token}, - ), - ), - ) + client, err := githubUtil.NewClient(ctx) + if err != nil { + fatal(err) + } semVer, err := projInfo.ToSemver() if err != nil { diff --git a/go.sum b/go.sum index fc55d188..795a92ae 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/pkg/changelog/changelog.go b/pkg/changelog/changelog.go index bc559c09..cb943fbd 100644 --- a/pkg/changelog/changelog.go +++ b/pkg/changelog/changelog.go @@ -50,17 +50,13 @@ func (k Kind) String() string { return "" } -// Kinds is a list of Kind which implements sort.Interface. +// Kinds is a list of Kind. type Kinds []Kind -func (k Kinds) Len() int { return len(k) } -func (k Kinds) Less(i, j int) bool { return k[i] < k[j] } -func (k Kinds) Swap(i, j int) { k[i], k[j] = k[j], k[i] } - -// ParseKinds converts a slash-separated list of Kind to a list of Kind. -func ParseKinds(s string) Kinds { +// ParseKinds converts a slice of strings to a slice of Kind. +func ParseKinds(s []string) Kinds { m := make(map[Kind]struct{}) - for _, k := range strings.Split(s, "/") { + for _, k := range s { switch k { case "CHANGE": m[kindChange] = struct{}{} @@ -77,7 +73,7 @@ func ParseKinds(s string) Kinds { for k := range m { kinds = append(kinds, k) } - sort.Stable(kinds) + sort.SliceStable(kinds, func(i, j int) bool { return kinds[i] < kinds[j] }) return kinds } @@ -89,6 +85,28 @@ func (k Kinds) String() string { return strings.Join(s, "/") } +// Before returns whether the receiver should sort before the other. +func (k Kinds) Before(other Kinds) bool { + if len(k) == 0 { + return len(other) == 0 + } + if len(other) == 0 { + return true + } + + n := len(k) + if len(k) > len(other) { + n = len(other) + } + for j := 0; j < n; j++ { + if k[j] == other[j] { + continue + } + return k[j] < other[j] + } + return len(k) <= len(other) +} + // Change represents a change description. type Change struct { Text string @@ -98,33 +116,8 @@ type Change struct { type Changes []Change func (c Changes) Sorted() error { - less := func(k1, k2 Kinds) bool { - if len(k1) == 0 { - if len(k2) == 0 { - return true - } - return false - } - if len(k2) == 0 { - return true - } - - n := len(k1) - if len(k1) > len(k2) { - n = len(k2) - } - for j := 0; j < n; j++ { - if k1[j] == k2[j] { - continue - } - return k1[j] < k2[j] - } - return len(k1) <= len(k2) - } - for i := 0; i < len(c)-1; i++ { - k1, k2 := c[i].Kinds, c[i+1].Kinds - if !less(k1, k2) { + if !c[i].Kinds.Before(c[i+1].Kinds) { return errors.Errorf("%q should be after %q", c[i].Text, c[i+1].Text) } } @@ -181,7 +174,8 @@ func ReadEntry(r io.Reader, version string) (*Entry, error) { } m := reChange.FindStringSubmatch(line) if len(m) > 1 { - entry.Changes = append(entry.Changes, Change{Text: line, Kinds: ParseKinds(m[1])}) + kinds := strings.Split(m[1], "/") + entry.Changes = append(entry.Changes, Change{Text: line, Kinds: ParseKinds(kinds)}) } lines = append(lines, line) } diff --git a/pkg/changelog/changelog_test.go b/pkg/changelog/changelog_test.go index c187ec8a..b843594b 100644 --- a/pkg/changelog/changelog_test.go +++ b/pkg/changelog/changelog_test.go @@ -247,27 +247,27 @@ This is the first stable release. func TestKinds(t *testing.T) { for _, tc := range []struct { - in string + in []string exp Kinds }{ { - in: "CHANGE", + in: []string{"CHANGE"}, exp: Kinds{kindChange}, }, { - in: "BUGFIX/CHANGE", + in: []string{"BUGFIX", "CHANGE"}, exp: Kinds{kindChange, kindBugfix}, }, { - in: "BUGFIX/BUGFIX", + in: []string{"BUGFIX", "BUGFIX"}, exp: Kinds{kindBugfix}, }, { - in: "BUGFIX/INVALID", + in: []string{"BUGFIX", "INVALID"}, exp: Kinds{kindBugfix}, }, { - in: "INVALID", + in: []string{"INVALID"}, }, } { t.Run("", func(t *testing.T) { @@ -279,6 +279,77 @@ func TestKinds(t *testing.T) { } } +func TestKindsBefore(t *testing.T) { + for _, tc := range []struct { + first Kinds + second Kinds + + before bool + }{ + { + first: Kinds{}, + before: true, + }, + { + first: Kinds{kindChange}, + second: Kinds{}, + before: true, + }, + { + first: Kinds{kindChange}, + second: Kinds{kindChange}, + before: true, + }, + { + first: Kinds{kindChange}, + second: Kinds{kindBugfix}, + before: true, + }, + { + first: Kinds{kindFeature}, + second: Kinds{kindChange}, + before: false, + }, + { + first: Kinds{kindChange}, + second: Kinds{kindChange, kindBugfix}, + before: true, + }, + { + first: Kinds{kindChange, kindBugfix}, + second: Kinds{kindChange}, + before: false, + }, + { + first: Kinds{kindChange, kindFeature, kindBugfix}, + second: Kinds{kindChange, kindBugfix}, + before: true, + }, + { + first: Kinds{kindChange, kindBugfix}, + second: Kinds{kindChange, kindFeature, kindBugfix}, + before: false, + }, + { + first: Kinds{kindChange, kindFeature, kindBugfix}, + second: Kinds{kindChange, kindBugfix}, + before: true, + }, + { + first: Kinds{}, + second: Kinds{kindChange, kindBugfix}, + before: false, + }, + } { + t.Run("", func(t *testing.T) { + before := tc.first.Before(tc.second) + if before != tc.before { + t.Fatalf("expected %t but got: %t", tc.before, before) + } + }) + } +} + func TestChangesSorted(t *testing.T) { for _, tc := range []struct { in Changes diff --git a/pkg/github/github.go b/pkg/github/github.go new file mode 100644 index 00000000..0eab1c89 --- /dev/null +++ b/pkg/github/github.go @@ -0,0 +1,66 @@ +// Copyright © 2019 Prometheus Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package github + +import ( + "context" + "os" + + "github.com/google/go-github/v25/github" + "github.com/pkg/errors" + "golang.org/x/oauth2" +) + +// ReadAll iterates over GitHub pages. +func ReadAll(list func(*github.ListOptions) (*github.Response, error)) error { + opt := github.ListOptions{PerPage: 10} + for { + resp, err := list(&opt) + if err != nil { + return err + } + if resp == nil || resp.NextPage == 0 { + return nil + } + opt.Page = resp.NextPage + } +} + +// TokenVarName is the name of the environment variable containing the token. +const TokenVarName = "GITHUB_TOKEN" + +// NewClient returns a new GitHub client with an authentication token read from TokenVarName. +func NewClient(ctx context.Context) (*github.Client, error) { + token := os.Getenv(TokenVarName) + if len(token) == 0 { + return nil, errors.Errorf("%s not defined", TokenVarName) + } + + c := github.NewClient( + oauth2.NewClient( + ctx, + oauth2.StaticTokenSource( + &oauth2.Token{ + AccessToken: token, + }, + ), + ), + ) + _, _, err := c.Zen(ctx) + if err != nil { + return nil, err + } + return c, nil +}