sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/cla/cla.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 cla 18 19 import ( 20 "fmt" 21 "time" 22 23 "github.com/sirupsen/logrus" 24 25 "regexp" 26 27 "sigs.k8s.io/prow/pkg/config" 28 "sigs.k8s.io/prow/pkg/github" 29 "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 = "cla" 36 claContextName = "EasyCLA" 37 maxRetries = 5 38 ) 39 40 var ( 41 checkCLARe = regexp.MustCompile(`(?mi)^/check-cla\s*$`) 42 ) 43 44 func init() { 45 plugins.RegisterStatusEventHandler(pluginName, handleStatusEvent, helpProvider) 46 plugins.RegisterGenericCommentHandler(pluginName, handleCommentEvent, helpProvider) 47 } 48 49 func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) { 50 // The {WhoCanUse, Usage, Examples, Config} fields are omitted because this plugin cannot be 51 // manually triggered and is not configurable. 52 pluginHelp := &pluginhelp.PluginHelp{ 53 Description: "The cla plugin manages the application and removal of the 'cncf-cla' prefixed labels on pull requests as a reaction to the " + claContextName + " github status context. It is also responsible for warning unauthorized PR authors that they need to sign the CNCF CLA before their PR will be merged.", 54 } 55 pluginHelp.AddCommand(pluginhelp.Command{ 56 Usage: "/check-cla", 57 Description: "Forces rechecking of the CLA status.", 58 Featured: true, 59 WhoCanUse: "Anyone", 60 Examples: []string{"/check-cla"}, 61 }) 62 return pluginHelp, nil 63 } 64 65 type gitHubClient interface { 66 AddLabel(owner, repo string, number int, label string) error 67 RemoveLabel(owner, repo string, number int, label string) error 68 GetPullRequest(owner, repo string, number int) (*github.PullRequest, error) 69 FindIssues(query, sort string, asc bool) ([]github.Issue, error) 70 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 71 GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error) 72 } 73 74 func handleStatusEvent(pc plugins.Agent, se github.StatusEvent) error { 75 return handle(pc.GitHubClient, pc.Logger, se) 76 } 77 78 // 1. Check that the status event received from the webhook is for the CNCF-CLA. 79 // 2. Use the github search API to search for the PRs which match the commit hash corresponding to the status event. 80 // 3. For each issue that matches, check that the PR's HEAD commit hash against the commit hash for which the status 81 // was received. This is because we only care about the status associated with the last (latest) commit in a PR. 82 // 4. Set the corresponding CLA label if needed. 83 func handle(gc gitHubClient, log *logrus.Entry, se github.StatusEvent) error { 84 if se.State == "" || se.Context == "" { 85 return fmt.Errorf("invalid status event delivered with empty state/context") 86 } 87 88 if se.Context != claContextName { 89 // Not the CNCF CLA context, do not process this. 90 return nil 91 } 92 93 if se.State == github.StatusPending { 94 // do nothing and wait for state to be updated. 95 return nil 96 } 97 98 org := se.Repo.Owner.Login 99 repo := se.Repo.Name 100 log.Info("Searching for PRs matching the commit.") 101 102 var issues []github.Issue 103 var err error 104 for i := 0; i < maxRetries; i++ { 105 issues, err = gc.FindIssues(fmt.Sprintf("%s repo:%s/%s type:pr state:open", se.SHA, org, repo), "", false) 106 if err != nil { 107 return fmt.Errorf("error searching for issues matching commit: %w", err) 108 } 109 if len(issues) > 0 { 110 break 111 } 112 time.Sleep(10 * time.Second) 113 } 114 log.Infof("Found %d PRs matching commit.", len(issues)) 115 116 for _, issue := range issues { 117 l := log.WithField("pr", issue.Number) 118 hasCncfYes := issue.HasLabel(labels.ClaYes) 119 hasCncfNo := issue.HasLabel(labels.ClaNo) 120 if hasCncfYes && se.State == github.StatusSuccess { 121 // Nothing to update. 122 l.Infof("PR has up-to-date %s label.", labels.ClaYes) 123 continue 124 } 125 126 if hasCncfNo && (se.State == github.StatusFailure || se.State == github.StatusError) { 127 // Nothing to update. 128 l.Infof("PR has up-to-date %s label.", labels.ClaNo) 129 continue 130 } 131 132 l.Info("PR labels may be out of date. Getting pull request info.") 133 pr, err := gc.GetPullRequest(org, repo, issue.Number) 134 if err != nil { 135 l.WithError(err).Warningf("Unable to fetch PR-%d from %s/%s.", issue.Number, org, repo) 136 continue 137 } 138 139 // Check if this is the latest commit in the PR. 140 if pr.Head.SHA != se.SHA { 141 l.Info("Event is not for PR HEAD, skipping.") 142 continue 143 } 144 145 number := pr.Number 146 if se.State == github.StatusSuccess { 147 if hasCncfNo { 148 if err := gc.RemoveLabel(org, repo, number, labels.ClaNo); err != nil { 149 l.WithError(err).Warningf("Could not remove %s label.", labels.ClaNo) 150 } 151 } 152 if err := gc.AddLabel(org, repo, number, labels.ClaYes); err != nil { 153 l.WithError(err).Warningf("Could not add %s label.", labels.ClaYes) 154 } 155 continue 156 } 157 158 // If we end up here, the status is a failure/error. 159 if hasCncfYes { 160 if err := gc.RemoveLabel(org, repo, number, labels.ClaYes); err != nil { 161 l.WithError(err).Warningf("Could not remove %s label.", labels.ClaYes) 162 } 163 } 164 if err := gc.AddLabel(org, repo, number, labels.ClaNo); err != nil { 165 l.WithError(err).Warningf("Could not add %s label.", labels.ClaNo) 166 } 167 } 168 return nil 169 } 170 171 func handleCommentEvent(pc plugins.Agent, ce github.GenericCommentEvent) error { 172 return handleComment(pc.GitHubClient, pc.Logger, &ce) 173 } 174 175 func handleComment(gc gitHubClient, log *logrus.Entry, e *github.GenericCommentEvent) error { 176 // Only consider open PRs and new comments. 177 if e.IssueState != "open" || e.Action != github.GenericCommentActionCreated { 178 return nil 179 } 180 // Only consider "/check-cla" comments. 181 if !checkCLARe.MatchString(e.Body) { 182 return nil 183 } 184 185 org := e.Repo.Owner.Login 186 repo := e.Repo.Name 187 number := e.Number 188 hasCLAYes := false 189 hasCLANo := false 190 191 // Check for existing cla labels. 192 issueLabels, err := gc.GetIssueLabels(org, repo, number) 193 if err != nil { 194 log.WithError(err).Errorf("Failed to get the labels on %s/%s#%d.", org, repo, number) 195 } 196 for _, candidate := range issueLabels { 197 if candidate.Name == labels.ClaYes { 198 hasCLAYes = true 199 } 200 // Could theoretically have both yes/no labels. 201 if candidate.Name == labels.ClaNo { 202 hasCLANo = true 203 } 204 } 205 206 pr, err := gc.GetPullRequest(org, repo, e.Number) 207 if err != nil { 208 log.WithError(err).Errorf("Unable to fetch PR-%d from %s/%s.", e.Number, org, repo) 209 } 210 211 // Check for the cla in past commit statuses, and add/remove corresponding cla label if necessary. 212 ref := pr.Head.SHA 213 combined, err := gc.GetCombinedStatus(org, repo, ref) 214 if err != nil { 215 log.WithError(err).Errorf("Failed to get statuses on %s/%s#%d", org, repo, number) 216 } 217 218 for _, status := range combined.Statuses { 219 220 // Only consider the context we care about 221 if status.Context == claContextName { 222 223 // Success state implies that the cla exists, so label should be cncf-cla:yes. 224 if status.State == github.StatusSuccess { 225 226 // Remove cncf-cla:no (if label exists). 227 if hasCLANo { 228 if err := gc.RemoveLabel(org, repo, number, labels.ClaNo); err != nil { 229 log.WithError(err).Warningf("Could not remove %s label.", labels.ClaNo) 230 } 231 } 232 233 // Add cncf-cla:yes (if label doesn't exist). 234 if !hasCLAYes { 235 if err := gc.AddLabel(org, repo, number, labels.ClaYes); err != nil { 236 log.WithError(err).Warningf("Could not add %s label.", labels.ClaYes) 237 } 238 } 239 240 // Failure state implies that the cla does not exist, so label should be cncf-cla:no. 241 } else if status.State == github.StatusFailure { 242 243 // Remove cncf-cla:yes (if label exists). 244 if hasCLAYes { 245 if err := gc.RemoveLabel(org, repo, number, labels.ClaYes); err != nil { 246 log.WithError(err).Warningf("Could not remove %s label.", labels.ClaYes) 247 } 248 } 249 250 // Add cncf-cla:no (if label doesn't exist). 251 if !hasCLANo { 252 if err := gc.AddLabel(org, repo, number, labels.ClaNo); err != nil { 253 log.WithError(err).Warningf("Could not add %s label.", labels.ClaNo) 254 } 255 } 256 } 257 258 // No need to consider other contexts once you find the one you need. 259 break 260 } 261 } 262 return nil 263 }