sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/tide/github.go (about) 1 /* 2 Copyright 2017 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 tide 18 19 import ( 20 "bytes" 21 "context" 22 "errors" 23 "fmt" 24 "strconv" 25 "strings" 26 "sync" 27 "time" 28 29 utilerrors "k8s.io/apimachinery/pkg/util/errors" 30 prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1" 31 "sigs.k8s.io/prow/pkg/config" 32 "sigs.k8s.io/prow/pkg/git/types" 33 "sigs.k8s.io/prow/pkg/git/v2" 34 "sigs.k8s.io/prow/pkg/github" 35 "sigs.k8s.io/prow/pkg/tide/blockers" 36 37 githubql "github.com/shurcooL/githubv4" 38 "github.com/sirupsen/logrus" 39 ) 40 41 type querier func(ctx context.Context, q interface{}, vars map[string]interface{}, org string) error 42 43 func datedQuery(q string, start, end time.Time) string { 44 return fmt.Sprintf("%s %s", q, dateToken(start, end)) 45 } 46 47 // Enforcing interface implementation check at compile time 48 var _ provider = (*GitHubProvider)(nil) 49 50 // GitHubProvider implements provider, used by tide Controller for 51 // interacting directly with GitHub. 52 // 53 // Tide Controller should only use GitHubProvider for communicating with GitHub. 54 type GitHubProvider struct { 55 cfg config.Getter 56 ghc githubClient 57 gc git.ClientFactory 58 usesGitHubAppsAuth bool 59 60 *mergeChecker 61 logger *logrus.Entry 62 } 63 64 func newGitHubProvider( 65 logger *logrus.Entry, 66 ghc githubClient, 67 gc git.ClientFactory, 68 cfg config.Getter, 69 mergeChecker *mergeChecker, 70 usesGitHubAppsAuth bool, 71 ) *GitHubProvider { 72 return &GitHubProvider{ 73 logger: logger, 74 ghc: ghc, 75 gc: gc, 76 cfg: cfg, 77 usesGitHubAppsAuth: usesGitHubAppsAuth, 78 mergeChecker: mergeChecker, 79 } 80 } 81 82 func (gi *GitHubProvider) blockers() (blockers.Blockers, error) { 83 label := gi.cfg().Tide.BlockerLabel 84 if label == "" { 85 return blockers.Blockers{}, nil 86 } 87 88 gi.logger.WithField("blocker_label", label).Debug("Searching for blocker issues") 89 orgExcepts, repos := gi.cfg().Tide.Queries.OrgExceptionsAndRepos() 90 orgs := make([]string, 0, len(orgExcepts)) 91 for org := range orgExcepts { 92 orgs = append(orgs, org) 93 } 94 orgRepoQuery := orgRepoQueryStrings(orgs, repos.UnsortedList(), orgExcepts) 95 return blockers.FindAll(gi.ghc, gi.logger, label, orgRepoQuery, gi.usesGitHubAppsAuth) 96 } 97 98 // Query gets all open PRs based on tide configuration. 99 func (gi *GitHubProvider) Query() (map[string]CodeReviewCommon, error) { 100 lock := sync.Mutex{} 101 wg := sync.WaitGroup{} 102 prs := make(map[string]CodeReviewCommon) 103 var errs []error 104 for i, query := range gi.cfg().Tide.Queries { 105 106 // Use org-sharded queries only when GitHub apps auth is in use 107 var queries map[string]string 108 if gi.usesGitHubAppsAuth { 109 queries = query.OrgQueries() 110 } else { 111 queries = map[string]string{"": query.Query()} 112 } 113 114 for org, q := range queries { 115 org, q, i := org, q, i 116 wg.Add(1) 117 go func() { 118 defer wg.Done() 119 results, err := gi.search(gi.ghc.QueryWithGitHubAppsSupport, gi.logger, q, time.Time{}, time.Now(), org) 120 121 resultString := "success" 122 if err != nil { 123 resultString = "error" 124 } 125 tideMetrics.queryResults.WithLabelValues(strconv.Itoa(i), org, resultString).Inc() 126 127 lock.Lock() 128 defer lock.Unlock() 129 if err != nil && len(results) == 0 { 130 gi.logger.WithField("query", q).WithError(err).Warn("Failed to execute query.") 131 errs = append(errs, fmt.Errorf("query %d, err: %w", i, err)) 132 return 133 } 134 if err != nil { 135 gi.logger.WithError(err).WithField("query", q).Warning("found partial results") 136 } 137 138 for _, pr := range results { 139 crc := CodeReviewCommonFromPullRequest(&pr) 140 prs[prKey(crc)] = *crc 141 } 142 }() 143 } 144 } 145 wg.Wait() 146 147 return prs, utilerrors.NewAggregate(errs) 148 } 149 150 func (gi *GitHubProvider) GetRef(org, repo, ref string) (string, error) { 151 return gi.ghc.GetRef(org, repo, ref) 152 } 153 154 func (gi *GitHubProvider) GetTideContextPolicy(org, repo, branch string, baseSHAGetter config.RefGetter, pr *CodeReviewCommon) (contextChecker, error) { 155 return gi.cfg().GetTideContextPolicy(gi.gc, org, repo, branch, baseSHAGetter, pr.HeadRefOID) 156 } 157 158 func (gi *GitHubProvider) prMergeMethod(crc *CodeReviewCommon) *types.PullRequestMergeType { 159 return gi.mergeChecker.prMergeMethod(gi.cfg().Tide, crc) 160 } 161 162 func (gi *GitHubProvider) search(query querier, log *logrus.Entry, q string, start, end time.Time, org string) ([]PullRequest, error) { 163 start = floor(start) 164 end = floor(end) 165 log = log.WithFields(logrus.Fields{ 166 "query": q, 167 "start": start.String(), 168 "end": end.String(), 169 }) 170 requestStart := time.Now() 171 var cursor *githubql.String 172 vars := map[string]interface{}{ 173 "query": githubql.String(datedQuery(q, start, end)), 174 "searchCursor": cursor, 175 } 176 177 var totalCost, remaining int 178 var ret []PullRequest 179 var sq searchQuery 180 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 181 defer cancel() 182 for { 183 log.Debug("Sending query") 184 if err := query(ctx, &sq, vars, org); err != nil { 185 if cursor != nil { 186 err = fmt.Errorf("cursor: %q, err: %w", *cursor, err) 187 } 188 return ret, err 189 } 190 totalCost += int(sq.RateLimit.Cost) 191 remaining = int(sq.RateLimit.Remaining) 192 for _, n := range sq.Search.Nodes { 193 ret = append(ret, n.PullRequest) 194 } 195 if !sq.Search.PageInfo.HasNextPage { 196 break 197 } 198 cursor = &sq.Search.PageInfo.EndCursor 199 vars["searchCursor"] = cursor 200 log = log.WithField("searchCursor", *cursor) 201 } 202 log.WithFields(logrus.Fields{ 203 "duration": time.Since(requestStart).String(), 204 "pr_found_count": len(ret), 205 "cost": totalCost, 206 "remaining": remaining, 207 }).Debug("Finished query") 208 return ret, nil 209 } 210 211 func (gi *GitHubProvider) prepareMergeDetails(commitTemplates config.TideMergeCommitTemplate, pr CodeReviewCommon, mergeMethod types.PullRequestMergeType) github.MergeDetails { 212 ghMergeDetails := github.MergeDetails{ 213 SHA: pr.HeadRefOID, 214 MergeMethod: string(mergeMethod), 215 } 216 217 if commitTemplates.Title != nil { 218 var b bytes.Buffer 219 220 if err := commitTemplates.Title.Execute(&b, pr); err != nil { 221 gi.logger.Errorf("error executing commit title template: %v", err) 222 } else { 223 ghMergeDetails.CommitTitle = b.String() 224 } 225 } 226 227 if commitTemplates.Body != nil { 228 var b bytes.Buffer 229 230 if err := commitTemplates.Body.Execute(&b, pr); err != nil { 231 gi.logger.Errorf("error executing commit body template: %v", err) 232 } else { 233 ghMergeDetails.CommitMessage = b.String() 234 } 235 } 236 237 return ghMergeDetails 238 } 239 240 func (gi *GitHubProvider) mergePRs(sp subpool, prs []CodeReviewCommon, dontUpdateStatus *threadSafePRSet) ([]CodeReviewCommon, error) { 241 var merged []CodeReviewCommon 242 var failed []int 243 var errs []error 244 log := sp.log.WithField("merge-targets", prNumbers(prs)) 245 tideConfig := gi.cfg().Tide 246 247 for i, pr := range prs { 248 log := log.WithFields(pr.logFields()) 249 mergeMethod := gi.prMergeMethod(&pr) 250 if mergeMethod == nil { 251 err := fmt.Errorf("multiple merge method labels found for %s/%s#%d", sp.org, sp.repo, pr.Number) 252 log.WithError(err).Error("Multiple merge method labels are not supported.") 253 errs = append(errs, err) 254 failed = append(failed, pr.Number) 255 continue 256 } 257 258 // Ensure tide context has success state, otherwise PR merge will fail if branch protection 259 // in github is enabled and the loop to change tide context hasn't done it already 260 dontUpdateStatus.insert(sp.org, sp.repo, pr.Number) 261 if err := setTideStatusSuccess(pr, gi.ghc, gi.cfg(), log); err != nil { 262 log.WithError(err).Error("Unable to set tide context to SUCCESS.") 263 errs = append(errs, err) 264 failed = append(failed, pr.Number) 265 continue 266 } 267 268 commitTemplates := tideConfig.MergeCommitTemplate(config.OrgRepo{Org: sp.org, Repo: sp.repo}) 269 keepTrying, err := tryMerge(func() error { 270 ghMergeDetails := gi.prepareMergeDetails(commitTemplates, pr, *mergeMethod) 271 return gi.ghc.Merge(sp.org, sp.repo, pr.Number, ghMergeDetails) 272 }) 273 if err != nil { 274 // These are user errors, shouldn't be printed as tide errors 275 log.WithError(err).Debug("Merge failed.") 276 } else { 277 log.Info("Merged.") 278 merged = append(merged, pr) 279 } 280 if !keepTrying { 281 break 282 } 283 // If we successfully merged this PR and have more to merge, sleep to give 284 // GitHub time to recalculate mergeability. 285 if err == nil && i+1 < len(prs) { 286 sleep(time.Second * 5) 287 } 288 } 289 290 if len(errs) == 0 { 291 return merged, nil 292 } 293 294 // Construct a more informative error. 295 var batch string 296 if len(prs) > 1 { 297 batch = fmt.Sprintf(" from batch %v", prNumbers(prs)) 298 if len(merged) > 0 { 299 batch = fmt.Sprintf("%s, partial merge %v", batch, prNumbers(merged)) 300 } 301 } 302 return merged, fmt.Errorf("failed merging %v%s: %w", failed, batch, utilerrors.NewAggregate(errs)) 303 } 304 305 // headContexts gets the status contexts for the commit with OID == pr.HeadRefOID 306 // 307 // First, we try to get this value from the commits we got with the PR query. 308 // Unfortunately the 'last' commit ordering is determined by author date 309 // not commit date so if commits are reordered non-chronologically on the PR 310 // branch the 'last' commit isn't necessarily the logically last commit. 311 // We list multiple commits with the query to increase our chance of success, 312 // but if we don't find the head commit we have to ask GitHub for it 313 // specifically (this costs an API token). 314 // 315 // This function is very GitHub centric, make sure this that is only referenced 316 // by GitHub interactor. 317 func (gi *GitHubProvider) headContexts(pr *CodeReviewCommon) ([]Context, error) { 318 log := gi.logger 319 commits := pr.GitHubCommits() 320 if commits != nil { 321 for _, node := range commits.Nodes { 322 if string(node.Commit.OID) == pr.HeadRefOID { 323 return append(node.Commit.Status.Contexts, checkRunNodesToContexts(log, node.Commit.StatusCheckRollup.Contexts.Nodes)...), nil 324 } 325 } 326 } 327 // We didn't get the head commit from the query (the commits must not be 328 // logically ordered) so we need to specifically ask GitHub for the status 329 // and coerce it to a graphql type. 330 org := pr.Org 331 repo := pr.Repo 332 // Log this event so we can tune the number of commits we list to minimize this. 333 // TODO alvaroaleman: Add checkrun support here. Doesn't seem to happen often though, 334 // openshift doesn't have a single occurrence of this in the past seven days. 335 log.Warnf("'last' %d commits didn't contain logical last commit. Querying GitHub...", len(commits.Nodes)) 336 combined, err := gi.ghc.GetCombinedStatus(org, repo, pr.HeadRefOID) 337 if err != nil { 338 return nil, fmt.Errorf("failed to get the combined status: %w", err) 339 } 340 checkRunList, err := gi.ghc.ListCheckRuns(org, repo, pr.HeadRefOID) 341 if err != nil { 342 return nil, fmt.Errorf("Failed to list checkruns: %w", err) 343 } 344 checkRunNodes := make([]CheckRunNode, 0, len(checkRunList.CheckRuns)) 345 for _, checkRun := range checkRunList.CheckRuns { 346 checkRunNodes = append(checkRunNodes, CheckRunNode{CheckRun: CheckRun{ 347 Name: githubql.String(checkRun.Name), 348 // They are uppercase in the V4 api and lowercase in the V3 api 349 Conclusion: githubql.String(strings.ToUpper(checkRun.Conclusion)), 350 Status: githubql.String(strings.ToUpper(checkRun.Status)), 351 }}) 352 } 353 354 contexts := make([]Context, 0, len(combined.Statuses)+len(checkRunNodes)) 355 for _, status := range combined.Statuses { 356 contexts = append(contexts, Context{ 357 Context: githubql.String(status.Context), 358 Description: githubql.String(status.Description), 359 State: githubql.StatusState(strings.ToUpper(status.State)), 360 }) 361 } 362 contexts = append(contexts, checkRunNodesToContexts(log, checkRunNodes)...) 363 364 // Add a commit with these contexts to pr for future look ups. 365 if commits := pr.GitHubCommits(); commits != nil { 366 commits.Nodes = append(commits.Nodes, 367 struct{ Commit Commit }{ 368 Commit: Commit{ 369 OID: githubql.String(pr.HeadRefOID), 370 Status: struct{ Contexts []Context }{Contexts: contexts}, 371 }, 372 }, 373 ) 374 } 375 return contexts, nil 376 } 377 378 func (gi *GitHubProvider) GetPresubmits(identifier, baseBranch string, baseSHAGetter config.RefGetter, headSHAGetters ...config.RefGetter) ([]config.Presubmit, error) { 379 return gi.cfg().GetPresubmits(gi.gc, identifier, baseBranch, baseSHAGetter, headSHAGetters...) 380 } 381 382 func (gi *GitHubProvider) GetChangedFiles(org, repo string, number int) ([]string, error) { 383 changes, err := gi.ghc.GetPullRequestChanges(org, repo, number) 384 if err != nil { 385 return nil, fmt.Errorf("failed get PR changes: %v", err) 386 } 387 files := make([]string, 0, len(changes)) 388 for _, c := range changes { 389 files = append(files, c.Filename) 390 } 391 return files, nil 392 } 393 394 func (gi *GitHubProvider) refsForJob(sp subpool, prs []CodeReviewCommon) (prowapi.Refs, error) { 395 refs := prowapi.Refs{ 396 Org: sp.org, 397 Repo: sp.repo, 398 BaseRef: sp.branch, 399 BaseSHA: sp.sha, 400 } 401 for _, pr := range prs { 402 refs.Pulls = append( 403 refs.Pulls, 404 prowapi.Pull{ 405 Number: pr.Number, 406 Title: pr.Title, 407 Author: string(pr.AuthorLogin), 408 SHA: pr.HeadRefOID, 409 HeadRef: pr.HeadRefName, 410 }, 411 ) 412 } 413 return refs, nil 414 } 415 416 func (gi *GitHubProvider) labelsAndAnnotations(instance string, jobLabels, jobAnnotations map[string]string, changes ...CodeReviewCommon) (labels, annotations map[string]string) { 417 labels, annotations = jobLabels, jobAnnotations 418 return 419 } 420 421 func (gi *GitHubProvider) jobIsRequiredByTide(ps *config.Presubmit, pr *CodeReviewCommon) bool { 422 return ps.ContextRequired() || ps.RunBeforeMerge 423 } 424 425 // dateToken generates a GitHub search query token for the specified date range. 426 // See: https://help.github.com/articles/understanding-the-search-syntax/#query-for-dates 427 func dateToken(start, end time.Time) string { 428 // GitHub's GraphQL API silently fails if you provide it with an invalid time 429 // string. 430 // Dates before 1970 (unix epoch) are considered invalid. 431 startString, endString := "*", "*" 432 if start.Year() >= 1970 { 433 startString = start.Format(github.SearchTimeFormat) 434 } 435 if end.Year() >= 1970 { 436 endString = end.Format(github.SearchTimeFormat) 437 } 438 return fmt.Sprintf("updated:%s..%s", startString, endString) 439 } 440 441 func floor(t time.Time) time.Time { 442 if t.Before(github.FoundingYear) { 443 return github.FoundingYear 444 } 445 return t 446 } 447 448 // mergeChecker provides a function to check if a PR can be merged with 449 // the requested method and does not have a merge conflict. 450 // It caches results and should be cleared periodically with clearCache() 451 // 452 // This struct is GitHub specific, and should be used only by GitHubProvider. 453 type mergeChecker struct { 454 config config.Getter 455 ghc githubClient 456 457 sync.Mutex 458 cache map[config.OrgRepo]map[types.PullRequestMergeType]bool 459 } 460 461 // newMergeChecker creates a mergeChecker for GitHub, and should be used only by 462 // GitHubProvider. 463 func newMergeChecker(cfg config.Getter, ghc githubClient) *mergeChecker { 464 m := &mergeChecker{ 465 config: cfg, 466 ghc: ghc, 467 cache: map[config.OrgRepo]map[types.PullRequestMergeType]bool{}, 468 } 469 470 go m.clearCache() 471 return m 472 } 473 474 // clearCache is an internal function that's only used by newMergeChecker. 475 func (m *mergeChecker) clearCache() { 476 // Only do this once per token reset since it could be a bit expensive for 477 // Tide instances that handle hundreds of repos. 478 ticker := time.NewTicker(time.Hour) 479 for { 480 <-ticker.C 481 m.Lock() 482 m.cache = make(map[config.OrgRepo]map[types.PullRequestMergeType]bool) 483 m.Unlock() 484 } 485 } 486 487 // repoMethods is used only by isAllowedToMerge. 488 // 489 // As a result it is also referenced only from GitHubProvider. 490 func (m *mergeChecker) repoMethods(orgRepo config.OrgRepo) (map[types.PullRequestMergeType]bool, error) { 491 m.Lock() 492 defer m.Unlock() 493 494 repoMethods, ok := m.cache[orgRepo] 495 if !ok { 496 fullRepo, err := m.ghc.GetRepo(orgRepo.Org, orgRepo.Repo) 497 if err != nil { 498 return nil, err 499 } 500 logrus.WithFields(logrus.Fields{ 501 "org": orgRepo.Org, 502 "repo": orgRepo.Repo, 503 "AllowMergeCommit": fullRepo.AllowMergeCommit, 504 "AllowSquashMerge": fullRepo.AllowSquashMerge, 505 "AllowRebaseMerge": fullRepo.AllowRebaseMerge, 506 }).Debug("GetRepo returns these values for repo methods") 507 repoMethods = map[types.PullRequestMergeType]bool{ 508 types.MergeMerge: fullRepo.AllowMergeCommit, 509 types.MergeSquash: fullRepo.AllowSquashMerge, 510 types.MergeRebase: fullRepo.AllowRebaseMerge, 511 } 512 m.cache[orgRepo] = repoMethods 513 } 514 return repoMethods, nil 515 } 516 517 // isAllowed checks if a PR does not have merge conflicts and requests an 518 // allowed merge method. If there is no error it returns a string explanation if 519 // not allowed or "" if allowed. 520 func (m *mergeChecker) isAllowedToMerge(crc *CodeReviewCommon) (string, error) { 521 // Get PullRequest struct for GitHub specific logic 522 pr := crc.GitHub 523 if pr == nil { 524 // This should not happen, as this mergeChecker is meant to be used by 525 // GitHub repos only 526 return "", errors.New("unexpected error: CodeReviewCommon should carry PullRequest struct") 527 } 528 if pr.Mergeable == githubql.MergeableStateConflicting { 529 return "PR has a merge conflict.", nil 530 } 531 mergeMethod := m.prMergeMethod(m.config().Tide, crc) 532 if mergeMethod == nil { 533 // Can happen when tide has conflicting labels: merge, squash, rebase 534 return "PR has conflicting merge method override labels", nil 535 } 536 if *mergeMethod == types.MergeRebase && !pr.CanBeRebased { 537 return "PR can't be rebased", nil 538 } 539 orgRepo := config.OrgRepo{Org: crc.Org, Repo: crc.Repo} 540 repoMethods, err := m.repoMethods(orgRepo) 541 if err != nil { 542 return "", fmt.Errorf("error getting repo data: %w", err) 543 } 544 if allowed, exists := repoMethods[*mergeMethod]; !exists { 545 // Should be impossible as well. 546 return "", fmt.Errorf("Programmer error! PR requested the unrecognized merge type %q", *mergeMethod) 547 } else if !allowed { 548 return fmt.Sprintf("Merge type %q disallowed by repo settings", *mergeMethod), nil 549 } 550 return "", nil 551 } 552 553 // prMergeMethod figures out merge method based on tide config, this could be 554 // overridden by GitHub labels. 555 func (mc *mergeChecker) prMergeMethod(c config.Tide, crc *CodeReviewCommon) *types.PullRequestMergeType { 556 repo := config.OrgRepo{Org: crc.Org, Repo: crc.Repo} 557 method := c.OrgRepoBranchMergeMethod(repo, crc.BaseRefName) 558 squashLabel := c.SquashLabel 559 rebaseLabel := c.RebaseLabel 560 mergeLabel := c.MergeLabel 561 if squashLabel != "" || rebaseLabel != "" || mergeLabel != "" { 562 labelCount := 0 563 if labels := crc.GitHubLabels(); labels != nil { 564 for _, prlabel := range labels.Nodes { 565 switch string(prlabel.Name) { 566 case "": 567 continue 568 case squashLabel: 569 method = types.MergeSquash 570 labelCount++ 571 case rebaseLabel: 572 method = types.MergeRebase 573 labelCount++ 574 case mergeLabel: 575 method = types.MergeMerge 576 labelCount++ 577 } 578 if labelCount > 1 { 579 return nil 580 } 581 } 582 } 583 } 584 return &method 585 }