sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/tide/status.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 "context" 21 "errors" 22 "fmt" 23 stdio "io" 24 "net/url" 25 "sort" 26 "strconv" 27 "strings" 28 "sync" 29 "time" 30 31 githubql "github.com/shurcooL/githubv4" 32 "github.com/sirupsen/logrus" 33 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 34 utilerrors "k8s.io/apimachinery/pkg/util/errors" 35 "k8s.io/apimachinery/pkg/util/sets" 36 ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" 37 prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1" 38 "sigs.k8s.io/yaml" 39 40 "sigs.k8s.io/prow/pkg/config" 41 "sigs.k8s.io/prow/pkg/git/v2" 42 "sigs.k8s.io/prow/pkg/github" 43 "sigs.k8s.io/prow/pkg/io" 44 "sigs.k8s.io/prow/pkg/tide/blockers" 45 ) 46 47 const ( 48 statusContext = "tide" 49 statusInPool = "In merge pool." 50 // statusNotInPool is a format string used when a PR is not in a tide pool. 51 // The '%s' field is populated with the reason why the PR is not in a 52 // tide pool or the empty string if the reason is unknown. See requirementDiff. 53 statusNotInPool = "Not mergeable.%s" 54 55 maxStatusDescriptionLength = 140 56 ) 57 58 type storedState struct { 59 // LatestPR is the update time of the most recent result 60 LatestPR metav1.Time 61 // PreviousQuery is the query most recently used for results 62 PreviousQuery string 63 } 64 65 // statusController is a goroutine runs in the background 66 type statusController struct { 67 pjClient ctrlruntimeclient.Client 68 logger *logrus.Entry 69 config config.Getter 70 ghProvider *GitHubProvider 71 ghc githubClient 72 gc git.ClientFactory 73 usesGitHubAppsAuth bool 74 75 // shutDown is used to signal to the main controller that the statusController 76 // has completed processing after newPoolPending is closed. 77 shutDown chan bool 78 79 // lastSyncStart is used to ensure that the status update period is at least 80 // the minimum status update period. 81 lastSyncStart time.Time 82 83 storedState map[string]storedState 84 storedStateLock sync.Mutex 85 opener io.Opener 86 path string 87 88 // Shared fields with sync controller 89 *statusUpdate 90 } 91 92 // statusUpdate contains the required fields from syncController when there is a 93 // pending pool update. 94 // 95 // statusController will use the values from syncController blindly. 96 type statusUpdate struct { 97 blocks blockers.Blockers 98 poolPRs map[string]CodeReviewCommon 99 baseSHAs map[string]string 100 requiredContexts map[string][]string 101 sync.Mutex 102 // dontUpdateStatus contains all PRs for which the Tide sync controller 103 // updated the status to success prior to merging. As the name suggests, 104 // the status controller must not update their status. 105 dontUpdateStatus *threadSafePRSet 106 // newPoolPending is a size 1 chan that signals that the main Tide loop has 107 // updated the 'poolPRs' field with a freshly updated pool. 108 newPoolPending chan bool 109 } 110 111 func (sc *statusController) shutdown() { 112 close(sc.newPoolPending) 113 <-sc.shutDown 114 } 115 116 // requirementDiff calculates the diff between a GitHub PR and a TideQuery. 117 // This diff is defined with a string that describes some subset of the 118 // differences and an integer counting the total number of differences. 119 // The diff count should always reflect the scale of the differences between 120 // the current state of the PR and the query, but the message returned need not 121 // attempt to convey all of that information if some differences are more severe. 122 // For instance, we need to convey that a PR is open against a forbidden branch 123 // more than we need to detail which status contexts are failed against the PR. 124 // To this end, some differences are given a higher diff weight than others. 125 // Note: an empty diff can be returned if the reason that the PR does not match 126 // the TideQuery is unknown. This can happen if this function's logic 127 // does not match GitHub's and does not indicate that the PR matches the query. 128 func requirementDiff(pr *PullRequest, q *config.TideQuery, cc contextChecker) (string, int) { 129 const maxLabelChars = 50 130 var desc string 131 var diff int 132 // Drops labels if needed to fit the description text area, but keep at least 1. 133 truncate := func(labels []string) []string { 134 i := 1 135 chars := len(labels[0]) 136 for ; i < len(labels); i++ { 137 if chars+len(labels[i]) > maxLabelChars { 138 break 139 } 140 chars += len(labels[i]) + 2 // ", " 141 } 142 return labels[:i] 143 } 144 145 // Weight incorrect branches with very high diff so that we select the query 146 // for the correct branch. 147 targetBranchDenied := false 148 for _, excludedBranch := range q.ExcludedBranches { 149 if string(pr.BaseRef.Name) == excludedBranch { 150 targetBranchDenied = true 151 break 152 } 153 } 154 // if no allowlist is configured, the target is OK by default 155 targetBranchAllowed := len(q.IncludedBranches) == 0 156 for _, includedBranch := range q.IncludedBranches { 157 if string(pr.BaseRef.Name) == includedBranch { 158 targetBranchAllowed = true 159 break 160 } 161 } 162 if targetBranchDenied || !targetBranchAllowed { 163 diff += 2000 164 if desc == "" { 165 desc = fmt.Sprintf(" Merging to branch %s is forbidden.", pr.BaseRef.Name) 166 } 167 } 168 169 qAuthor := github.NormLogin(q.Author) 170 prAuthor := github.NormLogin(string(pr.Author.Login)) 171 172 // Weight incorrect author with very high diff so that we select the query 173 // for the correct author. 174 if qAuthor != "" && prAuthor != qAuthor { 175 diff += 1000 176 if desc == "" { 177 desc = fmt.Sprintf(" Must be by author %s.", qAuthor) 178 } 179 } 180 181 // Weight incorrect milestone with relatively high diff so that we select the 182 // query for the correct milestone (but choose favor query for correct branch). 183 if q.Milestone != "" && (pr.Milestone == nil || string(pr.Milestone.Title) != q.Milestone) { 184 diff += 100 185 if desc == "" { 186 desc = fmt.Sprintf(" Must be in milestone %s.", q.Milestone) 187 } 188 } 189 190 // Weight incorrect labels and statues with low (normal) diff values. 191 var missingLabels []string 192 for _, l1 := range q.Labels { 193 var found bool 194 altLabels := sets.New[string](strings.Split(l1, ",")...) 195 for _, l2 := range pr.Labels.Nodes { 196 if altLabels.Has(string(l2.Name)) { 197 found = true 198 break 199 } 200 } 201 if !found { 202 missingLabels = append(missingLabels, strings.ReplaceAll(l1, ",", " or ")) 203 } 204 } 205 diff += len(missingLabels) 206 if desc == "" && len(missingLabels) > 0 { 207 sort.Strings(missingLabels) 208 trunced := truncate(missingLabels) 209 if len(trunced) == 1 { 210 desc = fmt.Sprintf(" Needs %s label.", trunced[0]) 211 } else { 212 desc = fmt.Sprintf(" Needs %s labels.", strings.Join(trunced, ", ")) 213 } 214 } 215 216 var presentLabels []string 217 for _, l1 := range q.MissingLabels { 218 for _, l2 := range pr.Labels.Nodes { 219 if string(l2.Name) == l1 { 220 presentLabels = append(presentLabels, l1) 221 break 222 } 223 } 224 } 225 diff += len(presentLabels) 226 if desc == "" && len(presentLabels) > 0 { 227 sort.Strings(presentLabels) 228 trunced := truncate(presentLabels) 229 if len(trunced) == 1 { 230 desc = fmt.Sprintf(" Should not have %s label.", trunced[0]) 231 } else { 232 desc = fmt.Sprintf(" Should not have %s labels.", strings.Join(trunced, ", ")) 233 } 234 } 235 236 // fixing label issues takes precedence over status contexts 237 var contexts []string 238 log := logrus.WithFields(pr.logFields()) 239 for _, commit := range pr.Commits.Nodes { 240 if commit.Commit.OID == pr.HeadRefOID { 241 for _, ctx := range unsuccessfulContexts(append(commit.Commit.Status.Contexts, checkRunNodesToContexts(log, commit.Commit.StatusCheckRollup.Contexts.Nodes)...), cc, log) { 242 contexts = append(contexts, string(ctx.Context)) 243 } 244 } 245 } 246 diff += len(contexts) 247 if desc == "" && len(contexts) > 0 { 248 sort.Strings(contexts) 249 trunced := truncate(contexts) 250 if len(trunced) == 1 { 251 desc = fmt.Sprintf(" Job %s has not succeeded.", trunced[0]) 252 } else { 253 desc = fmt.Sprintf(" Jobs %s have not succeeded.", strings.Join(trunced, ", ")) 254 } 255 } 256 257 if q.ReviewApprovedRequired && pr.ReviewDecision != githubql.PullRequestReviewDecisionApproved { 258 diff += 50 259 if desc == "" { 260 desc = " PullRequest is missing sufficient approving GitHub review(s)" 261 } 262 } 263 return desc, diff 264 } 265 266 // expectedStatus returns expected GitHub status state and description. 267 // If a PR is not mergeable, we have to select a TideQuery to compare it against 268 // in order to generate a diff for the status description. We choose the query 269 // for the repo that the PR is closest to meeting (as determined by the number 270 // of unmet/violated requirements). 271 func (sc *statusController) expectedStatus(log *logrus.Entry, queryMap *config.QueryMap, crc *CodeReviewCommon, pool map[string]CodeReviewCommon, ccg contextCheckerGetter, blocks blockers.Blockers, baseSHA string) (string, string, error) { 272 // Get PullRequest struct for GitHub specific logic 273 pr := crc.GitHub 274 if pr == nil { 275 // This should not happen, as this mergeChecker is meant to be used by 276 // GitHub repos only 277 return "", "", errors.New("unexpected error: CodeReviewCommon should carry PullRequest struct") 278 } 279 280 repo := config.OrgRepo{Org: crc.Org, Repo: crc.Repo} 281 282 if reason, err := sc.ghProvider.isAllowedToMerge(crc); err != nil { 283 return "", "", fmt.Errorf("error checking if merge is allowed: %w", err) 284 } else if reason != "" { 285 log.WithField("reason", reason).Debug("The PR is not mergeable") 286 return github.StatusError, fmt.Sprintf(statusNotInPool, " "+reason), nil 287 } 288 289 cc, err := ccg() 290 if err != nil { 291 return "", "", fmt.Errorf("failed to set up context register: %w", err) 292 } 293 294 if _, ok := pool[prKey(crc)]; !ok { 295 // if the branch is blocked forget checking for a diff 296 blockingIssues := blocks.GetApplicable(crc.Org, crc.Repo, crc.BaseRefName) 297 var numbers []string 298 for _, issue := range blockingIssues { 299 numbers = append(numbers, strconv.Itoa(issue.Number)) 300 } 301 if len(numbers) > 0 { 302 var s string 303 if len(numbers) > 1 { 304 s = "s" 305 } 306 return github.StatusError, fmt.Sprintf(statusNotInPool, fmt.Sprintf(" Merging is blocked by issue%s %s.", s, strings.Join(numbers, ", "))), nil 307 } 308 309 // hasFullfilledQuery is a weird state, it means that the PR is not in the pool but should be. It happens when all requirements were fulfilled 310 // at the time the status controller queried GitHub but not at the time the sync controller queried GitHub. 311 // We just fall through to check if there are missing jobs to avoid wasting api tokens by sending it to pending and then to success in the next 312 // sync or status controller iteration. 313 var hasFullfilledQuery bool 314 315 minDiffCount := -1 316 var minDiff string 317 for _, q := range queryMap.ForRepo(repo) { 318 diff, diffCount := requirementDiff(pr, &q, cc) 319 if diffCount == 0 { 320 hasFullfilledQuery = true 321 break 322 } else if sc.config().Tide.DisplayAllQueriesInStatus { 323 if diffCount >= 2000 { 324 // Query is for wrong branch 325 continue 326 } 327 if minDiff != "" { 328 minDiff = strings.TrimSuffix(minDiff, ".") + " OR" 329 } 330 minDiff += diff 331 } else if minDiffCount == -1 || diffCount < minDiffCount { 332 minDiffCount = diffCount 333 minDiff = diff 334 } 335 } 336 if sc.config().Tide.DisplayAllQueriesInStatus && minDiff == "" { 337 minDiff = " No Tide query for branch " + crc.BaseRefName + " found." 338 } 339 340 if !hasFullfilledQuery { 341 return github.StatusPending, fmt.Sprintf(statusNotInPool, minDiff), nil 342 } 343 } 344 345 indexKey := indexKeyPassingJobs(repo, baseSHA, crc.HeadRefOID) 346 passingUpToDatePJs := &prowapi.ProwJobList{} 347 if err := sc.pjClient.List(context.Background(), passingUpToDatePJs, ctrlruntimeclient.MatchingFields{indexNamePassingJobs: indexKey}); err != nil { 348 // Just log the error and return success, as the PR is in the merge pool 349 log.WithError(err).Error("Failed to list ProwJobs.") 350 return github.StatusSuccess, statusInPool, nil 351 } 352 353 var passingUpToDateContexts []string 354 for _, pj := range passingUpToDatePJs.Items { 355 passingUpToDateContexts = append(passingUpToDateContexts, pj.Spec.Context) 356 } 357 if diff := cc.MissingRequiredContexts(passingUpToDateContexts); len(diff) > 0 { 358 return github.StatePending, retestingStatus(diff), nil 359 } 360 return github.StatusSuccess, statusInPool, nil 361 } 362 363 func retestingStatus(retested []string) string { 364 sort.Strings(retested) 365 all := fmt.Sprintf(statusNotInPool, fmt.Sprintf(" Retesting: %s", strings.Join(retested, " "))) 366 if len(all) > maxStatusDescriptionLength { 367 s := "" 368 if len(retested) > 1 { 369 s = "s" 370 } 371 return fmt.Sprintf(statusNotInPool, fmt.Sprintf(" Retesting %d job%s.", len(retested), s)) 372 } 373 return all 374 } 375 376 // targetURL determines the URL used for more details in the status 377 // context on GitHub. If no PR dashboard is configured, we will use 378 // the administrative Prow overview. 379 func targetURL(c *config.Config, crc *CodeReviewCommon, log *logrus.Entry) string { 380 // Get PullRequest struct for GitHub specific logic 381 pr := crc.GitHub 382 if pr == nil { 383 // This should not happen, as this mergeChecker is meant to be used by 384 // GitHub repos only 385 return "" 386 } 387 388 var link string 389 orgRepo := config.OrgRepo{Org: crc.Org, Repo: crc.Repo} 390 if tideURL := c.Tide.GetTargetURL(orgRepo); tideURL != "" { 391 link = tideURL 392 } else if baseURL := c.Tide.GetPRStatusBaseURL(orgRepo); baseURL != "" { 393 parseURL, err := url.Parse(baseURL) 394 if err != nil { 395 log.WithError(err).Error("Failed to parse PR status base URL") 396 } else { 397 prQuery := fmt.Sprintf("is:pr repo:%s author:%s head:%s", pr.Repository.NameWithOwner, crc.AuthorLogin, crc.HeadRefName) 398 values := parseURL.Query() 399 values.Set("query", prQuery) 400 parseURL.RawQuery = values.Encode() 401 link = parseURL.String() 402 } 403 } 404 return link 405 } 406 407 // setStatues sets GitHub context status. 408 func (sc *statusController) setStatuses(all []CodeReviewCommon, pool map[string]CodeReviewCommon, blocks blockers.Blockers, baseSHAs map[string]string, requiredContexts map[string][]string) { 409 c := sc.config() 410 // queryMap caches which queries match a repo. 411 // Make a new one each sync loop as queries will change. 412 queryMap := c.Tide.Queries.QueryMap() 413 processed := sets.New[string]() 414 415 process := func(pr *CodeReviewCommon) { 416 processed.Insert(prKey(pr)) 417 log := sc.logger.WithFields(pr.logFields()) 418 contexts, err := sc.ghProvider.headContexts(pr) 419 if err != nil { 420 log.WithError(err).Error("Getting head commit status contexts, skipping...") 421 return 422 } 423 424 org := pr.Org 425 repo := pr.Repo 426 branch := pr.BaseRefName 427 headSHA := pr.HeadRefOID 428 // baseSHA is an empty string for any PR that doesn't have a corresponding merge pool 429 baseSHA := baseSHAs[poolKey(org, repo, branch)] 430 baseSHAGetter := newBaseSHAGetter(baseSHAs, sc.ghc, org, repo, branch) 431 432 cr := contextCheckerGetterFactory(c, sc.gc, org, repo, branch, baseSHAGetter, headSHA, requiredContexts[prKey(pr)]) 433 434 wantState, wantDesc, err := sc.expectedStatus(log, queryMap, pr, pool, cr, blocks, baseSHA) 435 if err != nil { 436 log.WithError(err).Error("getting expected status") 437 return 438 } 439 var actualState githubql.StatusState 440 var actualDesc string 441 for _, ctx := range contexts { 442 if string(ctx.Context) == statusContext { 443 actualState = ctx.State 444 actualDesc = string(ctx.Description) 445 } 446 } 447 if len(wantDesc) > maxStatusDescriptionLength { 448 original := wantDesc 449 wantDesc = fmt.Sprintf("%s...", wantDesc[0:(maxStatusDescriptionLength-3)]) 450 log.WithField("original-desc", original).Warn("GitHub status description needed to be truncated to fit GH API limit") 451 } 452 actualState = githubql.StatusState(strings.ToLower(string(actualState))) 453 if !sc.dontUpdateStatus.has(pr.Org, pr.Repo, pr.Number) && (wantState != string(actualState) || wantDesc != actualDesc) { 454 if err := sc.ghc.CreateStatus( 455 org, 456 repo, 457 headSHA, 458 github.Status{ 459 Context: statusContext, 460 State: wantState, 461 Description: wantDesc, 462 TargetURL: targetURL(c, pr, log), 463 }); err != nil && !github.IsNotFound(err) { 464 log.WithError(err).Errorf( 465 "Failed to set status context from %q to %q and description from %q to %q", 466 actualState, 467 wantState, 468 actualDesc, 469 wantDesc, 470 ) 471 } 472 } 473 } 474 475 for _, pr := range all { 476 process(&pr) 477 } 478 // The list of all open PRs may not contain a PR if it was merged before we 479 // listed all open PRs. To prevent a new PR that starts in the pool and 480 // immediately merges from missing a tide status context we need to ensure that 481 // every PR in the pool is processed even if it doesn't appear in all. 482 // 483 // Note: We could still fail to update a status context if the statusController 484 // falls behind the main Tide sync loop by multiple loops (if we are lapped). 485 // This would be unlikely to occur, could only occur if the status update sync 486 // period is longer than the main sync period, and would only result in a 487 // missing tide status context on a successfully merged PR. 488 for key, poolPR := range pool { 489 if !processed.Has(key) { 490 process(&poolPR) 491 } 492 } 493 } 494 495 func (sc *statusController) load() { 496 if sc.path == "" { 497 sc.logger.Debug("No stored state configured") 498 return 499 } 500 entry := sc.logger.WithField("path", sc.path) 501 reader, err := sc.opener.Reader(context.Background(), sc.path) 502 if err != nil { 503 entry.WithError(err).Warn("Cannot open stored state") 504 return 505 } 506 defer io.LogClose(reader) 507 508 buf, err := stdio.ReadAll(reader) 509 if err != nil { 510 entry.WithError(err).Warn("Cannot read stored state") 511 return 512 } 513 514 var stored map[string]storedState 515 if err := yaml.Unmarshal(buf, &stored); err != nil { 516 var singleStored storedState 517 if singleStoredErr := yaml.Unmarshal(buf, &singleStored); singleStoredErr == nil { 518 stored = map[string]storedState{"": singleStored} 519 } else { 520 entry.WithError(err).Warn("Cannot unmarshal stored state") 521 return 522 } 523 } 524 sc.storedStateLock.Lock() 525 sc.storedState = stored 526 sc.storedStateLock.Unlock() 527 } 528 529 func (sc *statusController) save(ticker *time.Ticker) { 530 for range ticker.C { 531 if sc.path == "" { 532 return 533 } 534 entry := sc.logger.WithField("path", sc.path) 535 sc.storedStateLock.Lock() 536 current := sc.storedState 537 sc.storedStateLock.Unlock() 538 buf, err := yaml.Marshal(current) 539 if err != nil { 540 entry.WithError(err).Warn("Cannot marshal state") 541 continue 542 } 543 writer, err := sc.opener.Writer(context.Background(), sc.path) 544 if err != nil { 545 entry.WithError(err).Warn("Cannot open state writer") 546 continue 547 } 548 if _, err = writer.Write(buf); err != nil { 549 entry.WithError(err).Warn("Cannot write state") 550 io.LogClose(writer) 551 continue 552 } 553 if err := writer.Close(); err != nil { 554 entry.WithError(err).Warn("Failed to close written state") 555 } 556 entry.Debug("Saved status state") 557 } 558 } 559 560 func (sc *statusController) run() { 561 sc.load() 562 ticks := time.NewTicker(time.Hour) 563 defer ticks.Stop() 564 go sc.save(ticks) 565 for { 566 // wait for a new pool 567 if !<-sc.newPoolPending { 568 // chan was closed 569 break 570 } 571 sc.waitSync() 572 } 573 close(sc.shutDown) 574 } 575 576 // waitSync waits until the minimum status update period has elapsed then syncs, 577 // returning the sync start time. 578 // If newPoolPending is closed while waiting (indicating a shutdown request) 579 // this function returns immediately without syncing. 580 func (sc *statusController) waitSync() { 581 // wait for the min sync period time to elapse if needed. 582 wait := time.After(time.Until(sc.lastSyncStart.Add(sc.config().Tide.StatusUpdatePeriod.Duration))) 583 for { 584 select { 585 case <-wait: 586 sc.statusUpdate.Lock() 587 pool := sc.poolPRs 588 blocks := sc.blocks 589 baseSHAs := sc.baseSHAs 590 if baseSHAs == nil { 591 baseSHAs = map[string]string{} 592 } 593 requiredContexts := sc.requiredContexts 594 sc.statusUpdate.Unlock() 595 sc.sync(pool, blocks, baseSHAs, requiredContexts) 596 return 597 case more := <-sc.newPoolPending: 598 if !more { 599 return 600 } 601 } 602 } 603 } 604 605 func (sc *statusController) sync(pool map[string]CodeReviewCommon, blocks blockers.Blockers, baseSHAs map[string]string, requiredContexts map[string][]string) { 606 sc.lastSyncStart = time.Now() 607 defer func() { 608 duration := time.Since(sc.lastSyncStart) 609 sc.logger.WithField("duration", duration.String()).Info("Statuses synced.") 610 tideMetrics.statusUpdateDuration.Set(duration.Seconds()) 611 tideMetrics.syncHeartbeat.WithLabelValues("status-update").Inc() 612 }() 613 614 sc.setStatuses(sc.search(), pool, blocks, baseSHAs, requiredContexts) 615 } 616 617 func (sc *statusController) search() []CodeReviewCommon { 618 rawQueries := sc.config().Tide.Queries 619 if len(rawQueries) == 0 { 620 return nil 621 } 622 623 orgExceptions, repos := rawQueries.OrgExceptionsAndRepos() 624 orgs := sets.KeySet[string](orgExceptions) 625 queries := openPRsQueries(sets.List(orgs), sets.List(repos), orgExceptions) 626 if !sc.usesGitHubAppsAuth { 627 //The queries for each org need to have their order maintained, otherwise it may be falsely flagged for changing 628 var orgs []string 629 for org := range queries { 630 orgs = append(orgs, org) 631 } 632 sort.Strings(orgs) 633 var query string 634 for _, org := range orgs { 635 query += " " + queries[org] 636 } 637 queries = map[string]string{"": query} 638 } 639 640 if sc.storedState == nil { 641 sc.storedState = map[string]storedState{} 642 } 643 644 var prs []CodeReviewCommon 645 var errs []error 646 var lock sync.Mutex 647 var wg sync.WaitGroup 648 649 for org, query := range queries { 650 org, query := org, query 651 wg.Add(1) 652 653 go func() { 654 defer wg.Done() 655 now := time.Now() 656 log := sc.logger.WithField("query", query) 657 658 sc.storedStateLock.Lock() 659 latestPR := sc.storedState[org].LatestPR 660 if query != sc.storedState[org].PreviousQuery { 661 // Query changed and/or tide restarted, recompute everything 662 log.WithField("previously", sc.storedState[org].PreviousQuery).Info("Query changed, resetting start time to zero") 663 sc.storedState[org] = storedState{PreviousQuery: query} 664 } 665 sc.storedStateLock.Unlock() 666 667 result, err := sc.ghProvider.search(sc.ghc.QueryWithGitHubAppsSupport, sc.logger, query, latestPR.Time, now, org) 668 log.WithField("duration", time.Since(now).String()).WithField("result_count", len(result)).Debug("Searched for open PRs.") 669 670 func() { 671 sc.storedStateLock.Lock() 672 defer sc.storedStateLock.Unlock() 673 674 log := log.WithField("latestPR", sc.storedState[org].LatestPR) 675 if len(result) == 0 { 676 log.Debug("no new results") 677 return 678 } 679 latest := result[len(result)-1].UpdatedAt 680 if latest.IsZero() { 681 log.Debug("latest PR has zero time") 682 return 683 } 684 sc.storedState[org] = storedState{ 685 LatestPR: metav1.Time{Time: latest.Add(-30 * time.Second)}, 686 PreviousQuery: sc.storedState[org].PreviousQuery, 687 } 688 log.WithField("latestPR", sc.storedState[org].LatestPR).Debug("Advanced start time") 689 }() 690 691 lock.Lock() 692 defer lock.Unlock() 693 694 for _, pr := range result { 695 pr := pr 696 prs = append(prs, *CodeReviewCommonFromPullRequest(&pr)) 697 } 698 errs = append(errs, err) 699 }() 700 701 } 702 wg.Wait() 703 704 err := utilerrors.NewAggregate(errs) 705 if err != nil { 706 log := sc.logger.WithError(err) 707 if len(prs) == 0 { 708 log.Error("Search failed") 709 return nil 710 } 711 log.Warn("Search partially completed") 712 } 713 714 return prs 715 } 716 717 // newBaseSHAGetter is a refGetter that will look up the baseSHA from GitHub if necessary 718 // and if it did so, store in in the baseSHA map 719 func newBaseSHAGetter(baseSHAs map[string]string, ghc githubClient, org, repo, branch string) config.RefGetter { 720 return func() (string, error) { 721 if sha, exists := baseSHAs[poolKey(org, repo, branch)]; exists { 722 return sha, nil 723 } 724 baseSHA, err := ghc.GetRef(org, repo, "heads/"+branch) 725 if err != nil { 726 return "", err 727 } 728 baseSHAs[poolKey(org, repo, branch)] = baseSHA 729 return baseSHAs[poolKey(org, repo, branch)], nil 730 } 731 } 732 733 func openPRsQueries(orgs, repos []string, orgExceptions map[string]sets.Set[string]) map[string]string { 734 result := map[string]string{} 735 for org, query := range orgRepoQueryStrings(orgs, repos, orgExceptions) { 736 result[org] = "is:pr state:open sort:updated-asc archived:false " + query 737 } 738 return result 739 } 740 741 const indexNamePassingJobs = "tide-passing-jobs" 742 743 func indexKeyPassingJobs(repo config.OrgRepo, baseSHA, headSHA string) string { 744 return fmt.Sprintf("%s@%s+%s", repo, baseSHA, headSHA) 745 } 746 747 func indexFuncPassingJobs(obj ctrlruntimeclient.Object) []string { 748 pj := obj.(*prowapi.ProwJob) 749 // We do not care about jobs other than presubmit and batch 750 if pj.Spec.Type != prowapi.PresubmitJob && pj.Spec.Type != prowapi.BatchJob { 751 return nil 752 } 753 if pj.Status.State != prowapi.SuccessState { 754 return nil 755 } 756 if pj.Spec.Refs == nil { 757 return nil 758 } 759 760 var result []string 761 for _, pull := range pj.Spec.Refs.Pulls { 762 result = append(result, indexKeyPassingJobs(config.OrgRepo{Org: pj.Spec.Refs.Org, Repo: pj.Spec.Refs.Repo}, pj.Spec.Refs.BaseSHA, pull.SHA)) 763 } 764 return result 765 } 766 767 type contextCheckerGetter = func() (contextChecker, error) 768 769 func contextCheckerGetterFactory(cfg *config.Config, gc git.ClientFactory, org, repo, branch string, baseSHAGetter config.RefGetter, headSHA string, requiredContexts []string) contextCheckerGetter { 770 return func() (contextChecker, error) { 771 contextPolicy, err := cfg.GetTideContextPolicy(gc, org, repo, branch, baseSHAGetter, headSHA) 772 if err != nil { 773 return nil, err 774 } 775 contextPolicy.RequiredContexts = requiredContexts 776 return contextPolicy, nil 777 } 778 } 779 780 type pullRequestIdentifier struct { 781 org string 782 repo string 783 number int 784 } 785 786 type threadSafePRSet struct { 787 data map[pullRequestIdentifier]struct{} 788 lock sync.RWMutex 789 } 790 791 func (s *threadSafePRSet) reset() { 792 s.lock.Lock() 793 defer s.lock.Unlock() 794 s.data = map[pullRequestIdentifier]struct{}{} 795 } 796 797 func (s *threadSafePRSet) has(org, repo string, number int) bool { 798 s.lock.RLock() 799 defer s.lock.RUnlock() 800 _, ok := s.data[pullRequestIdentifier{org: org, repo: repo, number: number}] 801 return ok 802 } 803 804 func (s *threadSafePRSet) insert(org, repo string, number int) { 805 s.lock.Lock() 806 defer s.lock.Unlock() 807 if s.data == nil { 808 s.data = map[pullRequestIdentifier]struct{}{} 809 } 810 s.data[pullRequestIdentifier{org: org, repo: repo, number: number}] = struct{}{} 811 }