github.com/yrj2011/jx-test-infra@v0.0.0-20190529031832-7a2065ee98eb/mungegithub/github/github.go (about) 1 /* 2 Copyright 2015 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 github 18 19 import ( 20 "bytes" 21 "context" 22 "encoding/json" 23 "errors" 24 "fmt" 25 "io/ioutil" 26 "math" 27 "net/http" 28 "net/url" 29 "regexp" 30 "strconv" 31 "strings" 32 "sync" 33 "text/tabwriter" 34 "time" 35 36 "k8s.io/apimachinery/pkg/util/sets" 37 "k8s.io/test-infra/mungegithub/options" 38 39 "github.com/golang/glog" 40 "github.com/google/go-github/github" 41 "github.com/gregjones/httpcache" 42 "github.com/gregjones/httpcache/diskcache" 43 "github.com/peterbourgon/diskv" 44 "golang.org/x/oauth2" 45 ) 46 47 const ( 48 // stolen from https://groups.google.com/forum/#!msg/golang-nuts/a9PitPAHSSU/ziQw1-QHw3EJ 49 maxInt = int(^uint(0) >> 1) 50 tokenLimit = 250 // How many github api tokens to not use 51 52 authenticatedTokenLimit = 5000 53 unauthenticatedTokenLimit = 60 54 55 headerRateRemaining = "X-RateLimit-Remaining" 56 headerRateReset = "X-RateLimit-Reset" 57 58 maxCommentLen = 65535 59 60 ghApproved = "APPROVED" 61 ghChangesRequested = "CHANGES_REQUESTED" 62 ) 63 64 var ( 65 releaseMilestoneRE = regexp.MustCompile(`^v[\d]+.[\d]+$`) 66 priorityLabelRE = regexp.MustCompile(`priority/[pP]([\d]+)`) 67 fixesIssueRE = regexp.MustCompile(`(?i)(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[\s]+#([\d]+)`) 68 reviewableFooterRE = regexp.MustCompile(`(?s)<!-- Reviewable:start -->.*<!-- Reviewable:end -->`) 69 htmlCommentRE = regexp.MustCompile(`(?s)<!--[^<>]*?-->\n?`) 70 maxTime = time.Unix(1<<63-62135596801, 999999999) // http://stackoverflow.com/questions/25065055/what-is-the-maximum-time-time-in-go 71 72 // How long we locally cache the combined status of an object. We will not 73 // hit the github API more than this often (per mungeObject) no matter how 74 // often a caller asks for the status. Ca be much much faster for testing 75 combinedStatusLifetime = 5 * time.Second 76 ) 77 78 func suggestOauthScopes(resp *github.Response, err error) error { 79 if resp != nil && resp.StatusCode == http.StatusForbidden { 80 if oauthScopes := resp.Header.Get("X-Accepted-OAuth-Scopes"); len(oauthScopes) > 0 { 81 err = fmt.Errorf("%v - are you using at least one of the following oauth scopes?: %s", err, oauthScopes) 82 } 83 } 84 return err 85 } 86 87 func stringPtr(val string) *string { return &val } 88 func boolPtr(val bool) *bool { return &val } 89 90 type callLimitRoundTripper struct { 91 sync.Mutex 92 delegate http.RoundTripper 93 remaining int 94 resetTime time.Time 95 } 96 97 func (c *callLimitRoundTripper) getTokenExcept(remaining int) { 98 c.Lock() 99 if c.remaining > remaining { 100 c.remaining-- 101 c.Unlock() 102 return 103 } 104 resetTime := c.resetTime 105 c.Unlock() 106 sleepTime := resetTime.Sub(time.Now()) + (1 * time.Minute) 107 if sleepTime > 0 { 108 glog.Errorf("*****************") 109 glog.Errorf("Ran out of github API tokens. Sleeping for %v minutes", sleepTime.Minutes()) 110 glog.Errorf("*****************") 111 } 112 // negative duration is fine, it means we are past the github api reset and we won't sleep 113 time.Sleep(sleepTime) 114 } 115 116 func (c *callLimitRoundTripper) getToken() { 117 c.getTokenExcept(tokenLimit) 118 } 119 120 func (c *callLimitRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 121 if c.delegate == nil { 122 c.delegate = http.DefaultTransport 123 } 124 c.getToken() 125 resp, err := c.delegate.RoundTrip(req) 126 c.Lock() 127 defer c.Unlock() 128 if resp != nil { 129 if remaining := resp.Header.Get(headerRateRemaining); remaining != "" { 130 c.remaining, _ = strconv.Atoi(remaining) 131 } 132 if reset := resp.Header.Get(headerRateReset); reset != "" { 133 if v, _ := strconv.ParseInt(reset, 10, 64); v != 0 { 134 c.resetTime = time.Unix(v, 0) 135 } 136 } 137 } 138 return resp, err 139 } 140 141 // By default github responds to PR requests with: 142 // Cache-Control:[private, max-age=60, s-maxage=60] 143 // Which means the httpcache would not consider anything stale for 60 seconds. 144 // However, when we re-check 'PR.mergeable' we need to skip the cache. 145 // I considered checking the req.URL.Path and only setting max-age=0 when 146 // getting a PR or getting the CombinedStatus, as these are the times we need 147 // a super fresh copy. But since all of the other calls are only going to be made 148 // once per poll loop the 60 second github freshness doesn't matter. So I can't 149 // think of a reason not to just keep this simple and always set max-age=0 on 150 // every request. 151 type zeroCacheRoundTripper struct { 152 delegate http.RoundTripper 153 } 154 155 func (r *zeroCacheRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 156 req.Header.Set("Cache-Control", "max-age=0") 157 delegate := r.delegate 158 if delegate == nil { 159 delegate = http.DefaultTransport 160 } 161 return delegate.RoundTrip(req) 162 } 163 164 // Config is how we are configured to talk to github and provides access 165 // methods for doing so. 166 type Config struct { 167 client *github.Client 168 apiLimit *callLimitRoundTripper 169 170 // BotName is the login for the authenticated user 171 BotName string 172 173 Org string 174 Project string 175 Url string 176 mergeMethod string 177 178 // Filters used when munging issues 179 State string 180 Labels []string 181 182 // token is private so it won't get printed in the logs. 183 token string 184 tokenFile string 185 tokenInUse string 186 187 httpCache httpcache.Cache 188 HTTPCacheDir string 189 HTTPCacheSize uint64 190 191 MinPRNumber int 192 MaxPRNumber int 193 194 // If true, don't make any mutating API calls 195 DryRun bool 196 197 // Base sleep time for retry loops. Defaults to 1 second. 198 BaseWaitTime time.Duration 199 200 // When we clear analytics we store the last values here 201 lastAnalytics analytics 202 analytics analytics 203 204 // Webhook configuration 205 HookHandler *WebHook 206 207 // Last fetch 208 since time.Time 209 } 210 211 type analytic struct { 212 Count int 213 CachedCount int 214 } 215 216 func (a *analytic) Call(config *Config, response *github.Response) { 217 if response != nil && response.Response.Header.Get(httpcache.XFromCache) != "" { 218 config.analytics.cachedAPICount++ 219 a.CachedCount++ 220 } 221 config.analytics.apiCount++ 222 a.Count++ 223 } 224 225 type analytics struct { 226 lastAPIReset time.Time 227 nextAnalyticUpdate time.Time // when we expect the next update 228 apiCount int // number of times we called a github API 229 cachedAPICount int // how many api calls were answered by the local cache 230 apiPerSec float64 231 232 AddLabels analytic 233 AddLabelToRepository analytic 234 RemoveLabels analytic 235 ListCollaborators analytic 236 GetIssue analytic 237 CloseIssue analytic 238 CreateIssue analytic 239 ListIssues analytic 240 ListIssueEvents analytic 241 ListCommits analytic 242 ListLabels analytic 243 GetCommit analytic 244 ListFiles analytic 245 GetCombinedStatus analytic 246 SetStatus analytic 247 GetPR analytic 248 AddAssignee analytic 249 RemoveAssignees analytic 250 ClosePR analytic 251 OpenPR analytic 252 GetContents analytic 253 ListComments analytic 254 ListReviewComments analytic 255 CreateComment analytic 256 DeleteComment analytic 257 EditComment analytic 258 Merge analytic 259 GetUser analytic 260 ClearMilestone analytic 261 SetMilestone analytic 262 ListMilestones analytic 263 GetBranch analytic 264 UpdateBranchProtection analytic 265 GetBranchProtection analytic 266 ListReviews analytic 267 } 268 269 func (a analytics) print() { 270 glog.Infof("Made %d API calls since the last Reset %f calls/sec", a.apiCount, a.apiPerSec) 271 272 buf := new(bytes.Buffer) 273 w := new(tabwriter.Writer) 274 w.Init(buf, 0, 0, 1, ' ', tabwriter.AlignRight) 275 fmt.Fprintf(w, "AddLabels\t%d\t\n", a.AddLabels.Count) 276 fmt.Fprintf(w, "AddLabelToRepository\t%d\t\n", a.AddLabelToRepository.Count) 277 fmt.Fprintf(w, "RemoveLabels\t%d\t\n", a.RemoveLabels.Count) 278 fmt.Fprintf(w, "ListCollaborators\t%d\t\n", a.ListCollaborators.Count) 279 fmt.Fprintf(w, "GetIssue\t%d\t\n", a.GetIssue.Count) 280 fmt.Fprintf(w, "CloseIssue\t%d\t\n", a.CloseIssue.Count) 281 fmt.Fprintf(w, "CreateIssue\t%d\t\n", a.CreateIssue.Count) 282 fmt.Fprintf(w, "ListIssues\t%d\t\n", a.ListIssues.Count) 283 fmt.Fprintf(w, "ListIssueEvents\t%d\t\n", a.ListIssueEvents.Count) 284 fmt.Fprintf(w, "ListCommits\t%d\t\n", a.ListCommits.Count) 285 fmt.Fprintf(w, "ListLabels\t%d\t\n", a.ListLabels.Count) 286 fmt.Fprintf(w, "GetCommit\t%d\t\n", a.GetCommit.Count) 287 fmt.Fprintf(w, "ListFiles\t%d\t\n", a.ListFiles.Count) 288 fmt.Fprintf(w, "GetCombinedStatus\t%d\t\n", a.GetCombinedStatus.Count) 289 fmt.Fprintf(w, "SetStatus\t%d\t\n", a.SetStatus.Count) 290 fmt.Fprintf(w, "GetPR\t%d\t\n", a.GetPR.Count) 291 fmt.Fprintf(w, "AddAssignee\t%d\t\n", a.AddAssignee.Count) 292 fmt.Fprintf(w, "ClosePR\t%d\t\n", a.ClosePR.Count) 293 fmt.Fprintf(w, "OpenPR\t%d\t\n", a.OpenPR.Count) 294 fmt.Fprintf(w, "GetContents\t%d\t\n", a.GetContents.Count) 295 fmt.Fprintf(w, "ListReviewComments\t%d\t\n", a.ListReviewComments.Count) 296 fmt.Fprintf(w, "ListComments\t%d\t\n", a.ListComments.Count) 297 fmt.Fprintf(w, "CreateComment\t%d\t\n", a.CreateComment.Count) 298 fmt.Fprintf(w, "DeleteComment\t%d\t\n", a.DeleteComment.Count) 299 fmt.Fprintf(w, "Merge\t%d\t\n", a.Merge.Count) 300 fmt.Fprintf(w, "GetUser\t%d\t\n", a.GetUser.Count) 301 fmt.Fprintf(w, "ClearMilestone\t%d\t\n", a.ClearMilestone.Count) 302 fmt.Fprintf(w, "SetMilestone\t%d\t\n", a.SetMilestone.Count) 303 fmt.Fprintf(w, "ListMilestones\t%d\t\n", a.ListMilestones.Count) 304 fmt.Fprintf(w, "GetBranch\t%d\t\n", a.GetBranch.Count) 305 fmt.Fprintf(w, "UpdateBranchProtection\t%d\t\n", a.UpdateBranchProtection.Count) 306 fmt.Fprintf(w, "GetBranchProctection\t%d\t\n", a.GetBranchProtection.Count) 307 fmt.Fprintf(w, "ListReviews\t%d\t\n", a.ListReviews.Count) 308 w.Flush() 309 glog.V(2).Infof("\n%v", buf) 310 } 311 312 // MungeObject is the object that mungers deal with. It is a combination of 313 // different github API objects. 314 type MungeObject struct { 315 config *Config 316 Issue *github.Issue 317 pr *github.PullRequest 318 commits []*github.RepositoryCommit 319 events []*github.IssueEvent 320 comments []*github.IssueComment 321 prComments []*github.PullRequestComment 322 prReviews []*github.PullRequestReview 323 commitFiles []*github.CommitFile 324 325 // we cache the combinedStatus for `combinedStatusLifetime` seconds. 326 combinedStatus *github.CombinedStatus 327 combinedStatusTime time.Time 328 329 Annotations map[string]string //annotations are things you can set yourself. 330 } 331 332 // Number is short for *obj.Issue.Number. 333 func (obj *MungeObject) Number() int { 334 return *obj.Issue.Number 335 } 336 337 // Project is getter for obj.config.Project. 338 func (obj *MungeObject) Project() string { 339 return obj.config.Project 340 } 341 342 // Org is getter for obj.config.Org. 343 func (obj *MungeObject) Org() string { 344 return obj.config.Org 345 } 346 347 // Config is a getter for obj.config. 348 func (obj *MungeObject) Config() *Config { 349 return obj.config 350 } 351 352 // IsRobot determines if the user is the robot running the munger 353 func (obj *MungeObject) IsRobot(user *github.User) bool { 354 return *user.Login == obj.config.BotName 355 } 356 357 // DebugStats is a structure that tells information about how we have interacted 358 // with github 359 type DebugStats struct { 360 Analytics analytics 361 APIPerSec float64 362 APICount int 363 CachedAPICount int 364 NextLoopTime time.Time 365 LimitRemaining int 366 LimitResetTime time.Time 367 } 368 369 // NewTestObject should NEVER be used outside of _test.go code. It creates a 370 // MungeObject with the given fields. Normally these should be filled in lazily 371 // as needed 372 func NewTestObject(config *Config, issue *github.Issue, pr *github.PullRequest, commits []*github.RepositoryCommit, events []*github.IssueEvent) *MungeObject { 373 return &MungeObject{ 374 config: config, 375 Issue: issue, 376 pr: pr, 377 commits: commits, 378 events: events, 379 Annotations: map[string]string{}, 380 } 381 } 382 383 // SetCombinedStatusLifetime will set the lifetime of CombinedStatus responses. 384 // Even though we would likely use conditional API calls hitting the CombinedStatus API 385 // every time we want to get a specific value is just too mean to github. This defaults 386 // to `combinedStatusLifetime` seconds. If you are doing local testing you may want to make 387 // this (much) shorter 388 func SetCombinedStatusLifetime(lifetime time.Duration) { 389 combinedStatusLifetime = lifetime 390 } 391 392 // RegisterOptions registers options for the github client and returns any that require a restart 393 // if they are changed. 394 func (config *Config) RegisterOptions(opts *options.Options) sets.String { 395 opts.RegisterString(&config.Org, "organization", "", "The github organization to scan") 396 opts.RegisterString(&config.Project, "project", "", "The github project to scan") 397 398 opts.RegisterString(&config.token, "token", "", "The OAuth Token to use for requests.") 399 opts.RegisterString(&config.tokenFile, "token-file", "", "The file containing the OAuth token to use for requests.") 400 opts.RegisterInt(&config.MinPRNumber, "min-pr-number", 0, "The minimum PR to start with") 401 opts.RegisterInt(&config.MaxPRNumber, "max-pr-number", maxInt, "The maximum PR to start with") 402 opts.RegisterString(&config.State, "state", "", "State of PRs to process: 'open', 'all', etc") 403 opts.RegisterStringSlice(&config.Labels, "labels", []string{}, "CSV list of label which should be set on processed PRs. Unset is all labels.") 404 opts.RegisterString(&config.HTTPCacheDir, "http-cache-dir", "", "Path to directory where github data can be cached across restarts, if unset use in memory cache") 405 opts.RegisterUint64(&config.HTTPCacheSize, "http-cache-size", 1000, "Maximum size for the HTTP cache (in MB)") 406 opts.RegisterString(&config.Url, "url", "", "The GitHub Enterprise server url (default: https://api.github.com/)") 407 opts.RegisterString(&config.mergeMethod, "merge-method", "merge", "The merge method to use: merge/squash/rebase") 408 409 return sets.NewString("token", "token-file", "min-pr-number", "max-pr-number", "state", "labels", "http-cache-dir", "http-cache-size", "url") 410 } 411 412 // Token returns the token. 413 func (config *Config) Token() string { 414 return config.tokenInUse 415 } 416 417 // PreExecute will initialize the Config. It MUST be run before the config 418 // may be used to get information from Github. 419 func (config *Config) PreExecute() error { 420 if len(config.Org) == 0 { 421 glog.Fatalf("The '%s' option is required.", "organization") 422 } 423 if len(config.Project) == 0 { 424 glog.Fatalf("The '%s' option is required.", "project") 425 } 426 427 token := config.token 428 if len(token) == 0 && len(config.tokenFile) != 0 { 429 data, err := ioutil.ReadFile(config.tokenFile) 430 if err != nil { 431 glog.Fatalf("error reading token file: %v", err) 432 } 433 token = strings.TrimSpace(string(data)) 434 config.tokenInUse = token 435 } 436 437 // We need to get our Transport/RoundTripper in order based on arguments 438 // oauth2 Transport // if we have an auth token 439 // zeroCacheRoundTripper // if we are using the cache want faster timeouts 440 // webCacheRoundTripper // if we are using the cache 441 // callLimitRoundTripper ** always 442 // [http.DefaultTransport] ** always implicit 443 444 var transport http.RoundTripper 445 446 callLimitTransport := &callLimitRoundTripper{ 447 remaining: tokenLimit + 500, // put in 500 so we at least have a couple to check our real limits 448 resetTime: time.Now().Add(1 * time.Minute), 449 } 450 config.apiLimit = callLimitTransport 451 transport = callLimitTransport 452 453 var t *httpcache.Transport 454 if config.HTTPCacheDir != "" { 455 maxBytes := config.HTTPCacheSize * 1000000 // convert M to B. This is storage so not base 2... 456 d := diskv.New(diskv.Options{ 457 BasePath: config.HTTPCacheDir, 458 CacheSizeMax: maxBytes, 459 }) 460 cache := diskcache.NewWithDiskv(d) 461 t = httpcache.NewTransport(cache) 462 config.httpCache = cache 463 } else { 464 cache := httpcache.NewMemoryCache() 465 t = httpcache.NewTransport(cache) 466 config.httpCache = cache 467 } 468 t.Transport = transport 469 470 zeroCacheTransport := &zeroCacheRoundTripper{ 471 delegate: t, 472 } 473 474 transport = zeroCacheTransport 475 476 var tokenObj *oauth2.Token 477 if len(token) > 0 { 478 tokenObj = &oauth2.Token{AccessToken: token} 479 } 480 if tokenObj != nil { 481 ts := oauth2.StaticTokenSource(tokenObj) 482 transport = &oauth2.Transport{ 483 Base: transport, 484 Source: oauth2.ReuseTokenSource(nil, ts), 485 } 486 } 487 488 client := &http.Client{ 489 Transport: transport, 490 } 491 config.client = github.NewClient(client) 492 if len(config.Url) > 0 { 493 url, err := url.Parse(config.Url) 494 if err != nil { 495 glog.Fatalf("Unable to parse url: %v: %v", config.Url, err) 496 } 497 config.client.BaseURL = url 498 } 499 config.ResetAPICount() 500 501 // passing an empty username returns information 502 // about the currently authenticated user 503 username := "" 504 user, _, err := config.client.Users.Get(context.Background(), username) 505 if err != nil { 506 return fmt.Errorf("failed to retrieve currently authenticatd user: %v", err) 507 } else if user == nil { 508 return errors.New("failed to retrieve currently authenticatd user: got nil result") 509 } else if user.Login == nil { 510 return errors.New("failed to retrieve currently authenticatd user: got nil result for user login") 511 } 512 config.BotName = *user.Login 513 514 return nil 515 } 516 517 // GetDebugStats returns information about the bot iself. Things like how many 518 // API calls has it made, how many of each type, etc. 519 func (config *Config) GetDebugStats() DebugStats { 520 d := DebugStats{ 521 Analytics: config.lastAnalytics, 522 APIPerSec: config.lastAnalytics.apiPerSec, 523 APICount: config.lastAnalytics.apiCount, 524 CachedAPICount: config.lastAnalytics.cachedAPICount, 525 NextLoopTime: config.lastAnalytics.nextAnalyticUpdate, 526 } 527 config.apiLimit.Lock() 528 defer config.apiLimit.Unlock() 529 d.LimitRemaining = config.apiLimit.remaining 530 d.LimitResetTime = config.apiLimit.resetTime 531 return d 532 } 533 534 func (config *Config) serveDebugStats(res http.ResponseWriter, req *http.Request) { 535 stats := config.GetDebugStats() 536 b, err := json.Marshal(stats) 537 if err != nil { 538 glog.Errorf("Unable to Marshal Status: %v: %v", stats, err) 539 res.Header().Set("Content-type", "text/plain") 540 res.WriteHeader(http.StatusInternalServerError) 541 return 542 } 543 res.Header().Set("Content-type", "application/json") 544 res.WriteHeader(http.StatusOK) 545 res.Write(b) 546 } 547 548 // ServeDebugStats will serve out debug information at the path 549 func (config *Config) ServeDebugStats(path string) { 550 http.HandleFunc(path, config.serveDebugStats) 551 } 552 553 // NextExpectedUpdate will set the debug information concerning when the 554 // mungers are likely to run again. 555 func (config *Config) NextExpectedUpdate(t time.Time) { 556 config.analytics.nextAnalyticUpdate = t 557 } 558 559 // ResetAPICount will both reset the counters of how many api calls have been 560 // made but will also print the information from the last run. 561 func (config *Config) ResetAPICount() { 562 since := time.Since(config.analytics.lastAPIReset) 563 config.analytics.apiPerSec = float64(config.analytics.apiCount) / since.Seconds() 564 config.lastAnalytics = config.analytics 565 config.analytics.print() 566 567 config.analytics = analytics{} 568 config.analytics.lastAPIReset = time.Now() 569 } 570 571 // SetClient should ONLY be used by testing. Normal commands should use PreExecute() 572 func (config *Config) SetClient(client *github.Client) { 573 config.client = client 574 } 575 576 func (config *Config) getPR(num int) (*github.PullRequest, error) { 577 pr, response, err := config.client.PullRequests.Get( 578 context.Background(), 579 config.Org, 580 config.Project, 581 num, 582 ) 583 config.analytics.GetPR.Call(config, response) 584 if err != nil { 585 err = suggestOauthScopes(response, err) 586 glog.Errorf("Error getting PR# %d: %v", num, err) 587 return nil, err 588 } 589 return pr, nil 590 } 591 592 func (config *Config) getIssue(num int) (*github.Issue, error) { 593 issue, resp, err := config.client.Issues.Get( 594 context.Background(), 595 config.Org, 596 config.Project, 597 num, 598 ) 599 config.analytics.GetIssue.Call(config, resp) 600 if err != nil { 601 err = suggestOauthScopes(resp, err) 602 glog.Errorf("getIssue: %v", err) 603 return nil, err 604 } 605 return issue, nil 606 } 607 608 func (config *Config) deleteCache(resp *github.Response) { 609 cache := config.httpCache 610 if cache == nil { 611 return 612 } 613 if resp.Response == nil { 614 return 615 } 616 req := resp.Response.Request 617 if req == nil { 618 return 619 } 620 cacheKey := req.URL.String() 621 glog.Infof("Deleting cache entry for %q", cacheKey) 622 cache.Delete(cacheKey) 623 } 624 625 // protects a branch and sets the required contexts 626 func (config *Config) setBranchProtection(name string, request *github.ProtectionRequest) error { 627 glog.Infof("Setting protections for branch: %s", name) 628 config.analytics.UpdateBranchProtection.Call(config, nil) 629 if config.DryRun { 630 return nil 631 } 632 _, resp, err := config.client.Repositories.UpdateBranchProtection( 633 context.Background(), 634 config.Org, 635 config.Project, 636 name, 637 request, 638 ) 639 if err != nil { 640 err = suggestOauthScopes(resp, err) 641 glog.Errorf("Unable to set branch protections for %s: %v", name, err) 642 return err 643 } 644 return nil 645 } 646 647 // needsBranchProtection returns false if the branch is protected by exactly the set of 648 // contexts in the argument, otherwise returns true. 649 func (config *Config) needsBranchProtection(prot *github.Protection, contexts []string) bool { 650 if prot == nil { 651 if len(contexts) == 0 { 652 return false 653 } 654 glog.Infof("Setting branch protections because Protection is nil or disabled.") 655 return true 656 } 657 if prot.RequiredStatusChecks == nil { 658 glog.Infof("Setting branch protections because branch.Protection.RequiredStatusChecks is nil") 659 return true 660 } 661 if prot.RequiredStatusChecks.Contexts == nil { 662 glog.Infof("Setting branch protections because Protection.RequiredStatusChecks.Contexts is wrong") 663 return true 664 } 665 if prot.RequiredStatusChecks.Strict { 666 glog.Infof("Setting branch protections because Protection.RequiredStatusChecks.Strict is wrong") 667 return true 668 } 669 if prot.EnforceAdmins == nil || prot.EnforceAdmins.Enabled { 670 glog.Infof("Setting branch protections because Protection.EnforceAdmins.Enabled is wrong") 671 return true 672 } 673 branchContexts := prot.RequiredStatusChecks.Contexts 674 675 oldSet := sets.NewString(branchContexts...) 676 newSet := sets.NewString(contexts...) 677 if !oldSet.Equal(newSet) { 678 glog.Infof("Updating branch protections old: %v new:%v", oldSet.List(), newSet.List()) 679 return true 680 } 681 return false 682 } 683 684 // SetBranchProtection protects a branch and sets the required contexts 685 func (config *Config) SetBranchProtection(name string, contexts []string) error { 686 branch, resp, err := config.client.Repositories.GetBranch( 687 context.Background(), 688 config.Org, 689 config.Project, 690 name, 691 ) 692 config.analytics.GetBranch.Call(config, resp) 693 if err != nil { 694 err = suggestOauthScopes(resp, err) 695 glog.Errorf("Failed to get the branch '%s': %v\n", name, err) 696 return err 697 } 698 var prot *github.Protection 699 if branch != nil && branch.Protected != nil && *branch.Protected { 700 prot, resp, err = config.client.Repositories.GetBranchProtection( 701 context.Background(), 702 config.Org, 703 config.Project, 704 name, 705 ) 706 config.analytics.GetBranchProtection.Call(config, resp) 707 if err != nil { 708 err = suggestOauthScopes(resp, err) 709 glog.Errorf("Got error getting branch protection for branch %s: %v", name, err) 710 return err 711 } 712 } 713 714 if !config.needsBranchProtection(prot, contexts) { 715 return nil 716 } 717 718 request := &github.ProtectionRequest{ 719 RequiredStatusChecks: &github.RequiredStatusChecks{ 720 Strict: false, 721 Contexts: contexts, 722 }, 723 RequiredPullRequestReviews: prot.RequiredPullRequestReviews, 724 Restrictions: unchangedRestrictionRequest(prot.Restrictions), 725 EnforceAdmins: false, 726 } 727 return config.setBranchProtection(name, request) 728 } 729 730 // unchangedRestrictionRequest generates a request that will 731 // not make any changes to the teams and users that can merge 732 // into a branch 733 func unchangedRestrictionRequest(restrictions *github.BranchRestrictions) *github.BranchRestrictionsRequest { 734 if restrictions == nil { 735 return nil 736 } 737 738 request := &github.BranchRestrictionsRequest{ 739 Users: []string{}, 740 Teams: []string{}, 741 } 742 743 if restrictions.Users != nil { 744 for _, user := range restrictions.Users { 745 request.Users = append(request.Users, *user.Login) 746 } 747 } 748 if restrictions.Teams != nil { 749 for _, team := range restrictions.Teams { 750 request.Teams = append(request.Teams, *team.Name) 751 } 752 } 753 return request 754 } 755 756 // Refresh will refresh the Issue (and PR if this is a PR) 757 // (not the commits or events) 758 func (obj *MungeObject) Refresh() bool { 759 num := *obj.Issue.Number 760 issue, err := obj.config.getIssue(num) 761 if err != nil { 762 glog.Errorf("Error in Refresh: %v", err) 763 return false 764 } 765 obj.Issue = issue 766 if !obj.IsPR() { 767 return true 768 } 769 pr, err := obj.config.getPR(*obj.Issue.Number) 770 if err != nil { 771 return false 772 } 773 obj.pr = pr 774 return true 775 } 776 777 // ListMilestones will return all milestones of the given `state` 778 func (config *Config) ListMilestones(state string) ([]*github.Milestone, bool) { 779 listopts := github.MilestoneListOptions{ 780 State: state, 781 } 782 milestones, resp, err := config.client.Issues.ListMilestones( 783 context.Background(), 784 config.Org, 785 config.Project, 786 &listopts, 787 ) 788 config.analytics.ListMilestones.Call(config, resp) 789 if err != nil { 790 glog.Errorf("Error getting milestones of state %q: %v", state, suggestOauthScopes(resp, err)) 791 return milestones, false 792 } 793 return milestones, true 794 } 795 796 // GetObject will return an object (with only the issue filled in) 797 func (config *Config) GetObject(num int) (*MungeObject, error) { 798 issue, err := config.getIssue(num) 799 if err != nil { 800 return nil, err 801 } 802 obj := &MungeObject{ 803 config: config, 804 Issue: issue, 805 Annotations: map[string]string{}, 806 } 807 return obj, nil 808 } 809 810 // NewIssue will file a new issue and return an object for it. 811 // If "owners" is not empty, the issue will be assigned to the owners. 812 func (config *Config) NewIssue(title, body string, labels []string, owners []string) (*MungeObject, error) { 813 config.analytics.CreateIssue.Call(config, nil) 814 glog.Infof("Creating an issue: %q", title) 815 if config.DryRun { 816 return nil, fmt.Errorf("can't make issues in dry-run mode") 817 } 818 if len(owners) == 0 { 819 owners = []string{} 820 } 821 if len(body) > maxCommentLen { 822 body = body[:maxCommentLen] 823 } 824 825 issue, resp, err := config.client.Issues.Create( 826 context.Background(), 827 config.Org, 828 config.Project, 829 &github.IssueRequest{ 830 Title: &title, 831 Body: &body, 832 Labels: &labels, 833 Assignees: &owners, 834 }, 835 ) 836 if err != nil { 837 err = suggestOauthScopes(resp, err) 838 glog.Errorf("createIssue: %v", err) 839 return nil, err 840 } 841 obj := &MungeObject{ 842 config: config, 843 Issue: issue, 844 Annotations: map[string]string{}, 845 } 846 return obj, nil 847 } 848 849 // GetBranchCommits gets recent commits for the given branch. 850 func (config *Config) GetBranchCommits(branch string, limit int) ([]*github.RepositoryCommit, error) { 851 commits := []*github.RepositoryCommit{} 852 page := 0 853 for { 854 commitsPage, response, err := config.client.Repositories.ListCommits( 855 context.Background(), 856 config.Org, 857 config.Project, 858 &github.CommitsListOptions{ 859 ListOptions: github.ListOptions{PerPage: 100, Page: page}, 860 SHA: branch, 861 }, 862 ) 863 config.analytics.ListCommits.Call(config, response) 864 if err != nil { 865 err = suggestOauthScopes(response, err) 866 glog.Errorf("Error reading commits for branch %s: %v", branch, err) 867 return nil, err 868 } 869 commits = append(commits, commitsPage...) 870 if response.LastPage == 0 || response.LastPage <= page || len(commits) > limit { 871 break 872 } 873 page++ 874 } 875 return commits, nil 876 } 877 878 // GetTokenUsage returns the api token usage of the current github user. 879 func (config *Config) GetTokenUsage() int { 880 config.apiLimit.Lock() 881 remaining := config.apiLimit.remaining 882 config.apiLimit.Unlock() 883 884 if config.tokenInUse != "" { 885 return authenticatedTokenLimit - remaining 886 } 887 return unauthenticatedTokenLimit - remaining 888 } 889 890 // Branch returns the branch the PR is for. Return "" if this is not a PR or 891 // it does not have the required information. 892 func (obj *MungeObject) Branch() (string, bool) { 893 pr, ok := obj.GetPR() 894 if !ok { 895 return "", ok 896 } 897 if pr.Base != nil && pr.Base.Ref != nil { 898 return *pr.Base.Ref, ok 899 } 900 return "", ok 901 } 902 903 // IsForBranch return true if the object is a PR for a branch with the given 904 // name. It return false if it is not a pr, it isn't against the given branch, 905 // or we can't tell 906 func (obj *MungeObject) IsForBranch(branch string) (bool, bool) { 907 objBranch, ok := obj.Branch() 908 if !ok { 909 return false, ok 910 } 911 if objBranch == branch { 912 return true, ok 913 } 914 return false, ok 915 } 916 917 // LastModifiedTime returns the time the last commit was made 918 // BUG: this should probably return the last time a git push happened or something like that. 919 func (obj *MungeObject) LastModifiedTime() (*time.Time, bool) { 920 var lastModified *time.Time 921 commits, ok := obj.GetCommits() 922 if !ok { 923 glog.Errorf("Error in LastModifiedTime, unable to get commits") 924 return lastModified, ok 925 } 926 for _, commit := range commits { 927 if commit.Commit == nil || commit.Commit.Committer == nil || commit.Commit.Committer.Date == nil { 928 glog.Errorf("PR %d: Found invalid RepositoryCommit: %v", *obj.Issue.Number, commit) 929 continue 930 } 931 if lastModified == nil || commit.Commit.Committer.Date.After(*lastModified) { 932 lastModified = commit.Commit.Committer.Date 933 } 934 } 935 return lastModified, true 936 } 937 938 // FirstLabelTime returns the first time the request label was added to an issue. 939 // If the label was never added you will get a nil time. 940 func (obj *MungeObject) FirstLabelTime(label string) *time.Time { 941 event := obj.labelEvent(label, firstTime) 942 if event == nil { 943 return nil 944 } 945 return event.CreatedAt 946 } 947 948 // Return true if 'a' is preferable to 'b'. Handle nil times! 949 type timePred func(a, b *time.Time) bool 950 951 func firstTime(a, b *time.Time) bool { 952 if a == nil { 953 return false 954 } 955 if b == nil { 956 return true 957 } 958 return !a.After(*b) 959 } 960 961 func lastTime(a, b *time.Time) bool { 962 if a == nil { 963 return false 964 } 965 if b == nil { 966 return true 967 } 968 return a.After(*b) 969 } 970 971 // labelEvent returns the event where the given label was added to an issue. 972 // 'pred' is used to select which label event is chosen if there are multiple. 973 func (obj *MungeObject) labelEvent(label string, pred timePred) *github.IssueEvent { 974 var labelTime *time.Time 975 var out *github.IssueEvent 976 events, ok := obj.GetEvents() 977 if !ok { 978 return nil 979 } 980 for _, event := range events { 981 if *event.Event == "labeled" && *event.Label.Name == label { 982 if pred(event.CreatedAt, labelTime) { 983 labelTime = event.CreatedAt 984 out = event 985 } 986 } 987 } 988 return out 989 } 990 991 // LabelTime returns the last time the request label was added to an issue. 992 // If the label was never added you will get a nil time. 993 func (obj *MungeObject) LabelTime(label string) (*time.Time, bool) { 994 event := obj.labelEvent(label, lastTime) 995 if event == nil { 996 glog.Errorf("Error in LabelTime, received nil event value") 997 return nil, false 998 } 999 return event.CreatedAt, true 1000 } 1001 1002 // LabelCreator returns the user who (last) created the given label 1003 func (obj *MungeObject) LabelCreator(label string) (*github.User, bool) { 1004 event := obj.labelEvent(label, lastTime) 1005 if event == nil || event.Actor == nil || event.Actor.Login == nil { 1006 glog.Errorf("Error in LabelCreator, received nil event value") 1007 return nil, false 1008 } 1009 return event.Actor, true 1010 } 1011 1012 // HasLabel returns if the label `name` is in the array of `labels` 1013 func (obj *MungeObject) HasLabel(name string) bool { 1014 labels := obj.Issue.Labels 1015 for i := range labels { 1016 label := &labels[i] 1017 if label.Name != nil && *label.Name == name { 1018 return true 1019 } 1020 } 1021 return false 1022 } 1023 1024 // HasLabels returns if all of the label `names` are in the array of `labels` 1025 func (obj *MungeObject) HasLabels(names []string) bool { 1026 for i := range names { 1027 if !obj.HasLabel(names[i]) { 1028 return false 1029 } 1030 } 1031 return true 1032 } 1033 1034 // LabelSet returns the name of all of the labels applied to the object as a 1035 // kubernetes string set. 1036 func (obj *MungeObject) LabelSet() sets.String { 1037 out := sets.NewString() 1038 for _, label := range obj.Issue.Labels { 1039 out.Insert(*label.Name) 1040 } 1041 return out 1042 } 1043 1044 // GetLabelsWithPrefix will return a slice of all label names in `labels` which 1045 // start with given prefix. 1046 func GetLabelsWithPrefix(labels []github.Label, prefix string) []string { 1047 var ret []string 1048 for _, label := range labels { 1049 if label.Name != nil && strings.HasPrefix(*label.Name, prefix) { 1050 ret = append(ret, *label.Name) 1051 } 1052 } 1053 return ret 1054 } 1055 1056 // AddLabel adds a single `label` to the issue 1057 func (obj *MungeObject) AddLabel(label string) error { 1058 return obj.AddLabels([]string{label}) 1059 } 1060 1061 // AddLabels will add all of the named `labels` to the issue 1062 func (obj *MungeObject) AddLabels(labels []string) error { 1063 config := obj.config 1064 prNum := *obj.Issue.Number 1065 config.analytics.AddLabels.Call(config, nil) 1066 glog.Infof("Adding labels %v to PR %d", labels, prNum) 1067 if len(labels) == 0 { 1068 glog.Info("No labels to add: quitting") 1069 return nil 1070 } 1071 1072 if config.DryRun { 1073 return nil 1074 } 1075 for _, l := range labels { 1076 label := github.Label{ 1077 Name: &l, 1078 } 1079 obj.Issue.Labels = append(obj.Issue.Labels, label) 1080 } 1081 _, resp, err := config.client.Issues.AddLabelsToIssue( 1082 context.Background(), 1083 obj.Org(), 1084 obj.Project(), 1085 prNum, 1086 labels, 1087 ) 1088 if err != nil { 1089 glog.Errorf("Failed to set labels %v for PR %d: %v", labels, prNum, suggestOauthScopes(resp, err)) 1090 return err 1091 } 1092 return nil 1093 } 1094 1095 // RemoveLabel will remove the `label` from the PR 1096 func (obj *MungeObject) RemoveLabel(label string) error { 1097 config := obj.config 1098 prNum := *obj.Issue.Number 1099 1100 which := -1 1101 for i, l := range obj.Issue.Labels { 1102 if l.Name != nil && *l.Name == label { 1103 which = i 1104 break 1105 } 1106 } 1107 if which != -1 { 1108 // We do this crazy delete since users might be iterating over `range obj.Issue.Labels` 1109 // Make a completely new copy and leave their ranging alone. 1110 temp := make([]github.Label, len(obj.Issue.Labels)-1) 1111 copy(temp, obj.Issue.Labels[:which]) 1112 copy(temp[which:], obj.Issue.Labels[which+1:]) 1113 obj.Issue.Labels = temp 1114 } 1115 1116 config.analytics.RemoveLabels.Call(config, nil) 1117 glog.Infof("Removing label %q to PR %d", label, prNum) 1118 if config.DryRun { 1119 return nil 1120 } 1121 resp, err := config.client.Issues.RemoveLabelForIssue( 1122 context.Background(), 1123 obj.Org(), 1124 obj.Project(), 1125 prNum, 1126 label, 1127 ) 1128 if err != nil { 1129 glog.Errorf("Failed to remove %v from issue %d: %v", label, prNum, suggestOauthScopes(resp, err)) 1130 return err 1131 } 1132 return nil 1133 } 1134 1135 // ModifiedAfterLabeled returns true if the PR was updated after the last time the 1136 // label was applied. 1137 func (obj *MungeObject) ModifiedAfterLabeled(label string) (after bool, ok bool) { 1138 labelTime, ok := obj.LabelTime(label) 1139 if !ok || labelTime == nil { 1140 glog.Errorf("Unable to find label time for: %q on %d", label, obj.Number()) 1141 return false, false 1142 } 1143 lastModifiedTime, ok := obj.LastModifiedTime() 1144 if !ok || lastModifiedTime == nil { 1145 glog.Errorf("Unable to find last modification time for %d", obj.Number()) 1146 return false, false 1147 } 1148 after = lastModifiedTime.After(*labelTime) 1149 return after, true 1150 } 1151 1152 // GetHeadAndBase returns the head SHA and the base ref, so that you can get 1153 // the base's sha in a second step. Purpose: if head and base SHA are the same 1154 // across two merge attempts, we don't need to rerun tests. 1155 func (obj *MungeObject) GetHeadAndBase() (headSHA, baseRef string, ok bool) { 1156 pr, ok := obj.GetPR() 1157 if !ok { 1158 return "", "", false 1159 } 1160 if pr.Head == nil || pr.Head.SHA == nil { 1161 return "", "", false 1162 } 1163 headSHA = *pr.Head.SHA 1164 if pr.Base == nil || pr.Base.Ref == nil { 1165 return "", "", false 1166 } 1167 baseRef = *pr.Base.Ref 1168 return headSHA, baseRef, true 1169 } 1170 1171 // GetSHAFromRef returns the current SHA of the given ref (i.e., branch). 1172 func (obj *MungeObject) GetSHAFromRef(ref string) (sha string, ok bool) { 1173 commit, response, err := obj.config.client.Repositories.GetCommit( 1174 context.Background(), 1175 obj.Org(), 1176 obj.Project(), 1177 ref, 1178 ) 1179 obj.config.analytics.GetCommit.Call(obj.config, response) 1180 if err != nil { 1181 glog.Errorf("Failed to get commit for %v, %v, %v: %v", obj.Org(), obj.Project(), ref, suggestOauthScopes(response, err)) 1182 return "", false 1183 } 1184 if commit.SHA == nil { 1185 return "", false 1186 } 1187 return *commit.SHA, true 1188 } 1189 1190 // ClearMilestone will remove a milestone if present 1191 func (obj *MungeObject) ClearMilestone() bool { 1192 if obj.Issue.Milestone == nil { 1193 return true 1194 } 1195 obj.config.analytics.ClearMilestone.Call(obj.config, nil) 1196 obj.Issue.Milestone = nil 1197 if obj.config.DryRun { 1198 return true 1199 } 1200 1201 // Send the request manually to work around go-github's use of 1202 // omitempty (precluding the use of null) in the json field 1203 // definition for milestone. 1204 // 1205 // Reference: https://github.com/google/go-github/issues/236 1206 u := fmt.Sprintf("repos/%v/%v/issues/%d", obj.Org(), obj.Project(), *obj.Issue.Number) 1207 req, err := obj.config.client.NewRequest("PATCH", u, &struct { 1208 Milestone interface{} `json:"milestone"` 1209 }{}) 1210 if err != nil { 1211 glog.Errorf("Failed to clear milestone on issue %d: %v", *obj.Issue.Number, err) 1212 return false 1213 } 1214 _, err = obj.config.client.Do(context.Background(), req, nil) 1215 if err != nil { 1216 glog.Errorf("Failed to clear milestone on issue %d: %v", *obj.Issue.Number, err) 1217 return false 1218 } 1219 return true 1220 } 1221 1222 // SetMilestone will set the milestone to the value specified 1223 func (obj *MungeObject) SetMilestone(title string) bool { 1224 milestones, ok := obj.config.ListMilestones("all") 1225 if !ok { 1226 glog.Errorf("Error in SetMilestone, obj.config.ListMilestones failed") 1227 return false 1228 } 1229 var milestone *github.Milestone 1230 for _, m := range milestones { 1231 if m.Title == nil || m.Number == nil { 1232 glog.Errorf("Found milestone with nil title of number: %v", m) 1233 continue 1234 } 1235 if *m.Title == title { 1236 milestone = m 1237 break 1238 } 1239 } 1240 if milestone == nil { 1241 glog.Errorf("Unable to find milestone with title %q", title) 1242 return false 1243 } 1244 1245 obj.config.analytics.SetMilestone.Call(obj.config, nil) 1246 obj.Issue.Milestone = milestone 1247 if obj.config.DryRun { 1248 return true 1249 } 1250 1251 _, resp, err := obj.config.client.Issues.Edit( 1252 context.Background(), 1253 obj.Org(), 1254 obj.Project(), 1255 *obj.Issue.Number, 1256 &github.IssueRequest{Milestone: milestone.Number}, 1257 ) 1258 if err != nil { 1259 glog.Errorf("Failed to set milestone %d on issue %d: %v", *milestone.Number, *obj.Issue.Number, suggestOauthScopes(resp, err)) 1260 return false 1261 } 1262 return true 1263 } 1264 1265 // ReleaseMilestone returns the name of the 'release' milestone or an empty string 1266 // if none found. Release milestones are determined by the format "vX.Y" 1267 func (obj *MungeObject) ReleaseMilestone() (string, bool) { 1268 milestone := obj.Issue.Milestone 1269 if milestone == nil { 1270 return "", true 1271 } 1272 title := milestone.Title 1273 if title == nil { 1274 glog.Errorf("Error in ReleaseMilestone, nil milestone.Title") 1275 return "", false 1276 } 1277 if !releaseMilestoneRE.MatchString(*title) { 1278 return "", true 1279 } 1280 return *title, true 1281 } 1282 1283 // ReleaseMilestoneDue returns the due date for a milestone. It ONLY looks at 1284 // milestones of the form 'vX.Y' where X and Y are integeters. Return the maximum 1285 // possible time if there is no milestone or the milestone doesn't look like a 1286 // release milestone 1287 func (obj *MungeObject) ReleaseMilestoneDue() (time.Time, bool) { 1288 milestone := obj.Issue.Milestone 1289 if milestone == nil { 1290 return maxTime, true 1291 } 1292 title := milestone.Title 1293 if title == nil { 1294 glog.Errorf("Error in ReleaseMilestoneDue, nil milestone.Title") 1295 return maxTime, false 1296 } 1297 if !releaseMilestoneRE.MatchString(*title) { 1298 return maxTime, true 1299 } 1300 if milestone.DueOn == nil { 1301 return maxTime, true 1302 } 1303 return *milestone.DueOn, true 1304 } 1305 1306 // Priority returns the priority an issue was labeled with. 1307 // The labels must take the form 'priority/[pP][0-9]+' 1308 // or math.MaxInt32 if unset 1309 // 1310 // If a PR has both priority/p0 and priority/p1 it will be considered a p0. 1311 func (obj *MungeObject) Priority() int { 1312 priority := math.MaxInt32 1313 priorityLabels := GetLabelsWithPrefix(obj.Issue.Labels, "priority/") 1314 for _, label := range priorityLabels { 1315 matches := priorityLabelRE.FindStringSubmatch(label) 1316 // First match should be the whole label, second match the number itself 1317 if len(matches) != 2 { 1318 continue 1319 } 1320 prio, err := strconv.Atoi(matches[1]) 1321 if err != nil { 1322 continue 1323 } 1324 if prio < priority { 1325 priority = prio 1326 } 1327 } 1328 return priority 1329 } 1330 1331 // MungeFunction is the type that must be implemented and passed to ForEachIssueDo 1332 type MungeFunction func(*MungeObject) error 1333 1334 // Collaborators is a set of all logins who can be 1335 // listed as assignees, reviewers or approvers for 1336 // issues and pull requests in this repo 1337 func (config *Config) Collaborators() (sets.String, error) { 1338 logins := sets.NewString() 1339 users, err := config.fetchAllCollaborators() 1340 if err != nil { 1341 return logins, err 1342 } 1343 for _, user := range users { 1344 if user.Login != nil && *user.Login != "" { 1345 logins.Insert(strings.ToLower(*user.Login)) 1346 } 1347 } 1348 return logins, nil 1349 } 1350 1351 func (config *Config) fetchAllCollaborators() ([]*github.User, error) { 1352 page := 1 1353 var result []*github.User 1354 for { 1355 glog.V(4).Infof("Fetching page %d of all users", page) 1356 listOpts := &github.ListOptions{PerPage: 100, Page: page} 1357 users, response, err := config.client.Repositories.ListCollaborators( 1358 context.Background(), 1359 config.Org, 1360 config.Project, 1361 listOpts, 1362 ) 1363 if err != nil { 1364 return nil, suggestOauthScopes(response, err) 1365 } 1366 config.analytics.ListCollaborators.Call(config, response) 1367 result = append(result, users...) 1368 if response.LastPage == 0 || response.LastPage <= page { 1369 break 1370 } 1371 page++ 1372 } 1373 return result, nil 1374 } 1375 1376 // UsersWithAccess returns two sets of users. The first set are users with push 1377 // access. The second set is the specific set of user with pull access. If the 1378 // repo is public all users will have pull access, but some with have it 1379 // explicitly 1380 func (config *Config) UsersWithAccess() ([]*github.User, []*github.User, error) { 1381 pushUsers := []*github.User{} 1382 pullUsers := []*github.User{} 1383 1384 users, err := config.fetchAllCollaborators() 1385 if err != nil { 1386 glog.Errorf("%v", err) 1387 return nil, nil, err 1388 } 1389 1390 for _, user := range users { 1391 if user.Permissions == nil || user.Login == nil { 1392 err := fmt.Errorf("found a user with nil Permissions or Login") 1393 glog.Errorf("%v", err) 1394 return nil, nil, err 1395 } 1396 perms := *user.Permissions 1397 if perms["push"] { 1398 pushUsers = append(pushUsers, user) 1399 } else if perms["pull"] { 1400 pullUsers = append(pullUsers, user) 1401 } 1402 } 1403 return pushUsers, pullUsers, nil 1404 } 1405 1406 // GetUser will return information about the github user with the given login name 1407 func (config *Config) GetUser(login string) (*github.User, error) { 1408 user, response, err := config.client.Users.Get(context.Background(), login) 1409 config.analytics.GetUser.Call(config, response) 1410 return user, err 1411 } 1412 1413 // DescribeUser returns the Login string, which may be nil. 1414 func DescribeUser(u *github.User) string { 1415 if u != nil && u.Login != nil { 1416 return *u.Login 1417 } 1418 return "<nil>" 1419 } 1420 1421 // IsPR returns if the obj is a PR or an Issue. 1422 func (obj *MungeObject) IsPR() bool { 1423 if obj.Issue.PullRequestLinks == nil { 1424 return false 1425 } 1426 return true 1427 } 1428 1429 // GetEvents returns a list of all events for a given pr. 1430 func (obj *MungeObject) GetEvents() ([]*github.IssueEvent, bool) { 1431 config := obj.config 1432 prNum := *obj.Issue.Number 1433 events := []*github.IssueEvent{} 1434 page := 1 1435 // Try to work around not finding events--suspect some cache invalidation bug when the number of pages changes. 1436 tryNextPageAnyway := false 1437 var lastResponse *github.Response 1438 for { 1439 eventPage, response, err := config.client.Issues.ListIssueEvents( 1440 context.Background(), 1441 obj.Org(), 1442 obj.Project(), 1443 prNum, 1444 &github.ListOptions{PerPage: 100, Page: page}, 1445 ) 1446 config.analytics.ListIssueEvents.Call(config, response) 1447 if err != nil { 1448 if tryNextPageAnyway { 1449 // Cached last page was actually truthful -- expected error. 1450 break 1451 } 1452 glog.Errorf("Error getting events for issue %d: %v", *obj.Issue.Number, suggestOauthScopes(response, err)) 1453 return nil, false 1454 } 1455 if tryNextPageAnyway { 1456 if len(eventPage) == 0 { 1457 break 1458 } 1459 glog.Infof("For %v: supposedly there weren't more events, but we asked anyway and found %v more.", prNum, len(eventPage)) 1460 obj.config.deleteCache(lastResponse) 1461 tryNextPageAnyway = false 1462 } 1463 events = append(events, eventPage...) 1464 if response.LastPage == 0 || response.LastPage <= page { 1465 if len(events)%100 == 0 { 1466 tryNextPageAnyway = true 1467 lastResponse = response 1468 } else { 1469 break 1470 } 1471 } 1472 page++ 1473 } 1474 obj.events = events 1475 return events, true 1476 } 1477 1478 func computeStatus(combinedStatus *github.CombinedStatus, requiredContexts []string) string { 1479 states := sets.String{} 1480 providers := sets.String{} 1481 1482 if len(requiredContexts) == 0 { 1483 return *combinedStatus.State 1484 } 1485 1486 requires := sets.NewString(requiredContexts...) 1487 for _, status := range combinedStatus.Statuses { 1488 if !requires.Has(*status.Context) { 1489 continue 1490 } 1491 states.Insert(*status.State) 1492 providers.Insert(*status.Context) 1493 } 1494 1495 missing := requires.Difference(providers) 1496 if missing.Len() != 0 { 1497 glog.V(8).Infof("Failed to find %v in CombinedStatus for %s", missing.List(), *combinedStatus.SHA) 1498 return "incomplete" 1499 } 1500 switch { 1501 case states.Has("pending"): 1502 return "pending" 1503 case states.Has("error"): 1504 return "error" 1505 case states.Has("failure"): 1506 return "failure" 1507 default: 1508 return "success" 1509 } 1510 } 1511 1512 func (obj *MungeObject) getCombinedStatus() (status *github.CombinedStatus, ok bool) { 1513 now := time.Now() 1514 if now.Before(obj.combinedStatusTime.Add(combinedStatusLifetime)) { 1515 return obj.combinedStatus, true 1516 } 1517 1518 config := obj.config 1519 pr, ok := obj.GetPR() 1520 if !ok { 1521 return nil, false 1522 } 1523 if pr.Head == nil { 1524 glog.Errorf("pr.Head is nil in getCombinedStatus for PR# %d", *obj.Issue.Number) 1525 return nil, false 1526 } 1527 // TODO If we have more than 100 statuses we need to deal with paging. 1528 combinedStatus, response, err := config.client.Repositories.GetCombinedStatus( 1529 context.Background(), 1530 obj.Org(), 1531 obj.Project(), 1532 *pr.Head.SHA, 1533 &github.ListOptions{}, 1534 ) 1535 config.analytics.GetCombinedStatus.Call(config, response) 1536 if err != nil { 1537 glog.Errorf("Failed to get combined status: %v", suggestOauthScopes(response, err)) 1538 return nil, false 1539 } 1540 obj.combinedStatus = combinedStatus 1541 obj.combinedStatusTime = now 1542 return combinedStatus, true 1543 } 1544 1545 // SetStatus allowes you to set the Github Status 1546 func (obj *MungeObject) SetStatus(state, url, description, statusContext string) bool { 1547 config := obj.config 1548 status := &github.RepoStatus{ 1549 State: &state, 1550 Description: &description, 1551 Context: &statusContext, 1552 } 1553 if len(url) > 0 { 1554 status.TargetURL = &url 1555 } 1556 pr, ok := obj.GetPR() 1557 if !ok { 1558 glog.Errorf("Error in SetStatus") 1559 return false 1560 } 1561 ref := *pr.Head.SHA 1562 glog.Infof("PR %d setting %q Github status to %q", *obj.Issue.Number, statusContext, description) 1563 config.analytics.SetStatus.Call(config, nil) 1564 if config.DryRun { 1565 return true 1566 } 1567 _, resp, err := config.client.Repositories.CreateStatus( 1568 context.Background(), 1569 obj.Org(), 1570 obj.Project(), 1571 ref, 1572 status, 1573 ) 1574 if err != nil { 1575 glog.Errorf("Unable to set status. PR %d Ref: %q: %v", *obj.Issue.Number, ref, suggestOauthScopes(resp, err)) 1576 return false 1577 } 1578 return false 1579 } 1580 1581 // GetStatus returns the actual requested status, or nil if not found 1582 func (obj *MungeObject) GetStatus(context string) (*github.RepoStatus, bool) { 1583 combinedStatus, ok := obj.getCombinedStatus() 1584 if !ok { 1585 glog.Errorf("Error in GetStatus, getCombinedStatus returned error") 1586 return nil, false 1587 } else if combinedStatus == nil { 1588 return nil, true 1589 } 1590 for _, status := range combinedStatus.Statuses { 1591 if *status.Context == context { 1592 return &status, true 1593 } 1594 } 1595 return nil, true 1596 } 1597 1598 // GetStatusState gets the current status of a PR. 1599 // * If any member of the 'requiredContexts' list is missing, it is 'incomplete' 1600 // * If any is 'pending', the PR is 'pending' 1601 // * If any is 'error', the PR is in 'error' 1602 // * If any is 'failure', the PR is 'failure' 1603 // * Otherwise the PR is 'success' 1604 func (obj *MungeObject) GetStatusState(requiredContexts []string) (string, bool) { 1605 combinedStatus, ok := obj.getCombinedStatus() 1606 if !ok || combinedStatus == nil { 1607 return "failure", ok 1608 } 1609 return computeStatus(combinedStatus, requiredContexts), ok 1610 } 1611 1612 // IsStatusSuccess makes sure that the combined status for all commits in a PR is 'success' 1613 func (obj *MungeObject) IsStatusSuccess(requiredContexts []string) (bool, bool) { 1614 status, ok := obj.GetStatusState(requiredContexts) 1615 if ok && status == "success" { 1616 return true, ok 1617 } 1618 return false, ok 1619 } 1620 1621 // GetStatusTime returns when the status was set 1622 func (obj *MungeObject) GetStatusTime(context string) (*time.Time, bool) { 1623 status, ok := obj.GetStatus(context) 1624 if status == nil || ok == false { 1625 return nil, false 1626 } 1627 if status.UpdatedAt != nil { 1628 return status.UpdatedAt, true 1629 } 1630 return status.CreatedAt, true 1631 } 1632 1633 // Sleep for the given amount of time and then write to the channel 1634 func timeout(sleepTime time.Duration, c chan bool) { 1635 time.Sleep(sleepTime) 1636 c <- true 1637 } 1638 1639 func (obj *MungeObject) doWaitStatus(pending bool, requiredContexts []string, c chan bool) { 1640 config := obj.config 1641 1642 sleepTime := 30 * time.Second 1643 // If the time was explicitly set, use that instead 1644 if config.BaseWaitTime != 0 { 1645 sleepTime = 30 * config.BaseWaitTime 1646 } 1647 1648 for { 1649 status, ok := obj.GetStatusState(requiredContexts) 1650 if !ok { 1651 time.Sleep(sleepTime) 1652 continue 1653 } 1654 var done bool 1655 if pending { 1656 done = (status == "pending") 1657 } else { 1658 done = (status != "pending") 1659 } 1660 if done { 1661 c <- true 1662 return 1663 } 1664 if config.DryRun { 1665 glog.V(4).Infof("PR# %d is not pending, would wait 30 seconds, but --dry-run was set", *obj.Issue.Number) 1666 c <- true 1667 return 1668 } 1669 if pending { 1670 glog.V(4).Infof("PR# %d is not pending, waiting for %f seconds", *obj.Issue.Number, sleepTime.Seconds()) 1671 } else { 1672 glog.V(4).Infof("PR# %d is pending, waiting for %f seconds", *obj.Issue.Number, sleepTime.Seconds()) 1673 } 1674 time.Sleep(sleepTime) 1675 1676 // If it has been closed, assume that we want to break from the poll loop early. 1677 obj.Refresh() 1678 if obj.Issue != nil && obj.Issue.State != nil && *obj.Issue.State == "closed" { 1679 c <- true 1680 } 1681 } 1682 } 1683 1684 // WaitForPending will wait for a PR to move into Pending. This is useful 1685 // because the request to test a PR again is asynchronous with the PR actually 1686 // moving into a pending state 1687 // returns true if it completed and false if it timed out 1688 func (obj *MungeObject) WaitForPending(requiredContexts []string, prMaxWaitTime time.Duration) bool { 1689 timeoutChan := make(chan bool, 1) 1690 done := make(chan bool, 1) 1691 // Wait for the github e2e test to start 1692 go timeout(prMaxWaitTime, timeoutChan) 1693 go obj.doWaitStatus(true, requiredContexts, done) 1694 select { 1695 case <-done: 1696 return true 1697 case <-timeoutChan: 1698 glog.Errorf("PR# %d timed out waiting to go \"pending\"", *obj.Issue.Number) 1699 return false 1700 } 1701 } 1702 1703 // WaitForNotPending will check if the github status is "pending" (CI still running) 1704 // if so it will sleep and try again until all required status hooks have complete 1705 // returns true if it completed and false if it timed out 1706 func (obj *MungeObject) WaitForNotPending(requiredContexts []string, prMaxWaitTime time.Duration) bool { 1707 timeoutChan := make(chan bool, 1) 1708 done := make(chan bool, 1) 1709 // Wait for the github e2e test to finish 1710 go timeout(prMaxWaitTime, timeoutChan) 1711 go obj.doWaitStatus(false, requiredContexts, done) 1712 select { 1713 case <-done: 1714 return true 1715 case <-timeoutChan: 1716 glog.Errorf("PR# %d timed out waiting to go \"not pending\"", *obj.Issue.Number) 1717 return false 1718 } 1719 } 1720 1721 // GetCommits returns all of the commits for a given PR 1722 func (obj *MungeObject) GetCommits() ([]*github.RepositoryCommit, bool) { 1723 if obj.commits != nil { 1724 return obj.commits, true 1725 } 1726 config := obj.config 1727 commits := []*github.RepositoryCommit{} 1728 page := 0 1729 for { 1730 commitsPage, response, err := config.client.PullRequests.ListCommits( 1731 context.Background(), 1732 obj.Org(), 1733 obj.Project(), 1734 *obj.Issue.Number, 1735 &github.ListOptions{PerPage: 100, Page: page}, 1736 ) 1737 config.analytics.ListCommits.Call(config, response) 1738 if err != nil { 1739 glog.Errorf("Error commits for PR %d: %v", *obj.Issue.Number, suggestOauthScopes(response, err)) 1740 return nil, false 1741 } 1742 commits = append(commits, commitsPage...) 1743 if response.LastPage == 0 || response.LastPage <= page { 1744 break 1745 } 1746 page++ 1747 } 1748 1749 filledCommits := []*github.RepositoryCommit{} 1750 for _, c := range commits { 1751 if c.SHA == nil { 1752 glog.Errorf("Invalid Repository Commit: %v", c) 1753 continue 1754 } 1755 commit, response, err := config.client.Repositories.GetCommit( 1756 context.Background(), 1757 obj.Org(), 1758 obj.Project(), 1759 *c.SHA, 1760 ) 1761 config.analytics.GetCommit.Call(config, response) 1762 if err != nil { 1763 glog.Errorf("Can't load commit %s %s %s: %v", obj.Org(), obj.Project(), *c.SHA, suggestOauthScopes(response, err)) 1764 continue 1765 } 1766 filledCommits = append(filledCommits, commit) 1767 } 1768 obj.commits = filledCommits 1769 return filledCommits, true 1770 } 1771 1772 // ListFiles returns all changed files in a pull-request 1773 func (obj *MungeObject) ListFiles() ([]*github.CommitFile, bool) { 1774 if obj.commitFiles != nil { 1775 return obj.commitFiles, true 1776 } 1777 1778 pr, ok := obj.GetPR() 1779 if !ok { 1780 return nil, ok 1781 } 1782 1783 prNum := *pr.Number 1784 allFiles := []*github.CommitFile{} 1785 1786 listOpts := &github.ListOptions{} 1787 1788 config := obj.config 1789 page := 1 1790 for { 1791 listOpts.Page = page 1792 glog.V(8).Infof("Fetching page %d of changed files for issue %d", page, prNum) 1793 files, response, err := obj.config.client.PullRequests.ListFiles( 1794 context.Background(), 1795 obj.Org(), 1796 obj.Project(), 1797 prNum, 1798 listOpts, 1799 ) 1800 config.analytics.ListFiles.Call(config, response) 1801 if err != nil { 1802 glog.Errorf("Unable to ListFiles: %v", suggestOauthScopes(response, err)) 1803 return nil, false 1804 } 1805 allFiles = append(allFiles, files...) 1806 if response.LastPage == 0 || response.LastPage <= page { 1807 break 1808 } 1809 page++ 1810 } 1811 obj.commitFiles = allFiles 1812 return allFiles, true 1813 } 1814 1815 // GetPR will return the PR of the object. 1816 func (obj *MungeObject) GetPR() (*github.PullRequest, bool) { 1817 if obj.pr != nil { 1818 return obj.pr, true 1819 } 1820 if !obj.IsPR() { 1821 return nil, false 1822 } 1823 pr, err := obj.config.getPR(*obj.Issue.Number) 1824 if err != nil { 1825 glog.Errorf("Error in GetPR: %v", err) 1826 return nil, false 1827 } 1828 obj.pr = pr 1829 return pr, true 1830 } 1831 1832 // Returns true if the github usr is in the list of assignees 1833 func userInList(user *github.User, assignees []string) bool { 1834 if user == nil { 1835 return false 1836 } 1837 1838 for _, assignee := range assignees { 1839 if *user.Login == assignee { 1840 return true 1841 } 1842 } 1843 1844 return false 1845 } 1846 1847 // RemoveAssignees removes the passed-in assignees from the github PR's assignees list 1848 func (obj *MungeObject) RemoveAssignees(assignees ...string) error { 1849 config := obj.config 1850 prNum := *obj.Issue.Number 1851 config.analytics.RemoveAssignees.Call(config, nil) 1852 glog.Infof("Unassigning %v from PR# %d to %v", assignees, prNum) 1853 if config.DryRun { 1854 return nil 1855 } 1856 _, resp, err := config.client.Issues.RemoveAssignees( 1857 context.Background(), 1858 obj.Org(), 1859 obj.Project(), 1860 prNum, 1861 assignees, 1862 ) 1863 if err != nil { 1864 err = suggestOauthScopes(resp, err) 1865 glog.Errorf("Error unassigning %v from PR# %d: %v", assignees, prNum, err) 1866 return err 1867 } 1868 1869 // Remove people from the local object. Replace with an entirely new copy of the list 1870 newIssueAssignees := []*github.User{} 1871 for _, user := range obj.Issue.Assignees { 1872 if userInList(user, assignees) { 1873 // Skip this user 1874 continue 1875 } 1876 newIssueAssignees = append(newIssueAssignees, user) 1877 } 1878 obj.Issue.Assignees = newIssueAssignees 1879 1880 return nil 1881 } 1882 1883 // AddAssignee will assign `prNum` to the `owner` where the `owner` is asignee's github login 1884 func (obj *MungeObject) AddAssignee(owner string) error { 1885 config := obj.config 1886 prNum := *obj.Issue.Number 1887 config.analytics.AddAssignee.Call(config, nil) 1888 glog.Infof("Assigning PR# %d to %v", prNum, owner) 1889 if config.DryRun { 1890 return nil 1891 } 1892 _, resp, err := config.client.Issues.AddAssignees( 1893 context.Background(), 1894 obj.Org(), 1895 obj.Project(), 1896 prNum, 1897 []string{owner}, 1898 ) 1899 if err != nil { 1900 err = suggestOauthScopes(resp, err) 1901 glog.Errorf("Error assigning issue #%d to %v: %v", prNum, owner, err) 1902 return err 1903 } 1904 1905 obj.Issue.Assignees = append(obj.Issue.Assignees, &github.User{ 1906 Login: &owner, 1907 }) 1908 1909 return nil 1910 } 1911 1912 // CloseIssuef will close the given issue with a message 1913 func (obj *MungeObject) CloseIssuef(format string, args ...interface{}) error { 1914 config := obj.config 1915 msg := fmt.Sprintf(format, args...) 1916 if msg != "" { 1917 if err := obj.WriteComment(msg); err != nil { 1918 return fmt.Errorf("failed to write comment to %v: %q: %v", *obj.Issue.Number, msg, err) 1919 } 1920 } 1921 closed := "closed" 1922 state := &github.IssueRequest{State: &closed} 1923 config.analytics.CloseIssue.Call(config, nil) 1924 glog.Infof("Closing issue #%d: %v", *obj.Issue.Number, msg) 1925 if config.DryRun { 1926 return nil 1927 } 1928 _, resp, err := config.client.Issues.Edit( 1929 context.Background(), 1930 obj.Org(), 1931 obj.Project(), 1932 *obj.Issue.Number, 1933 state, 1934 ) 1935 if err != nil { 1936 err = suggestOauthScopes(resp, err) 1937 glog.Errorf("Error closing issue #%d: %v: %v", *obj.Issue.Number, msg, err) 1938 return err 1939 } 1940 return nil 1941 } 1942 1943 // ClosePR will close the Given PR 1944 func (obj *MungeObject) ClosePR() bool { 1945 config := obj.config 1946 pr, ok := obj.GetPR() 1947 if !ok { 1948 return false 1949 } 1950 config.analytics.ClosePR.Call(config, nil) 1951 glog.Infof("Closing PR# %d", *pr.Number) 1952 if config.DryRun { 1953 return true 1954 } 1955 state := "closed" 1956 pr.State = &state 1957 _, resp, err := config.client.PullRequests.Edit( 1958 context.Background(), 1959 obj.Org(), 1960 obj.Project(), 1961 *pr.Number, 1962 pr, 1963 ) 1964 if err != nil { 1965 glog.Errorf("Failed to close pr %d: %v", *pr.Number, suggestOauthScopes(resp, err)) 1966 return false 1967 } 1968 return true 1969 } 1970 1971 // OpenPR will attempt to open the given PR. 1972 // It will attempt to reopen the pr `numTries` before returning an error 1973 // and giving up. 1974 func (obj *MungeObject) OpenPR(numTries int) bool { 1975 config := obj.config 1976 pr, ok := obj.GetPR() 1977 if !ok { 1978 glog.Errorf("Error in OpenPR") 1979 return false 1980 } 1981 config.analytics.OpenPR.Call(config, nil) 1982 glog.Infof("Opening PR# %d", *pr.Number) 1983 if config.DryRun { 1984 return true 1985 } 1986 state := "open" 1987 pr.State = &state 1988 // Try pretty hard to re-open, since it's pretty bad if we accidentally leave a PR closed 1989 for tries := 0; tries < numTries; tries++ { 1990 _, resp, err := config.client.PullRequests.Edit( 1991 context.Background(), 1992 obj.Org(), 1993 obj.Project(), 1994 *pr.Number, 1995 pr, 1996 ) 1997 if err == nil { 1998 return true 1999 } 2000 glog.Warningf("failed to re-open pr %d: %v", *pr.Number, suggestOauthScopes(resp, err)) 2001 time.Sleep(5 * time.Second) 2002 } 2003 if !ok { 2004 glog.Errorf("failed to re-open pr %d after %d tries, giving up", *pr.Number, numTries) 2005 } 2006 return false 2007 } 2008 2009 // GetFileContents will return the contents of the `file` in the repo at `sha` 2010 // as a string 2011 func (obj *MungeObject) GetFileContents(file, sha string) (string, error) { 2012 config := obj.config 2013 getOpts := &github.RepositoryContentGetOptions{Ref: sha} 2014 if len(sha) > 0 { 2015 getOpts.Ref = sha 2016 } 2017 output, _, response, err := config.client.Repositories.GetContents( 2018 context.Background(), 2019 obj.Org(), 2020 obj.Project(), 2021 file, 2022 getOpts, 2023 ) 2024 config.analytics.GetContents.Call(config, response) 2025 if err != nil { 2026 err = fmt.Errorf("unable to get %q at commit %q", file, sha) 2027 err = suggestOauthScopes(response, err) 2028 // I'm using .V(2) because .generated docs is still not in the repo... 2029 glog.V(2).Infof("%v", err) 2030 return "", err 2031 } 2032 if output == nil { 2033 err = fmt.Errorf("got empty contents for %q at commit %q", file, sha) 2034 glog.Errorf("%v", err) 2035 return "", err 2036 } 2037 content, err := output.GetContent() 2038 if err != nil { 2039 glog.Errorf("Unable to decode file contents: %v", err) 2040 return "", err 2041 } 2042 return content, nil 2043 } 2044 2045 // MergeCommit will return the sha of the merge. PRs which have not merged 2046 // (or if we hit an error) will return nil 2047 func (obj *MungeObject) MergeCommit() (*string, bool) { 2048 events, ok := obj.GetEvents() 2049 if !ok { 2050 return nil, false 2051 } 2052 for _, event := range events { 2053 if *event.Event == "merged" { 2054 return event.CommitID, true 2055 } 2056 } 2057 return nil, true 2058 } 2059 2060 // cleanIssueBody removes irrelevant parts from the issue body, 2061 // including Reviewable footers and extra whitespace. 2062 func cleanIssueBody(issueBody string) string { 2063 issueBody = reviewableFooterRE.ReplaceAllString(issueBody, "") 2064 issueBody = htmlCommentRE.ReplaceAllString(issueBody, "") 2065 return strings.TrimSpace(issueBody) 2066 } 2067 2068 // MergePR will merge the given PR, duh 2069 // "who" is who is doing the merging, like "submit-queue" 2070 func (obj *MungeObject) MergePR(who string) bool { 2071 config := obj.config 2072 prNum := *obj.Issue.Number 2073 config.analytics.Merge.Call(config, nil) 2074 glog.Infof("Merging PR# %d", prNum) 2075 if config.DryRun { 2076 return true 2077 } 2078 mergeBody := fmt.Sprintf("Automatic merge from %s.", who) 2079 obj.WriteComment(mergeBody) 2080 2081 if obj.Issue.Title != nil { 2082 mergeBody = fmt.Sprintf("%s\n\n%s", mergeBody, *obj.Issue.Title) 2083 } 2084 2085 // Get the text of the issue body 2086 issueBody := "" 2087 if obj.Issue.Body != nil { 2088 issueBody = cleanIssueBody(*obj.Issue.Body) 2089 } 2090 2091 // Get the text of the first commit 2092 firstCommit := "" 2093 if commits, ok := obj.GetCommits(); !ok { 2094 return false 2095 } else if commits[0].Commit.Message != nil { 2096 firstCommit = *commits[0].Commit.Message 2097 } 2098 2099 // Include the contents of the issue body if it is not the exact same text as was 2100 // included in the first commit. PRs with a single commit (by default when opened 2101 // via the web UI) have the same text as the first commit. If there are multiple 2102 // commits people often put summary info in the body. But sometimes, even with one 2103 // commit people will edit/update the issue body. So if there is any reason, include 2104 // the issue body in the merge commit in git. 2105 if !strings.Contains(firstCommit, issueBody) { 2106 mergeBody = fmt.Sprintf("%s\n\n%s", mergeBody, issueBody) 2107 } 2108 2109 option := &github.PullRequestOptions{ 2110 MergeMethod: config.mergeMethod, 2111 } 2112 2113 _, resp, err := config.client.PullRequests.Merge( 2114 context.Background(), 2115 obj.Org(), 2116 obj.Project(), 2117 prNum, 2118 mergeBody, 2119 option, 2120 ) 2121 2122 // The github API https://developer.github.com/v3/pulls/#merge-a-pull-request-merge-button indicates 2123 // we will only get the below error if we provided a particular sha to merge PUT. We aren't doing that 2124 // so our best guess is that the API also provides this error message when it is recalulating 2125 // "mergeable". So if we get this error, check "IsPRMergeable()" which should sleep just a bit until 2126 // github is finished calculating. If my guess is correct, that also means we should be able to 2127 // then merge this PR, so try again. 2128 if err != nil && strings.Contains(err.Error(), "branch was modified. Review and try the merge again.") { 2129 if mergeable, _ := obj.IsMergeable(); mergeable { 2130 _, resp, err = config.client.PullRequests.Merge( 2131 context.Background(), 2132 obj.Org(), 2133 obj.Project(), 2134 prNum, 2135 mergeBody, 2136 nil, 2137 ) 2138 } 2139 } 2140 if err != nil { 2141 glog.Errorf("Failed to merge PR: %d: %v", prNum, suggestOauthScopes(resp, err)) 2142 return false 2143 } 2144 return true 2145 } 2146 2147 // GetPRFixesList returns a list of issue numbers that are referenced in the PR body. 2148 func (obj *MungeObject) GetPRFixesList() []int { 2149 prBody := "" 2150 if obj.Issue.Body != nil { 2151 prBody = *obj.Issue.Body 2152 } 2153 matches := fixesIssueRE.FindAllStringSubmatch(prBody, -1) 2154 if matches == nil { 2155 return nil 2156 } 2157 2158 issueNums := []int{} 2159 for _, match := range matches { 2160 if num, err := strconv.Atoi(match[1]); err == nil { 2161 issueNums = append(issueNums, num) 2162 } 2163 } 2164 return issueNums 2165 } 2166 2167 // ListReviewComments returns all review (diff) comments for the PR in question 2168 func (obj *MungeObject) ListReviewComments() ([]*github.PullRequestComment, bool) { 2169 if obj.prComments != nil { 2170 return obj.prComments, true 2171 } 2172 2173 pr, ok := obj.GetPR() 2174 if !ok { 2175 return nil, ok 2176 } 2177 prNum := *pr.Number 2178 allComments := []*github.PullRequestComment{} 2179 2180 listOpts := &github.PullRequestListCommentsOptions{ListOptions: github.ListOptions{PerPage: 100}} 2181 2182 config := obj.config 2183 page := 1 2184 // Try to work around not finding comments--suspect some cache invalidation bug when the number of pages changes. 2185 tryNextPageAnyway := false 2186 var lastResponse *github.Response 2187 for { 2188 listOpts.ListOptions.Page = page 2189 glog.V(8).Infof("Fetching page %d of comments for PR %d", page, prNum) 2190 comments, response, err := obj.config.client.PullRequests.ListComments( 2191 context.Background(), 2192 obj.Org(), 2193 obj.Project(), 2194 prNum, 2195 listOpts, 2196 ) 2197 config.analytics.ListReviewComments.Call(config, response) 2198 if err != nil { 2199 if tryNextPageAnyway { 2200 // Cached last page was actually truthful -- expected error. 2201 break 2202 } 2203 glog.Errorf("Failed to fetch page of comments for PR %d: %v", prNum, suggestOauthScopes(response, err)) 2204 return nil, false 2205 } 2206 if tryNextPageAnyway { 2207 if len(comments) == 0 { 2208 break 2209 } 2210 glog.Infof("For %v: supposedly there weren't more review comments, but we asked anyway and found %v more.", prNum, len(comments)) 2211 obj.config.deleteCache(lastResponse) 2212 tryNextPageAnyway = false 2213 } 2214 allComments = append(allComments, comments...) 2215 if response.LastPage == 0 || response.LastPage <= page { 2216 if len(allComments)%100 == 0 { 2217 tryNextPageAnyway = true 2218 lastResponse = response 2219 } else { 2220 break 2221 } 2222 } 2223 page++ 2224 } 2225 obj.prComments = allComments 2226 return allComments, true 2227 } 2228 2229 // WithListOpt configures the options to list comments of github issue. 2230 type WithListOpt func(*github.IssueListCommentsOptions) *github.IssueListCommentsOptions 2231 2232 // ListComments returns all comments for the issue/PR in question 2233 func (obj *MungeObject) ListComments() ([]*github.IssueComment, bool) { 2234 config := obj.config 2235 issueNum := *obj.Issue.Number 2236 allComments := []*github.IssueComment{} 2237 2238 if obj.comments != nil { 2239 return obj.comments, true 2240 } 2241 2242 listOpts := &github.IssueListCommentsOptions{ListOptions: github.ListOptions{PerPage: 100}} 2243 2244 page := 1 2245 // Try to work around not finding comments--suspect some cache invalidation bug when the number of pages changes. 2246 tryNextPageAnyway := false 2247 var lastResponse *github.Response 2248 for { 2249 listOpts.ListOptions.Page = page 2250 glog.V(8).Infof("Fetching page %d of comments for issue %d", page, issueNum) 2251 comments, response, err := obj.config.client.Issues.ListComments( 2252 context.Background(), 2253 obj.Org(), 2254 obj.Project(), 2255 issueNum, 2256 listOpts, 2257 ) 2258 config.analytics.ListComments.Call(config, response) 2259 if err != nil { 2260 if tryNextPageAnyway { 2261 // Cached last page was actually truthful -- expected error. 2262 break 2263 } 2264 glog.Errorf("Failed to fetch page of comments for issue %d: %v", issueNum, suggestOauthScopes(response, err)) 2265 return nil, false 2266 } 2267 if tryNextPageAnyway { 2268 if len(comments) == 0 { 2269 break 2270 } 2271 glog.Infof("For %v: supposedly there weren't more comments, but we asked anyway and found %v more.", issueNum, len(comments)) 2272 obj.config.deleteCache(lastResponse) 2273 tryNextPageAnyway = false 2274 } 2275 allComments = append(allComments, comments...) 2276 if response.LastPage == 0 || response.LastPage <= page { 2277 if len(comments)%100 == 0 { 2278 tryNextPageAnyway = true 2279 lastResponse = response 2280 } else { 2281 break 2282 } 2283 } 2284 page++ 2285 } 2286 obj.comments = allComments 2287 return allComments, true 2288 } 2289 2290 // WriteComment will send the `msg` as a comment to the specified PR 2291 func (obj *MungeObject) WriteComment(msg string) error { 2292 config := obj.config 2293 prNum := obj.Number() 2294 config.analytics.CreateComment.Call(config, nil) 2295 comment := msg 2296 if len(comment) > 512 { 2297 comment = comment[:512] 2298 } 2299 glog.Infof("Commenting in %d: %q", prNum, comment) 2300 if config.DryRun { 2301 return nil 2302 } 2303 if len(msg) > maxCommentLen { 2304 glog.Info("Comment in %d was larger than %d and was truncated", prNum, maxCommentLen) 2305 msg = msg[:maxCommentLen] 2306 } 2307 _, resp, err := config.client.Issues.CreateComment( 2308 context.Background(), 2309 obj.Org(), 2310 obj.Project(), 2311 prNum, 2312 &github.IssueComment{Body: &msg}, 2313 ) 2314 if err != nil { 2315 err = suggestOauthScopes(resp, err) 2316 glog.Errorf("%v", err) 2317 return err 2318 } 2319 return nil 2320 } 2321 2322 // DeleteComment will remove the specified comment 2323 func (obj *MungeObject) DeleteComment(comment *github.IssueComment) error { 2324 config := obj.config 2325 prNum := *obj.Issue.Number 2326 config.analytics.DeleteComment.Call(config, nil) 2327 if comment.ID == nil { 2328 err := fmt.Errorf("Found a comment with nil id for Issue %d", prNum) 2329 glog.Errorf("Found a comment with nil id for Issue %d", prNum) 2330 return err 2331 } 2332 which := -1 2333 for i, c := range obj.comments { 2334 if c.ID == nil || *c.ID != *comment.ID { 2335 continue 2336 } 2337 which = i 2338 } 2339 if which != -1 { 2340 // We do this crazy delete since users might be iterating over `range obj.comments` 2341 // Make a completely new copy and leave their ranging alone. 2342 temp := make([]*github.IssueComment, len(obj.comments)-1) 2343 copy(temp, obj.comments[:which]) 2344 copy(temp[which:], obj.comments[which+1:]) 2345 obj.comments = temp 2346 } 2347 body := "UNKNOWN" 2348 if comment.Body != nil { 2349 body = *comment.Body 2350 } 2351 author := "UNKNOWN" 2352 if comment.User != nil && comment.User.Login != nil { 2353 author = *comment.User.Login 2354 } 2355 glog.Infof("Removing comment %d from Issue %d. Author:%s Body:%q", *comment.ID, prNum, author, body) 2356 if config.DryRun { 2357 return nil 2358 } 2359 resp, err := config.client.Issues.DeleteComment( 2360 context.Background(), 2361 obj.Org(), 2362 obj.Project(), 2363 *comment.ID, 2364 ) 2365 if err != nil { 2366 err = suggestOauthScopes(resp, err) 2367 glog.Errorf("Error removing comment: %v", err) 2368 return err 2369 } 2370 return nil 2371 } 2372 2373 // EditComment will change the specified comment's body. 2374 func (obj *MungeObject) EditComment(comment *github.IssueComment, body string) error { 2375 config := obj.config 2376 prNum := *obj.Issue.Number 2377 config.analytics.EditComment.Call(config, nil) 2378 if comment.ID == nil { 2379 err := fmt.Errorf("Found a comment with nil id for Issue %d", prNum) 2380 glog.Errorf("Found a comment with nil id for Issue %d", prNum) 2381 return err 2382 } 2383 author := "UNKNOWN" 2384 if comment.User != nil && comment.User.Login != nil { 2385 author = *comment.User.Login 2386 } 2387 shortBody := body 2388 if len(shortBody) > 512 { 2389 shortBody = shortBody[:512] 2390 } 2391 glog.Infof("Editing comment %d from Issue %d. Author:%s New Body:%q", *comment.ID, prNum, author, shortBody) 2392 if config.DryRun { 2393 return nil 2394 } 2395 if len(body) > maxCommentLen { 2396 glog.Info("Comment in %d was larger than %d and was truncated", prNum, maxCommentLen) 2397 body = body[:maxCommentLen] 2398 } 2399 patch := github.IssueComment{Body: &body} 2400 ic, resp, err := config.client.Issues.EditComment( 2401 context.Background(), 2402 obj.Org(), 2403 obj.Project(), 2404 *comment.ID, 2405 &patch, 2406 ) 2407 if err != nil { 2408 err = suggestOauthScopes(resp, err) 2409 glog.Errorf("Error editing comment: %v", err) 2410 return err 2411 } 2412 comment.Body = ic.Body 2413 return nil 2414 } 2415 2416 // IsMergeable will return if the PR is mergeable. It will pause and get the 2417 // PR again if github did not respond the first time. So the hopefully github 2418 // will have a response the second time. If we have no answer twice, we return 2419 // false 2420 func (obj *MungeObject) IsMergeable() (bool, bool) { 2421 if !obj.IsPR() { 2422 return false, true 2423 } 2424 pr, ok := obj.GetPR() 2425 if !ok { 2426 return false, ok 2427 } 2428 prNum := obj.Number() 2429 // Github might not have computed mergeability yet. Try again a few times. 2430 for try := 1; try <= 5 && pr.Mergeable == nil && (pr.Merged == nil || *pr.Merged == false); try++ { 2431 glog.V(4).Infof("Waiting for mergeability on %q %d", *pr.Title, prNum) 2432 // Sleep for 2-32 seconds on successive attempts. 2433 // Worst case, we'll wait for up to a minute for GitHub 2434 // to compute it before bailing out. 2435 baseDelay := time.Second 2436 if obj.config.BaseWaitTime != 0 { // Allow shorter delays in tests. 2437 baseDelay = obj.config.BaseWaitTime 2438 } 2439 time.Sleep((1 << uint(try)) * baseDelay) 2440 ok := obj.Refresh() 2441 if !ok { 2442 return false, ok 2443 } 2444 pr, ok = obj.GetPR() 2445 if !ok { 2446 return false, ok 2447 } 2448 } 2449 if pr.Merged != nil && *pr.Merged { 2450 glog.Errorf("Found that PR #%d is merged while looking up mergeability, Skipping", prNum) 2451 return false, false 2452 } 2453 if pr.Mergeable == nil { 2454 glog.Errorf("No mergeability information for %q %d, Skipping", *pr.Title, prNum) 2455 return false, false 2456 } 2457 return *pr.Mergeable, true 2458 } 2459 2460 // IsMerged returns if the issue in question was already merged 2461 func (obj *MungeObject) IsMerged() (bool, bool) { 2462 if !obj.IsPR() { 2463 glog.Errorf("Issue: %d is not a PR and is thus 'merged' is indeterminate", *obj.Issue.Number) 2464 return false, false 2465 } 2466 pr, ok := obj.GetPR() 2467 if !ok { 2468 return false, false 2469 } 2470 if pr.Merged != nil { 2471 return *pr.Merged, true 2472 } 2473 glog.Errorf("Unable to determine if PR %d was merged", *obj.Issue.Number) 2474 return false, false 2475 } 2476 2477 // MergedAt returns the time an issue was merged (for nil if unmerged) 2478 func (obj *MungeObject) MergedAt() (*time.Time, bool) { 2479 if !obj.IsPR() { 2480 return nil, false 2481 } 2482 pr, ok := obj.GetPR() 2483 if !ok { 2484 return nil, false 2485 } 2486 return pr.MergedAt, true 2487 } 2488 2489 // ListReviews returns a list of the Pull Request Reviews on a PR. 2490 func (obj *MungeObject) ListReviews() ([]*github.PullRequestReview, bool) { 2491 if obj.prReviews != nil { 2492 return obj.prReviews, true 2493 } 2494 if !obj.IsPR() { 2495 return nil, false 2496 } 2497 2498 pr, ok := obj.GetPR() 2499 if !ok { 2500 return nil, false 2501 } 2502 prNum := *pr.Number 2503 allReviews := []*github.PullRequestReview{} 2504 2505 listOpts := &github.ListOptions{PerPage: 100} 2506 2507 config := obj.config 2508 page := 1 2509 // Try to work around not finding comments--suspect some cache invalidation bug when the number of pages changes. 2510 tryNextPageAnyway := false 2511 var lastResponse *github.Response 2512 for { 2513 listOpts.Page = page 2514 glog.V(8).Infof("Fetching page %d of reviews for pr %d", page, prNum) 2515 reviews, response, err := obj.config.client.PullRequests.ListReviews( 2516 context.Background(), 2517 obj.Org(), 2518 obj.Project(), 2519 prNum, 2520 listOpts, 2521 ) 2522 config.analytics.ListReviews.Call(config, response) 2523 if err != nil { 2524 if tryNextPageAnyway { 2525 // Cached last page was actually truthful -- expected error. 2526 break 2527 } 2528 glog.Errorf("Failed to fetch page %d of reviews for pr %d: %v", page, prNum, suggestOauthScopes(response, err)) 2529 return nil, false 2530 } 2531 if tryNextPageAnyway { 2532 if len(reviews) == 0 { 2533 break 2534 } 2535 glog.Infof("For %v: supposedly there weren't more reviews, but we asked anyway and found %v more.", prNum, len(reviews)) 2536 obj.config.deleteCache(lastResponse) 2537 tryNextPageAnyway = false 2538 } 2539 allReviews = append(allReviews, reviews...) 2540 if response.LastPage == 0 || response.LastPage <= page { 2541 if len(allReviews)%100 == 0 { 2542 tryNextPageAnyway = true 2543 lastResponse = response 2544 } else { 2545 break 2546 } 2547 } 2548 page++ 2549 } 2550 obj.prReviews = allReviews 2551 return allReviews, true 2552 } 2553 2554 func (obj *MungeObject) CollectGHReviewStatus() ([]*github.PullRequestReview, []*github.PullRequestReview, bool) { 2555 reviews, ok := obj.ListReviews() 2556 if !ok { 2557 glog.Warning("Cannot get all reviews") 2558 return nil, nil, false 2559 } 2560 var approvedReviews, changesRequestReviews []*github.PullRequestReview 2561 latestReviews := make(map[string]*github.PullRequestReview) 2562 for _, review := range reviews { 2563 reviewer := review.User.GetLogin() 2564 if r, exist := latestReviews[reviewer]; !exist || r.GetSubmittedAt().Before(review.GetSubmittedAt()) { 2565 latestReviews[reviewer] = review 2566 } 2567 } 2568 2569 for _, review := range latestReviews { 2570 if review.GetState() == ghApproved { 2571 approvedReviews = append(approvedReviews, review) 2572 } else if review.GetState() == ghChangesRequested { 2573 changesRequestReviews = append(changesRequestReviews, review) 2574 } 2575 } 2576 return approvedReviews, changesRequestReviews, true 2577 } 2578 2579 func (config *Config) runMungeFunction(obj *MungeObject, fn MungeFunction) error { 2580 if obj.Issue.Number == nil { 2581 glog.Infof("Skipping issue with no number, very strange") 2582 return nil 2583 } 2584 if obj.Issue.User == nil || obj.Issue.User.Login == nil { 2585 glog.V(2).Infof("Skipping PR %d with no user info %#v.", *obj.Issue.Number, obj.Issue.User) 2586 return nil 2587 } 2588 if *obj.Issue.Number < config.MinPRNumber { 2589 glog.V(6).Infof("Dropping %d < %d", *obj.Issue.Number, config.MinPRNumber) 2590 return nil 2591 } 2592 if *obj.Issue.Number > config.MaxPRNumber { 2593 glog.V(6).Infof("Dropping %d > %d", *obj.Issue.Number, config.MaxPRNumber) 2594 return nil 2595 } 2596 2597 // Update pull-requests references if we track them with webhooks 2598 if config.HookHandler != nil { 2599 if obj.IsPR() { 2600 if pr, ok := obj.GetPR(); !ok { 2601 return nil 2602 } else if pr.Head != nil && pr.Head.Ref != nil && pr.Head.SHA != nil { 2603 config.HookHandler.UpdatePullRequest(*obj.Issue.Number, *pr.Head.SHA) 2604 } 2605 } 2606 } 2607 2608 glog.V(2).Infof("----==== %d ====----", *obj.Issue.Number) 2609 glog.V(8).Infof("Issue %d labels: %v isPR: %v", *obj.Issue.Number, obj.Issue.Labels, obj.Issue.PullRequestLinks != nil) 2610 if err := fn(obj); err != nil { 2611 return err 2612 } 2613 return nil 2614 } 2615 2616 // ForEachIssueDo will run for each Issue in the project that matches: 2617 // * pr.Number >= minPRNumber 2618 // * pr.Number <= maxPRNumber 2619 func (config *Config) ForEachIssueDo(fn MungeFunction) error { 2620 page := 1 2621 count := 0 2622 2623 extraIssues := sets.NewInt() 2624 // Add issues modified by a received event 2625 if config.HookHandler != nil { 2626 extraIssues.Insert(config.HookHandler.PopIssues()...) 2627 } else { 2628 // We're not using the webhooks, make sure we fetch all 2629 // the issues 2630 config.since = time.Time{} 2631 } 2632 2633 // It's a new day, let's restart from scratch. 2634 // Use PST timezone to determine when its a new day. 2635 pst := time.FixedZone("PacificStandardTime", -8*60*60 /* seconds offset from UTC */) 2636 if time.Now().In(pst).Format("Jan 2 2006") != config.since.In(pst).Format("Jan 2 2006") { 2637 config.since = time.Time{} 2638 } 2639 2640 since := time.Now() 2641 for { 2642 glog.V(4).Infof("Fetching page %d of issues", page) 2643 issues, response, err := config.client.Issues.ListByRepo( 2644 context.Background(), 2645 config.Org, 2646 config.Project, 2647 &github.IssueListByRepoOptions{ 2648 Sort: "created", 2649 State: config.State, 2650 Labels: config.Labels, 2651 Direction: "asc", 2652 ListOptions: github.ListOptions{PerPage: 100, Page: page}, 2653 Since: config.since, 2654 }, 2655 ) 2656 config.analytics.ListIssues.Call(config, response) 2657 if err != nil { 2658 return suggestOauthScopes(response, err) 2659 } 2660 for i := range issues { 2661 obj := &MungeObject{ 2662 config: config, 2663 Issue: issues[i], 2664 Annotations: map[string]string{}, 2665 } 2666 count++ 2667 err := config.runMungeFunction(obj, fn) 2668 if err != nil { 2669 return err 2670 } 2671 if obj.Issue.UpdatedAt != nil && obj.Issue.UpdatedAt.After(since) { 2672 since = *obj.Issue.UpdatedAt 2673 } 2674 if obj.Issue.Number != nil { 2675 delete(extraIssues, *obj.Issue.Number) 2676 } 2677 } 2678 if response.LastPage == 0 || response.LastPage <= page { 2679 break 2680 } 2681 page++ 2682 } 2683 config.since = since 2684 2685 // Handle additional issues 2686 for id := range extraIssues { 2687 obj, err := config.GetObject(id) 2688 if err != nil { 2689 return err 2690 } 2691 count++ 2692 glog.V(2).Info("Munging extra-issue: ", id) 2693 err = config.runMungeFunction(obj, fn) 2694 if err != nil { 2695 return err 2696 } 2697 } 2698 2699 glog.Infof("Munged %d modified issues. (%d because of status change)", count, len(extraIssues)) 2700 2701 return nil 2702 } 2703 2704 // ListAllIssues grabs all issues matching the options, so you don't have to 2705 // worry about paging. Enforces some constraints, like min/max PR number and 2706 // having a valid user. 2707 func (config *Config) ListAllIssues(listOpts *github.IssueListByRepoOptions) ([]*github.Issue, error) { 2708 allIssues := []*github.Issue{} 2709 page := 1 2710 for { 2711 glog.V(4).Infof("Fetching page %d of issues", page) 2712 listOpts.ListOptions = github.ListOptions{PerPage: 100, Page: page} 2713 issues, response, err := config.client.Issues.ListByRepo( 2714 context.Background(), 2715 config.Org, 2716 config.Project, 2717 listOpts, 2718 ) 2719 config.analytics.ListIssues.Call(config, response) 2720 if err != nil { 2721 return nil, suggestOauthScopes(response, err) 2722 } 2723 for i := range issues { 2724 issue := issues[i] 2725 if issue.Number == nil { 2726 glog.Infof("Skipping issue with no number, very strange") 2727 continue 2728 } 2729 if issue.User == nil || issue.User.Login == nil { 2730 glog.V(2).Infof("Skipping PR %d with no user info %#v.", *issue.Number, issue.User) 2731 continue 2732 } 2733 if *issue.Number < config.MinPRNumber { 2734 glog.V(6).Infof("Dropping %d < %d", *issue.Number, config.MinPRNumber) 2735 continue 2736 } 2737 if *issue.Number > config.MaxPRNumber { 2738 glog.V(6).Infof("Dropping %d > %d", *issue.Number, config.MaxPRNumber) 2739 continue 2740 } 2741 allIssues = append(allIssues, issue) 2742 } 2743 if response.LastPage == 0 || response.LastPage <= page { 2744 break 2745 } 2746 page++ 2747 } 2748 return allIssues, nil 2749 } 2750 2751 // GetLabels grabs all labels from a particular repository so you don't have to 2752 // worry about paging. 2753 func (config *Config) GetLabels() ([]*github.Label, error) { 2754 var listOpts github.ListOptions 2755 var allLabels []*github.Label 2756 page := 1 2757 for { 2758 glog.V(4).Infof("Fetching page %d of labels", page) 2759 listOpts = github.ListOptions{PerPage: 100, Page: page} 2760 labels, response, err := config.client.Issues.ListLabels( 2761 context.Background(), 2762 config.Org, 2763 config.Project, 2764 &listOpts, 2765 ) 2766 config.analytics.ListLabels.Call(config, response) 2767 if err != nil { 2768 return nil, suggestOauthScopes(response, err) 2769 } 2770 for i := range labels { 2771 allLabels = append(allLabels, labels[i]) 2772 } 2773 if response.LastPage == 0 || response.LastPage <= page { 2774 break 2775 } 2776 page++ 2777 } 2778 return allLabels, nil 2779 } 2780 2781 // AddLabel adds a single github label to the repository. 2782 func (config *Config) AddLabel(label *github.Label) error { 2783 config.analytics.AddLabelToRepository.Call(config, nil) 2784 glog.Infof("Adding label %v to %v, %v", *label.Name, config.Org, config.Project) 2785 if config.DryRun { 2786 return nil 2787 } 2788 _, resp, err := config.client.Issues.CreateLabel( 2789 context.Background(), 2790 config.Org, 2791 config.Project, 2792 label, 2793 ) 2794 if err != nil { 2795 return suggestOauthScopes(resp, err) 2796 } 2797 return nil 2798 }