github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/tide/tide.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 "encoding/json" 25 "fmt" 26 "net/http" 27 "sort" 28 "strings" 29 "sync" 30 "time" 31 32 "github.com/prometheus/client_golang/prometheus" 33 githubql "github.com/shurcooL/githubv4" 34 "github.com/sirupsen/logrus" 35 36 "k8s.io/apimachinery/pkg/util/sets" 37 "k8s.io/test-infra/prow/config" 38 "k8s.io/test-infra/prow/git" 39 "k8s.io/test-infra/prow/github" 40 "k8s.io/test-infra/prow/kube" 41 "k8s.io/test-infra/prow/pjutil" 42 "k8s.io/test-infra/prow/tide/blockers" 43 "k8s.io/test-infra/prow/tide/history" 44 ) 45 46 type kubeClient interface { 47 ListProwJobs(string) ([]kube.ProwJob, error) 48 CreateProwJob(kube.ProwJob) (kube.ProwJob, error) 49 } 50 51 type githubClient interface { 52 CreateStatus(string, string, string, github.Status) error 53 GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error) 54 GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error) 55 GetRef(string, string, string) (string, error) 56 Merge(string, string, int, github.MergeDetails) error 57 Query(context.Context, interface{}, map[string]interface{}) error 58 } 59 60 type contextChecker interface { 61 // IsOptional tells whether a context is optional. 62 IsOptional(string) bool 63 // MissingRequiredContexts tells if required contexts are missing from the list of contexts provided. 64 MissingRequiredContexts([]string) []string 65 } 66 67 // Controller knows how to sync PRs and PJs. 68 type Controller struct { 69 logger *logrus.Entry 70 ca *config.Agent 71 ghc githubClient 72 kc kubeClient 73 gc *git.Client 74 75 sc *statusController 76 77 m sync.Mutex 78 pools []Pool 79 80 // changedFiles caches the names of files changed by PRs. 81 // Cache entries expire if they are not used during a sync loop. 82 changedFiles *changedFilesAgent 83 84 History *history.History 85 } 86 87 // Action represents what actions the controller can take. It will take 88 // exactly one action each sync. 89 type Action string 90 91 // Constants for various actions the controller might take 92 const ( 93 Wait Action = "WAIT" 94 Trigger = "TRIGGER" 95 TriggerBatch = "TRIGGER_BATCH" 96 Merge = "MERGE" 97 MergeBatch = "MERGE_BATCH" 98 PoolBlocked = "BLOCKED" 99 ) 100 101 // recordableActions is the subset of actions that we keep historical record of. 102 // Ignore idle actions to avoid flooding the records with useless data. 103 var recordableActions = map[Action]bool{ 104 Trigger: true, 105 TriggerBatch: true, 106 Merge: true, 107 MergeBatch: true, 108 } 109 110 // Pool represents information about a tide pool. There is one for every 111 // org/repo/branch combination that has PRs in the pool. 112 type Pool struct { 113 Org string 114 Repo string 115 Branch string 116 117 // PRs with passing tests, pending tests, and missing or failed tests. 118 // Note that these results are rolled up. If all tests for a PR are passing 119 // except for one pending, it will be in PendingPRs. 120 SuccessPRs []PullRequest 121 PendingPRs []PullRequest 122 MissingPRs []PullRequest 123 124 // Empty if there is no pending batch. 125 BatchPending []PullRequest 126 127 // Which action did we last take, and to what target(s), if any. 128 Action Action 129 Target []PullRequest 130 Blockers []blockers.Blocker 131 Error string 132 } 133 134 // Prometheus Metrics 135 var ( 136 tideMetrics = struct { 137 // Per pool 138 pooledPRs *prometheus.GaugeVec 139 updateTime *prometheus.GaugeVec 140 merges *prometheus.HistogramVec 141 142 // Singleton 143 syncDuration prometheus.Gauge 144 statusUpdateDuration prometheus.Gauge 145 }{ 146 pooledPRs: prometheus.NewGaugeVec(prometheus.GaugeOpts{ 147 Name: "pooledprs", 148 Help: "Number of PRs in each Tide pool.", 149 }, []string{ 150 "org", 151 "repo", 152 "branch", 153 }), 154 updateTime: prometheus.NewGaugeVec(prometheus.GaugeOpts{ 155 Name: "updatetime", 156 Help: "The last time each subpool was synced. (Used to determine 'pooledprs' freshness.)", 157 }, []string{ 158 "org", 159 "repo", 160 "branch", 161 }), 162 163 merges: prometheus.NewHistogramVec(prometheus.HistogramOpts{ 164 Name: "merges", 165 Help: "Histogram of merges where values are the number of PRs merged together.", 166 Buckets: []float64{1, 2, 3, 4, 5, 7, 10, 15, 25}, 167 }, []string{ 168 "org", 169 "repo", 170 "branch", 171 }), 172 173 syncDuration: prometheus.NewGauge(prometheus.GaugeOpts{ 174 Name: "syncdur", 175 Help: "The duration of the last loop of the sync controller.", 176 }), 177 178 statusUpdateDuration: prometheus.NewGauge(prometheus.GaugeOpts{ 179 Name: "statusupdatedur", 180 Help: "The duration of the last loop of the status update controller.", 181 }), 182 } 183 ) 184 185 func init() { 186 prometheus.MustRegister(tideMetrics.pooledPRs) 187 prometheus.MustRegister(tideMetrics.updateTime) 188 prometheus.MustRegister(tideMetrics.merges) 189 prometheus.MustRegister(tideMetrics.syncDuration) 190 prometheus.MustRegister(tideMetrics.statusUpdateDuration) 191 } 192 193 // NewController makes a Controller out of the given clients. 194 func NewController(ghcSync, ghcStatus *github.Client, kc *kube.Client, ca *config.Agent, gc *git.Client, logger *logrus.Entry) *Controller { 195 if logger == nil { 196 logger = logrus.NewEntry(logrus.StandardLogger()) 197 } 198 sc := &statusController{ 199 logger: logger.WithField("controller", "status-update"), 200 ghc: ghcStatus, 201 ca: ca, 202 newPoolPending: make(chan bool, 1), 203 shutDown: make(chan bool), 204 205 trackedOrgs: sets.NewString(), 206 trackedRepos: sets.NewString(), 207 } 208 go sc.run() 209 return &Controller{ 210 logger: logger.WithField("controller", "sync"), 211 ghc: ghcSync, 212 kc: kc, 213 ca: ca, 214 gc: gc, 215 sc: sc, 216 changedFiles: &changedFilesAgent{ 217 ghc: ghcSync, 218 nextChangeCache: make(map[changeCacheKey][]string), 219 }, 220 History: history.New(1000), 221 } 222 } 223 224 // Shutdown signals the statusController to stop working and waits for it to 225 // finish its last update loop before terminating. 226 // Controller.Sync() should not be used after this function is called. 227 func (c *Controller) Shutdown() { 228 c.sc.shutdown() 229 } 230 231 func prKey(pr *PullRequest) string { 232 return fmt.Sprintf("%s#%d", string(pr.Repository.NameWithOwner), int(pr.Number)) 233 } 234 235 // org/repo#number -> pr 236 func byRepoAndNumber(prs []PullRequest) map[string]PullRequest { 237 m := make(map[string]PullRequest) 238 for _, pr := range prs { 239 key := prKey(&pr) 240 m[key] = pr 241 } 242 return m 243 } 244 245 // newExpectedContext creates a Context with Expected state. 246 func newExpectedContext(c string) Context { 247 return Context{ 248 Context: githubql.String(c), 249 State: githubql.StatusStateExpected, 250 Description: githubql.String(""), 251 } 252 } 253 254 // contextsToStrings converts a list Context to a list of string 255 func contextsToStrings(contexts []Context) []string { 256 var names []string 257 for _, c := range contexts { 258 names = append(names, string(c.Context)) 259 } 260 return names 261 } 262 263 // Sync runs one sync iteration. 264 func (c *Controller) Sync() error { 265 start := time.Now() 266 defer func() { 267 duration := time.Since(start) 268 c.logger.WithField("duration", duration.String()).Info("Synced") 269 tideMetrics.syncDuration.Set(duration.Seconds()) 270 }() 271 defer c.changedFiles.prune() 272 273 ctx := context.Background() 274 c.logger.Debug("Building tide pool.") 275 prs := make(map[string]PullRequest) 276 for _, q := range c.ca.Config().Tide.Queries { 277 results, err := newSearchExecutor(ctx, c.ghc, c.logger, q.Query()).search() 278 if err != nil { 279 return err 280 } 281 for _, pr := range results { 282 prs[prKey(&pr)] = pr 283 } 284 } 285 286 var pjs []kube.ProwJob 287 var blocks blockers.Blockers 288 var err error 289 if len(prs) > 0 { 290 pjs, err = c.kc.ListProwJobs(kube.EmptySelector) 291 if err != nil { 292 return err 293 } 294 295 if label := c.ca.Config().Tide.BlockerLabel; label != "" { 296 c.logger.Debugf("Searching for blocking issues (label %q).", label) 297 orgExcepts, repos := c.ca.Config().Tide.Queries.OrgExceptionsAndRepos() 298 orgs := make([]string, 0, len(orgExcepts)) 299 for org := range orgExcepts { 300 orgs = append(orgs, org) 301 } 302 orgRepoQuery := orgRepoQueryString(orgs, repos.UnsortedList(), orgExcepts) 303 blocks, err = blockers.FindAll(c.ghc, c.logger, label, orgRepoQuery) 304 if err != nil { 305 return err 306 } 307 } 308 } 309 // Partition PRs into subpools and filter out non-pool PRs. 310 rawPools, err := c.dividePool(prs, pjs) 311 if err != nil { 312 return err 313 } 314 filteredPools := c.filterSubpools(c.ca.Config().Tide.MaxGoroutines, rawPools) 315 316 // Notify statusController about the new pool. 317 c.sc.Lock() 318 c.sc.poolPRs = poolPRMap(filteredPools) 319 select { 320 case c.sc.newPoolPending <- true: 321 default: 322 } 323 c.sc.Unlock() 324 325 // Sync subpools in parallel. 326 poolChan := make(chan Pool, len(filteredPools)) 327 subpoolsInParallel( 328 c.ca.Config().Tide.MaxGoroutines, 329 filteredPools, 330 func(sp *subpool) { 331 pool, err := c.syncSubpool(*sp, blocks.GetApplicable(sp.org, sp.repo, sp.branch)) 332 if err != nil { 333 sp.log.WithError(err).Errorf("Error syncing subpool.") 334 } 335 poolChan <- pool 336 }, 337 ) 338 339 close(poolChan) 340 pools := make([]Pool, 0, len(poolChan)) 341 for pool := range poolChan { 342 pools = append(pools, pool) 343 } 344 sortPools(pools) 345 c.m.Lock() 346 defer c.m.Unlock() 347 c.pools = pools 348 return nil 349 } 350 351 func (c *Controller) ServeHTTP(w http.ResponseWriter, r *http.Request) { 352 c.m.Lock() 353 defer c.m.Unlock() 354 b, err := json.Marshal(c.pools) 355 if err != nil { 356 c.logger.WithError(err).Error("Encoding JSON.") 357 b = []byte("[]") 358 } 359 if _, err = w.Write(b); err != nil { 360 c.logger.WithError(err).Error("Writing JSON response.") 361 } 362 } 363 364 func subpoolsInParallel(goroutines int, sps map[string]*subpool, process func(*subpool)) { 365 // Load the subpools into a channel for use as a work queue. 366 queue := make(chan *subpool, len(sps)) 367 for _, sp := range sps { 368 queue <- sp 369 } 370 close(queue) 371 372 if goroutines > len(queue) { 373 goroutines = len(queue) 374 } 375 wg := &sync.WaitGroup{} 376 wg.Add(goroutines) 377 for i := 0; i < goroutines; i++ { 378 go func() { 379 defer wg.Done() 380 for sp := range queue { 381 process(sp) 382 } 383 }() 384 } 385 wg.Wait() 386 } 387 388 // filterSubpools filters non-pool PRs out of the initially identified subpools, 389 // deleting any pools that become empty. 390 // See filterSubpool for filtering details. 391 func (c *Controller) filterSubpools(goroutines int, raw map[string]*subpool) map[string]*subpool { 392 filtered := make(map[string]*subpool) 393 var lock sync.Mutex 394 395 subpoolsInParallel( 396 goroutines, 397 raw, 398 func(sp *subpool) { 399 if err := c.initSubpoolData(sp); err != nil { 400 sp.log.WithError(err).Error("Error initializing subpool.") 401 return 402 } 403 key := poolKey(sp.org, sp.repo, sp.branch) 404 if spFiltered := filterSubpool(c.ghc, sp); spFiltered != nil { 405 sp.log.WithField("key", key).WithField("pool", spFiltered).Debug("filtered sub-pool") 406 407 lock.Lock() 408 filtered[key] = spFiltered 409 lock.Unlock() 410 } else { 411 sp.log.WithField("key", key).WithField("pool", spFiltered).Debug("filtering sub-pool removed all PRs") 412 } 413 }, 414 ) 415 return filtered 416 } 417 418 func (c *Controller) initSubpoolData(sp *subpool) error { 419 var err error 420 sp.presubmitContexts, err = c.presubmitsByPull(sp) 421 if err != nil { 422 return fmt.Errorf("error determining required presubmit prowjobs: %v", err) 423 } 424 sp.cc, err = c.ca.Config().GetTideContextPolicy(sp.org, sp.repo, sp.branch) 425 if err != nil { 426 return fmt.Errorf("error setting up context checker: %v", err) 427 } 428 return nil 429 } 430 431 // filterSubpool filters PRs from an initially identified subpool, returning the 432 // filtered subpool. 433 // If the subpool becomes empty 'nil' is returned to indicate that the subpool 434 // should be deleted. 435 func filterSubpool(ghc githubClient, sp *subpool) *subpool { 436 var toKeep []PullRequest 437 for _, pr := range sp.prs { 438 if !filterPR(ghc, sp, &pr) { 439 toKeep = append(toKeep, pr) 440 } 441 } 442 if len(toKeep) == 0 { 443 return nil 444 } 445 sp.prs = toKeep 446 return sp 447 } 448 449 // filterPR indicates if a PR should be filtered out of the subpool. 450 // Specifically we filter out PRs that: 451 // - Have known merge conflicts. 452 // - Have failing or missing status contexts. 453 // - Have pending required status contexts that are not associated with a 454 // ProwJob. (This ensures that the 'tide' context indicates that the pending 455 // status is preventing merge. Required ProwJob statuses are allowed to be 456 // 'pending' because this prevents kicking PRs from the pool when Tide is 457 // retesting them.) 458 func filterPR(ghc githubClient, sp *subpool, pr *PullRequest) bool { 459 log := sp.log.WithFields(pr.logFields()) 460 // Skip PRs that are known to be unmergeable. 461 if pr.Mergeable == githubql.MergeableStateConflicting { 462 log.Debug("filtering out PR as it is unmergeable") 463 return true 464 } 465 // Filter out PRs with unsuccessful contexts unless the only unsuccessful 466 // contexts are pending required prowjobs. 467 contexts, err := headContexts(log, ghc, pr) 468 if err != nil { 469 log.WithError(err).Error("Getting head contexts.") 470 return true 471 } 472 pjContexts := sp.presubmitContexts[int(pr.Number)] 473 for _, ctx := range unsuccessfulContexts(contexts, sp.cc, log) { 474 if ctx.State != githubql.StatusStatePending || !pjContexts.Has(string(ctx.Context)) { 475 log.WithField("context", ctx.Context).Debug("filtering out PR as unsuccessful context is not a pending Prow-controlled context") 476 return true 477 } 478 } 479 480 return false 481 } 482 483 // poolPRMap collects all subpool PRs into a map containing all pooled PRs. 484 func poolPRMap(subpoolMap map[string]*subpool) map[string]PullRequest { 485 prs := make(map[string]PullRequest) 486 for _, sp := range subpoolMap { 487 for _, pr := range sp.prs { 488 prs[prKey(&pr)] = pr 489 } 490 } 491 return prs 492 } 493 494 type simpleState string 495 496 const ( 497 noneState simpleState = "none" 498 pendingState simpleState = "pending" 499 successState simpleState = "success" 500 ) 501 502 func toSimpleState(s kube.ProwJobState) simpleState { 503 if s == kube.TriggeredState || s == kube.PendingState { 504 return pendingState 505 } else if s == kube.SuccessState { 506 return successState 507 } 508 return noneState 509 } 510 511 // isPassingTests returns whether or not all contexts set on the PR except for 512 // the tide pool context are passing. 513 func isPassingTests(log *logrus.Entry, ghc githubClient, pr PullRequest, cc contextChecker) bool { 514 log = log.WithFields(pr.logFields()) 515 contexts, err := headContexts(log, ghc, &pr) 516 if err != nil { 517 log.WithError(err).Error("Getting head commit status contexts.") 518 // If we can't get the status of the commit, assume that it is failing. 519 return false 520 } 521 unsuccessful := unsuccessfulContexts(contexts, cc, log) 522 return len(unsuccessful) == 0 523 } 524 525 // unsuccessfulContexts determines which contexts from the list that we care about are 526 // failed. For instance, we do not care about our own context. 527 // If the branchProtection is set to only check for required checks, we will skip 528 // all non-required tests. If required tests are missing from the list, they will be 529 // added to the list of failed contexts. 530 func unsuccessfulContexts(contexts []Context, cc contextChecker, log *logrus.Entry) []Context { 531 var failed []Context 532 for _, ctx := range contexts { 533 if string(ctx.Context) == statusContext { 534 continue 535 } 536 if cc.IsOptional(string(ctx.Context)) { 537 continue 538 } 539 if ctx.State != githubql.StatusStateSuccess { 540 failed = append(failed, ctx) 541 } 542 } 543 for _, c := range cc.MissingRequiredContexts(contextsToStrings(contexts)) { 544 failed = append(failed, newExpectedContext(c)) 545 } 546 547 log.Debugf("from %d total contexts (%v) found %d failing contexts: %v", len(contexts), contextsToStrings(contexts), len(failed), contextsToStrings(failed)) 548 return failed 549 } 550 551 func pickSmallestPassingNumber(log *logrus.Entry, ghc githubClient, prs []PullRequest, cc contextChecker) (bool, PullRequest) { 552 smallestNumber := -1 553 var smallestPR PullRequest 554 for _, pr := range prs { 555 if smallestNumber != -1 && int(pr.Number) >= smallestNumber { 556 continue 557 } 558 if len(pr.Commits.Nodes) < 1 { 559 continue 560 } 561 if !isPassingTests(log, ghc, pr, cc) { 562 continue 563 } 564 smallestNumber = int(pr.Number) 565 smallestPR = pr 566 } 567 return smallestNumber > -1, smallestPR 568 } 569 570 // accumulateBatch returns a list of PRs that can be merged after passing batch 571 // testing, if any exist. It also returns a list of PRs currently being batch 572 // tested. 573 func accumulateBatch(presubmits map[int]sets.String, prs []PullRequest, pjs []kube.ProwJob, log *logrus.Entry) ([]PullRequest, []PullRequest) { 574 log.Debug("accumulating PRs for batch testing") 575 if len(presubmits) == 0 { 576 log.Debug("no presubmits configured, no batch can be triggered") 577 return nil, nil 578 } 579 prNums := make(map[int]PullRequest) 580 for _, pr := range prs { 581 prNums[int(pr.Number)] = pr 582 } 583 type accState struct { 584 prs []PullRequest 585 jobStates map[string]simpleState 586 // Are the pull requests in the ref still acceptable? That is, do they 587 // still point to the heads of the PRs? 588 validPulls bool 589 } 590 states := make(map[string]*accState) 591 for _, pj := range pjs { 592 if pj.Spec.Type != kube.BatchJob { 593 continue 594 } 595 // First validate the batch job's refs. 596 ref := pj.Spec.Refs.String() 597 if _, ok := states[ref]; !ok { 598 state := &accState{ 599 jobStates: make(map[string]simpleState), 600 validPulls: true, 601 } 602 for _, pull := range pj.Spec.Refs.Pulls { 603 if pr, ok := prNums[pull.Number]; ok && string(pr.HeadRefOID) == pull.SHA { 604 state.prs = append(state.prs, pr) 605 } else if !ok { 606 state.validPulls = false 607 log.WithField("batch", ref).WithFields(pr.logFields()).Debug("batch job invalid, PR left pool") 608 break 609 } else { 610 state.validPulls = false 611 log.WithField("batch", ref).WithFields(pr.logFields()).Debug("batch job invalid, PR HEAD changed") 612 break 613 } 614 } 615 states[ref] = state 616 } 617 if !states[ref].validPulls { 618 // The batch contains a PR ref that has changed. Skip it. 619 continue 620 } 621 622 // Batch job refs are valid. Now accumulate job states by batch ref. 623 context := pj.Spec.Context 624 jobState := toSimpleState(pj.Status.State) 625 // Store the best result for this ref+context. 626 if s, ok := states[ref].jobStates[context]; !ok || s == noneState || jobState == successState { 627 states[ref].jobStates[context] = jobState 628 } 629 } 630 var pendingBatch, successBatch []PullRequest 631 for ref, state := range states { 632 if !state.validPulls { 633 continue 634 } 635 requiredPresubmits := sets.NewString() 636 for _, pr := range state.prs { 637 requiredPresubmits = requiredPresubmits.Union(presubmits[int(pr.Number)]) 638 } 639 overallState := successState 640 for _, p := range requiredPresubmits.List() { 641 if s, ok := state.jobStates[p]; !ok || s == noneState { 642 overallState = noneState 643 log.WithField("batch", ref).Debugf("batch invalid, required presubmit %s is not passing", p) 644 break 645 } else if s == pendingState && overallState == successState { 646 overallState = pendingState 647 } 648 } 649 switch overallState { 650 // Currently we only consider 1 pending batch and 1 success batch at a time. 651 // If more are somehow present they will be ignored. 652 case pendingState: 653 pendingBatch = state.prs 654 case successState: 655 successBatch = state.prs 656 } 657 } 658 return successBatch, pendingBatch 659 } 660 661 // accumulate returns the supplied PRs sorted into three buckets based on their 662 // accumulated state across the presubmits. 663 func accumulate(presubmits map[int]sets.String, prs []PullRequest, pjs []kube.ProwJob, log *logrus.Entry) (successes, pendings, nones []PullRequest) { 664 for _, pr := range prs { 665 // Accumulate the best result for each job. 666 psStates := make(map[string]simpleState) 667 for _, pj := range pjs { 668 if pj.Spec.Type != kube.PresubmitJob { 669 continue 670 } 671 if pj.Spec.Refs.Pulls[0].Number != int(pr.Number) { 672 continue 673 } 674 if pj.Spec.Refs.Pulls[0].SHA != string(pr.HeadRefOID) { 675 continue 676 } 677 678 name := pj.Spec.Context 679 oldState := psStates[name] 680 newState := toSimpleState(pj.Status.State) 681 if oldState == noneState || oldState == "" { 682 psStates[name] = newState 683 } else if oldState == pendingState && newState == successState { 684 psStates[name] = successState 685 } 686 } 687 // The overall result is the worst of the best. 688 overallState := successState 689 for _, ps := range presubmits[int(pr.Number)].List() { 690 if s, ok := psStates[ps]; !ok { 691 overallState = noneState 692 log.WithFields(pr.logFields()).Debugf("missing presubmit %s", ps) 693 break 694 } else if s == noneState { 695 overallState = noneState 696 log.WithFields(pr.logFields()).Debugf("presubmit %s not passing", ps) 697 break 698 } else if s == pendingState { 699 log.WithFields(pr.logFields()).Debugf("presubmit %s pending", ps) 700 overallState = pendingState 701 } 702 } 703 if overallState == successState { 704 successes = append(successes, pr) 705 } else if overallState == pendingState { 706 pendings = append(pendings, pr) 707 } else { 708 nones = append(nones, pr) 709 } 710 } 711 return 712 } 713 714 func prNumbers(prs []PullRequest) []int { 715 var nums []int 716 for _, pr := range prs { 717 nums = append(nums, int(pr.Number)) 718 } 719 return nums 720 } 721 722 func (c *Controller) pickBatch(sp subpool, cc contextChecker) ([]PullRequest, error) { 723 // we must choose the oldest PRs for the batch 724 sort.Slice(sp.prs, func(i, j int) bool { return sp.prs[i].Number < sp.prs[j].Number }) 725 726 var candidates []PullRequest 727 for _, pr := range sp.prs { 728 if isPassingTests(sp.log, c.ghc, pr, cc) { 729 candidates = append(candidates, pr) 730 } 731 } 732 733 if len(candidates) == 0 { 734 sp.log.Debugf("of %d possible PRs, none were passing tests, no batch will be created", len(sp.prs)) 735 return nil, nil 736 } 737 sp.log.Debugf("of %d possible PRs, %d are passing tests", len(sp.prs), len(candidates)) 738 739 r, err := c.gc.Clone(sp.org + "/" + sp.repo) 740 if err != nil { 741 return nil, err 742 } 743 defer r.Clean() 744 if err := r.Config("user.name", "prow"); err != nil { 745 return nil, err 746 } 747 if err := r.Config("user.email", "prow@localhost"); err != nil { 748 return nil, err 749 } 750 if err := r.Config("commit.gpgsign", "false"); err != nil { 751 sp.log.Warningf("Cannot set gpgsign=false in gitconfig: %v", err) 752 } 753 if err := r.Checkout(sp.sha); err != nil { 754 return nil, err 755 } 756 757 var res []PullRequest 758 for _, pr := range candidates { 759 if ok, err := r.Merge(string(pr.HeadRefOID)); err != nil { 760 // we failed to abort the merge and our git client is 761 // in a bad state; it must be cleaned before we try again 762 return nil, err 763 } else if ok { 764 res = append(res, pr) 765 // TODO: Make this configurable per subpool. 766 if len(res) == 5 { 767 break 768 } 769 } 770 } 771 return res, nil 772 } 773 774 func (c *Controller) mergePRs(sp subpool, prs []PullRequest) error { 775 var merged []int 776 defer func() { 777 if len(merged) == 0 { 778 return 779 } 780 tideMetrics.merges.WithLabelValues(sp.org, sp.repo, sp.branch).Observe(float64(len(merged))) 781 }() 782 783 augmentError := func(e error, pr PullRequest) error { 784 var batch string 785 if len(prs) > 1 { 786 batch = fmt.Sprintf(" from batch %v", prNumbers(prs)) 787 if len(merged) > 0 { 788 batch = fmt.Sprintf("%s, partial merge %v", batch, merged) 789 } 790 } 791 return fmt.Errorf("failed merging #%d%s: %v", int(pr.Number), batch, e) 792 } 793 794 log := sp.log.WithField("merge-targets", prNumbers(prs)) 795 maxRetries := 3 796 for i, pr := range prs { 797 backoff := time.Second * 4 798 log := log.WithFields(pr.logFields()) 799 mergeMethod := c.ca.Config().Tide.MergeMethod(sp.org, sp.repo) 800 if squashLabel := c.ca.Config().Tide.SquashLabel; squashLabel != "" { 801 for _, prlabel := range pr.Labels.Nodes { 802 if string(prlabel.Name) == squashLabel { 803 mergeMethod = github.MergeSquash 804 break 805 } 806 } 807 } 808 for retry := 0; retry < maxRetries; retry++ { 809 if err := c.ghc.Merge(sp.org, sp.repo, int(pr.Number), github.MergeDetails{ 810 SHA: string(pr.HeadRefOID), 811 MergeMethod: string(mergeMethod), 812 }); err != nil { 813 // TODO: Add a config option to abort batches if a PR in the batch 814 // cannot be merged for any reason. This would skip merging 815 // not just the changed PR, but also the other PRs in the batch. 816 // This shouldn't be the default behavior as merging batches is high 817 // priority and this is unlikely to be problematic. 818 // Note: We would also need to be able to roll back any merges for the 819 // batch that were already successfully completed before the failure. 820 // Ref: https://github.com/kubernetes/test-infra/issues/10621 821 if _, ok := err.(github.ModifiedHeadError); ok { 822 // This is a possible source of incorrect behavior. If someone 823 // modifies their PR as we try to merge it in a batch then we 824 // end up in an untested state. This is unlikely to cause any 825 // real problems. 826 log.WithError(err).Warning("Merge failed: PR was modified.") 827 break 828 } else if _, ok = err.(github.UnmergablePRBaseChangedError); ok { 829 // Github complained that the base branch was modified. This is a 830 // strange error because the API doesn't even allow the request to 831 // specify the base branch sha, only the head sha. 832 // We suspect that github is complaining because we are making the 833 // merge requests too rapidly and it cannot recompute mergability 834 // in time. https://github.com/kubernetes/test-infra/issues/5171 835 // We handle this by sleeping for a few seconds before trying to 836 // merge again. 837 log.WithError(err).Warning("Merge failed: Base branch was modified.") 838 if retry+1 < maxRetries { 839 time.Sleep(backoff) 840 backoff *= 2 841 } 842 } else if _, ok = err.(github.UnauthorizedToPushError); ok { 843 // Github let us know that the token used cannot push to the branch. 844 // Even if the robot is set up to have write access to the repo, an 845 // overzealous branch protection setting will not allow the robot to 846 // push to a specific branch. 847 log.WithError(err).Error("Merge failed: Branch needs to be configured to allow this robot to push.") 848 // We won't be able to merge the other PRs. 849 return augmentError(err, pr) 850 } else if _, ok = err.(github.MergeCommitsForbiddenError); ok { 851 // Github let us know that the merge method configured for this repo 852 // is not allowed by other repo settings, so we should let the admins 853 // know that the configuration needs to be updated. 854 log.WithError(err).Error("Merge failed: Tide needs to be configured to use the 'rebase' merge method for this repo or the repo needs to allow merge commits.") 855 // We won't be able to merge the other PRs. 856 return augmentError(err, pr) 857 } else if _, ok = err.(github.UnmergablePRError); ok { 858 log.WithError(err).Error("Merge failed: PR is unmergable. Do the Tide merge requirements match the GitHub settings for the repo?") 859 break 860 } else { 861 log.WithError(err).Error("Merge failed.") 862 return augmentError(err, pr) 863 } 864 } else { 865 log.Info("Merged.") 866 merged = append(merged, int(pr.Number)) 867 // If we have more PRs to merge, sleep to give Github time to recalculate 868 // mergeability. 869 if i+1 < len(prs) { 870 time.Sleep(time.Second * 3) 871 } 872 break 873 } 874 } 875 } 876 return nil 877 } 878 879 func (c *Controller) trigger(sp subpool, presubmitContexts map[int]sets.String, prs []PullRequest) error { 880 requiredContexts := sets.NewString() 881 for _, pr := range prs { 882 requiredContexts = requiredContexts.Union(presubmitContexts[int(pr.Number)]) 883 } 884 885 // TODO(cjwagner): DRY this out when generalizing triggering code (and code to determine required and to-run jobs). 886 for _, ps := range c.ca.Config().Presubmits[sp.org+"/"+sp.repo] { 887 if ps.SkipReport || !ps.RunsAgainstBranch(sp.branch) || !requiredContexts.Has(ps.Context) { 888 continue 889 } 890 891 refs := kube.Refs{ 892 Org: sp.org, 893 Repo: sp.repo, 894 BaseRef: sp.branch, 895 BaseSHA: sp.sha, 896 } 897 for _, pr := range prs { 898 refs.Pulls = append( 899 refs.Pulls, 900 kube.Pull{ 901 Number: int(pr.Number), 902 Author: string(pr.Author.Login), 903 SHA: string(pr.HeadRefOID), 904 }, 905 ) 906 } 907 var spec kube.ProwJobSpec 908 if len(prs) == 1 { 909 spec = pjutil.PresubmitSpec(ps, refs) 910 } else { 911 spec = pjutil.BatchSpec(ps, refs) 912 } 913 pj := pjutil.NewProwJob(spec, ps.Labels) 914 if _, err := c.kc.CreateProwJob(pj); err != nil { 915 return fmt.Errorf("failed to create a ProwJob for job: %q, PRs: %v: %v", spec.Job, prNumbers(prs), err) 916 } 917 } 918 return nil 919 } 920 921 func (c *Controller) takeAction(sp subpool, batchPending, successes, pendings, nones, batchMerges []PullRequest) (Action, []PullRequest, error) { 922 // Merge the batch! 923 if len(batchMerges) > 0 { 924 return MergeBatch, batchMerges, c.mergePRs(sp, batchMerges) 925 } 926 // Do not merge PRs while waiting for a batch to complete. We don't want to 927 // invalidate the old batch result. 928 if len(successes) > 0 && len(batchPending) == 0 { 929 if ok, pr := pickSmallestPassingNumber(sp.log, c.ghc, successes, sp.cc); ok { 930 return Merge, []PullRequest{pr}, c.mergePRs(sp, []PullRequest{pr}) 931 } 932 } 933 // If no presubmits are configured, just wait. 934 if len(sp.presubmitContexts) == 0 { 935 return Wait, nil, nil 936 } 937 // If we have no serial jobs pending or successful, trigger one. 938 if len(nones) > 0 && len(pendings) == 0 && len(successes) == 0 { 939 if ok, pr := pickSmallestPassingNumber(sp.log, c.ghc, nones, sp.cc); ok { 940 return Trigger, []PullRequest{pr}, c.trigger(sp, sp.presubmitContexts, []PullRequest{pr}) 941 } 942 } 943 // If we have no batch, trigger one. 944 if len(sp.prs) > 1 && len(batchPending) == 0 { 945 batch, err := c.pickBatch(sp, sp.cc) 946 if err != nil { 947 return Wait, nil, err 948 } 949 if len(batch) > 1 { 950 return TriggerBatch, batch, c.trigger(sp, sp.presubmitContexts, batch) 951 } 952 } 953 return Wait, nil, nil 954 } 955 956 // changedFilesAgent queries and caches the names of files changed by PRs. 957 // Cache entries expire if they are not used during a sync loop. 958 type changedFilesAgent struct { 959 ghc githubClient 960 changeCache map[changeCacheKey][]string 961 // nextChangeCache caches file change info that is relevant this sync for use next sync. 962 // This becomes the new changeCache when prune() is called at the end of each sync. 963 nextChangeCache map[changeCacheKey][]string 964 sync.RWMutex 965 } 966 967 type changeCacheKey struct { 968 org, repo string 969 number int 970 sha string 971 } 972 973 // prChanges gets the files changed by the PR, either from the cache or by 974 // querying GitHub. 975 func (c *changedFilesAgent) prChanges(pr *PullRequest) ([]string, error) { 976 cacheKey := changeCacheKey{ 977 org: string(pr.Repository.Owner.Login), 978 repo: string(pr.Repository.Name), 979 number: int(pr.Number), 980 sha: string(pr.HeadRefOID), 981 } 982 983 c.RLock() 984 changedFiles, ok := c.changeCache[cacheKey] 985 if ok { 986 c.RUnlock() 987 c.Lock() 988 c.nextChangeCache[cacheKey] = changedFiles 989 c.Unlock() 990 return changedFiles, nil 991 } 992 if changedFiles, ok = c.nextChangeCache[cacheKey]; ok { 993 c.RUnlock() 994 return changedFiles, nil 995 } 996 c.RUnlock() 997 998 // We need to query the changes from GitHub. 999 changes, err := c.ghc.GetPullRequestChanges( 1000 string(pr.Repository.Owner.Login), 1001 string(pr.Repository.Name), 1002 int(pr.Number), 1003 ) 1004 if err != nil { 1005 return nil, fmt.Errorf("error getting PR changes for #%d: %v", int(pr.Number), err) 1006 } 1007 changedFiles = make([]string, 0, len(changes)) 1008 for _, change := range changes { 1009 changedFiles = append(changedFiles, change.Filename) 1010 } 1011 1012 c.Lock() 1013 c.nextChangeCache[cacheKey] = changedFiles 1014 c.Unlock() 1015 return changedFiles, nil 1016 } 1017 1018 // prune removes any cached file changes that were not used since the last prune. 1019 func (c *changedFilesAgent) prune() { 1020 c.Lock() 1021 defer c.Unlock() 1022 c.changeCache = c.nextChangeCache 1023 c.nextChangeCache = make(map[changeCacheKey][]string) 1024 } 1025 1026 func (c *Controller) presubmitsByPull(sp *subpool) (map[int]sets.String, error) { 1027 presubmits := make(map[int]sets.String, len(sp.prs)) 1028 record := func(num int, context string) { 1029 if jobs, ok := presubmits[num]; ok { 1030 jobs.Insert(context) 1031 } else { 1032 presubmits[num] = sets.NewString(context) 1033 } 1034 } 1035 1036 for _, ps := range c.ca.Config().Presubmits[sp.org+"/"+sp.repo] { 1037 if !ps.ContextRequired() || !ps.RunsAgainstBranch(sp.branch) { 1038 continue 1039 } 1040 1041 if ps.AlwaysRun { 1042 // Every PR requires this job. 1043 for _, pr := range sp.prs { 1044 record(int(pr.Number), ps.Context) 1045 } 1046 } else if ps.RunIfChanged != "" { 1047 // This is a run if changed job so we need to check if each PR requires it. 1048 for _, pr := range sp.prs { 1049 changedFiles, err := c.changedFiles.prChanges(&pr) 1050 if err != nil { 1051 return nil, err 1052 } 1053 if ps.RunsAgainstChanges(changedFiles) { 1054 record(int(pr.Number), ps.Context) 1055 } 1056 } 1057 } 1058 } 1059 return presubmits, nil 1060 } 1061 1062 func (c *Controller) syncSubpool(sp subpool, blocks []blockers.Blocker) (Pool, error) { 1063 sp.log.Infof("Syncing subpool: %d PRs, %d PJs.", len(sp.prs), len(sp.pjs)) 1064 successes, pendings, nones := accumulate(sp.presubmitContexts, sp.prs, sp.pjs, sp.log) 1065 batchMerge, batchPending := accumulateBatch(sp.presubmitContexts, sp.prs, sp.pjs, sp.log) 1066 sp.log.WithFields(logrus.Fields{ 1067 "prs-passing": prNumbers(successes), 1068 "prs-pending": prNumbers(pendings), 1069 "prs-missing": prNumbers(nones), 1070 "batch-passing": prNumbers(batchMerge), 1071 "batch-pending": prNumbers(batchPending), 1072 }).Info("Subpool accumulated.") 1073 1074 var act Action 1075 var targets []PullRequest 1076 var err error 1077 var errorString string 1078 if len(blocks) > 0 { 1079 act = PoolBlocked 1080 } else { 1081 act, targets, err = c.takeAction(sp, batchPending, successes, pendings, nones, batchMerge) 1082 if err != nil { 1083 errorString = err.Error() 1084 } 1085 if recordableActions[act] { 1086 c.History.Record( 1087 poolKey(sp.org, sp.repo, sp.branch), 1088 string(act), 1089 sp.sha, 1090 errorString, 1091 prMeta(targets...), 1092 ) 1093 } 1094 } 1095 1096 sp.log.WithFields(logrus.Fields{ 1097 "action": string(act), 1098 "targets": prNumbers(targets), 1099 }).Info("Subpool synced.") 1100 tideMetrics.pooledPRs.WithLabelValues(sp.org, sp.repo, sp.branch).Set(float64(len(sp.prs))) 1101 tideMetrics.updateTime.WithLabelValues(sp.org, sp.repo, sp.branch).Set(float64(time.Now().Unix())) 1102 return Pool{ 1103 Org: sp.org, 1104 Repo: sp.repo, 1105 Branch: sp.branch, 1106 1107 SuccessPRs: successes, 1108 PendingPRs: pendings, 1109 MissingPRs: nones, 1110 1111 BatchPending: batchPending, 1112 1113 Action: act, 1114 Target: targets, 1115 Blockers: blocks, 1116 Error: errorString, 1117 }, 1118 err 1119 } 1120 1121 func prMeta(prs ...PullRequest) []history.PRMeta { 1122 var res []history.PRMeta 1123 for _, pr := range prs { 1124 res = append(res, history.PRMeta{ 1125 Num: int(pr.Number), 1126 Author: string(pr.Author.Login), 1127 Title: string(pr.Title), 1128 SHA: string(pr.HeadRefOID), 1129 }) 1130 } 1131 return res 1132 } 1133 1134 func sortPools(pools []Pool) { 1135 sort.Slice(pools, func(i, j int) bool { 1136 if string(pools[i].Org) != string(pools[j].Org) { 1137 return string(pools[i].Org) < string(pools[j].Org) 1138 } 1139 if string(pools[i].Repo) != string(pools[j].Repo) { 1140 return string(pools[i].Repo) < string(pools[j].Repo) 1141 } 1142 return string(pools[i].Branch) < string(pools[j].Branch) 1143 }) 1144 1145 sortPRs := func(prs []PullRequest) { 1146 sort.Slice(prs, func(i, j int) bool { return int(prs[i].Number) < int(prs[j].Number) }) 1147 } 1148 for i := range pools { 1149 sortPRs(pools[i].SuccessPRs) 1150 sortPRs(pools[i].PendingPRs) 1151 sortPRs(pools[i].MissingPRs) 1152 sortPRs(pools[i].BatchPending) 1153 } 1154 } 1155 1156 type subpool struct { 1157 log *logrus.Entry 1158 org string 1159 repo string 1160 branch string 1161 sha string 1162 1163 pjs []kube.ProwJob 1164 prs []PullRequest 1165 1166 cc contextChecker 1167 presubmitContexts map[int]sets.String 1168 } 1169 1170 func poolKey(org, repo, branch string) string { 1171 return fmt.Sprintf("%s/%s:%s", org, repo, branch) 1172 } 1173 1174 // dividePool splits up the list of pull requests and prow jobs into a group 1175 // per repo and branch. It only keeps ProwJobs that match the latest branch. 1176 func (c *Controller) dividePool(pool map[string]PullRequest, pjs []kube.ProwJob) (map[string]*subpool, error) { 1177 sps := make(map[string]*subpool) 1178 for _, pr := range pool { 1179 org := string(pr.Repository.Owner.Login) 1180 repo := string(pr.Repository.Name) 1181 branch := string(pr.BaseRef.Name) 1182 branchRef := string(pr.BaseRef.Prefix) + string(pr.BaseRef.Name) 1183 fn := poolKey(org, repo, branch) 1184 if sps[fn] == nil { 1185 sha, err := c.ghc.GetRef(org, repo, strings.TrimPrefix(branchRef, "refs/")) 1186 if err != nil { 1187 return nil, err 1188 } 1189 sps[fn] = &subpool{ 1190 log: c.logger.WithFields(logrus.Fields{ 1191 "org": org, 1192 "repo": repo, 1193 "branch": branch, 1194 "base-sha": sha, 1195 }), 1196 org: org, 1197 repo: repo, 1198 branch: branch, 1199 sha: sha, 1200 } 1201 } 1202 sps[fn].prs = append(sps[fn].prs, pr) 1203 } 1204 for _, pj := range pjs { 1205 if pj.Spec.Type != kube.PresubmitJob && pj.Spec.Type != kube.BatchJob { 1206 continue 1207 } 1208 fn := poolKey(pj.Spec.Refs.Org, pj.Spec.Refs.Repo, pj.Spec.Refs.BaseRef) 1209 if sps[fn] == nil || pj.Spec.Refs.BaseSHA != sps[fn].sha { 1210 continue 1211 } 1212 sps[fn].pjs = append(sps[fn].pjs, pj) 1213 } 1214 return sps, nil 1215 } 1216 1217 // PullRequest holds graphql data about a PR, including its commits and their contexts. 1218 type PullRequest struct { 1219 Number githubql.Int 1220 Author struct { 1221 Login githubql.String 1222 } 1223 BaseRef struct { 1224 Name githubql.String 1225 Prefix githubql.String 1226 } 1227 HeadRefName githubql.String `graphql:"headRefName"` 1228 HeadRefOID githubql.String `graphql:"headRefOid"` 1229 Mergeable githubql.MergeableState 1230 Repository struct { 1231 Name githubql.String 1232 NameWithOwner githubql.String 1233 Owner struct { 1234 Login githubql.String 1235 } 1236 } 1237 Commits struct { 1238 Nodes []struct { 1239 Commit Commit 1240 } 1241 // Request the 'last' 4 commits hoping that one of them is the logically 'last' 1242 // commit with OID matching HeadRefOID. If we don't find it we have to use an 1243 // additional API token. (see the 'headContexts' func for details) 1244 // We can't raise this too much or we could hit the limit of 50,000 nodes 1245 // per query: https://developer.github.com/v4/guides/resource-limitations/#node-limit 1246 } `graphql:"commits(last: 4)"` 1247 Labels struct { 1248 Nodes []struct { 1249 Name githubql.String 1250 } 1251 } `graphql:"labels(first: 100)"` 1252 Milestone *struct { 1253 Title githubql.String 1254 } 1255 Title githubql.String 1256 } 1257 1258 // Commit holds graphql data about commits and which contexts they have 1259 type Commit struct { 1260 Status struct { 1261 Contexts []Context 1262 } 1263 OID githubql.String `graphql:"oid"` 1264 } 1265 1266 // Context holds graphql response data for github contexts. 1267 type Context struct { 1268 Context githubql.String 1269 Description githubql.String 1270 State githubql.StatusState 1271 } 1272 1273 type searchQuery struct { 1274 RateLimit struct { 1275 Cost githubql.Int 1276 Remaining githubql.Int 1277 } 1278 Search struct { 1279 PageInfo struct { 1280 HasNextPage githubql.Boolean 1281 EndCursor githubql.String 1282 } 1283 IssueCount githubql.Int 1284 Nodes []struct { 1285 PullRequest PullRequest `graphql:"... on PullRequest"` 1286 } 1287 } `graphql:"search(type: ISSUE, first: 100, after: $searchCursor, query: $query)"` 1288 } 1289 1290 func (pr *PullRequest) logFields() logrus.Fields { 1291 return logrus.Fields{ 1292 "org": string(pr.Repository.Owner.Login), 1293 "repo": string(pr.Repository.Name), 1294 "pr": int(pr.Number), 1295 "sha": string(pr.HeadRefOID), 1296 } 1297 } 1298 1299 // headContexts gets the status contexts for the commit with OID == pr.HeadRefOID 1300 // 1301 // First, we try to get this value from the commits we got with the PR query. 1302 // Unfortunately the 'last' commit ordering is determined by author date 1303 // not commit date so if commits are reordered non-chronologically on the PR 1304 // branch the 'last' commit isn't necessarily the logically last commit. 1305 // We list multiple commits with the query to increase our chance of success, 1306 // but if we don't find the head commit we have to ask Github for it 1307 // specifically (this costs an API token). 1308 func headContexts(log *logrus.Entry, ghc githubClient, pr *PullRequest) ([]Context, error) { 1309 for _, node := range pr.Commits.Nodes { 1310 if node.Commit.OID == pr.HeadRefOID { 1311 return node.Commit.Status.Contexts, nil 1312 } 1313 } 1314 // We didn't get the head commit from the query (the commits must not be 1315 // logically ordered) so we need to specifically ask Github for the status 1316 // and coerce it to a graphql type. 1317 org := string(pr.Repository.Owner.Login) 1318 repo := string(pr.Repository.Name) 1319 // Log this event so we can tune the number of commits we list to minimize this. 1320 log.Warnf("'last' %d commits didn't contain logical last commit. Querying Github...", len(pr.Commits.Nodes)) 1321 combined, err := ghc.GetCombinedStatus(org, repo, string(pr.HeadRefOID)) 1322 if err != nil { 1323 return nil, fmt.Errorf("failed to get the combined status: %v", err) 1324 } 1325 contexts := make([]Context, 0, len(combined.Statuses)) 1326 for _, status := range combined.Statuses { 1327 contexts = append( 1328 contexts, 1329 Context{ 1330 Context: githubql.String(status.Context), 1331 Description: githubql.String(status.Description), 1332 State: githubql.StatusState(strings.ToUpper(status.State)), 1333 }, 1334 ) 1335 } 1336 // Add a commit with these contexts to pr for future look ups. 1337 pr.Commits.Nodes = append(pr.Commits.Nodes, 1338 struct{ Commit Commit }{ 1339 Commit: Commit{ 1340 OID: pr.HeadRefOID, 1341 Status: struct{ Contexts []Context }{Contexts: contexts}, 1342 }, 1343 }, 1344 ) 1345 return contexts, nil 1346 } 1347 1348 func orgRepoQueryString(orgs, repos []string, orgExceptions map[string]sets.String) string { 1349 toks := make([]string, 0, len(orgs)) 1350 for _, o := range orgs { 1351 toks = append(toks, fmt.Sprintf("org:\"%s\"", o)) 1352 1353 for _, e := range orgExceptions[o].UnsortedList() { 1354 toks = append(toks, fmt.Sprintf("-repo:\"%s\"", e)) 1355 } 1356 } 1357 for _, r := range repos { 1358 toks = append(toks, fmt.Sprintf("repo:\"%s\"", r)) 1359 } 1360 return strings.Join(toks, " ") 1361 }