github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/plugins/blockade/blockade.go (about)

     1  /*
     2  Copyright 2017 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  // Package blockade defines a plugin that adds the 'do-not-merge/blocked-paths' label to PRs that
    18  // modify protected file paths.
    19  // Protected file paths are defined with the plugins.Blockade struct. A PR is blocked if any file
    20  // it changes is blocked by any Blockade. The process for determining if a file is blocked by a
    21  // Blockade is as follows:
    22  // By default, allow the file. Block if the file path matches any of block regexps, and does not
    23  // match any of the exception regexps.
    24  package blockade
    25  
    26  import (
    27  	"bytes"
    28  	"fmt"
    29  	"regexp"
    30  	"strings"
    31  
    32  	"github.com/sirupsen/logrus"
    33  	"k8s.io/test-infra/prow/github"
    34  	"k8s.io/test-infra/prow/labels"
    35  	"k8s.io/test-infra/prow/pluginhelp"
    36  	"k8s.io/test-infra/prow/plugins"
    37  )
    38  
    39  const (
    40  	// PluginName defines this plugin's registered name.
    41  	PluginName = "blockade"
    42  )
    43  
    44  var blockedPathsBody = fmt.Sprintf("Adding label: `%s` because PR changes a protected file.", labels.BlockedPaths)
    45  
    46  type githubClient interface {
    47  	GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error)
    48  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
    49  	AddLabel(owner, repo string, number int, label string) error
    50  	RemoveLabel(owner, repo string, number int, label string) error
    51  	CreateComment(org, repo string, number int, comment string) error
    52  }
    53  
    54  type pruneClient interface {
    55  	PruneComments(func(ic github.IssueComment) bool)
    56  }
    57  
    58  func init() {
    59  	plugins.RegisterPullRequestHandler(PluginName, handlePullRequest, helpProvider)
    60  }
    61  
    62  func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) {
    63  	// The {WhoCanUse, Usage, Examples} fields are omitted because this plugin cannot be triggered manually.
    64  	blockConfig := map[string]string{}
    65  	for _, repo := range enabledRepos {
    66  		parts := strings.Split(repo, "/")
    67  		if len(parts) != 2 {
    68  			return nil, fmt.Errorf("invalid repo in enabledRepos: %q", repo)
    69  		}
    70  		var buf bytes.Buffer
    71  		fmt.Fprint(&buf, "The following blockades apply in this repository:")
    72  		for _, blockade := range config.Blockades {
    73  			if !stringInSlice(parts[0], blockade.Repos) && !stringInSlice(repo, blockade.Repos) {
    74  				continue
    75  			}
    76  			fmt.Fprintf(&buf, "<br>Block reason: '%s'<br>&nbsp&nbsp&nbsp&nbspBlock regexps: %q<br>&nbsp&nbsp&nbsp&nbspException regexps: %q<br>", blockade.Explanation, blockade.BlockRegexps, blockade.ExceptionRegexps)
    77  		}
    78  		blockConfig[repo] = buf.String()
    79  	}
    80  	return &pluginhelp.PluginHelp{
    81  			Description: "The blockade plugin blocks pull requests from merging if they touch specific files. The plugin applies the '" + labels.BlockedPaths + "' label to pull requests that touch files that match a blockade's block regular expression and none of the corresponding exception regular expressions.",
    82  			Config:      blockConfig,
    83  		},
    84  		nil
    85  }
    86  
    87  type blockCalc func([]github.PullRequestChange, []blockade) summary
    88  
    89  type client struct {
    90  	ghc githubClient
    91  	log *logrus.Entry
    92  
    93  	blockCalc blockCalc
    94  }
    95  
    96  func handlePullRequest(pc plugins.Agent, pre github.PullRequestEvent) error {
    97  	cp, err := pc.CommentPruner()
    98  	if err != nil {
    99  		return err
   100  	}
   101  	return handle(pc.GitHubClient, pc.Logger, pc.PluginConfig.Blockades, cp, calculateBlocks, &pre)
   102  }
   103  
   104  // blockade is a compiled version of a plugins.Blockade config struct.
   105  type blockade struct {
   106  	blockRegexps, exceptionRegexps []*regexp.Regexp
   107  	explanation                    string
   108  }
   109  
   110  func (bd *blockade) isBlocked(file string) bool {
   111  	return matchesAny(file, bd.blockRegexps) && !matchesAny(file, bd.exceptionRegexps)
   112  }
   113  
   114  type summary map[string][]github.PullRequestChange
   115  
   116  func (s summary) String() string {
   117  	if len(s) == 0 {
   118  		return ""
   119  	}
   120  	var buf bytes.Buffer
   121  	fmt.Fprint(&buf, "#### Reasons for blocking this PR:\n")
   122  	for reason, files := range s {
   123  		fmt.Fprintf(&buf, "[%s]\n", reason)
   124  		for _, file := range files {
   125  			fmt.Fprintf(&buf, "- [%s](%s)\n\n", file.Filename, file.BlobURL)
   126  		}
   127  	}
   128  	return buf.String()
   129  }
   130  
   131  func handle(ghc githubClient, log *logrus.Entry, config []plugins.Blockade, cp pruneClient, blockCalc blockCalc, pre *github.PullRequestEvent) error {
   132  	if pre.Action != github.PullRequestActionSynchronize &&
   133  		pre.Action != github.PullRequestActionOpened &&
   134  		pre.Action != github.PullRequestActionReopened {
   135  		return nil
   136  	}
   137  
   138  	org := pre.Repo.Owner.Login
   139  	repo := pre.Repo.Name
   140  	issueLabels, err := ghc.GetIssueLabels(org, repo, pre.Number)
   141  	if err != nil {
   142  		return err
   143  	}
   144  
   145  	labelPresent := hasBlockedLabel(issueLabels)
   146  	blockades := compileApplicableBlockades(org, repo, log, config)
   147  	if len(blockades) == 0 && !labelPresent {
   148  		// Since the label is missing, we assume that we removed any associated comments.
   149  		return nil
   150  	}
   151  
   152  	var sum summary
   153  	if len(blockades) > 0 {
   154  		changes, err := ghc.GetPullRequestChanges(org, repo, pre.Number)
   155  		if err != nil {
   156  			return err
   157  		}
   158  		sum = blockCalc(changes, blockades)
   159  	}
   160  
   161  	shouldBlock := len(sum) > 0
   162  	if shouldBlock && !labelPresent {
   163  		// Add the label and leave a comment explaining why the label was added.
   164  		if err := ghc.AddLabel(org, repo, pre.Number, labels.BlockedPaths); err != nil {
   165  			return err
   166  		}
   167  		msg := plugins.FormatResponse(pre.PullRequest.User.Login, blockedPathsBody, sum.String())
   168  		return ghc.CreateComment(org, repo, pre.Number, msg)
   169  	} else if !shouldBlock && labelPresent {
   170  		// Remove the label and delete any comments created by this plugin.
   171  		if err := ghc.RemoveLabel(org, repo, pre.Number, labels.BlockedPaths); err != nil {
   172  			return err
   173  		}
   174  		cp.PruneComments(func(ic github.IssueComment) bool {
   175  			return strings.Contains(ic.Body, blockedPathsBody)
   176  		})
   177  	}
   178  	return nil
   179  }
   180  
   181  // compileApplicableBlockades filters the specified blockades and compiles those that apply to the repo.
   182  func compileApplicableBlockades(org, repo string, log *logrus.Entry, blockades []plugins.Blockade) []blockade {
   183  	if len(blockades) == 0 {
   184  		return nil
   185  	}
   186  
   187  	orgRepo := fmt.Sprintf("%s/%s", org, repo)
   188  	var compiled []blockade
   189  	for _, raw := range blockades {
   190  		// Only consider blockades that apply to this repo.
   191  		if !stringInSlice(org, raw.Repos) && !stringInSlice(orgRepo, raw.Repos) {
   192  			continue
   193  		}
   194  		b := blockade{}
   195  		for _, str := range raw.BlockRegexps {
   196  			if reg, err := regexp.Compile(str); err != nil {
   197  				log.WithError(err).Errorf("Failed to compile the blockade regexp '%s'.", str)
   198  			} else {
   199  				b.blockRegexps = append(b.blockRegexps, reg)
   200  			}
   201  		}
   202  		if len(b.blockRegexps) == 0 {
   203  			continue
   204  		}
   205  		if raw.Explanation == "" {
   206  			b.explanation = "Files are protected"
   207  		} else {
   208  			b.explanation = raw.Explanation
   209  		}
   210  		for _, str := range raw.ExceptionRegexps {
   211  			if reg, err := regexp.Compile(str); err != nil {
   212  				log.WithError(err).Errorf("Failed to compile the blockade regexp '%s'.", str)
   213  			} else {
   214  				b.exceptionRegexps = append(b.exceptionRegexps, reg)
   215  			}
   216  		}
   217  		compiled = append(compiled, b)
   218  	}
   219  	return compiled
   220  }
   221  
   222  // calculateBlocks determines if a PR should be blocked and returns the summary describing the block.
   223  func calculateBlocks(changes []github.PullRequestChange, blockades []blockade) summary {
   224  	sum := make(summary)
   225  	for _, change := range changes {
   226  		for _, b := range blockades {
   227  			if b.isBlocked(change.Filename) {
   228  				sum[b.explanation] = append(sum[b.explanation], change)
   229  			}
   230  		}
   231  	}
   232  	return sum
   233  }
   234  
   235  func hasBlockedLabel(githubLabels []github.Label) bool {
   236  	label := strings.ToLower(labels.BlockedPaths)
   237  	for _, elem := range githubLabels {
   238  		if strings.ToLower(elem.Name) == label {
   239  			return true
   240  		}
   241  	}
   242  	return false
   243  }
   244  
   245  func matchesAny(str string, regexps []*regexp.Regexp) bool {
   246  	for _, reg := range regexps {
   247  		if reg.MatchString(str) {
   248  			return true
   249  		}
   250  	}
   251  	return false
   252  }
   253  
   254  func stringInSlice(str string, slice []string) bool {
   255  	for _, elem := range slice {
   256  		if elem == str {
   257  			return true
   258  		}
   259  	}
   260  	return false
   261  }