github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/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  }