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