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  }