github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/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  	"strings"
    27  	"time"
    28  
    29  	"k8s.io/test-infra/prow/github"
    30  	"k8s.io/test-infra/prow/pluginhelp"
    31  	"k8s.io/test-infra/prow/plugins"
    32  
    33  	"github.com/sirupsen/logrus"
    34  )
    35  
    36  var (
    37  	handlePRActions = map[github.PullRequestEventAction]bool{
    38  		github.PullRequestActionOpened:    true,
    39  		github.PullRequestActionReopened:  true,
    40  		github.PullRequestActionLabeled:   true,
    41  		github.PullRequestActionUnlabeled: true,
    42  	}
    43  
    44  	handleIssueActions = map[github.IssueEventAction]bool{
    45  		github.IssueActionOpened:    true,
    46  		github.IssueActionReopened:  true,
    47  		github.IssueActionLabeled:   true,
    48  		github.IssueActionUnlabeled: true,
    49  	}
    50  )
    51  
    52  const (
    53  	pluginName = "require-matching-label"
    54  )
    55  
    56  type githubClient interface {
    57  	AddLabel(org, repo string, number int, label string) error
    58  	RemoveLabel(org, repo string, number int, label string) error
    59  	CreateComment(org, repo string, number int, content string) error
    60  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
    61  }
    62  
    63  type commentPruner interface {
    64  	PruneComments(shouldPrune func(github.IssueComment) bool)
    65  }
    66  
    67  func init() {
    68  	plugins.RegisterIssueHandler(pluginName, handleIssue, helpProvider)
    69  	plugins.RegisterPullRequestHandler(pluginName, handlePullRequest, helpProvider)
    70  }
    71  
    72  func helpProvider(config *plugins.Configuration, _ []string) (*pluginhelp.PluginHelp, error) {
    73  	descs := make([]string, 0, len(config.RequireMatchingLabel))
    74  	for _, cfg := range config.RequireMatchingLabel {
    75  		descs = append(descs, cfg.Describe())
    76  	}
    77  	// Only the 'Description' and 'Config' fields are necessary because this plugin does not react
    78  	// to any commands.
    79  	return &pluginhelp.PluginHelp{
    80  			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.`,
    81  			Config: map[string]string{
    82  				"": fmt.Sprintf("The plugin has the following configurations:\n<ul><li>%s</li></ul>", strings.Join(descs, "</li><li>")),
    83  			},
    84  		},
    85  		nil
    86  }
    87  
    88  type event struct {
    89  	org    string
    90  	repo   string
    91  	number int
    92  	author string
    93  	// The PR's base branch. If empty this is an Issue, not a PR.
    94  	branch string
    95  	// The label that was added or removed. If empty this is an open or reopen event.
    96  	label string
    97  	// The labels currently on the issue. For PRs this is not contained in the webhook payload and may be omitted.
    98  	currentLabels []github.Label
    99  }
   100  
   101  func handleIssue(pc plugins.Agent, ie github.IssueEvent) error {
   102  	if !handleIssueActions[ie.Action] {
   103  		return nil
   104  	}
   105  	e := &event{
   106  		org:           ie.Repo.Owner.Login,
   107  		repo:          ie.Repo.Name,
   108  		number:        ie.Issue.Number,
   109  		author:        ie.Issue.User.Login,
   110  		label:         ie.Label.Name, // This will be empty for non-label events.
   111  		currentLabels: ie.Issue.Labels,
   112  	}
   113  	cp, err := pc.CommentPruner()
   114  	if err != nil {
   115  		return err
   116  	}
   117  	return handle(pc.Logger, pc.GitHubClient, cp, pc.PluginConfig.RequireMatchingLabel, e)
   118  }
   119  
   120  func handlePullRequest(pc plugins.Agent, pre github.PullRequestEvent) error {
   121  	if !handlePRActions[pre.Action] {
   122  		return nil
   123  	}
   124  	e := &event{
   125  		org:    pre.Repo.Owner.Login,
   126  		repo:   pre.Repo.Name,
   127  		number: pre.PullRequest.Number,
   128  		branch: pre.PullRequest.Base.Ref,
   129  		author: pre.PullRequest.User.Login,
   130  		label:  pre.Label.Name, // This will be empty for non-label events.
   131  	}
   132  	cp, err := pc.CommentPruner()
   133  	if err != nil {
   134  		return err
   135  	}
   136  	return handle(pc.Logger, pc.GitHubClient, cp, pc.PluginConfig.RequireMatchingLabel, e)
   137  }
   138  
   139  // matchingConfigs filters irrelevant RequireMtchingLabel configs from
   140  // the list of all configs.
   141  // `branch` should be empty for Issues and non-empty for PRs.
   142  // `label` should be omitted in the case of 'open' and 'reopen' actions.
   143  func matchingConfigs(org, repo, branch, label string, allConfigs []plugins.RequireMatchingLabel) []plugins.RequireMatchingLabel {
   144  	var filtered []plugins.RequireMatchingLabel
   145  	for _, cfg := range allConfigs {
   146  		// Check if the config applies to this issue type.
   147  		if (branch == "" && !cfg.Issues) || (branch != "" && !cfg.PRs) {
   148  			continue
   149  		}
   150  		// Check if the config applies to this 'org[/repo][/branch]'.
   151  		if org != cfg.Org ||
   152  			(cfg.Repo != "" && cfg.Repo != repo) ||
   153  			(cfg.Branch != "" && branch != "" && cfg.Branch != branch) {
   154  			continue
   155  		}
   156  		// If we are reacting to a label event, see if it is relevant.
   157  		if label != "" && !cfg.Re.MatchString(label) {
   158  			continue
   159  		}
   160  		filtered = append(filtered, cfg)
   161  	}
   162  	return filtered
   163  }
   164  
   165  func handle(log *logrus.Entry, ghc githubClient, cp commentPruner, configs []plugins.RequireMatchingLabel, e *event) error {
   166  	// Find any configs that may be relevant to this event.
   167  	matchConfigs := matchingConfigs(e.org, e.repo, e.branch, e.label, configs)
   168  	if len(matchConfigs) == 0 {
   169  		return nil
   170  	}
   171  
   172  	if e.label == "" /* not a label event */ {
   173  		// If we are reacting to a PR or Issue being created or reopened, we should wait a
   174  		// few seconds to allow other automation to apply labels in order to minimize thrashing.
   175  		// We use the max grace period from applicable configs.
   176  		gracePeriod := time.Duration(0)
   177  		for _, cfg := range matchConfigs {
   178  			if cfg.GracePeriodDuration > gracePeriod {
   179  				gracePeriod = cfg.GracePeriodDuration
   180  			}
   181  		}
   182  		time.Sleep(gracePeriod)
   183  		// If currentLabels was populated it is now stale.
   184  		e.currentLabels = nil
   185  	}
   186  	if e.currentLabels == nil {
   187  		var err error
   188  		e.currentLabels, err = ghc.GetIssueLabels(e.org, e.repo, e.number)
   189  		if err != nil {
   190  			return fmt.Errorf("error getting the issue or pr's labels: %v", err)
   191  		}
   192  	}
   193  
   194  	// Handle the potentially relevant configs.
   195  	for _, cfg := range matchConfigs {
   196  		hasMissingLabel := false
   197  		hasMatchingLabel := false
   198  		for _, label := range e.currentLabels {
   199  			hasMissingLabel = hasMissingLabel || label.Name == cfg.MissingLabel
   200  			hasMatchingLabel = hasMatchingLabel || cfg.Re.MatchString(label.Name)
   201  		}
   202  
   203  		if hasMatchingLabel && hasMissingLabel {
   204  			if err := ghc.RemoveLabel(e.org, e.repo, e.number, cfg.MissingLabel); err != nil {
   205  				log.WithError(err).Errorf("Failed to remove %q label.", cfg.MissingLabel)
   206  			}
   207  			if cfg.MissingComment != "" {
   208  				cp.PruneComments(func(comment github.IssueComment) bool {
   209  					return strings.Contains(comment.Body, cfg.MissingComment)
   210  				})
   211  			}
   212  		} else if !hasMatchingLabel && !hasMissingLabel {
   213  			if err := ghc.AddLabel(e.org, e.repo, e.number, cfg.MissingLabel); err != nil {
   214  				log.WithError(err).Errorf("Failed to add %q label.", cfg.MissingLabel)
   215  			}
   216  			if cfg.MissingComment != "" {
   217  				msg := plugins.FormatSimpleResponse(e.author, cfg.MissingComment)
   218  				if err := ghc.CreateComment(e.org, e.repo, e.number, msg); err != nil {
   219  					log.WithError(err).Error("Failed to create comment.")
   220  				}
   221  			}
   222  		}
   223  
   224  	}
   225  	return nil
   226  }