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 }