github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/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  	"fmt"
    22  	"net/url"
    23  	"sort"
    24  	"strings"
    25  	"sync"
    26  	"time"
    27  
    28  	githubql "github.com/shurcooL/githubv4"
    29  	"github.com/sirupsen/logrus"
    30  
    31  	"k8s.io/apimachinery/pkg/util/sets"
    32  	"k8s.io/test-infra/prow/config"
    33  	"k8s.io/test-infra/prow/github"
    34  )
    35  
    36  const (
    37  	statusContext string = "tide"
    38  	statusInPool         = "In merge pool."
    39  	// statusNotInPool is a format string used when a PR is not in a tide pool.
    40  	// The '%s' field is populated with the reason why the PR is not in a
    41  	// tide pool or the empty string if the reason is unknown. See requirementDiff.
    42  	statusNotInPool = "Not mergeable.%s"
    43  )
    44  
    45  type statusController struct {
    46  	logger *logrus.Entry
    47  	ca     *config.Agent
    48  	ghc    githubClient
    49  
    50  	// newPoolPending is a size 1 chan that signals that the main Tide loop has
    51  	// updated the 'poolPRs' field with a freshly updated pool.
    52  	newPoolPending chan bool
    53  	// shutDown is used to signal to the main controller that the statusController
    54  	// has completed processing after newPoolPending is closed.
    55  	shutDown chan bool
    56  
    57  	// lastSyncStart is used to ensure that the status update period is at least
    58  	// the minimum status update period.
    59  	lastSyncStart time.Time
    60  	// lastSuccessfulQueryStart is used to only list PRs that have changed since
    61  	// we last successfully listed PRs in order to make status context updates
    62  	// cheaper.
    63  	lastSuccessfulQueryStart time.Time
    64  
    65  	// trackedOrgs and trackedRepos are the sets of orgs and repos that are
    66  	// 'up to date' on, in the sense that we have already processed all open PRs
    67  	// updated before lastSuccessfulQueryStart for these orgs and repos.
    68  	trackedOrgs  sets.String
    69  	trackedRepos sets.String
    70  
    71  	sync.Mutex
    72  	poolPRs map[string]PullRequest
    73  }
    74  
    75  func (sc *statusController) shutdown() {
    76  	close(sc.newPoolPending)
    77  	<-sc.shutDown
    78  }
    79  
    80  // requirementDiff calculates the diff between a PR and a TideQuery.
    81  // This diff is defined with a string that describes some subset of the
    82  // differences and an integer counting the total number of differences.
    83  // The diff count should always reflect the scale of the differences between
    84  // the current state of the PR and the query, but the message returned need not
    85  // attempt to convey all of that information if some differences are more severe.
    86  // For instance, we need to convey that a PR is open against a forbidden branch
    87  // more than we need to detail which status contexts are failed against the PR.
    88  // To this end, some differences are given a higher diff weight than others.
    89  // Note: an empty diff can be returned if the reason that the PR does not match
    90  // the TideQuery is unknown. This can happen if this function's logic
    91  // does not match GitHub's and does not indicate that the PR matches the query.
    92  func requirementDiff(pr *PullRequest, q *config.TideQuery, cc contextChecker) (string, int) {
    93  	const maxLabelChars = 50
    94  	var desc string
    95  	var diff int
    96  	// Drops labels if needed to fit the description text area, but keep at least 1.
    97  	truncate := func(labels []string) []string {
    98  		i := 1
    99  		chars := len(labels[0])
   100  		for ; i < len(labels); i++ {
   101  			if chars+len(labels[i]) > maxLabelChars {
   102  				break
   103  			}
   104  			chars += len(labels[i]) + 2 // ", "
   105  		}
   106  		return labels[:i]
   107  	}
   108  
   109  	// Weight incorrect branches with very high diff so that we select the query
   110  	// for the correct branch.
   111  	targetBranchBlacklisted := false
   112  	for _, excludedBranch := range q.ExcludedBranches {
   113  		if string(pr.BaseRef.Name) == excludedBranch {
   114  			targetBranchBlacklisted = true
   115  			break
   116  		}
   117  	}
   118  	// if no whitelist is configured, the target is OK by default
   119  	targetBranchWhitelisted := len(q.IncludedBranches) == 0
   120  	for _, includedBranch := range q.IncludedBranches {
   121  		if string(pr.BaseRef.Name) == includedBranch {
   122  			targetBranchWhitelisted = true
   123  			break
   124  		}
   125  	}
   126  	if targetBranchBlacklisted || !targetBranchWhitelisted {
   127  		diff += 1000
   128  		if desc == "" {
   129  			desc = fmt.Sprintf(" Merging to branch %s is forbidden.", pr.BaseRef.Name)
   130  		}
   131  	}
   132  
   133  	// Weight incorrect milestone with relatively high diff so that we select the
   134  	// query for the correct milestone (but choose favor query for correct branch).
   135  	if q.Milestone != "" && (pr.Milestone == nil || string(pr.Milestone.Title) != q.Milestone) {
   136  		diff += 100
   137  		if desc == "" {
   138  			desc = fmt.Sprintf(" Must be in milestone %s.", q.Milestone)
   139  		}
   140  	}
   141  
   142  	// Weight incorrect labels and statues with low (normal) diff values.
   143  	var missingLabels []string
   144  	for _, l1 := range q.Labels {
   145  		var found bool
   146  		for _, l2 := range pr.Labels.Nodes {
   147  			if string(l2.Name) == l1 {
   148  				found = true
   149  				break
   150  			}
   151  		}
   152  		if !found {
   153  			missingLabels = append(missingLabels, l1)
   154  		}
   155  	}
   156  	diff += len(missingLabels)
   157  	if desc == "" && len(missingLabels) > 0 {
   158  		sort.Strings(missingLabels)
   159  		trunced := truncate(missingLabels)
   160  		if len(trunced) == 1 {
   161  			desc = fmt.Sprintf(" Needs %s label.", trunced[0])
   162  		} else {
   163  			desc = fmt.Sprintf(" Needs %s labels.", strings.Join(trunced, ", "))
   164  		}
   165  	}
   166  
   167  	var presentLabels []string
   168  	for _, l1 := range q.MissingLabels {
   169  		for _, l2 := range pr.Labels.Nodes {
   170  			if string(l2.Name) == l1 {
   171  				presentLabels = append(presentLabels, l1)
   172  				break
   173  			}
   174  		}
   175  	}
   176  	diff += len(presentLabels)
   177  	if desc == "" && len(presentLabels) > 0 {
   178  		sort.Strings(presentLabels)
   179  		trunced := truncate(presentLabels)
   180  		if len(trunced) == 1 {
   181  			desc = fmt.Sprintf(" Should not have %s label.", trunced[0])
   182  		} else {
   183  			desc = fmt.Sprintf(" Should not have %s labels.", strings.Join(trunced, ", "))
   184  		}
   185  	}
   186  
   187  	// fixing label issues takes precedence over status contexts
   188  	var contexts []string
   189  	for _, commit := range pr.Commits.Nodes {
   190  		if commit.Commit.OID == pr.HeadRefOID {
   191  			for _, ctx := range unsuccessfulContexts(commit.Commit.Status.Contexts, cc, logrus.New().WithFields(pr.logFields())) {
   192  				contexts = append(contexts, string(ctx.Context))
   193  			}
   194  		}
   195  	}
   196  	diff += len(contexts)
   197  	if desc == "" && len(contexts) > 0 {
   198  		sort.Strings(contexts)
   199  		trunced := truncate(contexts)
   200  		if len(trunced) == 1 {
   201  			desc = fmt.Sprintf(" Job %s has not succeeded.", trunced[0])
   202  		} else {
   203  			desc = fmt.Sprintf(" Jobs %s have not succeeded.", strings.Join(trunced, ", "))
   204  		}
   205  	}
   206  
   207  	// TODO(cjwagner): List reviews (states:[APPROVED], first: 1) as part of open
   208  	// PR query.
   209  
   210  	return desc, diff
   211  }
   212  
   213  // Returns expected status state and description.
   214  // If a PR is not mergeable, we have to select a TideQuery to compare it against
   215  // in order to generate a diff for the status description. We choose the query
   216  // for the repo that the PR is closest to meeting (as determined by the number
   217  // of unmet/violated requirements).
   218  func expectedStatus(queryMap *config.QueryMap, pr *PullRequest, pool map[string]PullRequest, cc contextChecker) (string, string) {
   219  	if _, ok := pool[prKey(pr)]; !ok {
   220  		minDiffCount := -1
   221  		var minDiff string
   222  		for _, q := range queryMap.ForRepo(string(pr.Repository.Owner.Login), string(pr.Repository.Name)) {
   223  			diff, diffCount := requirementDiff(pr, &q, cc)
   224  			if minDiffCount == -1 || diffCount < minDiffCount {
   225  				minDiffCount = diffCount
   226  				minDiff = diff
   227  			}
   228  		}
   229  		return github.StatusPending, fmt.Sprintf(statusNotInPool, minDiff)
   230  	}
   231  	return github.StatusSuccess, statusInPool
   232  }
   233  
   234  // targetURL determines the URL used for more details in the status
   235  // context on GitHub. If no PR dashboard is configured, we will use
   236  // the administrative Prow overview.
   237  func targetURL(c *config.Agent, pr *PullRequest, log *logrus.Entry) string {
   238  	var link string
   239  	if tideURL := c.Config().Tide.TargetURL; tideURL != "" {
   240  		link = tideURL
   241  	} else if baseURL := c.Config().Tide.PRStatusBaseURL; baseURL != "" {
   242  		parseURL, err := url.Parse(baseURL)
   243  		if err != nil {
   244  			log.WithError(err).Error("Failed to parse PR status base URL")
   245  		} else {
   246  			prQuery := fmt.Sprintf("is:pr repo:%s author:%s head:%s", pr.Repository.NameWithOwner, pr.Author.Login, pr.HeadRefName)
   247  			values := parseURL.Query()
   248  			values.Set("query", prQuery)
   249  			parseURL.RawQuery = values.Encode()
   250  			link = parseURL.String()
   251  		}
   252  	}
   253  	return link
   254  }
   255  
   256  func (sc *statusController) setStatuses(all []PullRequest, pool map[string]PullRequest) {
   257  	// queryMap caches which queries match a repo.
   258  	// Make a new one each sync loop as queries will change.
   259  	queryMap := sc.ca.Config().Tide.Queries.QueryMap()
   260  	processed := sets.NewString()
   261  
   262  	process := func(pr *PullRequest) {
   263  		processed.Insert(prKey(pr))
   264  		log := sc.logger.WithFields(pr.logFields())
   265  		contexts, err := headContexts(log, sc.ghc, pr)
   266  		if err != nil {
   267  			log.WithError(err).Error("Getting head commit status contexts, skipping...")
   268  			return
   269  		}
   270  		cr, err := sc.ca.Config().GetTideContextPolicy(
   271  			string(pr.Repository.Owner.Login),
   272  			string(pr.Repository.Name),
   273  			string(pr.BaseRef.Name))
   274  		if err != nil {
   275  			log.WithError(err).Error("setting up context register")
   276  			return
   277  		}
   278  
   279  		wantState, wantDesc := expectedStatus(queryMap, pr, pool, cr)
   280  		var actualState githubql.StatusState
   281  		var actualDesc string
   282  		for _, ctx := range contexts {
   283  			if string(ctx.Context) == statusContext {
   284  				actualState = ctx.State
   285  				actualDesc = string(ctx.Description)
   286  			}
   287  		}
   288  		if wantState != strings.ToLower(string(actualState)) || wantDesc != actualDesc {
   289  			if err := sc.ghc.CreateStatus(
   290  				string(pr.Repository.Owner.Login),
   291  				string(pr.Repository.Name),
   292  				string(pr.HeadRefOID),
   293  				github.Status{
   294  					Context:     statusContext,
   295  					State:       wantState,
   296  					Description: wantDesc,
   297  					TargetURL:   targetURL(sc.ca, pr, log),
   298  				}); err != nil {
   299  				log.WithError(err).Errorf(
   300  					"Failed to set status context from %q to %q.",
   301  					string(actualState),
   302  					wantState,
   303  				)
   304  			}
   305  		}
   306  	}
   307  
   308  	for _, pr := range all {
   309  		process(&pr)
   310  	}
   311  	// The list of all open PRs may not contain a PR if it was merged before we
   312  	// listed all open PRs. To prevent a new PR that starts in the pool and
   313  	// immediately merges from missing a tide status context we need to ensure that
   314  	// every PR in the pool is processed even if it doesn't appear in all.
   315  	//
   316  	// Note: We could still fail to update a status context if the statusController
   317  	// falls behind the main Tide sync loop by multiple loops (if we are lapped).
   318  	// This would be unlikely to occur, could only occur if the status update sync
   319  	// period is longer than the main sync period, and would only result in a
   320  	// missing tide status context on a successfully merged PR.
   321  	for key, poolPR := range pool {
   322  		if !processed.Has(key) {
   323  			process(&poolPR)
   324  		}
   325  	}
   326  }
   327  
   328  func (sc *statusController) run() {
   329  	for {
   330  		// wait for a new pool
   331  		if !<-sc.newPoolPending {
   332  			// chan was closed
   333  			break
   334  		}
   335  		sc.waitSync()
   336  	}
   337  	close(sc.shutDown)
   338  }
   339  
   340  // waitSync waits until the minimum status update period has elapsed then syncs,
   341  // returning the sync start time.
   342  // If newPoolPending is closed while waiting (indicating a shutdown request)
   343  // this function returns immediately without syncing.
   344  func (sc *statusController) waitSync() {
   345  	// wait for the min sync period time to elapse if needed.
   346  	wait := time.After(time.Until(sc.lastSyncStart.Add(sc.ca.Config().Tide.StatusUpdatePeriod)))
   347  	for {
   348  		select {
   349  		case <-wait:
   350  			sc.Lock()
   351  			pool := sc.poolPRs
   352  			sc.Unlock()
   353  			sc.sync(pool)
   354  			return
   355  		case more := <-sc.newPoolPending:
   356  			if !more {
   357  				return
   358  			}
   359  		}
   360  	}
   361  }
   362  
   363  func (sc *statusController) sync(pool map[string]PullRequest) {
   364  	sc.lastSyncStart = time.Now()
   365  	defer func() {
   366  		duration := time.Since(sc.lastSyncStart)
   367  		sc.logger.WithField("duration", duration.String()).Info("Statuses synced.")
   368  		tideMetrics.statusUpdateDuration.Set(duration.Seconds())
   369  	}()
   370  
   371  	sc.setStatuses(sc.search(), pool)
   372  }
   373  
   374  func (sc *statusController) search() []PullRequest {
   375  	// Note: negative repo matches are ignored for simplicity when tracking orgs.
   376  	// This means that the addition/removal of a negative repo token on a query
   377  	// with an existing org token for the parent org won't cause statuses to be
   378  	// updated until PRs are individually bumped or Tide is restarted.
   379  	// The actual queries must still consider negative matches in order to avoid
   380  	// adding statuses to excluded repos.
   381  	orgExceptions, repos := sc.ca.Config().Tide.Queries.OrgExceptionsAndRepos()
   382  	orgs := sets.StringKeySet(orgExceptions)
   383  	freshOrgs, freshRepos := orgs.Difference(sc.trackedOrgs), repos.Difference(sc.trackedRepos)
   384  	oldOrgs, oldRepos := sc.trackedOrgs.Difference(orgs), sc.trackedRepos.Difference(repos)
   385  	// Stop tracking orgs and repos that aren't queried this loop.
   386  	sc.trackedOrgs.Delete(oldOrgs.UnsortedList()...)
   387  	sc.trackedRepos.Delete(oldRepos.UnsortedList()...)
   388  	// Determine the query for tracked PRs now before we modify 'trackedOrgs' and 'trackedRepos'.
   389  	var trackedQuery string
   390  	if sc.trackedOrgs.Len() > 0 || sc.trackedRepos.Len() > 0 {
   391  		trackedQuery = openPRsQuery(sc.trackedOrgs.UnsortedList(), sc.trackedRepos.UnsortedList(), orgExceptions)
   392  	}
   393  	queryStartTime := time.Now()
   394  
   395  	var lock sync.Mutex
   396  	var wg sync.WaitGroup
   397  	var allPRs []PullRequest
   398  	// Query fresh orgs and repos individually and since the beginning of time.
   399  	// These queries are larger and more likely to fail so we query for targets individually.
   400  	singleTargetSearch := func(query, target string, tracked sets.String) {
   401  		defer wg.Done()
   402  		searcher := newSearchExecutor(context.Background(), sc.ghc, sc.logger, query)
   403  		prs, err := searcher.search()
   404  		if err != nil {
   405  			sc.logger.WithError(err).Errorf("Searching for open PRs in %s.", target)
   406  			return
   407  		}
   408  		func() {
   409  			lock.Lock()
   410  			defer lock.Unlock()
   411  			allPRs = append(allPRs, prs...)
   412  			tracked.Insert(target)
   413  		}()
   414  	}
   415  
   416  	wg.Add(freshOrgs.Len() + freshRepos.Len())
   417  	for _, org := range freshOrgs.UnsortedList() {
   418  		go singleTargetSearch(openPRsQuery([]string{org}, nil, orgExceptions), org, sc.trackedOrgs)
   419  	}
   420  	for _, repo := range freshRepos.UnsortedList() {
   421  		go singleTargetSearch(openPRsQuery(nil, []string{repo}, nil), repo, sc.trackedRepos)
   422  	}
   423  	wg.Wait()
   424  
   425  	// Query tracked orgs and repos together and only since the last time we queried.
   426  	// We offset for 30 seconds of overlap because GitHub sometimes doesn't
   427  	// include recently changed/new PRs in the query results.
   428  	if trackedQuery != "" {
   429  		sinceTime := sc.lastSuccessfulQueryStart.Add(-30 * time.Second)
   430  		searcher := newSearchExecutor(context.Background(), sc.ghc, sc.logger, trackedQuery)
   431  		prs, err := searcher.searchSince(sinceTime)
   432  		if err != nil {
   433  			sc.logger.WithError(err).Error("Searching for open PRs from 'tracked' orgs and repos.")
   434  		} else {
   435  			allPRs = append(allPRs, prs...)
   436  		}
   437  	}
   438  
   439  	// We were able to find all open PRs so update the last successful query time.
   440  	sc.lastSuccessfulQueryStart = queryStartTime
   441  	return allPRs
   442  }
   443  
   444  func openPRsQuery(orgs, repos []string, orgExceptions map[string]sets.String) string {
   445  	return "is:pr state:open " + orgRepoQueryString(orgs, repos, orgExceptions)
   446  }