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