sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/cherrypickapproved/cherrypickapproved.go (about)

     1  /*
     2  Copyright 2023 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 cherrypickapproved
    18  
    19  import (
    20  	"fmt"
    21  	"regexp"
    22  	"strings"
    23  	"time"
    24  
    25  	"github.com/sirupsen/logrus"
    26  	"sigs.k8s.io/prow/pkg/config"
    27  	"sigs.k8s.io/prow/pkg/github"
    28  	"sigs.k8s.io/prow/pkg/labels"
    29  	"sigs.k8s.io/prow/pkg/pluginhelp"
    30  	"sigs.k8s.io/prow/pkg/plugins"
    31  )
    32  
    33  // PluginName defines this plugin's registered name.
    34  const PluginName = "cherry-pick-approved"
    35  
    36  func init() {
    37  	plugins.RegisterReviewEventHandler(PluginName, handlePullRequestReviewEvent, helpProvider)
    38  }
    39  
    40  func helpProvider(cfg *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    41  	yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{
    42  		CherryPickApproved: []plugins.CherryPickApproved{
    43  			{BranchRegexp: "^release-*"},
    44  		},
    45  	})
    46  	if err != nil {
    47  		logrus.WithError(err).Warnf("cannot generate comments for %s plugin", PluginName)
    48  	}
    49  
    50  	return &pluginhelp.PluginHelp{
    51  		Description: fmt.Sprintf(
    52  			"The %s plugin helps a defined set of maintainers to approve cherry-picks by using GitHub reviews",
    53  			PluginName,
    54  		),
    55  		Config: map[string]string{
    56  			"": fmt.Sprintf(
    57  				"The %s plugin treats PRs against branch names satisfying the `branchregexp` as cherry-pick PRs. "+
    58  					"There needs to be a defined `approvers` list for the plugin to "+
    59  					"be able to distinguish cherry-pick approvers from regular maintainers.",
    60  				PluginName,
    61  			),
    62  		},
    63  		Snippet: yamlSnippet,
    64  	}, nil
    65  }
    66  
    67  type handler struct {
    68  	impl
    69  }
    70  
    71  func newHandler() *handler {
    72  	return &handler{
    73  		impl: &defaultImpl{},
    74  	}
    75  }
    76  
    77  //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate
    78  //counterfeiter:generate . impl
    79  type impl interface {
    80  	GetCombinedStatus(gc plugins.PluginGitHubClient, org, repo, ref string) (*github.CombinedStatus, error)
    81  	GetIssueLabels(gc plugins.PluginGitHubClient, org, repo string, number int) ([]github.Label, error)
    82  	AddLabel(gc plugins.PluginGitHubClient, org, repo string, number int, label string) error
    83  	RemoveLabel(gc plugins.PluginGitHubClient, org, repo string, number int, label string) error
    84  }
    85  
    86  type defaultImpl struct{}
    87  
    88  func (*defaultImpl) GetCombinedStatus(gc plugins.PluginGitHubClient, org, repo, ref string) (*github.CombinedStatus, error) {
    89  	return gc.GetCombinedStatus(org, repo, ref)
    90  }
    91  
    92  func (*defaultImpl) GetIssueLabels(gc plugins.PluginGitHubClient, org, repo string, number int) ([]github.Label, error) {
    93  	return gc.GetIssueLabels(org, repo, number)
    94  }
    95  
    96  func (*defaultImpl) AddLabel(gc plugins.PluginGitHubClient, org, repo string, number int, label string) error {
    97  	return gc.AddLabel(org, repo, number, label)
    98  
    99  }
   100  
   101  func (*defaultImpl) RemoveLabel(gc plugins.PluginGitHubClient, org, repo string, number int, label string) error {
   102  	return gc.RemoveLabel(org, repo, number, label)
   103  }
   104  
   105  func handlePullRequestReviewEvent(pc plugins.Agent, e github.ReviewEvent) error {
   106  	if err := newHandler().handle(pc.Logger, pc.GitHubClient, e, pc.PluginConfig.CherryPickApproved); err != nil {
   107  		pc.Logger.WithError(err).Error("skipping")
   108  		return err
   109  	}
   110  	return nil
   111  }
   112  
   113  func (h *handler) handle(log *logrus.Entry, gc plugins.PluginGitHubClient, e github.ReviewEvent, cfgs []plugins.CherryPickApproved) error {
   114  	funcStart := time.Now()
   115  
   116  	org := e.Repo.Owner.Login
   117  	repo := e.Repo.Name
   118  	branch := e.PullRequest.Base.Ref
   119  	prNumber := e.PullRequest.Number
   120  
   121  	defer func() {
   122  		log.WithField("duration", time.Since(funcStart).String()).
   123  			WithField("org", org).
   124  			WithField("repo", repo).
   125  			WithField("branch", branch).
   126  			WithField("pr", prNumber).
   127  			Debug("Completed handlePullRequestReviewEvent")
   128  	}()
   129  
   130  	var (
   131  		approvers []string
   132  		branchRe  *regexp.Regexp
   133  	)
   134  
   135  	// Filter configurations
   136  	foundRepoOrg := false
   137  	for _, cfg := range cfgs {
   138  		if cfg.Org == org && cfg.Repo == repo {
   139  			foundRepoOrg = true
   140  			approvers = cfg.Approvers
   141  			branchRe = cfg.BranchRe
   142  		}
   143  	}
   144  
   145  	if !foundRepoOrg {
   146  		log.Debugf("Skipping because repo %s/%s is not part of plugin configuration", org, repo)
   147  		return nil
   148  	}
   149  
   150  	if len(approvers) == 0 {
   151  		log.Debug("Skipping because no cherry-pick approvers configured")
   152  		return nil
   153  	}
   154  
   155  	if branchRe == nil || !branchRe.MatchString(branch) {
   156  		log.Debugf("Skipping because no release branch regex match for branch: %s", branch)
   157  		return nil
   158  	}
   159  
   160  	// Only react to reviews that are being submitted (not edited or dismissed).
   161  	if e.Action != github.ReviewActionSubmitted {
   162  		return nil
   163  	}
   164  
   165  	// The review webhook returns state as lowercase, while the review API
   166  	// returns state as uppercase. Uppercase the value here so it always
   167  	// matches the constant.
   168  	if github.ReviewState(strings.ToUpper(string(e.Review.State))) != github.ReviewStateApproved {
   169  		return nil
   170  	}
   171  
   172  	// Check the PR state to not have failed tests
   173  	combinedStatus, err := h.GetCombinedStatus(gc, org, repo, e.PullRequest.Head.SHA)
   174  	if err != nil {
   175  		return fmt.Errorf("get combined status: %w", err)
   176  	}
   177  	for _, status := range combinedStatus.Statuses {
   178  		state := status.State
   179  		if state == github.StatusError || state == github.StatusFailure {
   180  			log.Infof("Skipping PR %d because tests failed", prNumber)
   181  			return nil
   182  		}
   183  	}
   184  
   185  	// Validate the labels
   186  	issueLabels, err := h.GetIssueLabels(gc, org, repo, prNumber)
   187  	if err != nil {
   188  		return fmt.Errorf("get issue labels: %w", err)
   189  	}
   190  
   191  	hasCherryPickApprovedLabel := github.HasLabel(labels.CpApproved, issueLabels)
   192  	hasCherryPickUnapprovedLabel := github.HasLabel(labels.CpUnapproved, issueLabels)
   193  	hasLGTMLabel := github.HasLabel(labels.LGTM, issueLabels)
   194  	hasApprovedLabel := github.HasLabel(labels.Approved, issueLabels)
   195  	hasInvalidLabels := github.HasLabels(
   196  		[]string{
   197  			labels.BlockedPaths,
   198  			labels.ClaNo,
   199  			labels.Hold,
   200  			labels.InvalidOwners,
   201  			labels.InvalidBug,
   202  			labels.MergeCommits,
   203  			labels.NeedsOkToTest,
   204  			labels.NeedsRebase,
   205  			labels.ReleaseNoteLabelNeeded,
   206  			labels.WorkInProgress,
   207  		},
   208  		issueLabels,
   209  	)
   210  
   211  	isApprover := false
   212  	for _, approver := range approvers {
   213  		if e.Review.User.Login == approver {
   214  			isApprover = true
   215  		}
   216  	}
   217  	if !isApprover {
   218  		log.Infof("Skipping PR %d because user %s is not an approver from configured list: %v", prNumber, e.Review.User.Login, approvers)
   219  		return nil
   220  	}
   221  
   222  	if hasLGTMLabel && hasApprovedLabel && !hasInvalidLabels {
   223  		if !hasCherryPickApprovedLabel {
   224  			if err := h.AddLabel(gc, org, repo, prNumber, labels.CpApproved); err != nil {
   225  				log.WithError(err).Errorf("failed to add the label: %s", labels.CpApproved)
   226  			}
   227  		}
   228  
   229  		if hasCherryPickUnapprovedLabel {
   230  			if err := h.RemoveLabel(gc, org, repo, prNumber, labels.CpUnapproved); err != nil {
   231  				log.WithError(err).Errorf("failed to remove the label: %s", labels.CpUnapproved)
   232  			}
   233  		}
   234  	}
   235  
   236  	return nil
   237  }