sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/require-matching-label/require-matching-label.go (about) 1 /* 2 Copyright 2018 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 requirematchinglabel implements the `require-matching-label` plugin. 18 // This is a configurable plugin that applies a label (and possibly comments) 19 // when an issue or PR does not have any labels matching a regexp. If a label 20 // is added that matches the regexp, the 'MissingLabel' is removed and the comment 21 // is deleted. 22 package requirematchinglabel 23 24 import ( 25 "fmt" 26 "regexp" 27 "strings" 28 "time" 29 30 "sigs.k8s.io/prow/pkg/config" 31 "sigs.k8s.io/prow/pkg/github" 32 "sigs.k8s.io/prow/pkg/pluginhelp" 33 "sigs.k8s.io/prow/pkg/plugins" 34 35 "github.com/sirupsen/logrus" 36 ) 37 38 var ( 39 handlePRActions = map[github.PullRequestEventAction]bool{ 40 github.PullRequestActionOpened: true, 41 github.PullRequestActionReopened: true, 42 github.PullRequestActionLabeled: true, 43 github.PullRequestActionUnlabeled: true, 44 } 45 46 handleIssueActions = map[github.IssueEventAction]bool{ 47 github.IssueActionOpened: true, 48 github.IssueActionReopened: true, 49 github.IssueActionLabeled: true, 50 github.IssueActionUnlabeled: true, 51 } 52 53 checkRequireLabelsRe = regexp.MustCompile(`(?mi)^/check-required-labels\s*$`) 54 ) 55 56 const ( 57 pluginName = "require-matching-label" 58 ) 59 60 type githubClient interface { 61 AddLabel(org, repo string, number int, label string) error 62 RemoveLabel(org, repo string, number int, label string) error 63 CreateComment(org, repo string, number int, content string) error 64 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 65 GetPullRequest(org, repo string, number int) (*github.PullRequest, error) 66 } 67 68 type commentPruner interface { 69 PruneComments(shouldPrune func(github.IssueComment) bool) 70 } 71 72 func init() { 73 plugins.RegisterIssueHandler(pluginName, handleIssue, helpProvider) 74 plugins.RegisterPullRequestHandler(pluginName, handlePullRequest, helpProvider) 75 plugins.RegisterGenericCommentHandler(pluginName, handleCommentEvent, helpProvider) 76 } 77 78 func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) { 79 descs := make([]string, 0, len(config.RequireMatchingLabel)) 80 for _, cfg := range config.RequireMatchingLabel { 81 descs = append(descs, cfg.Describe()) 82 } 83 // Only the 'Description' and 'Config' fields are necessary because this plugin does not react 84 // to any commands. 85 yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{ 86 RequireMatchingLabel: []plugins.RequireMatchingLabel{ 87 { 88 Org: "org", 89 Repo: "repo", 90 Branch: "master", 91 PRs: true, 92 Issues: true, 93 Regexp: "^kind/", 94 MissingLabel: "needs-kind", 95 MissingComment: "Please add a label referencing the kind.", 96 GracePeriod: "5s", 97 }, 98 }, 99 }) 100 if err != nil { 101 logrus.WithError(err).Warnf("cannot generate comments for %s plugin", pluginName) 102 } 103 pluginHelp := &pluginhelp.PluginHelp{ 104 Description: `The require-matching-label plugin is a configurable plugin that applies a label to issues and/or PRs that do not have any labels matching a regular expression. An example of this is applying a 'needs-sig' label to all issues that do not have a 'sig/*' label. This plugin can have multiple configurations to provide this kind of behavior for multiple different label sets. The configuration allows issue type, PR branch, and an optional explanation comment to be specified.`, 105 Config: map[string]string{ 106 "": fmt.Sprintf("The plugin has the following configurations:\n<ul><li>%s</li></ul>", strings.Join(descs, "</li><li>")), 107 }, 108 Snippet: yamlSnippet, 109 } 110 pluginHelp.AddCommand(pluginhelp.Command{ 111 Usage: "/check-required-labels", 112 Description: "Checks for required labels.", 113 Featured: true, 114 WhoCanUse: "Anyone", 115 Examples: []string{"/check-required-labels"}, 116 }) 117 return pluginHelp, nil 118 } 119 120 type event struct { 121 org string 122 repo string 123 number int 124 author string 125 // The PR's base branch. If empty this is an Issue, not a PR. 126 branch string 127 // The label that was added or removed. If empty this is an open or reopen event. 128 label string 129 // The labels currently on the issue. For PRs this is not contained in the webhook payload and may be omitted. 130 currentLabels []github.Label 131 } 132 133 func handleIssue(pc plugins.Agent, ie github.IssueEvent) error { 134 if !handleIssueActions[ie.Action] { 135 return nil 136 } 137 e := &event{ 138 org: ie.Repo.Owner.Login, 139 repo: ie.Repo.Name, 140 number: ie.Issue.Number, 141 author: ie.Issue.User.Login, 142 label: ie.Label.Name, // This will be empty for non-label events. 143 currentLabels: ie.Issue.Labels, 144 } 145 cp, err := pc.CommentPruner() 146 if err != nil { 147 return err 148 } 149 return handle(pc.Logger, pc.GitHubClient, cp, pc.PluginConfig.RequireMatchingLabel, e) 150 } 151 152 func handlePullRequest(pc plugins.Agent, pre github.PullRequestEvent) error { 153 if !handlePRActions[pre.Action] { 154 return nil 155 } 156 e := &event{ 157 org: pre.Repo.Owner.Login, 158 repo: pre.Repo.Name, 159 number: pre.PullRequest.Number, 160 branch: pre.PullRequest.Base.Ref, 161 author: pre.PullRequest.User.Login, 162 label: pre.Label.Name, // This will be empty for non-label events. 163 } 164 cp, err := pc.CommentPruner() 165 if err != nil { 166 return err 167 } 168 return handle(pc.Logger, pc.GitHubClient, cp, pc.PluginConfig.RequireMatchingLabel, e) 169 } 170 171 // matchingConfigs filters irrelevant RequireMtchingLabel configs from 172 // the list of all configs. 173 // `branch` should be empty for Issues and non-empty for PRs. 174 // `label` should be omitted in the case of 'open' and 'reopen' actions. 175 func matchingConfigs(org, repo, branch, label string, allConfigs []plugins.RequireMatchingLabel) []plugins.RequireMatchingLabel { 176 var filtered []plugins.RequireMatchingLabel 177 for _, cfg := range allConfigs { 178 // Check if the config applies to this issue type. 179 if (branch == "" && !cfg.Issues) || (branch != "" && !cfg.PRs) { 180 continue 181 } 182 // Check if the config applies to this 'org[/repo][/branch]'. 183 if org != cfg.Org || 184 (cfg.Repo != "" && cfg.Repo != repo) || 185 (cfg.Branch != "" && branch != "" && cfg.Branch != branch) { 186 continue 187 } 188 // If we are reacting to a label event, see if it is relevant. 189 if label != "" && !cfg.Re.MatchString(label) { 190 continue 191 } 192 filtered = append(filtered, cfg) 193 } 194 return filtered 195 } 196 197 func handle(log *logrus.Entry, ghc githubClient, cp commentPruner, configs []plugins.RequireMatchingLabel, e *event) error { 198 // Find any configs that may be relevant to this event. 199 matchConfigs := matchingConfigs(e.org, e.repo, e.branch, e.label, configs) 200 if len(matchConfigs) == 0 { 201 return nil 202 } 203 204 if e.label == "" /* not a label event */ { 205 // If we are reacting to a PR or Issue being created or reopened, we should wait a 206 // few seconds to allow other automation to apply labels in order to minimize thrashing. 207 // We use the max grace period from applicable configs. 208 gracePeriod := time.Duration(0) 209 for _, cfg := range matchConfigs { 210 if cfg.GracePeriodDuration > gracePeriod { 211 gracePeriod = cfg.GracePeriodDuration 212 } 213 } 214 time.Sleep(gracePeriod) 215 // If currentLabels was populated it is now stale. 216 e.currentLabels = nil 217 } 218 if e.currentLabels == nil { 219 var err error 220 e.currentLabels, err = ghc.GetIssueLabels(e.org, e.repo, e.number) 221 if err != nil { 222 return fmt.Errorf("error getting the issue or pr's labels: %w", err) 223 } 224 } 225 226 // Handle the potentially relevant configs. 227 for _, cfg := range matchConfigs { 228 hasMissingLabel := false 229 hasMatchingLabel := false 230 for _, label := range e.currentLabels { 231 hasMissingLabel = hasMissingLabel || label.Name == cfg.MissingLabel 232 hasMatchingLabel = hasMatchingLabel || cfg.Re.MatchString(label.Name) 233 } 234 235 if hasMatchingLabel && hasMissingLabel { 236 if err := ghc.RemoveLabel(e.org, e.repo, e.number, cfg.MissingLabel); err != nil { 237 log.WithError(err).Errorf("Failed to remove %q label.", cfg.MissingLabel) 238 } 239 if cfg.MissingComment != "" { 240 cp.PruneComments(func(comment github.IssueComment) bool { 241 return strings.Contains(comment.Body, cfg.MissingComment) 242 }) 243 } 244 } else if !hasMatchingLabel && !hasMissingLabel { 245 if err := ghc.AddLabel(e.org, e.repo, e.number, cfg.MissingLabel); err != nil { 246 log.WithError(err).Errorf("Failed to add %q label.", cfg.MissingLabel) 247 } 248 if cfg.MissingComment != "" { 249 msg := plugins.FormatSimpleResponse(cfg.MissingComment) 250 if err := ghc.CreateComment(e.org, e.repo, e.number, msg); err != nil { 251 log.WithError(err).Error("Failed to create comment.") 252 } 253 } 254 } 255 256 } 257 return nil 258 } 259 260 func handleCommentEvent(pc plugins.Agent, ce github.GenericCommentEvent) error { 261 // Only consider open PRs and new comments. 262 if ce.IssueState != "open" || ce.Action != github.GenericCommentActionCreated { 263 return nil 264 } 265 // Only consider "/check-required-labels" comments. 266 if !checkRequireLabelsRe.MatchString(ce.Body) { 267 return nil 268 } 269 270 cp, err := pc.CommentPruner() 271 if err != nil { 272 return err 273 } 274 275 return handleComment(pc.Logger, pc.GitHubClient, cp, pc.PluginConfig.RequireMatchingLabel, &ce) 276 } 277 278 func handleComment(log *logrus.Entry, ghc githubClient, cp commentPruner, configs []plugins.RequireMatchingLabel, e *github.GenericCommentEvent) error { 279 org := e.Repo.Owner.Login 280 repo := e.Repo.Name 281 number := e.Number 282 283 event := &event{ 284 org: org, 285 repo: repo, 286 number: number, 287 author: e.User.Login, 288 } 289 if e.IsPR { 290 pr, err := ghc.GetPullRequest(org, repo, number) 291 if err != nil { 292 return err 293 } 294 event.branch = pr.Base.Ref 295 } 296 return handle(log, ghc, cp, configs, event) 297 }