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>    Block reason: '%s'<br>    Block regexps: %q<br>    Exception regexps: %q<br>", *blockade.BranchRegexp, blockade.Explanation, blockade.BlockRegexps, blockade.ExceptionRegexps) 75 } else { 76 fmt.Fprintf(&buf, "<br>Block reason: '%s'<br>    Block regexps: %q<br>    Exception 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 }