sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/label/label.go (about) 1 /* 2 Copyright 2016 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 label 18 19 import ( 20 "fmt" 21 "regexp" 22 "strings" 23 24 "github.com/sirupsen/logrus" 25 26 "k8s.io/apimachinery/pkg/util/sets" 27 "sigs.k8s.io/prow/pkg/config" 28 "sigs.k8s.io/prow/pkg/github" 29 prowlabels "sigs.k8s.io/prow/pkg/labels" 30 "sigs.k8s.io/prow/pkg/pluginhelp" 31 "sigs.k8s.io/prow/pkg/plugins" 32 ) 33 34 const ( 35 PluginName = "label" 36 ) 37 38 var ( 39 defaultLabels = []string{"kind", "priority", "area"} 40 needsLabels = []string{"kind", "priority", "sig", "triage"} // "needs-*" 41 commentRegex = regexp.MustCompile(`(?s)<!--(.*?)-->`) 42 labelRegex = regexp.MustCompile(`(?m)^/(area|committee|kind|language|priority|sig|triage|wg)\s*(.*?)\s*$`) 43 removeLabelRegex = regexp.MustCompile(`(?m)^/remove-(area|committee|kind|language|priority|sig|triage|wg)\s*(.*?)\s*$`) 44 customLabelRegex = regexp.MustCompile(`(?m)^/label\s*(.*?)\s*$`) 45 customRemoveLabelRegex = regexp.MustCompile(`(?m)^/remove-label\s*(.*?)\s*$`) 46 ) 47 48 func init() { 49 plugins.RegisterGenericCommentHandler(PluginName, handleGenericComment, helpProvider) 50 plugins.RegisterPullRequestHandler(PluginName, handlePullRequest, helpProvider) 51 } 52 53 func configString(labels []string) string { 54 var formattedLabels []string 55 for _, label := range labels { 56 formattedLabels = append(formattedLabels, fmt.Sprintf(`"%s/*"`, label)) 57 } 58 return fmt.Sprintf("The label plugin will work on %s and %s labels.", strings.Join(formattedLabels[:len(formattedLabels)-1], ", "), formattedLabels[len(formattedLabels)-1]) 59 } 60 61 func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) { 62 labels := []string{} 63 labels = append(labels, defaultLabels...) 64 labels = append(labels, config.Label.AdditionalLabels...) 65 yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{ 66 Label: plugins.Label{ 67 AdditionalLabels: []string{"api-review", "community/discussion"}, 68 RestrictedLabels: map[string][]plugins.RestrictedLabel{ 69 "*": {{ 70 Label: "restricted-label", 71 AllowedTeams: []string{"authorized-team"}, 72 AllowedUsers: []string{"alice", "bob"}, 73 AssignOn: []plugins.AssignOnLabel{{Label: "other-label"}}, 74 }}, 75 }, 76 }, 77 }) 78 if err != nil { 79 logrus.WithError(err).Warnf("cannot generate comments for %s plugin", PluginName) 80 } 81 pluginHelp := &pluginhelp.PluginHelp{ 82 Description: "The label plugin provides commands that add or remove certain types of labels. Labels of the following types can be manipulated: 'area/*', 'committee/*', 'kind/*', 'language/*', 'priority/*', 'sig/*', 'triage/*', and 'wg/*'. More labels can be configured to be used via the /label command. Restricted labels are only able to be added by the teams and users present in their configuration, and those users can be automatically assigned when another label is added using the assign_on config.", 83 Config: map[string]string{ 84 "": configString(labels), 85 }, 86 Snippet: yamlSnippet, 87 } 88 pluginHelp.AddCommand(pluginhelp.Command{ 89 Usage: "/[remove-](area|committee|kind|language|priority|sig|triage|wg|label) <target>", 90 Description: "Applies or removes a label from one of the recognized types of labels.", 91 Featured: false, 92 WhoCanUse: "Anyone can trigger this command on issues and PRs. `triage/accepted` can only be added by org members. Restricted labels are only able to be added by teams and users in their configuration.", 93 Examples: []string{"/kind bug", "/remove-area prow", "/sig testing", "/language zh", "/label foo-bar-baz"}, 94 }) 95 return pluginHelp, nil 96 } 97 98 func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error { 99 return handleComment(pc.GitHubClient, pc.Logger, pc.PluginConfig.Label, &e) 100 } 101 102 func handlePullRequest(pc plugins.Agent, e github.PullRequestEvent) error { 103 return handleLabelAdd(pc.GitHubClient, pc.Logger, pc.PluginConfig.Label, &e) 104 } 105 106 type githubClient interface { 107 CreateComment(owner, repo string, number int, comment string) error 108 AddLabel(owner, repo string, number int, label string) error 109 IsMember(org, user string) (bool, error) 110 RemoveLabel(owner, repo string, number int, label string) error 111 GetRepoLabels(owner, repo string) ([]github.Label, error) 112 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 113 TeamBySlugHasMember(org string, teamSlug string, memberLogin string) (bool, error) 114 AssignIssue(owner, repo string, number int, assignees []string) error 115 } 116 117 // Get Labels from Regexp matches 118 func getLabelsFromREMatches(matches [][]string) (labels []string) { 119 for _, match := range matches { 120 for _, label := range strings.Split(strings.TrimSpace(match[0]), " ")[1:] { 121 label = strings.ToLower(match[1] + "/" + strings.TrimSpace(label)) 122 labels = append(labels, label) 123 } 124 } 125 return 126 } 127 128 // getLabelsFromGenericMatches returns label matches with extra labels if those 129 // have been configured in the plugin config. 130 func getLabelsFromGenericMatches(matches [][]string, labelFilter func(string) bool, invalidLabels *[]string) []string { 131 var labels []string 132 for _, match := range matches { 133 parts := strings.Split(strings.TrimSpace(match[0]), " ") 134 if ((parts[0] != "/label") && (parts[0] != "/remove-label")) || len(parts) != 2 { 135 continue 136 } 137 if labelFilter(strings.ToLower(parts[1])) { 138 labels = append(labels, strings.ToLower(parts[1])) 139 } else { 140 *invalidLabels = append(*invalidLabels, match[0]) 141 } 142 } 143 return labels 144 } 145 146 func handleComment(gc githubClient, log *logrus.Entry, config plugins.Label, e *github.GenericCommentEvent) error { 147 if e.Action != github.GenericCommentActionCreated { 148 return nil 149 } 150 151 bodyWithoutComments := commentRegex.ReplaceAllString(e.Body, "") 152 labelMatches := labelRegex.FindAllStringSubmatch(bodyWithoutComments, -1) 153 removeLabelMatches := removeLabelRegex.FindAllStringSubmatch(bodyWithoutComments, -1) 154 customLabelMatches := customLabelRegex.FindAllStringSubmatch(bodyWithoutComments, -1) 155 customRemoveLabelMatches := customRemoveLabelRegex.FindAllStringSubmatch(bodyWithoutComments, -1) 156 if len(labelMatches) == 0 && len(removeLabelMatches) == 0 && len(customLabelMatches) == 0 && len(customRemoveLabelMatches) == 0 { 157 return nil 158 } 159 160 org := e.Repo.Owner.Login 161 repo := e.Repo.Name 162 user := e.User.Login 163 164 repoLabels, err := gc.GetRepoLabels(org, repo) 165 if err != nil { 166 return err 167 } 168 labels, err := gc.GetIssueLabels(org, repo, e.Number) 169 if err != nil { 170 return err 171 } 172 issueLabels := make([]string, len(labels)) 173 for i, l := range labels { 174 issueLabels[i] = l.Name 175 } 176 177 RepoLabelsExisting := sets.Set[string]{} 178 for _, l := range repoLabels { 179 RepoLabelsExisting.Insert(strings.ToLower(l.Name)) 180 } 181 var ( 182 nonexistent []string 183 noSuchLabelsInRepo []string 184 noSuchLabelsOnIssue []string 185 labelsToAdd []string 186 labelsToRemove []string 187 nonMemberTriageAccepted bool 188 ) 189 190 additionalLabelSet := sets.Set[string]{} 191 for _, label := range config.AdditionalLabels { 192 additionalLabelSet.Insert(strings.ToLower(label)) 193 } 194 restrictedLabels := config.RestrictedLabelsFor(e.Repo.Owner.Login, e.Repo.Name) 195 labelFilter := func(label string) bool { 196 label = strings.ToLower(label) 197 _, restrictedLabel := restrictedLabels[label] 198 return restrictedLabel || additionalLabelSet.Has(label) 199 } 200 201 // Get labels to add and labels to remove from regexp matches 202 labelsToAdd = append(getLabelsFromREMatches(labelMatches), getLabelsFromGenericMatches(customLabelMatches, labelFilter, &nonexistent)...) 203 labelsToRemove = append(getLabelsFromREMatches(removeLabelMatches), getLabelsFromGenericMatches(customRemoveLabelMatches, labelFilter, &nonexistent)...) 204 205 for _, needsCategory := range needsLabels { 206 needsLabel := fmt.Sprintf("needs-%s", needsCategory) 207 if !RepoLabelsExisting.Has(needsLabel) { 208 // Repo doesn't have the needs-* label. 209 continue 210 } 211 removed := labelsWithCategory(labelsToRemove, needsCategory) 212 if removed.Len() == 0 || labelsWithCategory(labelsToAdd, needsCategory).Len() > 0 { 213 // If a category is not being removed, or also being added, don't add needs-* label. 214 continue 215 } 216 if removed.IsSuperset(labelsWithCategory(issueLabels, needsCategory)) { 217 // If all the labels in a needed category are being removed, add the needs-* label. 218 labelsToAdd = append(labelsToAdd, needsLabel) 219 } 220 } 221 222 // Add labels 223 for _, labelToAdd := range labelsToAdd { 224 if github.HasLabel(labelToAdd, labels) { 225 continue 226 } 227 228 if !RepoLabelsExisting.Has(labelToAdd) { 229 noSuchLabelsInRepo = append(noSuchLabelsInRepo, labelToAdd) 230 continue 231 } 232 233 // only org members can add triage/accepted 234 if labelToAdd == prowlabels.TriageAccepted { 235 if member, err := gc.IsMember(org, user); err != nil { 236 log.WithError(err).Errorf("error in IsMember(%s): %v", org, err) 237 continue 238 } else if !member { 239 nonMemberTriageAccepted = true 240 continue 241 } 242 } 243 244 canSetLabel, canNotSetLabelReason, err := canUserSetLabel(gc, org, e.User.Login, labelToAdd, restrictedLabels) 245 if err != nil { 246 log.WithError(err).WithField("label", labelToAdd).Error("failed to check if user can set label") 247 continue 248 } 249 250 if !canSetLabel { 251 gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(bodyWithoutComments, e.HTMLURL, e.User.Login, canNotSetLabelReason)) 252 continue 253 } 254 255 if err := gc.AddLabel(org, repo, e.Number, labelToAdd); err != nil { 256 log.WithError(err).WithField("label", labelToAdd).Error("GitHub failed to add the label") 257 } 258 } 259 260 // Remove labels 261 for _, labelToRemove := range labelsToRemove { 262 if !github.HasLabel(labelToRemove, labels) { 263 noSuchLabelsOnIssue = append(noSuchLabelsOnIssue, labelToRemove) 264 continue 265 } 266 267 if !RepoLabelsExisting.Has(labelToRemove) { 268 continue 269 } 270 271 canSetLabel, canNotSetLabelReason, err := canUserSetLabel(gc, org, e.User.Login, labelToRemove, restrictedLabels) 272 if err != nil { 273 log.WithError(err).WithField("label", labelToRemove).Error("failed to check if user can set label") 274 continue 275 } 276 277 if !canSetLabel { 278 gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(bodyWithoutComments, e.HTMLURL, e.User.Login, canNotSetLabelReason)) 279 continue 280 } 281 282 if err := gc.RemoveLabel(org, repo, e.Number, labelToRemove); err != nil { 283 log.WithError(err).WithField("label", labelToRemove).Error("GitHub failed to remove the label") 284 } 285 } 286 287 if len(nonexistent) > 0 { 288 log.Infof("Nonexistent labels: %v", nonexistent) 289 msg := fmt.Sprintf("The label(s) `%s` cannot be applied. These labels are supported: `%s`. Is this label configured under `labels -> additional_labels` or `labels -> restricted_labels` in `plugin.yaml`?", strings.Join(nonexistent, ", "), strings.Join(append(config.AdditionalLabels, sets.List(sets.KeySet[string](restrictedLabels))...), ", ")) 290 return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(bodyWithoutComments, e.HTMLURL, e.User.Login, msg)) 291 } 292 293 if len(noSuchLabelsInRepo) > 0 { 294 log.Infof("Labels missing in repo: %v", noSuchLabelsInRepo) 295 msg := fmt.Sprintf("The label(s) `%s` cannot be applied, because the repository doesn't have them.", strings.Join(noSuchLabelsInRepo, ", ")) 296 return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(bodyWithoutComments, e.HTMLURL, e.User.Login, msg)) 297 } 298 299 // Tried to remove Labels that were not present on the Issue 300 if len(noSuchLabelsOnIssue) > 0 { 301 msg := fmt.Sprintf("Those labels are not set on the issue: `%v`", strings.Join(noSuchLabelsOnIssue, ", ")) 302 return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(bodyWithoutComments, e.HTMLURL, e.User.Login, msg)) 303 } 304 305 if nonMemberTriageAccepted { 306 msg := fmt.Sprintf("The label `%s` cannot be applied. Only GitHub organization members can add the label.", prowlabels.TriageAccepted) 307 return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(bodyWithoutComments, e.HTMLURL, e.User.Login, msg)) 308 } 309 310 return nil 311 } 312 313 func canUserSetLabel(ghc githubClient, org string, user string, label string, restrictedLabels map[string]plugins.RestrictedLabel) (canSet bool, canNotSetReason string, err error) { 314 config, isRestricted := restrictedLabels[label] 315 if !isRestricted { 316 return true, "", nil 317 } 318 319 for _, allowedUser := range config.AllowedUsers { 320 if strings.EqualFold(allowedUser, user) { 321 return true, "", nil 322 } 323 } 324 325 for _, team := range config.AllowedTeams { 326 isMember, err := ghc.TeamBySlugHasMember(org, team, user) 327 if err != nil { 328 return false, "", err 329 } 330 if isMember { 331 return true, "", nil 332 } 333 } 334 335 return false, fmt.Sprintf("Can not set label %s: Must be member in one of these teams: %v", label, config.AllowedTeams), nil 336 } 337 338 func handleLabelAdd(gc githubClient, log *logrus.Entry, config plugins.Label, e *github.PullRequestEvent) error { 339 if e.Action != github.PullRequestActionLabeled { 340 return nil 341 } 342 343 org := e.Repo.Owner.Login 344 repo := e.Repo.Name 345 number := e.PullRequest.Number 346 restrictedLabels := config.RestrictedLabelsFor(e.Repo.Owner.Login, e.Repo.Name) 347 348 for _, restrictedLabel := range restrictedLabels { 349 for _, assignOn := range restrictedLabel.AssignOn { 350 if strings.EqualFold(e.Label.Name, assignOn.Label) { 351 log.WithField("label", restrictedLabel.Label).Info("Assigning users for restricted label") 352 // It's okay to re-assign users so no need to check if they are assigned 353 if err := gc.AssignIssue(org, repo, number, restrictedLabel.AllowedUsers); err != nil { 354 log.WithError(err).WithField("label", restrictedLabel.Label).Error("GitHub failed to assign reviewers for the label") 355 } 356 } 357 } 358 } 359 return nil 360 } 361 362 func labelsWithCategory(labels []string, category string) sets.Set[string] { 363 categorized := sets.Set[string]{} 364 prefix := category + "/" 365 for _, s := range labels { 366 if strings.HasPrefix(s, prefix) { 367 categorized.Insert(s) 368 } 369 } 370 return categorized 371 }