From 4125276dae3b2052a88adea3250d0e7129e06721 Mon Sep 17 00:00:00 2001 From: Solly Ross Date: Thu, 24 Sep 2020 14:01:51 -0700 Subject: [PATCH 1/4] Add vincepri to approvers He's into release tooling stuff and his work is going into this repo. --- OWNERS_ALIASES | 1 + 1 file changed, 1 insertion(+) diff --git a/OWNERS_ALIASES b/OWNERS_ALIASES index 4d2e7e8..076c480 100644 --- a/OWNERS_ALIASES +++ b/OWNERS_ALIASES @@ -11,6 +11,7 @@ aliases: # non-admin folks who can approve any PRs in the repo kubebuilder-approvers: - camilamacedo86 + - vincepri # folks who can review and LGTM any PRs in the repo (doesn't include # approvers & admins -- those count too via the OWNERS file) From 39377a5523e637d0e3b12d1d9b5ef956c56f3a77 Mon Sep 17 00:00:00 2001 From: Solly Ross Date: Wed, 30 Sep 2020 17:33:19 -0700 Subject: [PATCH 2/4] Release note composer This adds in a release notes composer that's based on controller-runtime's `hack/release/` tooling & the subsequent cluster-api release tooling written by vincepri, whence it draws pretty heavily. --- notes/common/common_test.go | 85 ++++ notes/common/error.go | 48 +++ notes/common/prefix.go | 95 +++++ notes/compose/branch_test.go | 195 ++++++++++ notes/compose/compose_suite_test.go | 65 ++++ notes/compose/log_test.go | 254 ++++++++++++ notes/compose/notes.go | 579 ++++++++++++++++++++++++++++ notes/compose/versions_test.go | 454 ++++++++++++++++++++++ notes/git/utils.go | 135 +++++++ notes/go.mod | 10 + notes/go.sum | 116 ++++++ notes/relnotes.go | 314 +++++++++++++++ 12 files changed, 2350 insertions(+) create mode 100644 notes/common/common_test.go create mode 100644 notes/common/error.go create mode 100644 notes/common/prefix.go create mode 100644 notes/compose/branch_test.go create mode 100644 notes/compose/compose_suite_test.go create mode 100644 notes/compose/log_test.go create mode 100644 notes/compose/notes.go create mode 100644 notes/compose/versions_test.go create mode 100644 notes/git/utils.go create mode 100644 notes/go.mod create mode 100644 notes/go.sum create mode 100644 notes/relnotes.go diff --git a/notes/common/common_test.go b/notes/common/common_test.go new file mode 100644 index 0000000..41ae642 --- /dev/null +++ b/notes/common/common_test.go @@ -0,0 +1,85 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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 common_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" + + . "sigs.k8s.io/kubebuilder-release-tools/notes/common" +) + +func TestCommon(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Common Release Not Parsing Suite") +} + +var _ = Describe("PR title parsing", func() { + DescribeTable("prefix to type", + func(title string, expectedType PRType, expectedTitle string) { + prType, finalTitle := PRTypeFromTitle(title) + Expect(prType).To(Equal(expectedType)) + Expect(finalTitle).To(Equal(expectedTitle)) + }, + Entry("should match breaking from ⚠", "⚠ Change leaderlock from ConfigMap to ConfigMapsLeasesResourceLock", BreakingPR, "Change leaderlock from ConfigMap to ConfigMapsLeasesResourceLock"), + Entry("should match breaking from :warning:", ":warning: admission responses with raw Status", BreakingPR, "admission responses with raw Status"), + Entry("should match feature from ✨", "✨CreateOrPatch", FeaturePR, "CreateOrPatch"), + Entry("should match feature from :sparkles:", ":sparkles: Add error check for multiple apiTypes as reconciliation object", FeaturePR, "Add error check for multiple apiTypes as reconciliation object"), + Entry("should match bugfix from 🐛", "🐛 Controller.Watch() should not store watches if already started", BugfixPR, "Controller.Watch() should not store watches if already started"), + Entry("should match bugfix from :bug:", ":bug: Ensure that webhook server is thread/start-safe", BugfixPR, "Ensure that webhook server is thread/start-safe"), + Entry("should match docs from 📖", "📖 Nit: improve doc string", DocsPR, "Nit: improve doc string"), + Entry("should match docs from :book:", ":book: Fix typo", DocsPR, "Fix typo"), + Entry("should match infra from 🌱", "🌱 some infra stuff (couldn't find in log)", InfraPR, "some infra stuff (couldn't find in log)"), + Entry("should match infra from :seedling:", ":seedling: Update Go mod version to 1.15", InfraPR, "Update Go mod version to 1.15"), + Entry("should match infra from 🏃(deprecated)", "🏃 hack/setup-envtest.sh: follow-up from #1092", InfraPR, "hack/setup-envtest.sh: follow-up from #1092"), + Entry("should match infra from :running: (deprecated)", ":running: Proposal to extract cluster-specifics out of the Manager", InfraPR, "Proposal to extract cluster-specifics out of the Manager"), + Entry("should put anything else as uncategorized", "blah blah", UncategorizedPR, "blah blah"), + ) + + It("should strip space from the start and end of the final title", func() { + prType, title := PRTypeFromTitle(":sparkles: this is a feature") + Expect(title).To(Equal("this is a feature")) + Expect(prType).To(Equal(FeaturePR)) + }) + + It("should strip space before considering the prefix", func() { + prType, title := PRTypeFromTitle(" :sparkles:this is a feature") + Expect(title).To(Equal("this is a feature")) + Expect(prType).To(Equal(FeaturePR)) + }) + + It("should strip variation selectors from the start of the final title", func() { + prType, title := PRTypeFromTitle("✨\uFE0FTruly sparkly") + Expect(prType).To(Equal(FeaturePR)) + Expect(title).To(Equal("Truly sparkly")) + }) + + It("should ingore emoji in the middle of the message", func() { + prType, title := PRTypeFromTitle("this is not a ✨ feature") + Expect(title).To(Equal("this is not a ✨ feature")) + Expect(prType).To(Equal(UncategorizedPR)) + }) + + It("should ignore github text->emoji in the middle of the message", func() { + prType, title := PRTypeFromTitle("this is not a :sparkles: feature") + Expect(title).To(Equal("this is not a :sparkles: feature")) + Expect(prType).To(Equal(UncategorizedPR)) + }) +}) diff --git a/notes/common/error.go b/notes/common/error.go new file mode 100644 index 0000000..7058fe5 --- /dev/null +++ b/notes/common/error.go @@ -0,0 +1,48 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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 common + +import ( + "errors" + "fmt" + "os/exec" +) + +// ErrOut wraps exec.ExitErrors so that the message displays their +// stderr output. If the error is not an exist error, or does not +// wrap one, this returns the error without any changes. +func ErrOut(err error) error { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return err + } + return errOut{actual: exitErr} +} + +// errOut is an Error that prints the underlying ExitError's stderr in addition +// to the normal message. +type errOut struct { + actual *exec.ExitError +} + +func (e errOut) Error() string { + return fmt.Sprintf("[%v] %q", e.actual.Error(), string(e.actual.Stderr)) +} + +func (e errOut) Unwrap() error { + return e.actual +} diff --git a/notes/common/prefix.go b/notes/common/prefix.go new file mode 100644 index 0000000..db8fb59 --- /dev/null +++ b/notes/common/prefix.go @@ -0,0 +1,95 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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 common + +import ( + "strings" +) + +type PRType int + +const ( + UncategorizedPR PRType = iota + BreakingPR + FeaturePR + BugfixPR + DocsPR + InfraPR +) + +// NB(directxman12): These are constants because some folks' dev environments like +// to inject extra combining characters into the mix (generally variation selector 16, +// which indicates emoji presentation), so we want to check that these are *just* the +// character without the combining parts. Note that they're a rune, so that they +// can *only* be one codepoint. +const ( + emojiFeature = string('✨') + emojiBugfix = string('🐛') + emojiDocs = string('📖') + emojiInfra = string('🌱') + emojiBreaking = string('⚠') + emojiInfraLegacy = string('🏃') +) + +func PRTypeFromTitle(title string) (PRType, string) { + title = strings.TrimSpace(title) + + if len(title) == 0 { + return UncategorizedPR, title + } + + var prType PRType + switch { + case strings.HasPrefix(title, ":sparkles:"), strings.HasPrefix(title, emojiFeature): + title = strings.TrimPrefix(title, ":sparkles:") + title = strings.TrimPrefix(title, emojiFeature) + prType = FeaturePR + case strings.HasPrefix(title, ":bug:"), strings.HasPrefix(title, emojiBugfix): + title = strings.TrimPrefix(title, ":bug:") + title = strings.TrimPrefix(title, emojiBugfix) + prType = BugfixPR + case strings.HasPrefix(title, ":book:"), strings.HasPrefix(title, emojiDocs): + title = strings.TrimPrefix(title, ":book:") + title = strings.TrimPrefix(title, emojiDocs) + prType = DocsPR + case strings.HasPrefix(title, ":seedling:"), strings.HasPrefix(title, emojiInfra): + title = strings.TrimPrefix(title, ":seedling:") + title = strings.TrimPrefix(title, emojiInfra) + prType = InfraPR + case strings.HasPrefix(title, ":warning:"), strings.HasPrefix(title, emojiBreaking): + title = strings.TrimPrefix(title, ":warning:") + title = strings.TrimPrefix(title, emojiBreaking) + prType = BreakingPR + case strings.HasPrefix(title, ":running:"), strings.HasPrefix(title, emojiInfraLegacy): + // This has been deprecated in favor of :seedling: + title = strings.TrimPrefix(title, ":running:") + title = strings.TrimPrefix(title, emojiInfraLegacy) + prType = InfraPR + default: + return UncategorizedPR, title + } + + // strip the variation selector from the title, if present + // (some systems sneak it in -- my guess is OSX) + title = strings.TrimPrefix(title, "\uFE0F") + + // NB(directxman12): there are a few other cases like the variation selector, + // but I can't seem to dig them up. If something doesn't parse as expected, + // check for zero-width characters and add handling here. + + return prType, strings.TrimSpace(title) +} diff --git a/notes/compose/branch_test.go b/notes/compose/branch_test.go new file mode 100644 index 0000000..9a604f3 --- /dev/null +++ b/notes/compose/branch_test.go @@ -0,0 +1,195 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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 compose_test + +import ( + "fmt" + + "github.com/blang/semver/v4" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + . "sigs.k8s.io/kubebuilder-release-tools/notes/compose" + "sigs.k8s.io/kubebuilder-release-tools/notes/git" +) + +var _ = Describe("Branches", func() { + Describe("finding the latest release", func() { + branch := ReleaseBranch{Version: semver.Version{Major: 1}} + It("should return ReleaseTag if there was a release in this branch's history", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + return git.Tag("v1.3.4"), nil + }, + } + + Expect(branch.LatestRelease(gitImpl, false)).To(Equal(ReleaseTag( + semver.Version{Major: 1, Minor: 3, Patch: 4}, + ))) + }) + + It("should support pre-release ReleaseTags", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + return git.Tag("v1.3.4-alpha.6"), nil + }, + } + + Expect(branch.LatestRelease(gitImpl, false)).To(Equal(ReleaseTag( + semver.Version{Major: 1, Minor: 3, Patch: 4, Pre: []semver.PRVersion{ + {VersionStr: "alpha"}, + {VersionNum: 6, IsNum: true}, + }}, + ))) + }) + + It("should return FirstCommit if no release exists yet", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + return git.Tag(""), fmt.Errorf("no tag found!") + }, + firstCommit: func(branchName string) (git.Commit, error) { + return git.Commit("abcdef"), nil + }, + } + Expect(branch.LatestRelease(gitImpl, false)).To(Equal(FirstCommit{ + Branch: branch, + Commit: git.Commit("abcdef"), + })) + }) + + It("should fail if no release exists and the first commit cannot be found", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + return git.Tag(""), fmt.Errorf("no tag found!") + }, + firstCommit: func(branchName string) (git.Commit, error) { + return git.Commit(""), fmt.Errorf("infinite parallel lines, non-euclidean git repository encountered!") + }, + } + _, err := branch.LatestRelease(gitImpl, false) + Expect(err).To(HaveOccurred()) + }) + + It("should reject tags from the wrong branch if asked to verify tags", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + return git.Tag("v0.6.7"), nil + }, + } + + _, err := branch.LatestRelease(gitImpl, true) + Expect(err).To(HaveOccurred()) + }) + + It("should accept tags from the wrong branch if not asked to verify tags", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + return git.Tag("v0.6.7"), nil + }, + } + + Expect(branch.LatestRelease(gitImpl, false)).To(Equal(ReleaseTag( + semver.Version{Minor: 6, Patch: 7}, + ))) + }) + }) + + Describe("verifying tags belong to this branch", func() { + Context("when dealing with release-X branches", func() { + branch := ReleaseBranch{Version: semver.Version{Major: 2}} + It("should accept tags with matching X versions", func() { + tag := ReleaseTag(semver.Version{Major: 2, Minor: 3, Patch: 1}) + Expect(branch.VerifyTagBelongs(tag)).To(Succeed()) + }) + It("should reject tags with different X versions", func() { + tag := ReleaseTag(semver.Version{Major: 1, Minor: 3, Patch: 1}) + Expect(branch.VerifyTagBelongs(tag)).NotTo(Succeed()) + }) + }) + Context("when dealing with release-0.Y branches", func() { + branch := ReleaseBranch{Version: semver.Version{Minor: 3}} + It("should accept tags with matching Y versions", func() { + tag := ReleaseTag(semver.Version{Major: 0, Minor: 3, Patch: 6}) + Expect(branch.VerifyTagBelongs(tag)).To(Succeed()) + }) + It("should reject tags with different Y versions", func() { + tag := ReleaseTag(semver.Version{Major: 0, Minor: 4, Patch: 1}) + Expect(branch.VerifyTagBelongs(tag)).NotTo(Succeed()) + }) + It("should reject tags with non-zero X versions", func() { + // NB: matching X version here + tag := ReleaseTag(semver.Version{Major: 1, Minor: 3}) + Expect(branch.VerifyTagBelongs(tag)).NotTo(Succeed()) + }) + }) + }) + + Describe("creating from a raw branch name", func() { + It("should accept release-X branches as vX.0.0 versions", func() { + Expect(ReleaseFromBranch("release-2")).To(Equal(ReleaseBranch{ + Version: semver.Version{Major: 2}, + })) + }) + + It("should accept release-0.Y branches as v0.Y.0 versions", func() { + Expect(ReleaseFromBranch("release-0.3")).To(Equal(ReleaseBranch{ + Version: semver.Version{Minor: 3}, + })) + }) + + It("should reject release-0", func() { + _, err := ReleaseFromBranch("release-0") + Expect(err).To(HaveOccurred()) + }) + + It("should reject release-0.0", func() { + _, err := ReleaseFromBranch("release-0.0") + Expect(err).To(HaveOccurred()) + }) + + It("should reject release-1.Y", func() { + _, err := ReleaseFromBranch("release-1.3") + Expect(err).To(HaveOccurred()) + }) + + It("should reject branches not starting with release-", func() { + _, err := ReleaseFromBranch("feature/sheep-shearing-machine") + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("when printing/reinterpreting", func() { + It("should print vX.y.z as release-X", func() { + branch := ReleaseBranch{Version: semver.Version{Major: 2}} + Expect(branch.String()).To(Equal("release-2")) + }) + + It("should print v0.Y.z as release-0.Y", func() { + branch := ReleaseBranch{Version: semver.Version{Minor: 3}} + Expect(branch.String()).To(Equal("release-0.3")) + }) + + It("should append @{u} if asked to use upstream branches", func() { + branch := ReleaseBranch{Version: semver.Version{Minor: 3}, UseUpstream: true} + Expect(branch.String()).To(Equal("release-0.3@{u}")) + + branch = ReleaseBranch{Version: semver.Version{Major: 2}, UseUpstream: true} + Expect(branch.String()).To(Equal("release-2@{u}")) + }) + }) +}) diff --git a/notes/compose/compose_suite_test.go b/notes/compose/compose_suite_test.go new file mode 100644 index 0000000..c69f57c --- /dev/null +++ b/notes/compose/compose_suite_test.go @@ -0,0 +1,65 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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 compose_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "sigs.k8s.io/kubebuilder-release-tools/notes/git" +) + +func TestCompose(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Compose Suite") +} + +type gitFuncs struct { + closestTag func(initial git.Committish) (git.Tag, error) + firstCommit func(branchName string) (git.Commit, error) + hasUpstream func(branchName string) error + mergeCommitsBetween func(start, end git.Committish) (string, error) + remoteForUpstreamFor func(branchName string) (string, error) + urlForRemote func(remote string) (string, error) +} + +func (f gitFuncs) ClosestTag(initial git.Committish) (git.Tag, error) { + if f.closestTag == nil { + panic("ClosestTag not expected") + } + return f.closestTag(initial) +} +func (f gitFuncs) FirstCommit(branchName string) (git.Commit, error) { + if f.firstCommit == nil { + panic("FirstCommit not expected") + } + return f.firstCommit(branchName) +} +func (f gitFuncs) HasUpstream(branchName string) error { + if f.hasUpstream == nil { + panic("HasUpstream not expected") + } + return f.hasUpstream(branchName) +} +func (f gitFuncs) MergeCommitsBetween(start, end git.Committish) (string, error) { + if f.mergeCommitsBetween == nil { + panic("MergeCommitsBetween not expected") + } + return f.mergeCommitsBetween(start, end) +} diff --git a/notes/compose/log_test.go b/notes/compose/log_test.go new file mode 100644 index 0000000..96d7280 --- /dev/null +++ b/notes/compose/log_test.go @@ -0,0 +1,254 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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 compose_test + +import ( + "fmt" + + "github.com/blang/semver/v4" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + . "sigs.k8s.io/kubebuilder-release-tools/notes/compose" + "sigs.k8s.io/kubebuilder-release-tools/notes/git" +) + +var ( + shortishCommitList = `commit ac380d61764a160b32946e606b0c9ecd2834e3e8 +Merge pull request #1165 from vincepri/backpor06-1163 + +:bug: [0.6] Controller.Watch() should not store watches if already started +commit 29c2e320531ea96428e10e4cca49e48751cf4ce5 +Merge pull request #1137 from vincepri/update-jsonpatch490-06 + +🌱 [0.6] Update json-patch to v4.9.0 +` + shortishChangeLog = ChangeLog{ + Bugs: []LogEntry{{Title: "[0.6] Controller.Watch() should not store watches if already started", PRNumber: "1165"}}, + Infra: []LogEntry{{Title: "[0.6] Update json-patch to v4.9.0", PRNumber: "1137"}}, + } +) + +var _ = Describe("Change Logs", func() { + It("should be able to just figure out the latest version if we don't ask for a specific one", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + return git.Tag("v0.6.3"), nil + }, + mergeCommitsBetween: func(start, end git.Committish) (string, error) { + if start.Committish() != "v0.6.3" || end.Committish() != "release-0.6" { + return "", fmt.Errorf("couldn't find commits for unexpected range %s..%s", start.Committish(), end.Committish()) + } + return shortishCommitList, nil + }, + } + currBranch := ReleaseBranch{Version: semver.Version{Minor: 6}} + + log, since, err := Changes(gitImpl, &currBranch) + Expect(err).NotTo(HaveOccurred()) + Expect(since).To(Equal(ReleaseTag(semver.Version{Minor: 6, Patch: 3}))) + Expect(log).NotTo(Equal(ChangeLog{})) // just don't be empty, we'll test other things later + }) + + It("should use the specified start point when we specify one", func() { + gitImpl := gitFuncs{ + mergeCommitsBetween: func(start, end git.Committish) (string, error) { + if start.Committish() != "abcdef" || end.Committish() != "release-0.6" { + return "", fmt.Errorf("couldn't find commits for unexpected range %s..%s", start.Committish(), end.Committish()) + } + return shortishCommitList, nil + }, + } + currBranch := ReleaseBranch{Version: semver.Version{Minor: 6}} + + log, err := ChangesSince(gitImpl, currBranch, git.SomeCommittish("abcdef")) + Expect(err).NotTo(HaveOccurred()) + Expect(log).NotTo(Equal(ChangeLog{})) // just don't be empty, we'll test other things later + }) + + It("should fail if we can't get the merge commits", func() { + gitImpl := gitFuncs{ + mergeCommitsBetween: func(start, end git.Committish) (string, error) { + // note for non-native speakers: "accidentally the X" is a meme-y colloquialism + return "", fmt.Errorf("couldn't find the commits -- did you accidentally the repository?") + }, + } + currBranch := ReleaseBranch{Version: semver.Version{Minor: 6}} + + _, err := ChangesSince(gitImpl, currBranch, git.SomeCommittish("abcdef")) + Expect(err).To(HaveOccurred()) + }) + + It("should turn merge commits into changelog entries", func() { + gitImpl := gitFuncs{ + mergeCommitsBetween: func(start, end git.Committish) (string, error) { + return ( + // a decent sampling of different commits -- at least + // one of each type, but not necessarily one of each indicator. + // The full range of indicators is tested in common. + `commit 6af4e7c71d4ca149837d2ed9a33fd8df98ac6103 +Merge pull request #1187 from vincepri/go115 + +:seedling: Update Go mod version to 1.15 +commit fdc6658a141b99a3fcb733c8a8000f98e6666f48 +Merge pull request #850 from akutz/feature/createOrPatch + +✨CreateOrPatch +commit be18097a47bdf9341e31a700cc1c2c23ebb48e42 +Merge pull request #1176 from prafull01/multi-apitype + +:sparkles: Add error check for multiple apiTypes as reconciliation object +commit 5757a389803ec368126bb1ff046ae3524dacbfcf +Merge pull request #1155 from DirectXMan12/bug/webhook-server-threadsafe + +:bug: Ensure that webhook server is thread/start-safe +commit ea6a506eb2b74d17606171d46675da4ec4053c5b +Merge pull request #1075 from alvaroaleman/add + +:running: Proposal to extract cluster-specifics out of the Manager +commit 22a2c58a47971ab46c2ff8fab1bf6494632cd1f5 +Merge pull request #1160 from daniel-hutao/patch-1 + +update Builder.Register() 's comment - one or more +commit 20af9010491c4e97a6d77219d8c22db9b99aa491 +Merge pull request #1163 from vincepri/watches-controller-bug + +🐛 Controller.Watch() should not store watches if already started +commit d6829e9c4db802eb4d5703d22c6cd87e8bbf91da +Merge pull request #1153 from gogolok/fix_typo + +:book: Fix typo +commit 4717461d1f66687d3a82288d3131302d64f11389 +Merge pull request #1144 from alvaroaleman/default-le-resourcelock + +⚠ Change leaderlock from ConfigMap to ConfigMapsLeasesResourceLock +commit be59d6426fe904ea87b348d49503112b8eb5ccef +Merge pull request #1129 from Shpectator/admission-webhooks-status-response + +:warning: admission responses with raw Status +`), nil + }, + } + currBranch := ReleaseBranch{Version: semver.Version{Minor: 6}} + + log, err := ChangesSince(gitImpl, currBranch, git.SomeCommittish("abcdef")) + Expect(err).NotTo(HaveOccurred()) + Expect(log).To(Equal(ChangeLog{ + Breaking: []LogEntry{ + { + PRNumber: "1144", + Title: "Change leaderlock from ConfigMap to ConfigMapsLeasesResourceLock", + }, + {PRNumber: "1129", Title: "admission responses with raw Status"}, + }, + Features: []LogEntry{ + {PRNumber: "850", Title: "CreateOrPatch"}, + { + PRNumber: "1176", + Title: "Add error check for multiple apiTypes as reconciliation object", + }, + }, + Bugs: []LogEntry{ + { + PRNumber: "1155", + Title: "Ensure that webhook server is thread/start-safe", + }, + { + PRNumber: "1163", + Title: "Controller.Watch() should not store watches if already started", + }, + }, + Docs: []LogEntry{ + {PRNumber: "1153", Title: "Fix typo"}, + }, + Infra: []LogEntry{ + {PRNumber: "1187", Title: "Update Go mod version to 1.15"}, + { + PRNumber: "1075", + Title: "Proposal to extract cluster-specifics out of the Manager", + }, + }, + Uncategorized: []LogEntry{ + { + PRNumber: "1160", + Title: "update Builder.Register() 's comment - one or more", + }, + }, + })) + }) + + It("should skip non-GitHub merge commits", func() { + + gitImpl := gitFuncs{ + mergeCommitsBetween: func(start, end git.Committish) (string, error) { + return ( + // a few valid commits mixed with some (real) bad merges from CR + `commit 06787b6b0e735e5a56fdfbcd8129effaefec3146 +Merge branch 'master' of github.com:bharathi-tenneti/controller-runtime + +commit fdc6658a141b99a3fcb733c8a8000f98e6666f48 +Merge pull request #850 from akutz/feature/createOrPatch + +✨CreateOrPatch +commit 334ea25a398a658afac27dc656e9f46893f79c6c +Merge branch 'upstream-master' + +commit 3738249414e4c4e8a2dce2e4328ca5dd00283876 +Merge branch 'master' into k8s-1.15.3 + +commit ea6a506eb2b74d17606171d46675da4ec4053c5b +Merge pull request #1075 from alvaroaleman/add + +:running: Proposal to extract cluster-specifics out of the Manager +commit be18097a47bdf9341e31a700cc1c2c23ebb48e42 +Merge pull request #1176 from prafull01/multi-apitype + +:sparkles: Add error check for multiple apiTypes as reconciliation object +commit 5757a389803ec368126bb1ff046ae3524dacbfcf +Merge pull request #1155 from DirectXMan12/bug/webhook-server-threadsafe + +:bug: Ensure that webhook server is thread/start-safe +`), nil + }, + } + currBranch := ReleaseBranch{Version: semver.Version{Minor: 6}} + + log, err := ChangesSince(gitImpl, currBranch, git.SomeCommittish("abcdef")) + Expect(err).NotTo(HaveOccurred()) + Expect(log).To(Equal(ChangeLog{ + Features: []LogEntry{ + {PRNumber: "850", Title: "CreateOrPatch"}, + { + PRNumber: "1176", + Title: "Add error check for multiple apiTypes as reconciliation object", + }, + }, + Bugs: []LogEntry{ + { + PRNumber: "1155", + Title: "Ensure that webhook server is thread/start-safe", + }, + }, + Infra: []LogEntry{ + { + PRNumber: "1075", + Title: "Proposal to extract cluster-specifics out of the Manager", + }, + }, + })) + }) +}) diff --git a/notes/compose/notes.go b/notes/compose/notes.go new file mode 100644 index 0000000..80071da --- /dev/null +++ b/notes/compose/notes.go @@ -0,0 +1,579 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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 compose + +import ( + "fmt" + golog "log" + "regexp" + "strconv" + "strings" + + "github.com/blang/semver/v4" + + "sigs.k8s.io/kubebuilder-release-tools/notes/common" + "sigs.k8s.io/kubebuilder-release-tools/notes/git" +) + +var ( + releaseRE = regexp.MustCompile(`^release-((?:0\.(?P[[:digit:]]+))|(?P[[:digit:]]+))$`) +) + +// TODO(directxman12): we could use go-git, but it doesn't implement +// git-describe, which is a pain to implement by hand. + +// ReleaseFromBranch extracts a major-ish (X or 0.Y) release given a branch name. +func ReleaseFromBranch(branchName string) (ReleaseBranch, error) { + parts := releaseRE.FindStringSubmatch(branchName) + if parts == nil { + return ReleaseBranch{}, fmt.Errorf("%q is not a valid release branch (release-0.Y or release-X)", branchName) + } + minorRaw := parts[releaseRE.SubexpIndex("minor")] + majorRaw := parts[releaseRE.SubexpIndex("major")] + switch { + case minorRaw != "": + minor, err := strconv.ParseUint(minorRaw, 10, 64) + if err != nil { + return ReleaseBranch{}, fmt.Errorf("could not parse minor version from %q: %w", minorRaw, err) + } + if minor == 0 { + return ReleaseBranch{}, fmt.Errorf("release-0.0 is not a valid release") + } + return ReleaseBranch{ + Version: semver.Version{Major: 0, Minor: minor}, + }, nil + case majorRaw != "": + major, err := strconv.ParseUint(majorRaw, 10, 64) + if err != nil { + return ReleaseBranch{}, fmt.Errorf("could not parse major version from %q: %w", majorRaw, err) + } + if major == 0 { + return ReleaseBranch{}, fmt.Errorf("release-0 is not a valid release") + } + return ReleaseBranch{ + Version: semver.Version{Major: major}, + }, nil + default: + return ReleaseBranch{}, fmt.Errorf("%q is not a valid release branch (release-0.Y or release-X)", branchName) + } +} + +// ReleaseBranch represents a branch associated with major-ish (X or 0.Y) set +// of releases. +type ReleaseBranch struct { + semver.Version + UseUpstream bool +} + +func (b ReleaseBranch) String() string { + upstreamPart := "" + if b.UseUpstream { + upstreamPart = "@{u}" + } + if b.Major == 0 { + return fmt.Sprintf("release-0.%d%s", b.Minor, upstreamPart) + } + return fmt.Sprintf("release-%d%s", b.Major, upstreamPart) +} +func (b ReleaseBranch) Committish() string { + return b.String() +} + +// ReleaseTag is a Committish that's actually a version-tag for a release. +type ReleaseTag semver.Version + +func (v ReleaseTag) Committish() string { + return "v" + semver.Version(v).String() +} +func (v ReleaseTag) String() string { + return v.Committish() +} +func (v ReleaseTag) Validate() error { + if len(v.Pre) != 0 && len(v.Pre) != 2 { + return fmt.Errorf("invalid pre-release info (must be -{alpha,beta,rc}.version)") + } + if len(v.Pre) == 2 && (v.Pre[0].IsNum || !v.Pre[1].IsNum) { + // TODO: validate alpha, beta, rc + return fmt.Errorf("invalid pre-release info (must be -{alpha,beta,rc}.version)") + } + return nil +} + +// FirstCommit is a Committish that's the first commit on a branch, generally +// used when the previous release tag does not exist. +type FirstCommit struct { + Commit git.Commit + Branch ReleaseBranch +} + +func (c FirstCommit) Committish() string { + return c.Commit.Committish() +} + +// parseReleaseTag parses a git tag name into a ReleaseTag. +func parseReleaseTag(tagRaw git.Tag) (*ReleaseTag, error) { + tagRawBytes := []byte(tagRaw) + if tagRawBytes[0] != 'v' { + return nil, fmt.Errorf("not a version tag (vX.Y.Z)") + } + tagRawBytes = tagRawBytes[1:] // skip the 'v' + + tagParsed, err := semver.Parse(string(tagRawBytes)) + if err != nil { + return nil, err + } + tag := ReleaseTag(tagParsed) + if err := tag.Validate(); err != nil { + return nil, err + } + return &tag, nil +} + +// LatestRelease returns the most recent ReleaseTag on this branch, or a the +// FirstCommit if none existed. +func (b ReleaseBranch) LatestRelease(gitImpl git.Git, checkVersion bool) (git.Committish, error) { + tagRaw, err := gitImpl.ClosestTag(b) + if err != nil { + golog.Printf("unable to get latest tag starting at %q, assuming we need to look for the first commit instead (%v)", b, err) + // try to get the first commit + commitSHA, commitErr := gitImpl.FirstCommit(b.String()) + if commitErr != nil { + // double wrap to get both errors + return nil, fmt.Errorf("unable to grab first commit on branch %q (%v), also unable to fetch most recent tag: %w", b, err, commitErr) + } + return FirstCommit{ + Branch: b, + Commit: commitSHA, + }, nil + } + + tag, err := parseReleaseTag(tagRaw) + if err != nil { + return nil, fmt.Errorf("latest tag %q on branch %q was not a (valid?) version: %w", tag, b, err) + } + + if !checkVersion { + golog.Printf("latest release on branch %q is probably %q", b, tag) + return ReleaseTag(*tag), nil + } + + golog.Printf("latest release on branch %q is probably %q", b, tag) + relTag := ReleaseTag(*tag) + return relTag, b.VerifyTagBelongs(relTag) +} + +// VerifyTagBelongs checks that a given tag has the correct major-ish version +// for this branch. +func (b ReleaseBranch) VerifyTagBelongs(tag ReleaseTag) error { + if tag.Major != b.Major || (tag.Major == 0 && tag.Minor != b.Minor) { + return fmt.Errorf("tag's version %v does not match the branch's version %v", tag, b) + } + return nil +} + +// checkOrClearUpstream verifies that the upstream exists for this branch and +// clears UseUpstream if it does not. If UseUpstream is already false, this is +// a no-op. +func checkOrClearUpstream(gitImpl git.Git, branch *ReleaseBranch) { + if !branch.UseUpstream { + return + } + if err := gitImpl.HasUpstream(branch.String()); err != nil { + branch.UseUpstream = false + golog.Printf("branch %q did not have an upstream, falling back to non-upstream (%v)", branch, err) + } +} + +// CurrentVersion locates the closest current version (release tag or first +// commit), starting at the HEAD of the current branch. If the branch has an +// upstream and is configured to use it, it'll try that first. If that doesn't +// work, it'll clear the UseUpstream field and try the non-upstream version. +// +// Furthermore, if it looks like the closest release actually shoulbelongs to the previous +// release branch, it'll double-check that branch instead, to get the actual most recent release. +// For instance, on a fresh `release-0.7` branch, the "latest" release might be `v0.6.0` +// (since the `v0.Y.0` releases are always off of the main branch), so it'll check `release-0.6` +// to find that the *actual* latest release is `v0.6.3`. +func CurrentVersion(gitImpl git.Git, branch *ReleaseBranch) (git.Committish, error) { + origUseUpstream := branch.UseUpstream // keep this around to keep trying later if necessary + checkOrClearUpstream(gitImpl, branch) + + latestHere, err := branch.LatestRelease(gitImpl, false) + if err != nil { + return nil, err + } + + tag, isTag := latestHere.(ReleaseTag) + if !isTag { + golog.Printf("no latest tag, not double-checking version matches") + return latestHere, nil + } + + switch { + case branch.Major == 0 && tag.Major == 0 && tag.Minor == branch.Minor-1: + // most recent tag is a release behind, check the previous branch: + // on the first release on a release branch, we'll generally end up + // seeing the first release of the last "major-ish" (X, or 0.Y), since + // that'll be the only one that ends up on master (the rest are on + // a release branch). Therefore, switch branches backwards to get the actual + // last tag. + prevRel := ReleaseBranch{ + Version: semver.Version{Major: 0, Minor: tag.Minor}, + UseUpstream: origUseUpstream, + } + golog.Printf("most recent tag %q is from last version (probably a 0.Y bump), double-checking previous release branch %q for actual latest version", tag.Committish(), prevRel) + checkOrClearUpstream(gitImpl, &prevRel) + return prevRel.LatestRelease(gitImpl, true) + case branch.Major == 1 && tag.Major == 0: + // ditto as above, except with 1 releases instead to 0.Y + prevRel := ReleaseBranch{ + Version: semver.Version{Minor: tag.Minor}, + UseUpstream: branch.UseUpstream, + } + golog.Printf("most recent tag %q is from last version (probably a 0.Y --> 1 bump), double-checking previous release branch %q for actual latest version", tag.Committish(), prevRel) + checkOrClearUpstream(gitImpl, &prevRel) + return prevRel.LatestRelease(gitImpl, true) + case branch.Major > 0 && tag.Major == branch.Major-1: + // same as the first case, except with X releases instead of 0.Y + prevRel := ReleaseBranch{ + Version: semver.Version{Major: tag.Major}, + UseUpstream: branch.UseUpstream, + } + golog.Printf("most recent tag %q is from last version (probably a X bump), double-checking previous release branch %q for actual latest version", tag.Committish(), prevRel) + checkOrClearUpstream(gitImpl, &prevRel) + return prevRel.LatestRelease(gitImpl, true) + default: + return tag, branch.VerifyTagBelongs(tag) + } +} + +// LogEntry contains a single changelog entry from a PR. +type LogEntry struct { + PRNumber string + Title string +} + +// ChangeLog holds all changes between a release and HEAD, organized by release type. +type ChangeLog struct { + Breaking []LogEntry + Features []LogEntry + Bugs []LogEntry + Docs []LogEntry + Infra []LogEntry + Uncategorized []LogEntry +} + +// entryFromCommit adds a changelog entry to this changelog +// based on the emoji marker in the title. +func (l *ChangeLog) entryFromCommit(prNum, title string) { + entry := LogEntry{PRNumber: prNum} + + prType, title := common.PRTypeFromTitle(title) + entry.Title = title + switch prType { + case common.FeaturePR: + l.Features = append(l.Features, entry) + case common.BugfixPR: + l.Bugs = append(l.Bugs, entry) + case common.DocsPR: + l.Docs = append(l.Docs, entry) + case common.InfraPR: + l.Infra = append(l.Infra, entry) + case common.BreakingPR: + l.Breaking = append(l.Breaking, entry) + case common.UncategorizedPR: + l.Uncategorized = append(l.Uncategorized, entry) + default: + panic(fmt.Sprintf("unrecognized internal PR type %v", prType)) + } +} + +// ReleaseKind indicates the "finality" of this release -- pre-release (alpha, +// beta, rc) or final. +type ReleaseKind int + +const ( + ReleaseFinal ReleaseKind = 0 + ReleaseAlpha ReleaseKind = 1 + ReleaseBeta ReleaseKind = 2 + ReleaseCandidate ReleaseKind = 3 +) + +// ReleaseInfo describes the desired type of release. +type ReleaseInfo struct { + // Kind is the finality of the release. + Kind ReleaseKind + // Pre10 indicates that if the current release is 0.Y, and we'd need a new + // major-ish version, choose v0.(Y+1) and not v1.0.0. + Pre10 bool +} + +// ExpectedNextVersion computes what the next version for should be given a set +// of changes, and desired type of release. +// +// Roughly, this means that, if one of the releases (current or desired next) +// is a final release: +// +// - 0.Y releases are equivalent to either X or X.Y releases +// - Breaking changes bump X +// - Features bump Y +// - Anything else just bumps Z +// +// If we're jumping between prereleases, ignore all that and either increment +// the pre-release number or reset the number to zero if we're switching types. +// +// If Pre10 is set, never jump to v1.0.0. +func (c ChangeLog) ExpectedNextVersion(currentVersion git.Committish, info ReleaseInfo) (ReleaseTag, error) { + tag, isTag := currentVersion.(ReleaseTag) + if !isTag { + res := ReleaseTag(semver.Version{ + Minor: 1, + }) + switch info.Kind { + case ReleaseAlpha: + res.Pre = []semver.PRVersion{{VersionStr: "alpha"}, {VersionNum: 0, IsNum: true}} + case ReleaseBeta: + res.Pre = []semver.PRVersion{{VersionStr: "beta"}, {VersionNum: 0, IsNum: true}} + case ReleaseCandidate: + res.Pre = []semver.PRVersion{{VersionStr: "rc"}, {VersionNum: 0, IsNum: true}} + } + return res, nil + } + + // final releases + newTag := tag + if info.Kind == ReleaseFinal { + // pre --> final: reset pre, keep version + if len(newTag.Pre) > 0 { + newTag.Pre = nil + return newTag, nil + } + + // final --> final: bump according to rules + return c.nextFinalVersion(tag, info.Pre10), nil + } + + // easy pre-release case: same type of pre-release + // alpha --> alpha || beta --> beta || rc --> rc + wasPre := len(tag.Pre) > 0 + alphaToAlpha := wasPre && tag.Pre[0] == semver.PRVersion{VersionStr: "alpha"} && info.Kind == ReleaseAlpha + betaToBeta := wasPre && tag.Pre[0] == semver.PRVersion{VersionStr: "beta"} && info.Kind == ReleaseBeta + candidateToCandidate := wasPre && tag.Pre[0] == semver.PRVersion{VersionStr: "candidate"} && info.Kind == ReleaseCandidate + if alphaToAlpha || betaToBeta || candidateToCandidate { + newTag := tag + // don't clobber old release + newTag.Pre = make([]semver.PRVersion, len(tag.Pre)) + copy(newTag.Pre, tag.Pre) + newTag.Pre[1].VersionNum++ + return newTag, nil + } + + // otherwise, if the old release was a final release... + if tag.Pre == nil { + // ...bump according to rules... + newTag = c.nextFinalVersion(tag, info.Pre10) + } + + // ...either way, add the appropriate new pre tag @ 0 + switch info.Kind { + case ReleaseAlpha: + newTag.Pre = []semver.PRVersion{{VersionStr: "alpha"}, {VersionNum: 0, IsNum: true}} + case ReleaseBeta: + newTag.Pre = []semver.PRVersion{{VersionStr: "beta"}, {VersionNum: 0, IsNum: true}} + case ReleaseCandidate: + newTag.Pre = []semver.PRVersion{{VersionStr: "rc"}, {VersionNum: 0, IsNum: true}} + } + + if semver.Version(newTag).LE(semver.Version(tag)) { + return newTag, fmt.Errorf("\"new\" version %q actually would be an older version than current %q", newTag.Committish(), tag.Committish()) + } + + return newTag, nil +} + +// nextFinalVersion computes the next "final" release given the current one and +// the desired (or lack thereof) to go to v1.0.0. +func (c ChangeLog) nextFinalVersion(current ReleaseTag, pre10 bool) ReleaseTag { + newTag := semver.Version(current) + newTag.Pre = nil + newTag.Build = nil + switch { + case len(c.Breaking) > 0: + if current.Major == 0 && pre10 { + newTag.IncrementMinor() + } else { + newTag.IncrementMajor() + } + case len(c.Features) > 0: + newTag.IncrementMinor() + // we're doing a new version anyway, so we probably at least need a patch + default: + newTag.IncrementPatch() + } + return ReleaseTag(newTag) +} + +// Changes computes the changelog from last release TO HEAD, returning both the +// changelog and the last release used. +func Changes(gitImpl git.Git, branch *ReleaseBranch) (log ChangeLog, since git.Committish, err error) { + since, err = CurrentVersion(gitImpl, branch) + if err != nil { + return ChangeLog{}, nil, err + } + + changes, err := ChangesSince(gitImpl, *branch, since) + return changes, since, err +} + +// ChangesSince computes the changelog from the given point to HEAD. +func ChangesSince(gitImpl git.Git, branch ReleaseBranch, since git.Committish) (ChangeLog, error) { + golog.Printf("finding changes since %q", since.Committish()) + + commitsRaw, err := gitImpl.MergeCommitsBetween(since, branch) + if err != nil { + return ChangeLog{}, fmt.Errorf("unable to list commits since %s on branch %q: %w", since.Committish(), branch, err) + } + + log := ChangeLog{} + + // do this parser-style + commitLines := strings.Split(commitsRaw, "\n") + lines := &lineReader{lines: commitLines} + for lines.more() { + var commit, prNumber, fork string + if !lines.expectScanf("commit %s", &commit) { + // skip terminating blank line, and others + // basically, just get to the next known good state + if lines.line() != "" { + golog.Printf("ignoring seemly non-commit line %q", lines.line()) + } + continue + } + if !lines.expectScanf("Merge pull request #%s from %s", &prNumber, &fork) { + // might be one of the mistakes that got into our history, just + // bail till the next commit they look like `Merge branch 'BR'`, + // generally + golog.Printf("skipping non-official merge commit (%q) with title %q", commit, lines.line()) + continue + } + if !lines.expectBlank() { + golog.Printf("got unexpected non-blank line %q, skipping till next commit", lines.line()) + continue + } + if !lines.next() { + break + } + log.entryFromCommit(prNumber, lines.line()) + } + + return log, nil +} + +// lineReader helps parsing line-by-line data, like rev-list output. +// start by setting lines. +type lineReader struct { + lines []string + cur string +} + +// next loads the next line, returning false if none are available. +func (l *lineReader) next() bool { + if len(l.lines) == 0 { + l.cur = "" + return false + } + l.cur = l.lines[0] + l.lines = l.lines[1:] + return true +} + +// more checks if the next call to next would return true. +func (l *lineReader) more() bool { + return len(l.lines) > 0 +} + +// line grabs the current line. +func (l *lineReader) line() string { + return l.cur +} + +// expectScanf loads a new line and scans it according to the supplied args, +// returning false if it didn't scan or no lines were available. +func (l *lineReader) expectScanf(fmtStr string, args ...interface{}) bool { + if !l.next() { + return false + } + n, err := fmt.Sscanf(l.cur, fmtStr, args...) + return err == nil && n == len(args) +} + +// expectScanf loads a new line and scans it according to the supplied args, +// returning false if it's not blank or no lines were available. +func (l *lineReader) expectBlank() bool { + if !l.next() { + return false + } + return l.cur == "" +} + +// IsPreReleaseToFinal figures out if we're going from a pre-release +// version to a final version. If true, current is guaranteed to be +// a ReleaseTag. +func IsPreReleaseToFinal(current git.Committish, next ReleaseTag) bool { + if len(next.Pre) != 0 { + return false + } + + tag, isTag := current.(ReleaseTag) + if !isTag { + return false + } + + return len(tag.Pre) != 0 +} + +// ClosestFinal finds the "closest" previous final release. For example, given +// `v0.7.0-rc.3`, the closest final release might be `v0.6.3`. +func ClosestFinal(gitImpl git.Git, current ReleaseTag) (*ReleaseTag, error) { + currentFinal := semver.Version(current) + currentFinal.Pre = nil + + toCheckTag := semver.Version(current) + var toCheck git.Committish = current + for len(toCheckTag.Pre) != 0 || toCheckTag.EQ(currentFinal) { + + tagRaw, err := gitImpl.ClosestTag(git.SomeCommittish(toCheck.Committish() + "~1")) + if err != nil { + return nil, err + } + latestTag, err := parseReleaseTag(tagRaw) + if err != nil { + golog.Printf("skipping non-release tag %q: %v", string(tagRaw), err) + toCheck = git.SomeCommittish(string(tagRaw)) + continue + } + toCheck = latestTag + toCheckTag = semver.Version(*latestTag) + } + + if toCheckTag.EQ(semver.Version(current)) || len(toCheckTag.Pre) != 0 { + return nil, fmt.Errorf("unable to locate previous final release, just found current one") + } + + tag := ReleaseTag(toCheckTag) + return &tag, nil +} diff --git a/notes/compose/versions_test.go b/notes/compose/versions_test.go new file mode 100644 index 0000000..888c234 --- /dev/null +++ b/notes/compose/versions_test.go @@ -0,0 +1,454 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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 compose_test + +import ( + "fmt" + + "github.com/blang/semver/v4" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + . "sigs.k8s.io/kubebuilder-release-tools/notes/compose" + "sigs.k8s.io/kubebuilder-release-tools/notes/git" +) + +var _ = Describe("Versions", func() { + Describe("finding the current one", func() { + It("should return the latest release on this branch if it matches the branch version", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + return git.Tag("v0.6.3"), nil + }, + } + + currBranch := ReleaseBranch{Version: semver.Version{Minor: 6}} + Expect(CurrentVersion(gitImpl, &currBranch)).To(Equal(ReleaseTag( + semver.Version{Minor: 6, Patch: 3}, + ))) + }) + + It("should return the first commit on this branch if no release exists", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + return git.Tag(""), fmt.Errorf("no tag found!") + }, + firstCommit: func(branchName string) (git.Commit, error) { + return git.Commit("abcdef"), nil + }, + } + + currBranch := ReleaseBranch{Version: semver.Version{Minor: 6}} + Expect(CurrentVersion(gitImpl, &currBranch)).To(Equal(FirstCommit{ + Branch: currBranch, + Commit: git.Commit("abcdef"), + })) + }) + + Context("when figuring out if upstreams should be used", func() { + It("should clear the upstream on the current branch if no upstream exists", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + if initial.Committish() != "release-0.6" { + return git.Tag(""), fmt.Errorf("supplied branch that was probably an upstream: %v", initial) + } + return git.Tag("v0.6.3"), nil + }, + hasUpstream: func(branchName string) error { + if branchName == "release-0.6@{u}" { + return fmt.Errorf("no upstream for this branch") + } + return nil + }, + } + + currBranch := ReleaseBranch{Version: semver.Version{Minor: 6}, UseUpstream: true} + _, err := CurrentVersion(gitImpl, &currBranch) + Expect(err).NotTo(HaveOccurred()) + Expect(currBranch.UseUpstream).To(BeFalse()) + }) + + It("should keep the upstream around if one does exist", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + if initial.Committish() != "release-0.6@{u}" { + return git.Tag(""), fmt.Errorf("supplied branch that was not an upstream: %v", initial) + } + return git.Tag("v0.6.3"), nil + }, + hasUpstream: func(branchName string) error { + if branchName == "release-0.6@{u}" { + return nil + } + return fmt.Errorf("no upstream for this branch") + }, + } + + currBranch := ReleaseBranch{Version: semver.Version{Minor: 6}, UseUpstream: true} + _, err := CurrentVersion(gitImpl, &currBranch) + Expect(err).NotTo(HaveOccurred()) + Expect(currBranch.UseUpstream).To(BeTrue()) + }) + + It("should keep using upstreams when looking back a branch, even if the current one lacked one, if we originally asked to", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + switch initial.Committish() { + case "release-0.6": + return git.Tag("v0.5.0"), nil + case "release-0.5@{u}": // still using the upstream for older branches + return git.Tag("v0.5.7"), nil + default: + panic("unexpected branch requested") + } + }, + hasUpstream: func(branchName string) error { + if branchName == "release-0.6@{u}" { + return fmt.Errorf("no upstream for this branch") + } + return nil + }, + } + + currBranch := ReleaseBranch{Version: semver.Version{Minor: 6}, UseUpstream: true} + Expect(CurrentVersion(gitImpl, &currBranch)).To(Equal(ReleaseTag( + semver.Version{Minor: 5, Patch: 7}, + ))) + Expect(currBranch.UseUpstream).To(BeFalse()) + }) + }) + + Context("when the latest release belongs to the previous release", func() { + It("should return the latest release on that release-0.(Y-1) branch", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + switch initial.Committish() { + case "release-0.7": + return git.Tag("v0.6.0"), nil + case "release-0.6": + return git.Tag("v0.6.3"), nil + default: + panic(fmt.Sprintf("got unexpected commit %v for ClosestTag", initial)) + } + }, + } + + currBranch := ReleaseBranch{Version: semver.Version{Minor: 7}} + Expect(CurrentVersion(gitImpl, &currBranch)).To(Equal(ReleaseTag( + semver.Version{Minor: 6, Patch: 3}, + ))) + }) + It("should return the latest release on that release-(X-1) branch", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + switch initial.Committish() { + case "release-2": + return git.Tag("v1.0.0"), nil + case "release-1": + return git.Tag("v1.9.6"), nil + default: + panic(fmt.Sprintf("got unexpected commit %v for ClosestTag", initial)) + } + }, + } + + currBranch := ReleaseBranch{Version: semver.Version{Major: 2}} + Expect(CurrentVersion(gitImpl, &currBranch)).To(Equal(ReleaseTag( + semver.Version{Major: 1, Minor: 9, Patch: 6}, + ))) + }) + It("should return the latest release on that release-0.Y branch if this is a release-1 branch", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + switch initial.Committish() { + case "release-1": + return git.Tag("v0.6.0"), nil + case "release-0.6": + return git.Tag("v0.6.3"), nil + default: + panic(fmt.Sprintf("got unexpected commit %v for ClosestTag", initial)) + } + }, + } + + currBranch := ReleaseBranch{Version: semver.Version{Major: 1}} + Expect(CurrentVersion(gitImpl, &currBranch)).To(Equal(ReleaseTag( + semver.Version{Minor: 6, Patch: 3}, + ))) + }) + + It("should fail if the previous branch has a release that doesn't belong to it", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + switch initial.Committish() { + case "release-1": + return git.Tag("v0.6.0"), nil + case "release-0.6": + return git.Tag("v0.5.0"), nil + default: + panic(fmt.Sprintf("got unexpected commit %v for ClosestTag", initial)) + } + }, + } + + currBranch := ReleaseBranch{Version: semver.Version{Major: 1}} + _, err := CurrentVersion(gitImpl, &currBranch) + Expect(err).To(HaveOccurred()) + }) + }) + + It("should fail if the latest release doesn't belong to the current or previous release branch", func() { + gitImpl := gitFuncs{ + closestTag: func(initial git.Committish) (git.Tag, error) { + return git.Tag("v0.6.0"), nil + }, + } + + currBranch := ReleaseBranch{Version: semver.Version{Major: 2}} + _, err := CurrentVersion(gitImpl, &currBranch) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("finding the next one", func() { + Context("when making final releases", func() { + // Test mostly with Pre10 because it's the default from the CLI + relInfo := ReleaseInfo{Kind: ReleaseFinal, Pre10: true} + + Context("from final releases", func() { + Context("with X>=1 releases", func() { + current := ReleaseTag(semver.Version{Major: 1, Minor: 6, Patch: 3}) + + It("should bump X on breaking changes", func() { + log := ChangeLog{ + Breaking: []LogEntry{{Title: "something major", PRNumber: "33"}}, + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Major: 2}, + ))) + }) + + It("should bump Y on new features", func() { + log := ChangeLog{ + Features: []LogEntry{{Title: "some feature", PRNumber: "44"}}, + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Major: 1, Minor: 7}, + ))) + }) + + It("should bump Z on anything else", func() { + log := ChangeLog{ + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Major: 1, Minor: 6, Patch: 4}, + ))) + }) + }) + + Context("with 0.Y releases", func() { + current := ReleaseTag(semver.Version{Minor: 6, Patch: 3}) + + It("should bump Y on breaking changes with Pre10 set to true", func() { + log := ChangeLog{ + Breaking: []LogEntry{{Title: "something major", PRNumber: "33"}}, + Features: []LogEntry{{Title: "some feature", PRNumber: "44"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Minor: 7}, + ))) + }) + + It("should bump Y on new features", func() { + log := ChangeLog{ + Features: []LogEntry{{Title: "some feature", PRNumber: "44"}}, + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Minor: 7}, + ))) + }) + + It("should bump Z on anything else", func() { + log := ChangeLog{ + Docs: []LogEntry{{Title: "some doc change", PRNumber: "66"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Minor: 6, Patch: 4}, + ))) + }) + + It("should bump 0.Y to 1.0.0 if Pre10 is false", func() { + v1Info := ReleaseInfo{Kind: ReleaseFinal} + log := ChangeLog{ + Breaking: []LogEntry{{Title: "something major", PRNumber: "33"}}, + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, v1Info)).To(Equal(ReleaseTag( + semver.Version{Major: 1}, + ))) + }) + }) + }) + Context("from pre-releases", func() { + current := ReleaseTag(semver.Version{Major: 2, Pre: []semver.PRVersion{ + {VersionStr: "rc"}, {VersionNum: 4, IsNum: true}, + }}) + It("should just clear the pre-release tag, keeping the version", func() { + log := ChangeLog{ + Breaking: []LogEntry{{Title: "something major", PRNumber: "33"}}, + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Major: 2}, + ))) + }) + }) + }) + + Context("when making pre-releases", func() { + // Test mostly with Pre10 because it's the default from the CLI + relInfo := ReleaseInfo{Kind: ReleaseBeta, Pre10: true} + betaPre := func(num uint64) []semver.PRVersion { + return []semver.PRVersion{{VersionStr: "beta"}, {VersionNum: num, IsNum: true}} + } + + Context("from final releases", func() { + Context("with X>=1 releases", func() { + current := ReleaseTag(semver.Version{Major: 1, Minor: 6, Patch: 3}) + + It("should bump X on breaking changes", func() { + log := ChangeLog{ + Breaking: []LogEntry{{Title: "something major", PRNumber: "33"}}, + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Major: 2, Pre: betaPre(0)}, + ))) + }) + + It("should bump Y on new features", func() { + log := ChangeLog{ + Features: []LogEntry{{Title: "some feature", PRNumber: "44"}}, + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Major: 1, Minor: 7, Pre: betaPre(0)}, + ))) + }) + + It("should bump Z on anything else", func() { + log := ChangeLog{ + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Major: 1, Minor: 6, Patch: 4, Pre: betaPre(0)}, + ))) + }) + }) + + Context("with 0.Y releases", func() { + current := ReleaseTag(semver.Version{Minor: 6, Patch: 3}) + + It("should bump Y on breaking changes with Pre10 set to true", func() { + log := ChangeLog{ + Breaking: []LogEntry{{Title: "something major", PRNumber: "33"}}, + Features: []LogEntry{{Title: "some feature", PRNumber: "44"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Minor: 7, Pre: betaPre(0)}, + ))) + }) + + It("should bump Y on new features", func() { + log := ChangeLog{ + Features: []LogEntry{{Title: "some feature", PRNumber: "44"}}, + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Minor: 7, Pre: betaPre(0)}, + ))) + }) + + It("should bump Z on anything else", func() { + log := ChangeLog{ + Docs: []LogEntry{{Title: "some doc change", PRNumber: "66"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Minor: 6, Patch: 4, Pre: betaPre(0)}, + ))) + }) + + It("should bump 0.Y to 1.0.0 if Pre10 is false", func() { + v1Info := ReleaseInfo{Kind: ReleaseBeta} + log := ChangeLog{ + Breaking: []LogEntry{{Title: "something major", PRNumber: "33"}}, + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, v1Info)).To(Equal(ReleaseTag( + semver.Version{Major: 1, Pre: betaPre(0)}, + ))) + }) + }) + }) + Context("from pre-releases", func() { + current := ReleaseTag(semver.Version{Major: 2, Pre: []semver.PRVersion{ + {VersionStr: "beta"}, {VersionNum: 0, IsNum: true}, + }}) + Context("with the same kind of pre-release", func() { + It("should just increment the pre-release number", func() { + log := ChangeLog{ + Breaking: []LogEntry{{Title: "something major", PRNumber: "33"}}, + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, relInfo)).To(Equal(ReleaseTag( + semver.Version{Major: 2, Pre: betaPre(1)}, + ))) + }) + }) + Context("with a different kind of pre-release", func() { + It("should reset the pre-release info to the desired state if it would be an increment", func() { + rcInfo := ReleaseInfo{Kind: ReleaseCandidate, Pre10: true} + log := ChangeLog{ + Breaking: []LogEntry{{Title: "something major", PRNumber: "33"}}, + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + Expect(log.ExpectedNextVersion(current, rcInfo)).To(Equal(ReleaseTag( + semver.Version{Major: 2, Pre: []semver.PRVersion{ + {VersionStr: "rc"}, {VersionNum: 0, IsNum: true}, + }}, + ))) + }) + + It("should reject trying to return to older pre-release kinds", func() { + alphaInfo := ReleaseInfo{Kind: ReleaseAlpha, Pre10: true} + log := ChangeLog{ + Breaking: []LogEntry{{Title: "something major", PRNumber: "33"}}, + Bugs: []LogEntry{{Title: "some bugfix", PRNumber: "55"}}, + } + + _, err := log.ExpectedNextVersion(current, alphaInfo) + Expect(err).To(HaveOccurred()) + }) + }) + }) + }) + }) +}) diff --git a/notes/git/utils.go b/notes/git/utils.go new file mode 100644 index 0000000..d1cdd6a --- /dev/null +++ b/notes/git/utils.go @@ -0,0 +1,135 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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 git + +import ( + "fmt" + "os/exec" + "strings" + + "sigs.k8s.io/kubebuilder-release-tools/notes/common" +) + +// Committish represents some git committish object. +type Committish interface { + Committish() string +} + +// Tag is a committish that is a git tag. +type Tag string + +func (t Tag) Committish() string { + return string(t) +} + +// Commit is a committish that is a git commit SHA. +type Commit string + +func (c Commit) Committish() string { + return string(c) +} + +// SomeCommittish is any ol' user-specified Committish. +type SomeCommittish string + +func (c SomeCommittish) Committish() string { + return string(c) +} + +// Git runs the git-related functionality used by the release notes package, +// (that way a mock can be produced). +type Git interface { + // ClosestTag finds the closest tag to the given committish. + ClosestTag(initial Committish) (Tag, error) + // FirstCommit finds the first commit on a given branch. + FirstCommit(branchName string) (Commit, error) + // HasUpstream checks if a given branch has an upstream, returning an error if it does not. + HasUpstream(branchName string) error + // MergeCommitsBetween shows all the merge commits between start and end, + // in %B (raw body) form. + MergeCommitsBetween(start, end Committish) (string, error) +} + +// Actual calls out to the git command to get results. +var Actual = actualGit{} + +// actualGit calls out to the git command to get results. +type actualGit struct{} + +func (actualGit) ClosestTag(initial Committish) (Tag, error) { + latestTagCmd := exec.Command("git", "describe", "--tags", "--abbrev=0", initial.Committish()) + tagRaw, err := latestTagCmd.Output() + if err != nil { + return Tag(""), common.ErrOut(err) + } + + return Tag(strings.TrimSpace(string(tagRaw))), nil +} +func (actualGit) FirstCommit(branchName string) (Commit, error) { + cmd := exec.Command("git", "rev-list", "--max-parents=0", branchName) + out, err := cmd.Output() + if err != nil { + return "", common.ErrOut(err) + } + return Commit(strings.TrimSpace(string(out))), nil +} +func (actualGit) HasUpstream(branchName string) error { + return exec.Command("git", "rev-parse", "--abbrev=0", "--symbolic-full-name", branchName).Run() +} +func (actualGit) CurrentBranch() (string, error) { + currentBranchName, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() + if err != nil { + return "", fmt.Errorf("unable to determine current branch from HEAD: %w", common.ErrOut(err)) + } + return strings.TrimSpace(string(currentBranchName)), err +} +func (actualGit) MergeCommitsBetween(start, end Committish) (string, error) { + listCommits := exec.Command("git", "rev-list", start.Committish()+".."+end.Committish(), "--merges", "--pretty=format:%B") + + commitsRaw, err := listCommits.Output() + if err != nil { + return "", err + } + return string(commitsRaw), nil +} + +// RemoteForUpstreamFor returns the remote for the upstream for the given branch. +func (actualGit) RemoteForUpstreamFor(branchName string) (string, error) { + remoteForBranch, err := exec.Command("git", "for-each-ref", "--format=%(upstream:remotename)", "refs/heads/"+branchName).Output() + if err != nil { + return "", common.ErrOut(err) + } + res := strings.TrimSpace(string(remoteForBranch)) + if res == "" { + return "", fmt.Errorf("no upstream/remote found") + } + return res, nil +} + +// URLForRemote returns the fetch URL for the given remote. +func (actualGit) URLForRemote(remote string) (string, error) { + upstreamURLRaw, err := exec.Command("git", "remote", "get-url", remote).Output() + if err != nil { + return "", common.ErrOut(err) + } + return strings.TrimSpace(string(upstreamURLRaw)), nil +} + +// Fetch fetches the given remote (including tags) +func (actualGit) Fetch(remote string) error { + return common.ErrOut(exec.Command("git", "fetch", "--tags", remote).Run()) +} diff --git a/notes/go.mod b/notes/go.mod new file mode 100644 index 0000000..a80cc09 --- /dev/null +++ b/notes/go.mod @@ -0,0 +1,10 @@ +module sigs.k8s.io/kubebuilder-release-tools/notes + +go 1.14 + +require ( + github.com/blang/semver/v4 v4.0.0 + github.com/go-git/go-git/v5 v5.1.0 + github.com/onsi/ginkgo v1.14.1 + github.com/onsi/gomega v1.10.2 +) diff --git a/notes/go.sum b/notes/go.sum new file mode 100644 index 0000000..ccccca6 --- /dev/null +++ b/notes/go.sum @@ -0,0 +1,116 @@ +github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/blang/semver v1.1.0 h1:ol1rO7QQB5uy7umSNV7VAmLugfLRD+17sYJujRNYPhg= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= +github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= +github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM= +github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-git v1.0.0 h1:YcN9iDGDoXuIw0vHls6rINwV416HYa0EB2X+RBsyYp4= +github.com/go-git/go-git v4.7.0+incompatible h1:+W9rgGY4DOKKdX2x6HxSR7HNeTxqiKrOvKnuittYVdA= +github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw= +github.com/go-git/go-git/v5 v5.1.0 h1:HxJn9g/E7eYvKW3Fm7Jt4ee8LXfPOm/H1cdDu8vEssk= +github.com/go-git/go-git/v5 v5.1.0/go.mod h1:ZKfuPUoY1ZqIG4QG9BDBh3G4gLM5zvPuSJAozQrZuyM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg= +github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= +github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.1 h1:jMU0WaQrP0a/YAEq8eJmJKjBoMs+pClEr1vDMlM/Do4= +github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.10.2 h1:aY/nuoWlKJud2J6U0E3NWsjlg+0GtwXxgEqthRdzlcs= +github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= +github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= +golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 h1:AeiKBIuRw3UomYXSbLy0Mc2dDLfdtbT/IVn4keq83P0= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/notes/relnotes.go b/notes/relnotes.go new file mode 100644 index 0000000..f269a64 --- /dev/null +++ b/notes/relnotes.go @@ -0,0 +1,314 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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. +*/ + +/* +This tool prints all the titles of all PRs from previous release to HEAD. +This needs to be run *before* a tag is created. + +Use these as the base of your release notes. +*/ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + "sigs.k8s.io/kubebuilder-release-tools/notes/compose" + "sigs.k8s.io/kubebuilder-release-tools/notes/git" +) + +var ( + fromTag = flag.String("from", "", "The tag or commit to start from.") + branchName = flag.String("branch", "", "The release branch to run on (defaults to current)") + showOthers = flag.String("show-others", "", "Comma-separate set of non-code changes to show (docs,infra)") + project = flag.String("project", "", "GitHub project in org/repo form to use to generate link to past releases (defaults to a value extracted from the remote of the branch or 'upstream'") + useUpstreams = flag.Bool("use-upstream", true, "try to compose information from upstream versions of the local release branches") + refreshUpstreams = flag.Bool("refresh-upstream", true, "git-fetch the remote for the current branch before continuing (only relevant if use-upstream is set)") + relType = flag.String("r", "final", "type of release -- final, alpha, beta, or rc") + forceV1 = flag.Bool("force-v1", false, "if the current release is 0.Y-style, assume the next 'major' release is 1.0 instead of being 0.Y-style") + extraInfoOnFinal = flag.Bool("print-full-final", true, "if the current release would bring us from pre-release to final, print the full changes since the last final release") +) + +// run wraps what would otherwise be main to have one error handler with +// detailed stderr on exec errors +func run() error { + if *fromTag == "" { + var err error + *branchName, err = git.Actual.CurrentBranch() + if err != nil { + return err + } + } + log.Printf("starting from branch %q", *branchName) + + branch, err := compose.ReleaseFromBranch(*branchName) + if err != nil { + return err + } + + if *useUpstreams { + branch.UseUpstream = true + if *refreshUpstreams { + if err := refreshUpstream(*branchName); err != nil { + // this might happen if we're on a new branch, so don't fret + fmt.Fprintf(os.Stderr, "\x1b[1;31munable to refresh upstream, continuing on without it -- you may want to do this manually\x1b[0m: %v\n", err) + } + } + } + + var ( + changes compose.ChangeLog + since git.Committish + ) + if *fromTag == "" { + changes, since, err = compose.Changes(git.Actual, &branch) + } else { + since = git.SomeCommittish(*fromTag) + changes, err = compose.ChangesSince(git.Actual, branch, since) + } + if err != nil { + return err + } + + if *project == "" { + var err error + if branch.UseUpstream { + // reset UseUpstream so we don't try to get the remote for an upstream itself + *project, err = findProject(compose.ReleaseBranch{Version: branch.Version}.String()) + } else { + log.Printf("current branch %q has no assicated upstream, assuming upstream remote is \"upstream\" for auto-setting project", branch) + *project, err = findProject("") + } + if err != nil { + log.Printf("unable to determine URL for upstream remote (set --project manually): %v", err) + } + } + + return printLog(branch, logChunk{ChangeLog: changes, since: since}) +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), `Usage of %[1]s [FLAGS]: + + Examples: + + # Prep for a beta release + %[1]s -r beta + + # Prep for a release that bumps version 0.Y to 1.0.0 + %[1]s --force-v1 + + # Show docs contributions in the release notes + %[1]s --show-others docs + + Flags: + +`, os.Args[0]) + + flag.PrintDefaults() + } + flag.Parse() + + err := run() + if err != nil { + log.Fatal(err) + } +} + +// logChunk is a piece of a full commit log. It contains one set of changes +// since a given committish. +type logChunk struct { + since git.Committish + compose.ChangeLog +} + +// Print prints the changes within this chunk along with a header indicating +// when these changes are from. +func (c *logChunk) Print() { + fmt.Printf("\n**changes since [%[1]s](https://github.com/%[2]s/releases/%[1]s)**\n", c.since.Committish(), *project) + + sectionIfPresent(c.Breaking, ":warning: Breaking Changes") + sectionIfPresent(c.Features, ":sparkles: New Features") + sectionIfPresent(c.Bugs, ":bug: Bug Fixes") + + optionals := strings.Split(*showOthers, ",") + for _, opt := range optionals { + switch opt { + case "docs": + sectionIfPresent(c.Docs, ":book: Documentation") + case "infra": + sectionIfPresent(c.Infra, ":seedling: Infra & Such") + case "": + // don't do anything + default: + log.Printf("unknown optinal section %q, skipping", opt) + } + } + + sectionIfPresent(c.Uncategorized, ":question: Sort these by hand") +} + +// release holds the name of the upcoming release, and the intermediate information +// used to make that decision. +type release struct { + compose.ReleaseInfo + next compose.ReleaseTag +} + +// releaseInfo computes compose.ReleaseInfo & the expected next release version +// given a branch and some changes. +func releaseInfo(branch compose.ReleaseBranch, changes logChunk) (release, error) { + relInfo := compose.ReleaseInfo{Pre10: !*forceV1} + switch *relType { + case "final": + relInfo.Kind = compose.ReleaseFinal + case "alpha": + relInfo.Kind = compose.ReleaseAlpha + case "beta": + relInfo.Kind = compose.ReleaseBeta + case "rc": + relInfo.Kind = compose.ReleaseCandidate + default: + return release{}, fmt.Errorf("unknown release type %q, must be final|alpha|beta|rc", *relType) + } + nextVer, err := changes.ExpectedNextVersion(changes.since, relInfo) + if err != nil { + return release{}, err + } + + return release{ + ReleaseInfo: relInfo, + next: nextVer, + }, nil +} + +// printLog prints the release log with appropriate header, changes-since link(s), +// and potentially a full extra change-log if we're going from pre-release to final. +func printLog(branch compose.ReleaseBranch, recentChanges logChunk) error { + if len(recentChanges.Breaking) > 0 { + fmt.Fprint(os.Stderr, "\x1b[1;31mbreaking changes this version\x1b[0m\n") + } + if len(recentChanges.Uncategorized) > 0 { + fmt.Fprint(os.Stderr, "\x1b[1;35munknown changes in this release -- categorize manually\x1b[0m\n") + } + + rel, err := releaseInfo(branch, recentChanges) + if err != nil { + return err + } + + // if we're going from pre-release to final, print out the total changes + var otherChanges *logChunk + if *extraInfoOnFinal && compose.IsPreReleaseToFinal(recentChanges.since, rel.next) { + // the cast is guaranteed by IsPreReleaseFinal + prev, err := compose.ClosestFinal(git.Actual, recentChanges.since.(compose.ReleaseTag)) + if err != nil { + return fmt.Errorf("unable to find last final release (try running with --print-full-final=false if that's expected): %w", err) + } + + otherLog, err := compose.ChangesSince(git.Actual, branch, *prev) + if err != nil { + return fmt.Errorf("unable to compute changes since last final release (try running with --print-full-final=false if that's expected): %w", err) + } + otherChanges = &logChunk{ + ChangeLog: otherLog, + since: *prev, + } + } + + // the actual log + fmt.Printf("# %s\n", rel.next) + + recentChanges.Print() + + if otherChanges != nil { + otherChanges.Print() + } + + fmt.Println("") + fmt.Println("*Thanks to all our contributors!*") + + return nil +} + +// formatEntry turns out a single log entry into a string form for printing. +func formatEntry(entry compose.LogEntry) string { + if entry.PRNumber == "" { + return entry.Title + } + return fmt.Sprintf("%s (#%s)", entry.Title, entry.PRNumber) +} + +// sectionIfPresent prints a section with the given title if any changes are +// present. +func sectionIfPresent(changes []compose.LogEntry, title string) { + if len(changes) > 0 { + fmt.Println("") + fmt.Printf("## %s\n", title) + fmt.Println("") + for _, change := range changes { + fmt.Printf("- %s\n", formatEntry(change)) + } + } +} + +// findProject guesses at the project for this repo. If a branch name is +// specified, it will be extracted from a github remote on the remote for the +// upstream for that branch. Otherwise, it'll be extracted from a github +// remote on the "upstream" remote. +func findProject(branchName string) (string, error) { + remote := "upstream" + if branchName != "" { + var err error + remote, err = git.Actual.RemoteForUpstreamFor(branchName) + if err != nil { + return "", fmt.Errorf("unable to determine upstream of branch %q: %w", branchName, err) + } + log.Printf("remote for branch %q was %q", branchName, remote) + } + + log.Printf("checking upstream URL for remote %q", remote) + upstreamURL, err := git.Actual.URLForRemote(remote) + if err != nil { + return "", fmt.Errorf("unable to determine upstream URL for %q: %w", remote, err) + } + + project := upstreamURL + + project = strings.TrimPrefix(project, "git@github.com:") + project = strings.TrimPrefix(project, "https://github.com/") + if project == upstreamURL { + return "", fmt.Errorf("unrecognized upstream URL format %q (expected either git@github.com:* or https://github.com/*)", upstreamURL) + } + + project = strings.TrimSuffix(project, ".git") + return project, nil +} + +func refreshUpstream(branchName string) error { + remote, err := git.Actual.RemoteForUpstreamFor(branchName) + if err != nil { + fmt.Errorf("unable to determine upstream of branch %q: %w", branchName, err) + } + if err := git.Actual.Fetch(remote); err != nil { + return fmt.Errorf("unable to refresh remote %q: %w", remote, err) + } + return nil +} From bf988a2ad974d184c9577f37bffbf592844b4d5c Mon Sep 17 00:00:00 2001 From: Solly Ross Date: Thu, 1 Oct 2020 01:59:58 -0700 Subject: [PATCH 3/4] Actions verifier framework This adds an actions verifier framework that makes it easy to run various checks against a pull request title/description, and send rich checks API results to GitHub. --- .gitignore | 3 + verify/common.go | 216 +++++++++++++++++++++++++++++++++++++++++++++++ verify/go.mod | 11 +++ verify/go.sum | 101 ++++++++++++++++++++++ 4 files changed, 331 insertions(+) create mode 100644 .gitignore create mode 100644 verify/common.go create mode 100644 verify/go.mod create mode 100644 verify/go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d772925 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*~ +*.swp +*.swo diff --git a/verify/common.go b/verify/common.go new file mode 100644 index 0000000..daa2d88 --- /dev/null +++ b/verify/common.go @@ -0,0 +1,216 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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 verify + +import ( + "fmt" + "os" + "encoding/json" + "errors" + "context" + "time" + "strings" + "sync" + + "github.com/google/go-github/v32/github" + "golang.org/x/oauth2" +) + +type ErrWithHelp interface { + error + Help() string +} + +type PRPlugin struct { + ForAction func(string) bool + ProcessPR func(pr *github.PullRequest) (string, error) + Name string + Title string +} + +func (p *PRPlugin) Entrypoint(env *ActionsEnv) error { + if p.ForAction != nil && !p.ForAction(env.Event.GetAction()) { + return nil + } + + repoParts := strings.Split(env.Event.GetRepo().GetFullName(), "/") + orgName, repoName := repoParts[0], repoParts[1] + + headSHA := env.Event.GetPullRequest().GetHead().GetSHA() + fmt.Printf("::debug::creating check run %q on %s/%s @ %s...\n", p.Name, orgName, repoName, headSHA) + + resRun, runResp, err := env.Client.Checks.CreateCheckRun(context.TODO(), orgName, repoName, github.CreateCheckRunOptions{ + Name: p.Name, + HeadSHA: headSHA, + Status: github.String("in_progress"), + }) + if err != nil { + return fmt.Errorf("unable to submit check result: %w", err) + } + + env.Debugf("create check API response: %+v", runResp) + env.Debugf("created run: %+v", resRun) + + successStatus, procErr := p.ProcessPR(env.Event.PullRequest) + + var summary, fullHelp, conclusion string + if procErr != nil { + summary = procErr.Error() + var helpErr ErrWithHelp + if errors.As(procErr, &helpErr) { + fullHelp = helpErr.Help() + } + conclusion = "failure" + } else { + summary = "Success" + fullHelp = successStatus + conclusion = "success" + } + completedAt := github.Timestamp{Time: time.Now()} + + // log in case we can't submit the result for some reason + env.Debugf("plugin result summary: %q", summary) + env.Debugf("plugin result details: %q", fullHelp) + env.Debugf("plugin conclusion: %q", conclusion) + + resRun, updateResp, err := env.Client.Checks.UpdateCheckRun(context.TODO(), orgName, repoName, resRun.GetID(), github.UpdateCheckRunOptions{ + Name: p.Name, + Status: github.String("completed"), + Conclusion: github.String(conclusion), + CompletedAt: &completedAt, + Output: &github.CheckRunOutput{ + Title: github.String(p.Title), + Summary: github.String(summary), + Text: github.String(fullHelp), + }, + }) + if err != nil { + return fmt.Errorf("unable to update check result: %w", err) + } + + env.Debugf("update check API response: %+v", updateResp) + env.Debugf("updated run: %+v", resRun) + + // return failure here too so that the whole suite fails (since the actions + // suite seems to ignore failing check runs when calculating general failure) + if procErr != nil { + return fmt.Errorf("failed: %v", procErr) + } + return nil +} + +type ActionsEnv struct { + Event *github.PullRequestEvent + Client *github.Client +} +func (ActionsEnv) Errorf(fmtStr string, args ...interface{}) { + fmt.Printf("::error::"+fmtStr+"\n", args...) +} +func (ActionsEnv) Debugf(fmtStr string, args ...interface{}) { + fmt.Printf("::debug::"+fmtStr+"\n", args...) +} +func (ActionsEnv) Warnf(fmtStr string, args ...interface{}) { + fmt.Printf("::warning::"+fmtStr+"\n", args...) +} + +func SetupEnv() (*ActionsEnv, error) { + if os.Getenv("GITHUB_ACTIONS") != "true" { + return nil, fmt.Errorf("not running in an action, bailing. Set GITHUB_ACTIONS and the other appropriate env vars if you really want to do this.") + } + + payloadPath := os.Getenv("GITHUB_EVENT_PATH") + if payloadPath == "" { + return nil, fmt.Errorf("no payload path set, something weird is up") + } + + payload, err := func() (github.PullRequestEvent, error) { + payloadRaw, err := os.Open(payloadPath) + if err != nil { + return github.PullRequestEvent{}, fmt.Errorf("unable to load payload file: %w", err) + } + defer payloadRaw.Close() + + var payload github.PullRequestEvent + if err := json.NewDecoder(payloadRaw).Decode(&payload); err != nil { + return payload, fmt.Errorf("unable to unmarshal payload: %w", err) + } + return payload, nil + }() + if err != nil { + return nil, err + } + + ctx := context.Background() + authClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: os.Getenv("INPUT_GITHUB_TOKEN")}, + )) + + return &ActionsEnv{ + Event: &payload, + Client: github.NewClient(authClient), + }, nil +} + +type ActionsCallback func(*ActionsEnv) error +func ActionsEntrypoint(cb ActionsCallback) { + env, err := SetupEnv() + if err != nil { + env.Errorf("%v", err) + os.Exit(1) + } + + if err := cb(env); err != nil { + env.Errorf("%v", err) + os.Exit(2) + } + fmt.Println("Success!") +} + +func RunPlugins(plugins ...PRPlugin) ActionsCallback { + return func(env *ActionsEnv) error { + res := make(chan error) + var done sync.WaitGroup + + for _, plugin := range plugins { + done.Add(1) + go func(plugin PRPlugin) { + defer done.Done() + res <- plugin.Entrypoint(env) + }(plugin) + } + + go func() { + done.Wait() + close(res) + }() + + errCount := 0 + for err := range res { + if err == nil { + continue + } + errCount++ + env.Errorf("%v", err) + } + + fmt.Printf("%d plugins ran\n", len(plugins)) + if errCount > 0 { + return fmt.Errorf("%d plugins had errors", errCount) + } + return nil + } +} diff --git a/verify/go.mod b/verify/go.mod new file mode 100644 index 0000000..8df9eb6 --- /dev/null +++ b/verify/go.mod @@ -0,0 +1,11 @@ +module sigs.k8s.io/kubebuilder-release-tools/verify + +go 1.15 + +replace sigs.k8s.io/kubebuilder-release-tools/notes => ../notes + +require ( + github.com/google/go-github/v32 v32.1.0 + golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be + sigs.k8s.io/kubebuilder-release-tools/notes v0.0.0-00010101000000-000000000000 +) diff --git a/verify/go.sum b/verify/go.sum new file mode 100644 index 0000000..047f41b --- /dev/null +++ b/verify/go.sum @@ -0,0 +1,101 @@ +github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= +github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw= +github.com/go-git/go-git/v5 v5.1.0/go.mod h1:ZKfuPUoY1ZqIG4QG9BDBh3G4gLM5zvPuSJAozQrZuyM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= +github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II= +github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= +golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/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/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 h1:AeiKBIuRw3UomYXSbLy0Mc2dDLfdtbT/IVn4keq83P0= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= From 3f8b03742f8230ee0b599386baef82e65a9deed7 Mon Sep 17 00:00:00 2001 From: Solly Ross Date: Thu, 1 Oct 2020 02:01:01 -0700 Subject: [PATCH 4/4] Add PR Verifier action This adds a github action that verifies PR titles according to the release notes rules, and verifies a couple of basic PR descriptiveness checks. It's automatically run on this repository. --- .github/workflows/main.yml | 16 ++++++ Dockerfile | 15 ++++++ action.yml | 9 ++++ notes/common/prefix.go | 37 +++++++++++++ notes/verify/title.go | 60 +++++++++++++++++++++ verify/cmd/runner.go | 105 +++++++++++++++++++++++++++++++++++++ 6 files changed, 242 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 Dockerfile create mode 100644 action.yml create mode 100644 notes/verify/title.go create mode 100644 verify/cmd/runner.go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..d140e6f --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,16 @@ +on: + pull_request_target: + types: [opened, edited, reopened] + +jobs: + verify: + runs-on: ubuntu-latest + name: verify PR contents + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Verifier action + id: verifier + uses: ./ + with: + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..056b8da --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.15 as build + +WORKDIR /go/src/verify +COPY verify verify +COPY notes notes +WORKDIR /go/src/verify/verify + +ENV CGO_ENABLED=0 +RUN go build -o /go/bin/verifypr ./cmd/ + +FROM gcr.io/distroless/static-debian10 + +COPY --from=build /go/bin/verifypr /verifypr + +ENTRYPOINT ["/verifypr"] diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..a89ca0a --- /dev/null +++ b/action.yml @@ -0,0 +1,9 @@ +name: 'Verify KubeBuilder PRs' +description: 'Verify PRs for the KubeBuilder project repos & similar' +inputs: + github_token: + description: "the github_token provided by the actions runner" + required: true +runs: + using: docker + image: 'Dockerfile' diff --git a/notes/common/prefix.go b/notes/common/prefix.go index db8fb59..2a6b966 100644 --- a/notes/common/prefix.go +++ b/notes/common/prefix.go @@ -17,10 +17,47 @@ limitations under the License. package common import ( + "fmt" "strings" ) type PRType int +func (t PRType) Emoji() string { + switch t { + case UncategorizedPR: + return "" + case BreakingPR: + return emojiBreaking + case FeaturePR: + return emojiFeature + case BugfixPR: + return emojiBugfix + case DocsPR: + return emojiDocs + case InfraPR: + return emojiInfra + default: + panic(fmt.Sprintf("unrecognized PR type %v", t)) + } +} +func (t PRType) String() string { + switch t { + case UncategorizedPR: + return "uncategorized" + case BreakingPR: + return "breaking" + case FeaturePR: + return "feature" + case BugfixPR: + return "bugfix" + case DocsPR: + return "docs" + case InfraPR: + return "infra" + default: + panic(fmt.Sprintf("unrecognized PR type %v", t)) + } +} const ( UncategorizedPR PRType = iota diff --git a/notes/verify/title.go b/notes/verify/title.go new file mode 100644 index 0000000..92522bf --- /dev/null +++ b/notes/verify/title.go @@ -0,0 +1,60 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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 verify + +import ( + "fmt" + + "sigs.k8s.io/kubebuilder-release-tools/notes/common" +) + +type prTitleError struct { + title string +} +func (e *prTitleError) Error() string { + return "no matching PR type indicator found in title" +} +func (e *prTitleError) Help() string { + return fmt.Sprintf( +`I saw a title of %[2]s%[1]s%[2]s, which doesn't seem to have any of the acceptable prefixes. + +You need to have one of these as the prefix of your PR title: + +- Breaking change: ⚠ (%[2]s:warning:%[2]s) +- Non-breaking feature: ✨ (%[2]s:sparkles:%[2]s) +- Patch fix: 🐛 (%[2]s:bug:%[2]s) +- Docs: 📖 (%[2]s:book:%[2]s) +- Infra/Tests/Other: 🌱 (%[2]s:seedling:%[2]s) + +More details can be found at [sigs.k8s.io/controller-runtime/VERSIONING.md](https://sigs.k8s.io/controller-runtime/VERSIONING.md).`, e.title, "`") +} + +// VerifyPRTitle checks that the PR title matches a valid PR type prefix, +// returning a message describing what was found on success, and a special +// error (with more detailed help via .Help) on failure. +func VerifyPRTitle(title string) (string, error) { + prType, finalTitle := common.PRTypeFromTitle(title) + if prType == common.UncategorizedPR { + return "", &prTitleError{title: title} + } + + return fmt.Sprintf( +`Found %s PR (%s) with final title: + + %s +`, prType.Emoji(), prType, finalTitle), nil +} diff --git a/verify/cmd/runner.go b/verify/cmd/runner.go new file mode 100644 index 0000000..c6d48ad --- /dev/null +++ b/verify/cmd/runner.go @@ -0,0 +1,105 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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 main + +import ( + "fmt" + "strings" + "regexp" + + "github.com/google/go-github/v32/github" + + notes "sigs.k8s.io/kubebuilder-release-tools/notes/common" + notesver "sigs.k8s.io/kubebuilder-release-tools/notes/verify" + "sigs.k8s.io/kubebuilder-release-tools/verify" +) + +type prErrs struct { + errs []string +} +func (e prErrs) Error() string { + return fmt.Sprintf("%d issues found with your PR description", len(e.errs)) +} +func (e prErrs) Help() string { + res := make([]string, len(e.errs)) + for _, err := range e.errs { + parts := strings.Split(err, "\n") + for i, part := range parts[1:] { + parts[i+1] = " "+part + } + res = append(res, "- "+strings.Join(parts, "\n")) + } + return strings.Join(res, "\n") +} + +func main() { + verify.ActionsEntrypoint(verify.RunPlugins( + verify.PRPlugin{ + Name: "PR Type", + Title: "PR Type in Title", + ProcessPR: func(pr *github.PullRequest) (string, error) { + return notesver.VerifyPRTitle(pr.GetTitle()) + }, + ForAction: func(action string) bool { + switch action { + case "opened", "edited", "reopened": + return true + default: + return false + } + }, + }, + + verify.PRPlugin{ + Name: "PR Desc", + Title: "Basic PR Descriptiveness Check", + ProcessPR: func(pr *github.PullRequest) (string, error) { + var errs []string + // TODO(directxman12): add warnings when we have them + + lineCnt := 0 + for _, line := range strings.Split(pr.GetBody(), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + lineCnt++ + } + if lineCnt < 2 { + errs = append(errs, "**your PR body is *really* short**.\n\nIt probably isn't descriptive enough.\nYou should give a description that highlights both what you're doing it and *why* you're doing it. Someone reading the PR description without clicking any issue links should be able to roughly understand what's going on") + } + + _, title := notes.PRTypeFromTitle(pr.GetTitle()) + if regexp.MustCompile(`#\d{1,}\b`).MatchString(title) { + errs = append(errs, "**Your PR has an issue number in the title.**\n\nThe title should just be descriptive.\nIssue numbers belong in the PR body as either `Fixes #XYZ` (if it closes the issue or PR), or something like `Related to #XYZ` (if it's just related).") + } + + if len(errs) == 0 { + return "Your PR description looks okay!", nil + } + return "", prErrs{errs: errs} + }, + ForAction: func(action string) bool { + switch action { + case "opened", "edited", "reopened": + return true + default: + return false + } + }, + }, + )) +}