sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/trigger/pull-request.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 trigger 18 19 import ( 20 "context" 21 "encoding/json" 22 "errors" 23 "fmt" 24 "net/url" 25 "strconv" 26 27 apierrors "k8s.io/apimachinery/pkg/api/errors" 28 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 klabels "k8s.io/apimachinery/pkg/labels" 30 utilerrors "k8s.io/apimachinery/pkg/util/errors" 31 32 prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1" 33 "sigs.k8s.io/prow/pkg/config" 34 "sigs.k8s.io/prow/pkg/github" 35 "sigs.k8s.io/prow/pkg/kube" 36 "sigs.k8s.io/prow/pkg/labels" 37 "sigs.k8s.io/prow/pkg/pjutil" 38 "sigs.k8s.io/prow/pkg/plugins" 39 ) 40 41 const ( 42 abortedDescription = "Aborted by trigger plugin." 43 ) 44 45 func handlePR(c Client, trigger plugins.Trigger, pr github.PullRequestEvent) error { 46 org, repo, a := orgRepoAuthor(pr.PullRequest) 47 author := string(a) 48 num := pr.PullRequest.Number 49 50 baseSHA := "" 51 baseSHAGetter := func() (string, error) { 52 var err error 53 baseSHA, err = c.GitHubClient.GetRef(org, repo, "heads/"+pr.PullRequest.Base.Ref) 54 if err != nil { 55 return "", fmt.Errorf("failed to get baseSHA: %w", err) 56 } 57 return baseSHA, nil 58 } 59 headSHAGetter := func() (string, error) { 60 return pr.PullRequest.Head.SHA, nil 61 } 62 63 presubmits := getPresubmits(c.Logger, c.GitClient, c.Config, org+"/"+repo, baseSHAGetter, headSHAGetter) 64 if len(presubmits) == 0 { 65 return nil 66 } 67 68 if baseSHA == "" { 69 if _, err := baseSHAGetter(); err != nil { 70 return err 71 } 72 } 73 74 switch pr.Action { 75 case github.PullRequestActionOpened: 76 // When a PR is opened, if the author is in the org then build it. 77 // Otherwise, ask for "/ok-to-test". There's no need to look for previous 78 // "/ok-to-test" comments since the PR was just opened! 79 trustedResponse, err := TrustedUser(c.GitHubClient, trigger.OnlyOrgMembers, trigger.TrustedApps, trigger.TrustedOrg, author, org, repo) 80 member := trustedResponse.IsTrusted 81 if err != nil { 82 return fmt.Errorf("could not check membership: %s", err) 83 } 84 if member { 85 // dedicated draft check for create to comment on the PR 86 if pr.PullRequest.Draft { 87 c.Logger.Info("Skipping all jobs for draft PR.") 88 return draftMsg(c.GitHubClient, pr.PullRequest) 89 } 90 c.Logger.Info("Starting all jobs for new PR.") 91 return buildAllButDrafts(c, &pr.PullRequest, pr.GUID, baseSHA, presubmits) 92 } 93 c.Logger.Infof("Welcome message to PR author %q.", author) 94 if err := welcomeMsg(c.GitHubClient, trigger, pr.PullRequest); err != nil { 95 return fmt.Errorf("could not welcome non-org member %q: %w", author, err) 96 } 97 case github.PullRequestActionReopened: 98 return buildAllIfTrusted(c, trigger, pr, baseSHA, presubmits) 99 case github.PullRequestActionEdited: 100 // if someone changes the base of their PR, we will get this 101 // event and the changes field will list that the base SHA and 102 // ref changes so we can detect such a case and retrigger tests 103 var changes struct { 104 Base struct { 105 Ref struct { 106 From string `json:"from"` 107 } `json:"ref"` 108 Sha struct { 109 From string `json:"from"` 110 } `json:"sha"` 111 } `json:"base"` 112 } 113 if err := json.Unmarshal(pr.Changes, &changes); err != nil { 114 // we're detecting this best-effort so we can forget about 115 // the event 116 return nil 117 } else if changes.Base.Ref.From != "" || changes.Base.Sha.From != "" { 118 // the base of the PR changed and we need to re-test it 119 return buildAllIfTrusted(c, trigger, pr, baseSHA, presubmits) 120 } 121 case github.PullRequestActionSynchronize: 122 var errs []error 123 if err := abortAllJobs(c, &pr.PullRequest); err != nil { 124 errs = append(errs, fmt.Errorf("failed to abort jobs: %w", err)) 125 } 126 return utilerrors.NewAggregate(append(errs, buildAllIfTrusted(c, trigger, pr, baseSHA, presubmits))) 127 case github.PullRequestActionLabeled: 128 // When a PR is LGTMd, if it is untrusted then build it once. 129 if pr.Label.Name == labels.LGTM { 130 _, trusted, err := TrustedPullRequest(c.GitHubClient, trigger, author, org, repo, num, nil) 131 if err != nil { 132 return fmt.Errorf("could not validate PR: %s", err) 133 } else if !trusted { 134 c.Logger.Info("Starting all jobs for untrusted PR with LGTM.") 135 return buildAllButDrafts(c, &pr.PullRequest, pr.GUID, baseSHA, presubmits) 136 } 137 } 138 if pr.Label.Name == labels.OkToTest { 139 // When the bot adds the label from an /ok-to-test command, 140 // we will trigger tests based on the comment event and do not 141 // need to trigger them here from the label, as well 142 botUserChecker, err := c.GitHubClient.BotUserChecker() 143 if err != nil { 144 return err 145 } 146 if botUserChecker(pr.Sender.Login) { 147 c.Logger.Debug("Label added by the bot, skipping.") 148 return nil 149 } 150 return buildAllButDrafts(c, &pr.PullRequest, pr.GUID, baseSHA, presubmits) 151 } 152 case github.PullRequestActionClosed: 153 if err := abortAllJobs(c, &pr.PullRequest); err != nil { 154 c.Logger.WithError(err).Error("Failed to abort jobs for closed pull request") 155 return err 156 } 157 case github.PullRequestActionReadyForReview: 158 return buildAllIfTrusted(c, trigger, pr, baseSHA, presubmits) 159 case github.PullRequestActionConvertedToDraft: 160 if err := abortAllJobs(c, &pr.PullRequest); err != nil { 161 c.Logger.WithError(err).Error("Failed to abort jobs for pull request converted to draft") 162 return err 163 } 164 } 165 166 return nil 167 } 168 169 func abortAllJobs(c Client, pr *github.PullRequest) error { 170 selector, err := labelSelectorForPR(pr) 171 if err != nil { 172 return fmt.Errorf("failed to construct label selector: %w", err) 173 } 174 175 jobs, err := c.ProwJobClient.List(context.TODO(), metav1.ListOptions{LabelSelector: selector.String()}) 176 if err != nil { 177 return fmt.Errorf("failed to list prowjobs for pr: %w", err) 178 } 179 180 var errs []error 181 for _, job := range jobs.Items { 182 // Do not abort jobs that already completed 183 if job.Complete() { 184 continue 185 } 186 job.Status.State = prowapi.AbortedState 187 job.Status.Description = abortedDescription 188 // We use Update and not Patch here, because we are not the authority of the .Status.State field 189 // and must not overwrite changes made to it in the interim by the responsible agent. 190 // The accepted trade-off for now is that this leads to failure if unrelated fields where changed 191 // by another different actor. 192 if _, err := c.ProwJobClient.Update(context.TODO(), &job, metav1.UpdateOptions{}); err != nil && !apierrors.IsConflict(err) { 193 errs = append(errs, fmt.Errorf("failed to abort job %s: %w", job.Name, err)) 194 } 195 } 196 197 return utilerrors.NewAggregate(errs) 198 } 199 200 func labelSelectorForPR(pr *github.PullRequest) (klabels.Selector, error) { 201 set := klabels.Set{ 202 kube.OrgLabel: pr.Base.Repo.Owner.Login, 203 kube.RepoLabel: pr.Base.Repo.Name, 204 kube.PullLabel: strconv.Itoa(pr.Number), 205 kube.ProwJobTypeLabel: string(prowapi.PresubmitJob), 206 } 207 selector := klabels.SelectorFromSet(set) 208 // Needed because of this gem: 209 // https://github.com/kubernetes/apimachinery/blob/f8e71527369e696bf041722b248ffcb32bae9edf/pkg/labels/selector.go#L883 210 if selector.Empty() { 211 return nil, errors.New("got back empty selector") 212 } 213 214 return selector, nil 215 } 216 217 type login string 218 219 func orgRepoAuthor(pr github.PullRequest) (string, string, login) { 220 org := pr.Base.Repo.Owner.Login 221 repo := pr.Base.Repo.Name 222 author := pr.User.Login 223 return org, repo, login(author) 224 } 225 226 func buildAllIfTrusted(c Client, trigger plugins.Trigger, pr github.PullRequestEvent, baseSHA string, presubmits []config.Presubmit) error { 227 // When a PR is updated, check that the user is in the org or that an org 228 // member has said "/ok-to-test" before building. There's no need to ask 229 // for "/ok-to-test" because we do that once when the PR is created. 230 org, repo, a := orgRepoAuthor(pr.PullRequest) 231 author := string(a) 232 num := pr.PullRequest.Number 233 l, trusted, err := TrustedPullRequest(c.GitHubClient, trigger, author, org, repo, num, nil) 234 if err != nil { 235 return fmt.Errorf("could not validate PR: %s", err) 236 } else if trusted { 237 // Eventually remove needs-ok-to-test 238 // Will not work for org members since labels are not fetched in this case 239 if github.HasLabel(labels.NeedsOkToTest, l) { 240 if err := c.GitHubClient.RemoveLabel(org, repo, num, labels.NeedsOkToTest); err != nil { 241 return err 242 } 243 } 244 c.Logger.Info("Starting all jobs for updated PR.") 245 return buildAllButDrafts(c, &pr.PullRequest, pr.GUID, baseSHA, presubmits) 246 } 247 return nil 248 } 249 250 func welcomeMsg(ghc githubClient, trigger plugins.Trigger, pr github.PullRequest) error { 251 var errors []error 252 org, repo, a := orgRepoAuthor(pr) 253 author := string(a) 254 encodedRepoFullName := url.QueryEscape(pr.Base.Repo.FullName) 255 var more string 256 if trigger.TrustedOrg != "" && trigger.TrustedOrg != org { 257 more = fmt.Sprintf("or [%s](https://github.com/orgs/%s/people) ", trigger.TrustedOrg, trigger.TrustedOrg) 258 } 259 260 var joinOrgURL string 261 if trigger.JoinOrgURL != "" { 262 joinOrgURL = trigger.JoinOrgURL 263 } else { 264 joinOrgURL = fmt.Sprintf("https://github.com/orgs/%s/people", org) 265 } 266 267 var comment string 268 if trigger.IgnoreOkToTest { 269 comment = fmt.Sprintf(`Hi @%s. Thanks for your PR. 270 271 PRs from untrusted users cannot be marked as trusted with `+"`/ok-to-test`"+` in this repo meaning untrusted PR authors can never trigger tests themselves. Collaborators can still trigger tests on the PR using `+"`/test all`"+`. 272 273 I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=%s). 274 275 <details> 276 277 %s 278 </details> 279 `, author, encodedRepoFullName, plugins.AboutThisBotWithoutCommands) 280 } else { 281 comment = fmt.Sprintf(`Hi @%s. Thanks for your PR. 282 283 I'm waiting for a [%s](https://github.com/orgs/%s/people) %smember to verify that this patch is reasonable to test. If it is, they should reply with `+"`/ok-to-test`"+` on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should [join the org](%s) to skip this step. 284 285 Once the patch is verified, the new status will be reflected by the `+"`%s`"+` label. 286 287 I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=%s). 288 289 <details> 290 291 %s 292 </details> 293 `, author, org, org, more, joinOrgURL, labels.OkToTest, encodedRepoFullName, plugins.AboutThisBotWithoutCommands) 294 295 l, err := ghc.GetIssueLabels(org, repo, pr.Number) 296 if err != nil { 297 errors = append(errors, err) 298 } else if !github.HasLabel(labels.OkToTest, l) { 299 // It is possible for bots and other automations to automatically 300 // add the ok-to-test label. If that's the case, then we will not 301 // add the needs-ok-to-test-label any more. 302 if err := ghc.AddLabel(org, repo, pr.Number, labels.NeedsOkToTest); err != nil { 303 errors = append(errors, err) 304 } 305 } 306 } 307 308 if err := ghc.CreateComment(org, repo, pr.Number, comment); err != nil { 309 errors = append(errors, err) 310 } 311 312 if len(errors) > 0 { 313 return utilerrors.NewAggregate(errors) 314 } 315 return nil 316 } 317 318 func draftMsg(ghc githubClient, pr github.PullRequest) error { 319 org, repo, _ := orgRepoAuthor(pr) 320 321 comment := "Skipping CI for Draft Pull Request.\nIf you want CI signal for your change, please convert it to an actual PR.\nYou can still manually trigger a test run with `/test all`" 322 return ghc.CreateComment(org, repo, pr.Number, comment) 323 } 324 325 // TrustedPullRequest returns whether or not the given PR should be tested. 326 // It first checks if the author is in the org, then looks for "ok-to-test" label. 327 // If already known, GitHub labels should be provided to save tokens. Otherwise, it fetches them. 328 func TrustedPullRequest(tprc trustedPullRequestClient, trigger plugins.Trigger, author, org, repo string, num int, l []github.Label) ([]github.Label, bool, error) { 329 // First check if the author is a member of the org. 330 if trustedResponse, err := TrustedUser(tprc, trigger.OnlyOrgMembers, trigger.TrustedApps, trigger.TrustedOrg, author, org, repo); err != nil { 331 return l, false, fmt.Errorf("error checking %s for trust: %w", author, err) 332 } else if trustedResponse.IsTrusted { 333 return l, true, nil 334 } 335 // Then check if PR has ok-to-test label 336 if l == nil { 337 var err error 338 l, err = tprc.GetIssueLabels(org, repo, num) 339 if err != nil { 340 return l, false, err 341 } 342 } 343 return l, github.HasLabel(labels.OkToTest, l), nil 344 } 345 346 // buildAllButDrafts ensures that all builds that should run and will be required are built, but skips draft PRs 347 func buildAllButDrafts(c Client, pr *github.PullRequest, eventGUID string, baseSHA string, presubmits []config.Presubmit) error { 348 if pr.Draft { 349 c.Logger.Info("Skipping all jobs for draft PR.") 350 return nil 351 } 352 return buildAll(c, pr, eventGUID, baseSHA, presubmits) 353 } 354 355 // buildAll ensures that all builds that should run and will be required are built 356 func buildAll(c Client, pr *github.PullRequest, eventGUID string, baseSHA string, presubmits []config.Presubmit) error { 357 org, repo, number, branch := pr.Base.Repo.Owner.Login, pr.Base.Repo.Name, pr.Number, pr.Base.Ref 358 changes := config.NewGitHubDeferredChangedFilesProvider(c.GitHubClient, org, repo, number) 359 toTest, err := pjutil.FilterPresubmits(pjutil.NewTestAllFilter(), changes, branch, presubmits, c.Logger) 360 if err != nil { 361 return err 362 } 363 return RunRequested(c, pr, baseSHA, toTest, eventGUID) 364 }