github.com/yrj2011/jx-test-infra@v0.0.0-20190529031832-7a2065ee98eb/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 contains a controller for managing a tide pool of PRs. The
    18  // controller will automatically retest PRs in the pool and merge them if they
    19  // pass tests.
    20  package tide
    21  
    22  import (
    23  	"context"
    24  	"fmt"
    25  	"net/url"
    26  	"sort"
    27  	"strings"
    28  	"sync"
    29  	"time"
    30  
    31  	"github.com/shurcooL/githubv4"
    32  	"github.com/sirupsen/logrus"
    33  
    34  	"k8s.io/apimachinery/pkg/util/sets"
    35  	"k8s.io/test-infra/prow/config"
    36  	"k8s.io/test-infra/prow/github"
    37  )
    38  
    39  const (
    40  	statusContext string = "tide"
    41  	statusInPool         = "In merge pool."
    42  	// statusNotInPool is a format string used when a PR is not in a tide pool.
    43  	// The '%s' field is populated with the reason why the PR is not in a
    44  	// tide pool or the empty string if the reason is unknown. See requirementDiff.
    45  	statusNotInPool = "Not mergeable.%s"
    46  )
    47  
    48  type statusController struct {
    49  	logger *logrus.Entry
    50  	ca     *config.Agent
    51  	ghc    githubClient
    52  
    53  	// newPoolPending is a size 1 chan that signals that the main Tide loop has
    54  	// updated the 'poolPRs' field with a freshly updated pool.
    55  	newPoolPending chan bool
    56  	// shutDown is used to signal to the main controller that the statusController
    57  	// has completed processing after newPoolPending is closed.
    58  	shutDown chan bool
    59  
    60  	// lastSyncStart is used to ensure that the status update period is at least
    61  	// the minimum status update period.
    62  	lastSyncStart time.Time
    63  	// lastSuccessfulQueryStart is used to only list PRs that have changed since
    64  	// we last successfully listed PRs in order to make status context updates
    65  	// cheaper.
    66  	lastSuccessfulQueryStart time.Time
    67  
    68  	sync.Mutex
    69  	poolPRs map[string]PullRequest
    70  }
    71  
    72  func (sc *statusController) shutdown() {
    73  	close(sc.newPoolPending)
    74  	<-sc.shutDown
    75  }
    76  
    77  // requirementDiff calculates the diff between a PR and a TideQuery.
    78  // This diff is defined with a string that describes some subset of the
    79  // differences and an integer counting the total number of differences.
    80  // The diff count should always reflect the total number of differences between
    81  // the current state of the PR and the query, but the message returned need not
    82  // attempt to convey all of that information if some differences are more severe.
    83  // For instance, we need to convey that a PR is open against a forbidden branch
    84  // more than we need to detail which status contexts are failed against the PR.
    85  // Note: an empty diff can be returned if the reason that the PR does not match
    86  // the TideQuery is unknown. This can happen happen if this function's logic
    87  // does not match GitHub's and does not indicate that the PR matches the query.
    88  func requirementDiff(pr *PullRequest, q *config.TideQuery, cc contextChecker) (string, int) {
    89  	const maxLabelChars = 50
    90  	var desc string
    91  	var diff int
    92  	// Drops labels if needed to fit the description text area, but keep at least 1.
    93  	truncate := func(labels []string) []string {
    94  		i := 1
    95  		chars := len(labels[0])
    96  		for ; i < len(labels); i++ {
    97  			if chars+len(labels[i]) > maxLabelChars {
    98  				break
    99  			}
   100  			chars += len(labels[i]) + 2 // ", "
   101  		}
   102  		return labels[:i]
   103  	}
   104  
   105  	for _, excludedBranch := range q.ExcludedBranches {
   106  		if string(pr.BaseRef.Name) == excludedBranch {
   107  			desc = fmt.Sprintf(" Merging to branch %s is forbidden.", pr.BaseRef.Name)
   108  			diff = 1
   109  		}
   110  	}
   111  
   112  	// if no whitelist is configured, the target is OK by default
   113  	targetBranchWhitelisted := len(q.IncludedBranches) == 0
   114  	for _, includedBranch := range q.IncludedBranches {
   115  		if string(pr.BaseRef.Name) == includedBranch {
   116  			targetBranchWhitelisted = true
   117  		}
   118  	}
   119  
   120  	if !targetBranchWhitelisted {
   121  		desc = fmt.Sprintf(" Merging to branch %s is forbidden.", pr.BaseRef.Name)
   122  		diff++
   123  	}
   124  
   125  	var missingLabels []string
   126  	for _, l1 := range q.Labels {
   127  		var found bool
   128  		for _, l2 := range pr.Labels.Nodes {
   129  			if string(l2.Name) == l1 {
   130  				found = true
   131  				break
   132  			}
   133  		}
   134  		if !found {
   135  			missingLabels = append(missingLabels, l1)
   136  		}
   137  	}
   138  	diff += len(missingLabels)
   139  	if desc == "" && len(missingLabels) > 0 {
   140  		sort.Strings(missingLabels)
   141  		trunced := truncate(missingLabels)
   142  		if len(trunced) == 1 {
   143  			desc = fmt.Sprintf(" Needs %s label.", trunced[0])
   144  		} else {
   145  			desc = fmt.Sprintf(" Needs %s labels.", strings.Join(trunced, ", "))
   146  		}
   147  	}
   148  
   149  	var presentLabels []string
   150  	for _, l1 := range q.MissingLabels {
   151  		for _, l2 := range pr.Labels.Nodes {
   152  			if string(l2.Name) == l1 {
   153  				presentLabels = append(presentLabels, l1)
   154  				break
   155  			}
   156  		}
   157  	}
   158  	diff += len(presentLabels)
   159  	if desc == "" && len(presentLabels) > 0 {
   160  		sort.Strings(presentLabels)
   161  		trunced := truncate(presentLabels)
   162  		if len(trunced) == 1 {
   163  			desc = fmt.Sprintf(" Should not have %s label.", trunced[0])
   164  		} else {
   165  			desc = fmt.Sprintf(" Should not have %s labels.", strings.Join(trunced, ", "))
   166  		}
   167  	}
   168  
   169  	// fixing label issues takes precedence over status contexts
   170  	var contexts []string
   171  	for _, commit := range pr.Commits.Nodes {
   172  		if commit.Commit.OID == pr.HeadRefOID {
   173  			for _, ctx := range unsuccessfulContexts(commit.Commit.Status.Contexts, cc, logrus.New().WithFields(pr.logFields())) {
   174  				contexts = append(contexts, string(ctx.Context))
   175  			}
   176  		}
   177  	}
   178  	diff += len(contexts)
   179  	if desc == "" && len(contexts) > 0 {
   180  		sort.Strings(contexts)
   181  		trunced := truncate(contexts)
   182  		if len(trunced) == 1 {
   183  			desc = fmt.Sprintf(" Job %s has not succeeded.", trunced[0])
   184  		} else {
   185  			desc = fmt.Sprintf(" Jobs %s have not succeeded.", strings.Join(trunced, ", "))
   186  		}
   187  	}
   188  
   189  	if q.Milestone != "" && (pr.Milestone == nil || string(pr.Milestone.Title) != q.Milestone) {
   190  		diff++
   191  		if desc == "" {
   192  			desc = fmt.Sprintf(" Must be in milestone %s.", q.Milestone)
   193  		}
   194  	}
   195  
   196  	// TODO(cjwagner): List reviews (states:[APPROVED], first: 1) as part of open
   197  	// PR query.
   198  
   199  	return desc, diff
   200  }
   201  
   202  // Returns expected status state and description.
   203  // If a PR is not mergeable, we have to select a TideQuery to compare it against
   204  // in order to generate a diff for the status description. We choose the query
   205  // for the repo that the PR is closest to meeting (as determined by the number
   206  // of unmet/violated requirements).
   207  func expectedStatus(queryMap config.QueryMap, pr *PullRequest, pool map[string]PullRequest, cc contextChecker) (string, string) {
   208  	if _, ok := pool[prKey(pr)]; !ok {
   209  		minDiffCount := -1
   210  		var minDiff string
   211  		for _, q := range queryMap.ForRepo(string(pr.Repository.Owner.Login), string(pr.Repository.Name)) {
   212  			diff, diffCount := requirementDiff(pr, &q, cc)
   213  			if minDiffCount == -1 || diffCount < minDiffCount {
   214  				minDiffCount = diffCount
   215  				minDiff = diff
   216  			}
   217  		}
   218  		return github.StatusPending, fmt.Sprintf(statusNotInPool, minDiff)
   219  	}
   220  	return github.StatusSuccess, statusInPool
   221  }
   222  
   223  // targetURL determines the URL used for more details in the status
   224  // context on GitHub. If no PR dashboard is configured, we will use
   225  // the administrative Prow overview.
   226  func targetURL(c *config.Agent, pr *PullRequest, log *logrus.Entry) string {
   227  	var link string
   228  	if tideURL := c.Config().Tide.TargetURL; tideURL != "" {
   229  		link = tideURL
   230  	} else if baseURL := c.Config().Tide.PRStatusBaseURL; baseURL != "" {
   231  		parseURL, err := url.Parse(baseURL)
   232  		if err != nil {
   233  			log.WithError(err).Error("Failed to parse PR status base URL")
   234  		} else {
   235  			prQuery := fmt.Sprintf("is:pr repo:%s author:%s head:%s", pr.Repository.NameWithOwner, pr.Author.Login, pr.HeadRefName)
   236  			values := parseURL.Query()
   237  			values.Set("query", prQuery)
   238  			parseURL.RawQuery = values.Encode()
   239  			link = parseURL.String()
   240  		}
   241  	}
   242  	return link
   243  }
   244  
   245  func (sc *statusController) setStatuses(all []PullRequest, pool map[string]PullRequest) {
   246  	queryMap := sc.ca.Config().Tide.Queries.QueryMap()
   247  	processed := sets.NewString()
   248  
   249  	process := func(pr *PullRequest) {
   250  		processed.Insert(prKey(pr))
   251  		log := sc.logger.WithFields(pr.logFields())
   252  		contexts, err := headContexts(log, sc.ghc, pr)
   253  		if err != nil {
   254  			log.WithError(err).Error("Getting head commit status contexts, skipping...")
   255  			return
   256  		}
   257  		cr, err := sc.ca.Config().GetTideContextPolicy(
   258  			string(pr.Repository.Owner.Login),
   259  			string(pr.Repository.Name),
   260  			string(pr.BaseRef.Name))
   261  		if err != nil {
   262  			log.WithError(err).Error("setting up context register")
   263  			return
   264  		}
   265  
   266  		wantState, wantDesc := expectedStatus(queryMap, pr, pool, cr)
   267  		var actualState githubv4.StatusState
   268  		var actualDesc string
   269  		for _, ctx := range contexts {
   270  			if string(ctx.Context) == statusContext {
   271  				actualState = ctx.State
   272  				actualDesc = string(ctx.Description)
   273  			}
   274  		}
   275  		if wantState != strings.ToLower(string(actualState)) || wantDesc != actualDesc {
   276  			if err := sc.ghc.CreateStatus(
   277  				string(pr.Repository.Owner.Login),
   278  				string(pr.Repository.Name),
   279  				string(pr.HeadRefOID),
   280  				github.Status{
   281  					Context:     statusContext,
   282  					State:       wantState,
   283  					Description: wantDesc,
   284  					TargetURL:   targetURL(sc.ca, pr, log),
   285  				}); err != nil {
   286  				log.WithError(err).Errorf(
   287  					"Failed to set status context from %q to %q.",
   288  					string(actualState),
   289  					wantState,
   290  				)
   291  			}
   292  		}
   293  	}
   294  
   295  	for _, pr := range all {
   296  		process(&pr)
   297  	}
   298  	// The list of all open PRs may not contain a PR if it was merged before we
   299  	// listed all open PRs. To prevent a new PR that starts in the pool and
   300  	// immediately merges from missing a tide status context we need to ensure that
   301  	// every PR in the pool is processed even if it doesn't appear in all.
   302  	//
   303  	// Note: We could still fail to update a status context if the statusController
   304  	// falls behind the main Tide sync loop by multiple loops (if we are lapped).
   305  	// This would be unlikely to occur, could only occur if the status update sync
   306  	// period is longer than the main sync period, and would only result in a
   307  	// missing tide status context on a successfully merged PR.
   308  	for key, poolPR := range pool {
   309  		if !processed.Has(key) {
   310  			process(&poolPR)
   311  		}
   312  	}
   313  }
   314  
   315  func (sc *statusController) run() {
   316  	for {
   317  		// wait for a new pool
   318  		if !<-sc.newPoolPending {
   319  			// chan was closed
   320  			break
   321  		}
   322  		sc.waitSync()
   323  	}
   324  	close(sc.shutDown)
   325  }
   326  
   327  // waitSync waits until the minimum status update period has elapsed then syncs,
   328  // returning the sync start time.
   329  // If newPoolPending is closed while waiting (indicating a shutdown request)
   330  // this function returns immediately without syncing.
   331  func (sc *statusController) waitSync() {
   332  	// wait for the min sync period time to elapse if needed.
   333  	wait := time.After(time.Until(sc.lastSyncStart.Add(sc.ca.Config().Tide.StatusUpdatePeriod)))
   334  	for {
   335  		select {
   336  		case <-wait:
   337  			sc.Lock()
   338  			pool := sc.poolPRs
   339  			sc.Unlock()
   340  			sc.sync(pool)
   341  			return
   342  		case more := <-sc.newPoolPending:
   343  			if !more {
   344  				return
   345  			}
   346  		}
   347  	}
   348  }
   349  
   350  func (sc *statusController) sync(pool map[string]PullRequest) {
   351  	sc.lastSyncStart = time.Now()
   352  
   353  	sinceTime := sc.lastSuccessfulQueryStart.Add(-10 * time.Second)
   354  	query := sc.ca.Config().Tide.Queries.AllPRsSince(sinceTime)
   355  	queryStartTime := time.Now()
   356  	allPRs, err := search(context.Background(), sc.ghc, sc.logger, query)
   357  	if err != nil {
   358  		sc.logger.WithError(err).Errorf("Searching for open PRs.")
   359  		return
   360  	}
   361  	// We were able to find all open PRs so update the last successful query time.
   362  	sc.lastSuccessfulQueryStart = queryStartTime
   363  	sc.setStatuses(allPRs, pool)
   364  }