diff --git a/prow/plugins/BUILD b/prow/plugins/BUILD index 5ddf0f1e996bf..000d327f562cb 100644 --- a/prow/plugins/BUILD +++ b/prow/plugins/BUILD @@ -46,6 +46,7 @@ filegroup( srcs = [ ":package-srcs", "//prow/plugins/assign:all-srcs", + "//prow/plugins/blockade:all-srcs", "//prow/plugins/cla:all-srcs", "//prow/plugins/close:all-srcs", "//prow/plugins/docs-no-retest:all-srcs", diff --git a/prow/plugins/blockade/BUILD b/prow/plugins/blockade/BUILD new file mode 100644 index 0000000000000..e7ce64080b72f --- /dev/null +++ b/prow/plugins/blockade/BUILD @@ -0,0 +1,38 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["blockade.go"], + visibility = ["//visibility:public"], + deps = [ + "//prow/github:go_default_library", + "//prow/plugins:go_default_library", + "//vendor/github.com/sirupsen/logrus:go_default_library", + ], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], + visibility = ["//visibility:public"], +) + +go_test( + name = "go_default_test", + srcs = ["blockade_test.go"], + library = ":go_default_library", + deps = [ + "//prow/github:go_default_library", + "//prow/github/fakegithub:go_default_library", + "//prow/plugins:go_default_library", + "//vendor/github.com/sirupsen/logrus:go_default_library", + ], +) diff --git a/prow/plugins/blockade/blockade.go b/prow/plugins/blockade/blockade.go new file mode 100644 index 0000000000000..08dc859baf486 --- /dev/null +++ b/prow/plugins/blockade/blockade.go @@ -0,0 +1,238 @@ +/* +Copyright 2017 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 blockade defines a plugin that adds the 'do-not-merge/blocked-paths' label to PRs that +// modify protected file paths. +// Protected file paths are defined with the plugins.Blockade struct. A PR is blocked if any file +// it changes is blocked by any Blockade. The process for determining if a file is blocked by a +// Blockade is as follows: +// By default, allow the file. Block if the file path matches any of block regexps, and does not +// match any of the exception regexps. +package blockade + +import ( + "bytes" + "fmt" + "regexp" + "strings" + + "github.com/sirupsen/logrus" + "k8s.io/test-infra/prow/github" + "k8s.io/test-infra/prow/plugins" +) + +const ( + pluginName = "blockade" + blockedPathsLabel = "do-not-merge/blocked-paths" +) + +var blockedPathsBody = fmt.Sprintf("Adding label: `%s` because PR changes a protected file.", blockedPathsLabel) + +type githubClient interface { + GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error) + GetIssueLabels(org, repo string, number int) ([]github.Label, error) + AddLabel(owner, repo string, number int, label string) error + RemoveLabel(owner, repo string, number int, label string) error + CreateComment(org, repo string, number int, comment string) error +} + +type pruneClient interface { + PruneComments(func(ic github.IssueComment) bool) +} + +func init() { + plugins.RegisterPullRequestHandler(pluginName, handlePullRequest) +} + +type blockCalc func([]github.PullRequestChange, []blockade) summary + +type client struct { + ghc githubClient + log *logrus.Entry + pruner pruneClient + + blockCalc blockCalc +} + +func handlePullRequest(pc plugins.PluginClient, pre github.PullRequestEvent) error { + c := &client{ + ghc: pc.GitHubClient, + log: pc.Logger, + pruner: pc.CommentPruner, + + blockCalc: calculateBlocks, + } + return handle(c, pc.PluginConfig.Blockades, &pre) +} + +// blockade is a compiled version of a plugins.Blockade config struct. +type blockade struct { + blockRegexps, exceptionRegexps []*regexp.Regexp + explanation string +} + +func (bd *blockade) isBlocked(file string) bool { + return matchesAny(file, bd.blockRegexps) && !matchesAny(file, bd.exceptionRegexps) +} + +type summary map[string][]github.PullRequestChange + +func (s summary) String() string { + if len(s) == 0 { + return "" + } + var buf bytes.Buffer + fmt.Fprint(&buf, "#### Reasons for blocking this PR:\n") + for reason, files := range s { + fmt.Fprintf(&buf, "[%s]\n", reason) + for _, file := range files { + fmt.Fprintf(&buf, "- [%s](%s)\n\n", file.Filename, file.BlobURL) + } + } + return buf.String() +} + +func handle(c *client, config []plugins.Blockade, pre *github.PullRequestEvent) error { + if pre.Action != github.PullRequestActionSynchronize && + pre.Action != github.PullRequestActionOpened && + pre.Action != github.PullRequestActionReopened { + return nil + } + + org := pre.Repo.Owner.Login + repo := pre.Repo.Name + labels, err := c.ghc.GetIssueLabels(org, repo, pre.Number) + if err != nil { + return err + } + + labelPresent := hasBlockedLabel(labels) + blockades := compileApplicableBlockades(org, repo, c.log, config) + if len(blockades) == 0 && !labelPresent { + // Since the label is missing, we assume that we removed any associated comments. + return nil + } + + var sum summary + if len(blockades) > 0 { + changes, err := c.ghc.GetPullRequestChanges(org, repo, pre.Number) + if err != nil { + return err + } + sum = c.blockCalc(changes, blockades) + } + + shouldBlock := len(sum) > 0 + if shouldBlock && !labelPresent { + // Add the label and leave a comment explaining why the label was added. + if err := c.ghc.AddLabel(org, repo, pre.Number, blockedPathsLabel); err != nil { + return err + } + msg := plugins.FormatResponse(pre.PullRequest.User.Login, blockedPathsBody, sum.String()) + return c.ghc.CreateComment(org, repo, pre.Number, msg) + } else if !shouldBlock && labelPresent { + // Remove the label and delete any comments created by this plugin. + if err := c.ghc.RemoveLabel(org, repo, pre.Number, blockedPathsLabel); err != nil { + return err + } + c.pruner.PruneComments(func(ic github.IssueComment) bool { + return strings.Contains(ic.Body, blockedPathsBody) + }) + } + return nil +} + +// compileApplicableBlockades filters the specified blockades and compiles those that apply to the repo. +func compileApplicableBlockades(org, repo string, log *logrus.Entry, blockades []plugins.Blockade) []blockade { + if len(blockades) == 0 { + return nil + } + + orgRepo := fmt.Sprintf("%s/%s", org, repo) + var compiled []blockade + for _, raw := range blockades { + // Only consider blockades that apply to this repo. + if !stringInSlice(org, raw.Repos) && !stringInSlice(orgRepo, raw.Repos) { + continue + } + b := blockade{} + for _, str := range raw.BlockRegexps { + if reg, err := regexp.Compile(str); err != nil { + log.WithError(err).Errorf("Failed to compile the blockade regexp '%s'.", str) + } else { + b.blockRegexps = append(b.blockRegexps, reg) + } + } + if len(b.blockRegexps) == 0 { + continue + } + if raw.Explanation == "" { + b.explanation = "Files are protected" + } else { + b.explanation = raw.Explanation + } + for _, str := range raw.ExceptionRegexps { + if reg, err := regexp.Compile(str); err != nil { + log.WithError(err).Errorf("Failed to compile the blockade regexp '%s'.", str) + } else { + b.exceptionRegexps = append(b.exceptionRegexps, reg) + } + } + compiled = append(compiled, b) + } + return compiled +} + +// calculateBlocks determines if a PR should be blocked and returns the summary describing the block. +func calculateBlocks(changes []github.PullRequestChange, blockades []blockade) summary { + sum := make(summary) + for _, change := range changes { + for _, b := range blockades { + if b.isBlocked(change.Filename) { + sum[b.explanation] = append(sum[b.explanation], change) + } + } + } + return sum +} + +func hasBlockedLabel(labels []github.Label) bool { + label := strings.ToLower(blockedPathsLabel) + for _, elem := range labels { + if strings.ToLower(elem.Name) == label { + return true + } + } + return false +} + +func matchesAny(str string, regexps []*regexp.Regexp) bool { + for _, reg := range regexps { + if reg.MatchString(str) { + return true + } + } + return false +} + +func stringInSlice(str string, slice []string) bool { + for _, elem := range slice { + if elem == str { + return true + } + } + return false +} diff --git a/prow/plugins/blockade/blockade_test.go b/prow/plugins/blockade/blockade_test.go new file mode 100644 index 0000000000000..f33a13d7e0b19 --- /dev/null +++ b/prow/plugins/blockade/blockade_test.go @@ -0,0 +1,360 @@ +/* +Copyright 2017 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 blockade + +import ( + "fmt" + "reflect" + "sort" + "strings" + "testing" + + "github.com/sirupsen/logrus" + + "k8s.io/test-infra/prow/github" + "k8s.io/test-infra/prow/github/fakegithub" + "k8s.io/test-infra/prow/plugins" +) + +var ( + // Sample changes: + docFile = github.PullRequestChange{Filename: "docs/documentation.md", BlobURL: ""} + docOwners = github.PullRequestChange{Filename: "docs/OWNERS", BlobURL: ""} + docOwners2 = github.PullRequestChange{Filename: "docs/2/OWNERS", BlobURL: ""} + srcGo = github.PullRequestChange{Filename: "src/code.go", BlobURL: ""} + srcSh = github.PullRequestChange{Filename: "src/shell.sh", BlobURL: ""} + docSh = github.PullRequestChange{Filename: "docs/shell.sh", BlobURL: ""} + + // Sample blockades: + blockDocs = plugins.Blockade{ + Repos: []string{"org/repo"}, + BlockRegexps: []string{`docs/.*`}, + Explanation: "1", + } + blockDocsExceptOwners = plugins.Blockade{ + Repos: []string{"org/repo"}, + BlockRegexps: []string{`docs/.*`}, + ExceptionRegexps: []string{`.*OWNERS`}, + Explanation: "2", + } + blockShell = plugins.Blockade{ + Repos: []string{"org/repo"}, + BlockRegexps: []string{`.*\.sh`}, + Explanation: "3", + } + blockAllOrg = plugins.Blockade{ + Repos: []string{"org"}, + BlockRegexps: []string{`.*`}, + Explanation: "4", + } + blockAllOther = plugins.Blockade{ + Repos: []string{"org2"}, + BlockRegexps: []string{`.*`}, + Explanation: "5", + } +) + +// TestCalculateBlocks validates that changes are blocked or allowed correctly. +func TestCalculateBlocks(t *testing.T) { + tcs := []struct { + name string + changes []github.PullRequestChange + config []plugins.Blockade + expectedSummary summary + }{ + { + name: "blocked by 1/1 blockade (no exceptions), extra file", + config: []plugins.Blockade{blockDocs}, + changes: []github.PullRequestChange{docFile, docOwners, srcGo}, + expectedSummary: summary{ + "1": []github.PullRequestChange{docFile, docOwners}, + }, + }, + { + name: "blocked by 1/1 blockade (1/2 files are exceptions), extra file", + config: []plugins.Blockade{blockDocsExceptOwners}, + changes: []github.PullRequestChange{docFile, docOwners, srcGo}, + expectedSummary: summary{ + "2": []github.PullRequestChange{docFile}, + }, + }, + { + name: "blocked by 0/1 blockades (2/2 exceptions), extra file", + config: []plugins.Blockade{blockDocsExceptOwners}, + changes: []github.PullRequestChange{docOwners, docOwners2, srcGo}, + expectedSummary: summary{}, + }, + { + name: "blocked by 0/1 blockades (no exceptions), extra file", + config: []plugins.Blockade{blockDocsExceptOwners}, + changes: []github.PullRequestChange{srcGo, srcSh}, + expectedSummary: summary{}, + }, + { + name: "blocked by 2/2 blockades (no exceptions), extra file", + config: []plugins.Blockade{blockDocsExceptOwners, blockShell}, + changes: []github.PullRequestChange{srcGo, srcSh, docFile}, + expectedSummary: summary{ + "2": []github.PullRequestChange{docFile}, + "3": []github.PullRequestChange{srcSh}, + }, + }, + { + name: "blocked by 2/2 blockades w/ single file", + config: []plugins.Blockade{blockDocsExceptOwners, blockShell}, + changes: []github.PullRequestChange{docSh}, + expectedSummary: summary{ + "2": []github.PullRequestChange{docSh}, + "3": []github.PullRequestChange{docSh}, + }, + }, + { + name: "blocked by 2/2 blockades w/ single file (1/2 exceptions)", + config: []plugins.Blockade{blockDocsExceptOwners, blockShell}, + changes: []github.PullRequestChange{docSh, docOwners}, + expectedSummary: summary{ + "2": []github.PullRequestChange{docSh}, + "3": []github.PullRequestChange{docSh}, + }, + }, + { + name: "blocked by 1/2 blockades (1/2 exceptions), extra file", + config: []plugins.Blockade{blockDocsExceptOwners, blockShell}, + changes: []github.PullRequestChange{srcSh, docOwners, srcGo}, + expectedSummary: summary{ + "3": []github.PullRequestChange{srcSh}, + }, + }, + { + name: "blocked by 0/2 blockades (1/2 exceptions), extra file", + config: []plugins.Blockade{blockDocsExceptOwners, blockShell}, + changes: []github.PullRequestChange{docOwners, srcGo}, + expectedSummary: summary{}, + }, + } + + for _, tc := range tcs { + blockades := compileApplicableBlockades("org", "repo", logrus.WithField("plugin", pluginName), tc.config) + sum := calculateBlocks(tc.changes, blockades) + if !reflect.DeepEqual(sum, tc.expectedSummary) { + t.Errorf("[%s] Expected summary: %#v, actual summary: %#v.", tc.name, tc.expectedSummary, sum) + } + } +} + +func TestSummaryString(t *testing.T) { + // Just one example for now. + tcs := []struct { + name string + sum summary + expectedContents []string + }{ + { + name: "Simple example", + sum: summary{ + "reason A": []github.PullRequestChange{docFile}, + "reason B": []github.PullRequestChange{srcGo, srcSh}, + }, + expectedContents: []string{ + "#### Reasons for blocking this PR:\n", + "[reason A]\n- [docs/documentation.md]()\n\n", + "[reason B]\n- [src/code.go]()\n\n- [src/shell.sh]()\n\n", + }, + }, + } + + for _, tc := range tcs { + got := tc.sum.String() + for _, expected := range tc.expectedContents { + if !strings.Contains(got, expected) { + t.Errorf("[%s] Expected summary %#v to contain %q, but got %q.", tc.name, tc.sum, expected, got) + } + } + } +} + +func formatLabel(label string) string { + return fmt.Sprintf("%s/%s#%d:%s", "org", "repo", 1, label) +} + +type fakePruner struct{} + +func (f *fakePruner) PruneComments(_ func(ic github.IssueComment) bool) {} + +// TestHandle validates that: +// - The correct labels are added/removed. +// - A comment is created when needed. +// - Uninteresting events are ignored. +// - Blockades that don't apply to this repo are ignored. +func TestHandle(t *testing.T) { + // Don't need to validate the following because they are validated by other tests: + // - Block calculation. (Whether or not changes justify blocking the PR.) + // - Comment contents, just existance. + otherLabel := "lgtm" + + tcs := []struct { + name string + action github.PullRequestEventAction + config []plugins.Blockade + hasLabel bool + filesBlock bool // This is ignored if there are no applicable blockades for the repo. + + labelAdded string + labelRemoved string + commentCreated bool + }{ + { + name: "Boring action", + action: github.PullRequestActionEdited, + config: []plugins.Blockade{blockDocsExceptOwners}, + hasLabel: false, + filesBlock: true, + }, + { + name: "Basic block", + action: github.PullRequestActionOpened, + config: []plugins.Blockade{blockDocsExceptOwners}, + hasLabel: false, + filesBlock: true, + + labelAdded: blockedPathsLabel, + commentCreated: true, + }, + { + name: "Basic block, already labeled", + action: github.PullRequestActionOpened, + config: []plugins.Blockade{blockDocsExceptOwners}, + hasLabel: true, + filesBlock: true, + }, + { + name: "Not blocked, not labeled", + action: github.PullRequestActionOpened, + config: []plugins.Blockade{blockDocsExceptOwners}, + hasLabel: false, + filesBlock: false, + }, + { + name: "Not blocked, has label", + action: github.PullRequestActionOpened, + config: []plugins.Blockade{blockDocsExceptOwners}, + hasLabel: true, + filesBlock: false, + + labelRemoved: blockedPathsLabel, + }, + { + name: "No blockade, not labeled", + action: github.PullRequestActionOpened, + config: []plugins.Blockade{}, + hasLabel: false, + filesBlock: true, + }, + { + name: "No blockade, has label", + action: github.PullRequestActionOpened, + config: []plugins.Blockade{}, + hasLabel: true, + filesBlock: true, + + labelRemoved: blockedPathsLabel, + }, + { + name: "Basic block (org scoped blockade)", + action: github.PullRequestActionOpened, + config: []plugins.Blockade{blockAllOrg}, + hasLabel: false, + filesBlock: true, + + labelAdded: blockedPathsLabel, + commentCreated: true, + }, + { + name: "Would be blocked, but blockade is not applicable; not labeled", + action: github.PullRequestActionOpened, + config: []plugins.Blockade{blockAllOther}, + hasLabel: false, + filesBlock: true, + }, + } + + for _, tc := range tcs { + expectAdded := []string{} + fakeClient := &fakegithub.FakeClient{ + ExistingLabels: []string{blockedPathsLabel, otherLabel}, + IssueComments: make(map[int][]github.IssueComment), + PullRequestChanges: make(map[int][]github.PullRequestChange), + LabelsAdded: []string{}, + LabelsRemoved: []string{}, + } + if tc.hasLabel { + label := formatLabel(blockedPathsLabel) + fakeClient.LabelsAdded = append(fakeClient.LabelsAdded, label) + expectAdded = append(expectAdded, label) + } + calcF := func(_ []github.PullRequestChange, blockades []blockade) summary { + if !tc.filesBlock { + return nil + } + sum := make(summary) + for _, b := range blockades { + // For this test assume 'docFile' is blocked by every blockade that is applicable to the repo. + sum[b.explanation] = []github.PullRequestChange{docFile} + } + return sum + } + pre := &github.PullRequestEvent{ + Action: tc.action, + Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"}, + Number: 1, + } + c := &client{ + ghc: fakeClient, + log: logrus.WithField("plugin", pluginName), + pruner: &fakePruner{}, + blockCalc: calcF, + } + if err := handle(c, tc.config, pre); err != nil { + t.Errorf("[%s] Unexpected error from handle: %v.", tc.name, err) + continue + } + + if tc.labelAdded != "" { + expectAdded = append(expectAdded, formatLabel(tc.labelAdded)) + } + sort.Strings(expectAdded) + sort.Strings(fakeClient.LabelsAdded) + if !reflect.DeepEqual(expectAdded, fakeClient.LabelsAdded) { + t.Errorf("[%s]: Expected labels to be added: %q, but got: %q.", tc.name, expectAdded, fakeClient.LabelsAdded) + } + expectRemoved := []string{} + if tc.labelRemoved != "" { + expectRemoved = append(expectRemoved, formatLabel(tc.labelRemoved)) + } + sort.Strings(expectRemoved) + sort.Strings(fakeClient.LabelsRemoved) + if !reflect.DeepEqual(expectRemoved, fakeClient.LabelsRemoved) { + t.Errorf("[%s]: Expected labels to be removed: %q, but got: %q.", tc.name, expectRemoved, fakeClient.LabelsRemoved) + } + + if count := len(fakeClient.IssueComments[1]); count > 1 { + t.Errorf("[%s] More than 1 comment created! (%d created).", tc.name, count) + } else if (count == 1) != tc.commentCreated { + t.Errorf("[%s] Expected comment created: %t, but got %t.", tc.name, tc.commentCreated, count == 1) + } + } +} diff --git a/prow/plugins/plugins.go b/prow/plugins/plugins.go index bf8076715bdd5..9ba91353345ef 100644 --- a/prow/plugins/plugins.go +++ b/prow/plugins/plugins.go @@ -138,6 +138,43 @@ type Configuration struct { Slack Slack `json:"slack,omitempty"` // ConfigUpdater holds config for the config-updater plugin. ConfigUpdater ConfigUpdater `json:"config_updater,omitempty"` + Blockades []Blockade `json:"blockades,omitempty"` +} + +/* + Blockade specifies a configuration for a single blockade.blockade. The configuration for the + blockade plugin is defined as a list of these structures. Here is an example of a complete + yaml config for the blockade plugin that is composed of 2 Blockade structs: + + blockades: + - repos: + - kubernetes-incubator + - kubernetes/kubernetes + - kubernetes/test-infra + blockregexps: + - "docs/.*" + - "other-docs/.*" + exceptionregexps: + - ".*OWNERS" + explanation: "Files in the 'docs' directory should not be modified except for OWNERS files" + - repos: + - kubernetes/test-infra + blockregexps: + - "mungegithub/.*" + exceptionregexps: + - "mungegithub/DeprecationWarning.md" + explanation: "Don't work on mungegithub! Work on Prow!" +*/ +type Blockade struct { + // Repos are either of the form org/repos or just org. + Repos []string `json:"repos,omitempty"` + // BlockRegexps are regular expressions matching the file paths to block. + BlockRegexps []string `json:"blockregexps,omitempty"` + // ExceptionRegexps are regular expressions matching the file paths that are exceptions to the BlockRegexps. + ExceptionRegexps []string `json:"exceptionregexps,omitempty"` + // Explanation is a string that will be included in the comment left when blocking a PR. This should + // be an explanation of why the paths specified are blockaded. + Explanation string `json:"explanation,omitempty"` } type Trigger struct {