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 }