github.com/abayer/test-infra@v0.0.5/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/pluginhelp"
    35  	"k8s.io/test-infra/prow/plugins"
    36  )
    37  
    38  const (
    39  	pluginName        = "blockade"
    40  	blockedPathsLabel = "do-not-merge/blocked-paths"
    41  )
    42  
    43  var blockedPathsBody = fmt.Sprintf("Adding label: `%s` because PR changes a protected file.", blockedPathsLabel)
    44  
    45  type githubClient interface {
    46  	GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error)
    47  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
    48  	AddLabel(owner, repo string, number int, label string) error
    49  	RemoveLabel(owner, repo string, number int, label string) error
    50  	CreateComment(org, repo string, number int, comment string) error
    51  }
    52  
    53  type pruneClient interface {
    54  	PruneComments(func(ic github.IssueComment) bool)
    55  }
    56  
    57  func init() {
    58  	plugins.RegisterPullRequestHandler(pluginName, handlePullRequest, helpProvider)
    59  }
    60  
    61  func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) {
    62  	// The {WhoCanUse, Usage, Examples} fields are omitted because this plugin cannot be triggered manually.
    63  	blockConfig := map[string]string{}
    64  	for _, repo := range enabledRepos {
    65  		parts := strings.Split(repo, "/")
    66  		if len(parts) != 2 {
    67  			return nil, fmt.Errorf("invalid repo in enabledRepos: %q", repo)
    68  		}
    69  		var buf bytes.Buffer
    70  		fmt.Fprint(&buf, "The following blockades apply in this repository:")
    71  		for _, blockade := range config.Blockades {
    72  			if !stringInSlice(parts[0], blockade.Repos) && !stringInSlice(repo, blockade.Repos) {
    73  				continue
    74  			}
    75  			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)
    76  		}
    77  		blockConfig[repo] = buf.String()
    78  	}
    79  	return &pluginhelp.PluginHelp{
    80  			Description: "The blockade plugin blocks pull requests from merging if they touch specific files. The plugin applies the '" + blockedPathsLabel + "' label to pull requests that touch files that match a blockade's block regular expression and none of the corresponding exception regular expressions.",
    81  			Config:      blockConfig,
    82  		},
    83  		nil
    84  }
    85  
    86  type blockCalc func([]github.PullRequestChange, []blockade) summary
    87  
    88  type client struct {
    89  	ghc    githubClient
    90  	log    *logrus.Entry
    91  	pruner pruneClient
    92  
    93  	blockCalc blockCalc
    94  }
    95  
    96  func handlePullRequest(pc plugins.PluginClient, pre github.PullRequestEvent) error {
    97  	c := &client{
    98  		ghc:    pc.GitHubClient,
    99  		log:    pc.Logger,
   100  		pruner: pc.CommentPruner,
   101  
   102  		blockCalc: calculateBlocks,
   103  	}
   104  	return handle(c, pc.PluginConfig.Blockades, &pre)
   105  }
   106  
   107  // blockade is a compiled version of a plugins.Blockade config struct.
   108  type blockade struct {
   109  	blockRegexps, exceptionRegexps []*regexp.Regexp
   110  	explanation                    string
   111  }
   112  
   113  func (bd *blockade) isBlocked(file string) bool {
   114  	return matchesAny(file, bd.blockRegexps) && !matchesAny(file, bd.exceptionRegexps)
   115  }
   116  
   117  type summary map[string][]github.PullRequestChange
   118  
   119  func (s summary) String() string {
   120  	if len(s) == 0 {
   121  		return ""
   122  	}
   123  	var buf bytes.Buffer
   124  	fmt.Fprint(&buf, "#### Reasons for blocking this PR:\n")
   125  	for reason, files := range s {
   126  		fmt.Fprintf(&buf, "[%s]\n", reason)
   127  		for _, file := range files {
   128  			fmt.Fprintf(&buf, "- [%s](%s)\n\n", file.Filename, file.BlobURL)
   129  		}
   130  	}
   131  	return buf.String()
   132  }
   133  
   134  func handle(c *client, config []plugins.Blockade, pre *github.PullRequestEvent) error {
   135  	if pre.Action != github.PullRequestActionSynchronize &&
   136  		pre.Action != github.PullRequestActionOpened &&
   137  		pre.Action != github.PullRequestActionReopened {
   138  		return nil
   139  	}
   140  
   141  	org := pre.Repo.Owner.Login
   142  	repo := pre.Repo.Name
   143  	labels, err := c.ghc.GetIssueLabels(org, repo, pre.Number)
   144  	if err != nil {
   145  		return err
   146  	}
   147  
   148  	labelPresent := hasBlockedLabel(labels)
   149  	blockades := compileApplicableBlockades(org, repo, c.log, config)
   150  	if len(blockades) == 0 && !labelPresent {
   151  		// Since the label is missing, we assume that we removed any associated comments.
   152  		return nil
   153  	}
   154  
   155  	var sum summary
   156  	if len(blockades) > 0 {
   157  		changes, err := c.ghc.GetPullRequestChanges(org, repo, pre.Number)
   158  		if err != nil {
   159  			return err
   160  		}
   161  		sum = c.blockCalc(changes, blockades)
   162  	}
   163  
   164  	shouldBlock := len(sum) > 0
   165  	if shouldBlock && !labelPresent {
   166  		// Add the label and leave a comment explaining why the label was added.
   167  		if err := c.ghc.AddLabel(org, repo, pre.Number, blockedPathsLabel); err != nil {
   168  			return err
   169  		}
   170  		msg := plugins.FormatResponse(pre.PullRequest.User.Login, blockedPathsBody, sum.String())
   171  		return c.ghc.CreateComment(org, repo, pre.Number, msg)
   172  	} else if !shouldBlock && labelPresent {
   173  		// Remove the label and delete any comments created by this plugin.
   174  		if err := c.ghc.RemoveLabel(org, repo, pre.Number, blockedPathsLabel); err != nil {
   175  			return err
   176  		}
   177  		c.pruner.PruneComments(func(ic github.IssueComment) bool {
   178  			return strings.Contains(ic.Body, blockedPathsBody)
   179  		})
   180  	}
   181  	return nil
   182  }
   183  
   184  // compileApplicableBlockades filters the specified blockades and compiles those that apply to the repo.
   185  func compileApplicableBlockades(org, repo string, log *logrus.Entry, blockades []plugins.Blockade) []blockade {
   186  	if len(blockades) == 0 {
   187  		return nil
   188  	}
   189  
   190  	orgRepo := fmt.Sprintf("%s/%s", org, repo)
   191  	var compiled []blockade
   192  	for _, raw := range blockades {
   193  		// Only consider blockades that apply to this repo.
   194  		if !stringInSlice(org, raw.Repos) && !stringInSlice(orgRepo, raw.Repos) {
   195  			continue
   196  		}
   197  		b := blockade{}
   198  		for _, str := range raw.BlockRegexps {
   199  			if reg, err := regexp.Compile(str); err != nil {
   200  				log.WithError(err).Errorf("Failed to compile the blockade regexp '%s'.", str)
   201  			} else {
   202  				b.blockRegexps = append(b.blockRegexps, reg)
   203  			}
   204  		}
   205  		if len(b.blockRegexps) == 0 {
   206  			continue
   207  		}
   208  		if raw.Explanation == "" {
   209  			b.explanation = "Files are protected"
   210  		} else {
   211  			b.explanation = raw.Explanation
   212  		}
   213  		for _, str := range raw.ExceptionRegexps {
   214  			if reg, err := regexp.Compile(str); err != nil {
   215  				log.WithError(err).Errorf("Failed to compile the blockade regexp '%s'.", str)
   216  			} else {
   217  				b.exceptionRegexps = append(b.exceptionRegexps, reg)
   218  			}
   219  		}
   220  		compiled = append(compiled, b)
   221  	}
   222  	return compiled
   223  }
   224  
   225  // calculateBlocks determines if a PR should be blocked and returns the summary describing the block.
   226  func calculateBlocks(changes []github.PullRequestChange, blockades []blockade) summary {
   227  	sum := make(summary)
   228  	for _, change := range changes {
   229  		for _, b := range blockades {
   230  			if b.isBlocked(change.Filename) {
   231  				sum[b.explanation] = append(sum[b.explanation], change)
   232  			}
   233  		}
   234  	}
   235  	return sum
   236  }
   237  
   238  func hasBlockedLabel(labels []github.Label) bool {
   239  	label := strings.ToLower(blockedPathsLabel)
   240  	for _, elem := range labels {
   241  		if strings.ToLower(elem.Name) == label {
   242  			return true
   243  		}
   244  	}
   245  	return false
   246  }
   247  
   248  func matchesAny(str string, regexps []*regexp.Regexp) bool {
   249  	for _, reg := range regexps {
   250  		if reg.MatchString(str) {
   251  			return true
   252  		}
   253  	}
   254  	return false
   255  }
   256  
   257  func stringInSlice(str string, slice []string) bool {
   258  	for _, elem := range slice {
   259  		if elem == str {
   260  			return true
   261  		}
   262  	}
   263  	return false
   264  }