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  }