github.com/shashidharatd/test-infra@v0.0.0-20171006011030-71304e1ca560/prow/github/client.go (about) 1 /* 2 Copyright 2017 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package github 18 19 import ( 20 "bytes" 21 "context" 22 "encoding/base64" 23 "encoding/json" 24 "fmt" 25 "io" 26 "io/ioutil" 27 "net/http" 28 "net/url" 29 "strconv" 30 "strings" 31 "sync" 32 "time" 33 34 "github.com/shurcooL/githubql" 35 "golang.org/x/oauth2" 36 ) 37 38 type Logger interface { 39 Debugf(s string, v ...interface{}) 40 } 41 42 type Client struct { 43 // If Logger is non-nil, log all method calls with it. 44 Logger Logger 45 46 gqlc *githubql.Client 47 client *http.Client 48 token string 49 base string 50 dry bool 51 fake bool 52 53 // botName is protected by this mutex. 54 mut sync.Mutex 55 botName string 56 } 57 58 const ( 59 maxRetries = 8 60 max404Retries = 2 61 maxSleepTime = 2 * time.Minute 62 initialDelay = 2 * time.Second 63 ) 64 65 // NewClient creates a new fully operational GitHub client. 66 func NewClient(token, base string) *Client { 67 return &Client{ 68 gqlc: githubql.NewClient(oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}))), 69 client: &http.Client{}, 70 token: token, 71 base: base, 72 dry: false, 73 } 74 } 75 76 // NewDryRunClient creates a new client that will not perform mutating actions 77 // such as setting statuses or commenting, but it will still query GitHub and 78 // use up API tokens. 79 func NewDryRunClient(token, base string) *Client { 80 return &Client{ 81 client: &http.Client{}, 82 token: token, 83 base: base, 84 dry: true, 85 } 86 } 87 88 // NewFakeClient creates a new client that will not perform any actions at all. 89 func NewFakeClient() *Client { 90 return &Client{ 91 fake: true, 92 dry: true, 93 } 94 } 95 96 func (c *Client) log(methodName string, args ...interface{}) { 97 if c.Logger == nil { 98 return 99 } 100 var as []string 101 for _, arg := range args { 102 as = append(as, fmt.Sprintf("%v", arg)) 103 } 104 c.Logger.Debugf("%s(%s)", methodName, strings.Join(as, ", ")) 105 } 106 107 var timeSleep = time.Sleep 108 109 type request struct { 110 method string 111 path string 112 accept string 113 requestBody interface{} 114 exitCodes []int 115 } 116 117 // Make a request with retries. If ret is not nil, unmarshal the response body 118 // into it. Returns an error if the exit code is not one of the provided codes. 119 func (c *Client) request(r *request, ret interface{}) (int, error) { 120 if c.fake || (c.dry && r.method != http.MethodGet) { 121 return r.exitCodes[0], nil 122 } 123 resp, err := c.requestRetry(r.method, r.path, r.accept, r.requestBody) 124 if err != nil { 125 return 0, err 126 } 127 defer resp.Body.Close() 128 b, err := ioutil.ReadAll(resp.Body) 129 if err != nil { 130 return 0, err 131 } 132 var okCode bool 133 for _, code := range r.exitCodes { 134 if code == resp.StatusCode { 135 okCode = true 136 break 137 } 138 } 139 if !okCode { 140 return resp.StatusCode, fmt.Errorf("status code %d not one of %v, body: %s", resp.StatusCode, r.exitCodes, string(b)) 141 } 142 if ret != nil { 143 if err := json.Unmarshal(b, ret); err != nil { 144 return 0, err 145 } 146 } 147 return resp.StatusCode, nil 148 } 149 150 // Retry on transport failures. Retries on 500s, retries after sleep on 151 // ratelimit exceeded, and retries 404s a couple times. 152 func (c *Client) requestRetry(method, path, accept string, body interface{}) (*http.Response, error) { 153 var resp *http.Response 154 var err error 155 backoff := initialDelay 156 for retries := 0; retries < maxRetries; retries++ { 157 resp, err = c.doRequest(method, path, accept, body) 158 if err == nil { 159 if resp.StatusCode == 404 && retries < max404Retries { 160 // Retry 404s a couple times. Sometimes GitHub is inconsistent in 161 // the sense that they send us an event such as "PR opened" but an 162 // immediate request to GET the PR returns 404. We don't want to 163 // retry more than a couple times in this case, because a 404 may 164 // be caused by a bad API call and we'll just burn through API 165 // tokens. 166 resp.Body.Close() 167 timeSleep(backoff) 168 backoff *= 2 169 } else if resp.StatusCode == 403 { 170 if resp.Header.Get("X-RateLimit-Remaining") == "0" { 171 // If we are out of API tokens, sleep first. The X-RateLimit-Reset 172 // header tells us the time at which we can request again. 173 var t int 174 if t, err = strconv.Atoi(resp.Header.Get("X-RateLimit-Reset")); err == nil { 175 // Sleep an extra second plus how long GitHub wants us to 176 // sleep. If it's going to take too long, then break. 177 sleepTime := time.Until(time.Unix(int64(t), 0)) + time.Second 178 if sleepTime > 0 && sleepTime < maxSleepTime { 179 timeSleep(sleepTime) 180 } else { 181 break 182 } 183 } 184 } else if oauthScopes := resp.Header.Get("X-Accepted-OAuth-Scopes"); len(oauthScopes) > 0 { 185 err = fmt.Errorf("is the account using at least one of the following oauth scopes?: %s", oauthScopes) 186 break 187 } 188 resp.Body.Close() 189 } else if resp.StatusCode < 500 { 190 // Normal, happy case. 191 break 192 } else { 193 // Retry 500 after a break. 194 resp.Body.Close() 195 timeSleep(backoff) 196 backoff *= 2 197 } 198 } else { 199 timeSleep(backoff) 200 backoff *= 2 201 } 202 } 203 return resp, err 204 } 205 206 func (c *Client) doRequest(method, path, accept string, body interface{}) (*http.Response, error) { 207 var buf io.Reader 208 if body != nil { 209 b, err := json.Marshal(body) 210 if err != nil { 211 return nil, err 212 } 213 buf = bytes.NewBuffer(b) 214 } 215 req, err := http.NewRequest(method, path, buf) 216 if err != nil { 217 return nil, err 218 } 219 req.Header.Set("Authorization", "Token "+c.token) 220 if accept == "" { 221 req.Header.Add("Accept", "application/vnd.github.v3+json") 222 } else { 223 req.Header.Add("Accept", accept) 224 } 225 // Disable keep-alive so that we don't get flakes when GitHub closes the 226 // connection prematurely. 227 // https://go-review.googlesource.com/#/c/3210/ fixed it for GET, but not 228 // for POST. 229 req.Close = true 230 return c.client.Do(req) 231 } 232 233 func (c *Client) BotName() (string, error) { 234 c.mut.Lock() 235 defer c.mut.Unlock() 236 if c.botName == "" { 237 var u User 238 _, err := c.request(&request{ 239 method: http.MethodGet, 240 path: fmt.Sprintf("%s/user", c.base), 241 exitCodes: []int{200}, 242 }, &u) 243 if err != nil { 244 return "", fmt.Errorf("fetching bot name from GitHub: %v", err) 245 } 246 c.botName = u.Login 247 } 248 return c.botName, nil 249 } 250 251 // IsMember returns whether or not the user is a member of the org. 252 func (c *Client) IsMember(org, user string) (bool, error) { 253 c.log("IsMember", org, user) 254 code, err := c.request(&request{ 255 method: http.MethodGet, 256 path: fmt.Sprintf("%s/orgs/%s/members/%s", c.base, org, user), 257 exitCodes: []int{204, 404, 302}, 258 }, nil) 259 if err != nil { 260 return false, err 261 } 262 if code == 204 { 263 return true, nil 264 } else if code == 404 { 265 return false, nil 266 } else if code == 302 { 267 return false, fmt.Errorf("requester is not %s org member", org) 268 } 269 // Should be unreachable. 270 return false, fmt.Errorf("unexpected status: %d", code) 271 } 272 273 // CreateComment creates a comment on the issue. 274 func (c *Client) CreateComment(org, repo string, number int, comment string) error { 275 c.log("CreateComment", org, repo, number, comment) 276 ic := IssueComment{ 277 Body: comment, 278 } 279 _, err := c.request(&request{ 280 method: http.MethodPost, 281 path: fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", c.base, org, repo, number), 282 requestBody: &ic, 283 exitCodes: []int{201}, 284 }, nil) 285 return err 286 } 287 288 // DeleteComment deletes the comment. 289 func (c *Client) DeleteComment(org, repo string, ID int) error { 290 c.log("DeleteComment", org, repo, ID) 291 _, err := c.request(&request{ 292 method: http.MethodDelete, 293 path: fmt.Sprintf("%s/repos/%s/%s/issues/comments/%d", c.base, org, repo, ID), 294 exitCodes: []int{204}, 295 }, nil) 296 return err 297 } 298 299 func (c *Client) EditComment(org, repo string, ID int, comment string) error { 300 c.log("EditComment", org, repo, ID, comment) 301 ic := IssueComment{ 302 Body: comment, 303 } 304 _, err := c.request(&request{ 305 method: http.MethodPatch, 306 path: fmt.Sprintf("%s/repos/%s/%s/issues/comments/%d", c.base, org, repo, ID), 307 requestBody: &ic, 308 exitCodes: []int{200}, 309 }, nil) 310 return err 311 } 312 313 func (c *Client) CreateCommentReaction(org, repo string, ID int, reaction string) error { 314 c.log("CreateCommentReaction", org, repo, ID, reaction) 315 r := Reaction{Content: reaction} 316 _, err := c.request(&request{ 317 method: http.MethodPost, 318 path: fmt.Sprintf("%s/repos/%s/%s/issues/comments/%d/reactions", c.base, org, repo, ID), 319 accept: "application/vnd.github.squirrel-girl-preview", 320 exitCodes: []int{201}, 321 requestBody: &r, 322 }, nil) 323 return err 324 } 325 326 func (c *Client) CreateIssueReaction(org, repo string, ID int, reaction string) error { 327 c.log("CreateIssueReaction", org, repo, ID, reaction) 328 r := Reaction{Content: reaction} 329 _, err := c.request(&request{ 330 method: http.MethodPost, 331 path: fmt.Sprintf("%s/repos/%s/%s/issues/%d/reactions", c.base, org, repo, ID), 332 accept: "application/vnd.github.squirrel-girl-preview", 333 requestBody: &r, 334 exitCodes: []int{200, 201}, 335 }, nil) 336 return err 337 } 338 339 // DeleteStaleComments iterates over comments on an issue/PR, deleting those which the 'isStale' 340 // function identifies as stale. If 'comments' is nil, the comments will be fetched from github. 341 func (c *Client) DeleteStaleComments(org, repo string, number int, comments []IssueComment, isStale func(IssueComment) bool) error { 342 var err error 343 if comments == nil { 344 comments, err = c.ListIssueComments(org, repo, number) 345 if err != nil { 346 return fmt.Errorf("failed to list comments while deleting stale comments. err: %v", err) 347 } 348 } 349 for _, comment := range comments { 350 if isStale(comment) { 351 if err := c.DeleteComment(org, repo, comment.ID); err != nil { 352 return fmt.Errorf("failed to delete stale comment with ID '%d'", comment.ID) 353 } 354 } 355 } 356 return nil 357 } 358 359 // readPaginatedResults iterates over all objects in the paginated 360 // result indicated by the given url. The newObj function should 361 // return a new slice of the expected type, and the accumulate 362 // function should accept that populated slice for each page of 363 // results. An error is returned if encountered in making calls to 364 // github or marshalling objects. 365 func (c *Client) readPaginatedResults(path string, newObj func() interface{}, accumulate func(interface{})) error { 366 url := fmt.Sprintf("%s%s?per_page=100", c.base, path) 367 for url != "" { 368 resp, err := c.requestRetry(http.MethodGet, url, "", nil) 369 if err != nil { 370 return err 371 } 372 defer resp.Body.Close() 373 if resp.StatusCode < 200 || resp.StatusCode > 299 { 374 return fmt.Errorf("return code not 2XX: %s", resp.Status) 375 } 376 377 b, err := ioutil.ReadAll(resp.Body) 378 if err != nil { 379 return err 380 } 381 382 obj := newObj() 383 if err := json.Unmarshal(b, obj); err != nil { 384 return err 385 } 386 387 accumulate(obj) 388 389 url = parseLinks(resp.Header.Get("Link"))["next"] 390 } 391 return nil 392 } 393 394 // ListIssueComments returns all comments on an issue. This may use more than 395 // one API token. 396 func (c *Client) ListIssueComments(org, repo string, number int) ([]IssueComment, error) { 397 c.log("ListIssueComments", org, repo, number) 398 if c.fake { 399 return nil, nil 400 } 401 path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", org, repo, number) 402 var comments []IssueComment 403 err := c.readPaginatedResults(path, 404 func() interface{} { 405 return &[]IssueComment{} 406 }, 407 func(obj interface{}) { 408 comments = append(comments, *(obj.(*[]IssueComment))...) 409 }, 410 ) 411 if err != nil { 412 return nil, err 413 } 414 return comments, nil 415 } 416 417 // GetPullRequest gets a pull request. 418 func (c *Client) GetPullRequest(org, repo string, number int) (*PullRequest, error) { 419 c.log("GetPullRequest", org, repo, number) 420 var pr PullRequest 421 _, err := c.request(&request{ 422 method: http.MethodGet, 423 path: fmt.Sprintf("%s/repos/%s/%s/pulls/%d", c.base, org, repo, number), 424 exitCodes: []int{200}, 425 }, &pr) 426 return &pr, err 427 } 428 429 // GetPullRequestChanges gets a list of files modified in a pull request. 430 func (c *Client) GetPullRequestChanges(org, repo string, number int) ([]PullRequestChange, error) { 431 c.log("GetPullRequestChanges", org, repo, number) 432 if c.fake { 433 return []PullRequestChange{}, nil 434 } 435 path := fmt.Sprintf("/repos/%s/%s/pulls/%d/files", org, repo, number) 436 var changes []PullRequestChange 437 err := c.readPaginatedResults(path, 438 func() interface{} { 439 return &[]PullRequestChange{} 440 }, 441 func(obj interface{}) { 442 changes = append(changes, *(obj.(*[]PullRequestChange))...) 443 }, 444 ) 445 if err != nil { 446 return nil, err 447 } 448 return changes, nil 449 } 450 451 // ListPullRequestComments returns all comments on a pull request. This may use 452 // more than one API token. 453 func (c *Client) ListPullRequestComments(org, repo string, number int) ([]ReviewComment, error) { 454 c.log("ListPullRequestComments", org, repo, number) 455 if c.fake { 456 return nil, nil 457 } 458 path := fmt.Sprintf("/repos/%s/%s/pulls/%d/comments", org, repo, number) 459 var comments []ReviewComment 460 err := c.readPaginatedResults(path, 461 func() interface{} { 462 return &[]ReviewComment{} 463 }, 464 func(obj interface{}) { 465 comments = append(comments, *(obj.(*[]ReviewComment))...) 466 }, 467 ) 468 if err != nil { 469 return nil, err 470 } 471 return comments, nil 472 } 473 474 // CreateStatus creates or updates the status of a commit. 475 func (c *Client) CreateStatus(org, repo, ref string, s Status) error { 476 c.log("CreateStatus", org, repo, ref, s) 477 _, err := c.request(&request{ 478 method: http.MethodPost, 479 path: fmt.Sprintf("%s/repos/%s/%s/statuses/%s", c.base, org, repo, ref), 480 requestBody: &s, 481 exitCodes: []int{201}, 482 }, nil) 483 return err 484 } 485 486 func (c *Client) GetRepos(org string, isUser bool) ([]Repo, error) { 487 c.log("GetRepos", org, isUser) 488 var ( 489 repos []Repo 490 nextURL string 491 ) 492 if c.fake { 493 return repos, nil 494 } 495 if isUser { 496 nextURL = fmt.Sprintf("%s/users/%s/repos", c.base, org) 497 } else { 498 nextURL = fmt.Sprintf("%s/orgs/%s/repos", c.base, org) 499 } 500 for nextURL != "" { 501 resp, err := c.requestRetry(http.MethodGet, nextURL, "", nil) 502 if err != nil { 503 return nil, err 504 } 505 defer resp.Body.Close() 506 if resp.StatusCode < 200 || resp.StatusCode > 299 { 507 return nil, fmt.Errorf("return code not 2XX: %s", resp.Status) 508 } 509 510 b, err := ioutil.ReadAll(resp.Body) 511 if err != nil { 512 return nil, err 513 } 514 515 var reps []Repo 516 if err := json.Unmarshal(b, &reps); err != nil { 517 return nil, err 518 } 519 repos = append(repos, reps...) 520 nextURL = parseLinks(resp.Header.Get("Link"))["next"] 521 } 522 return repos, nil 523 } 524 525 // Adds Label label/color to given org/repo 526 func (c *Client) AddRepoLabel(org, repo, label, color string) error { 527 c.log("AddRepoLabel", org, repo, label, color) 528 _, err := c.request(&request{ 529 method: http.MethodPost, 530 path: fmt.Sprintf("%s/repos/%s/%s/labels", c.base, org, repo), 531 requestBody: Label{Name: label, Color: color}, 532 exitCodes: []int{201}, 533 }, nil) 534 return err 535 } 536 537 // Updates org/repo label to label/color 538 func (c *Client) UpdateRepoLabel(org, repo, label, color string) error { 539 c.log("UpdateRepoLabel", org, repo, label, color) 540 _, err := c.request(&request{ 541 method: http.MethodPatch, 542 path: fmt.Sprintf("%s/repos/%s/%s/labels/%s", c.base, org, repo, label), 543 requestBody: Label{Name: label, Color: color}, 544 exitCodes: []int{200}, 545 }, nil) 546 return err 547 } 548 549 // GetCombinedStatus returns the latest statuses for a given ref. 550 func (c *Client) GetCombinedStatus(org, repo, ref string) (*CombinedStatus, error) { 551 c.log("GetCombinedStatus", org, repo, ref) 552 var combinedStatus CombinedStatus 553 _, err := c.request(&request{ 554 method: http.MethodGet, 555 path: fmt.Sprintf("%s/repos/%s/%s/commits/%s/status", c.base, org, repo, ref), 556 exitCodes: []int{200}, 557 }, &combinedStatus) 558 return &combinedStatus, err 559 } 560 561 // getLabels is a helper function that retrieves a paginated list of labels from a github URI path. 562 func (c *Client) getLabels(path string) ([]Label, error) { 563 var labels []Label 564 if c.fake { 565 return labels, nil 566 } 567 err := c.readPaginatedResults(path, 568 func() interface{} { 569 return &[]Label{} 570 }, 571 func(obj interface{}) { 572 labels = append(labels, *(obj.(*[]Label))...) 573 }, 574 ) 575 if err != nil { 576 return nil, err 577 } 578 return labels, nil 579 } 580 581 func (c *Client) GetRepoLabels(org, repo string) ([]Label, error) { 582 c.log("GetRepoLabels", org, repo) 583 return c.getLabels(fmt.Sprintf("/repos/%s/%s/labels", org, repo)) 584 } 585 586 func (c *Client) GetIssueLabels(org, repo string, number int) ([]Label, error) { 587 c.log("GetIssueLabels", org, repo, number) 588 return c.getLabels(fmt.Sprintf("/repos/%s/%s/issues/%d/labels", org, repo, number)) 589 } 590 591 func (c *Client) AddLabel(org, repo string, number int, label string) error { 592 c.log("AddLabel", org, repo, number, label) 593 _, err := c.request(&request{ 594 method: http.MethodPost, 595 path: fmt.Sprintf("%s/repos/%s/%s/issues/%d/labels", c.base, org, repo, number), 596 requestBody: []string{label}, 597 exitCodes: []int{200}, 598 }, nil) 599 return err 600 } 601 602 func (c *Client) RemoveLabel(org, repo string, number int, label string) error { 603 c.log("RemoveLabel", org, repo, number, label) 604 _, err := c.request(&request{ 605 method: http.MethodDelete, 606 path: fmt.Sprintf("%s/repos/%s/%s/issues/%d/labels/%s", c.base, org, repo, number, label), 607 // GitHub sometimes returns 200 for this call, which is a bug on their end. 608 exitCodes: []int{200, 204}, 609 }, nil) 610 return err 611 } 612 613 type MissingUsers struct { 614 Users []string 615 action string 616 } 617 618 func (m MissingUsers) Error() string { 619 return fmt.Sprintf("could not %s the following user(s): %s.", m.action, strings.Join(m.Users, ", ")) 620 } 621 622 func (c *Client) AssignIssue(org, repo string, number int, logins []string) error { 623 c.log("AssignIssue", org, repo, number, logins) 624 assigned := make(map[string]bool) 625 var i Issue 626 _, err := c.request(&request{ 627 method: http.MethodPost, 628 path: fmt.Sprintf("%s/repos/%s/%s/issues/%d/assignees", c.base, org, repo, number), 629 requestBody: map[string][]string{"assignees": logins}, 630 exitCodes: []int{201}, 631 }, &i) 632 if err != nil { 633 return err 634 } 635 for _, assignee := range i.Assignees { 636 assigned[NormLogin(assignee.Login)] = true 637 } 638 missing := MissingUsers{action: "assign"} 639 for _, login := range logins { 640 if !assigned[NormLogin(login)] { 641 missing.Users = append(missing.Users, login) 642 } 643 } 644 if len(missing.Users) > 0 { 645 return missing 646 } 647 return nil 648 } 649 650 type ExtraUsers struct { 651 Users []string 652 action string 653 } 654 655 func (e ExtraUsers) Error() string { 656 return fmt.Sprintf("could not %s the following user(s): %s.", e.action, strings.Join(e.Users, ", ")) 657 } 658 659 func (c *Client) UnassignIssue(org, repo string, number int, logins []string) error { 660 c.log("UnassignIssue", org, repo, number, logins) 661 assigned := make(map[string]bool) 662 var i Issue 663 _, err := c.request(&request{ 664 method: http.MethodDelete, 665 path: fmt.Sprintf("%s/repos/%s/%s/issues/%d/assignees", c.base, org, repo, number), 666 requestBody: map[string][]string{"assignees": logins}, 667 exitCodes: []int{200}, 668 }, &i) 669 if err != nil { 670 return err 671 } 672 for _, assignee := range i.Assignees { 673 assigned[NormLogin(assignee.Login)] = true 674 } 675 extra := ExtraUsers{action: "unassign"} 676 for _, login := range logins { 677 if assigned[NormLogin(login)] { 678 extra.Users = append(extra.Users, login) 679 } 680 } 681 if len(extra.Users) > 0 { 682 return extra 683 } 684 return nil 685 } 686 687 // CreateReview creates a review using the draft. 688 func (c *Client) CreateReview(org, repo string, number int, r DraftReview) error { 689 c.log("CreateReview", org, repo, number, r) 690 _, err := c.request(&request{ 691 method: http.MethodPost, 692 path: fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", c.base, org, repo, number), 693 accept: "application/vnd.github.black-cat-preview+json", 694 requestBody: r, 695 exitCodes: []int{200}, 696 }, nil) 697 return err 698 } 699 700 func (c *Client) tryRequestReview(org, repo string, number int, logins []string) (int, error) { 701 c.log("RequestReview", org, repo, number, logins) 702 var pr PullRequest 703 return c.request(&request{ 704 method: http.MethodPost, 705 path: fmt.Sprintf("%s/repos/%s/%s/pulls/%d/requested_reviewers", c.base, org, repo, number), 706 accept: "application/vnd.github.black-cat-preview+json", 707 requestBody: map[string][]string{"reviewers": logins}, 708 exitCodes: []int{http.StatusCreated /*201*/}, 709 }, &pr) 710 } 711 712 // RequestReview tries to add the users listed in 'logins' as requested reviewers of the specified PR. 713 // If any user in the 'logins' slice is not a contributor of the repo, the entire POST will fail 714 // without adding any reviewers. The github API response does not specify which user(s) were invalid 715 // so if we fail to request reviews from the members of 'logins' we try to request reviews from 716 // each member individually. We try first with all users in 'logins' for efficiency in the common case. 717 func (c *Client) RequestReview(org, repo string, number int, logins []string) error { 718 statusCode, err := c.tryRequestReview(org, repo, number, logins) 719 if err != nil && statusCode == http.StatusUnprocessableEntity /*422*/ { 720 // Failed to set all members of 'logins' as reviewers, try individually. 721 missing := MissingUsers{action: "request a PR review from"} 722 for _, user := range logins { 723 statusCode, err = c.tryRequestReview(org, repo, number, []string{user}) 724 if err != nil && statusCode == http.StatusUnprocessableEntity /*422*/ { 725 // User is not a contributor. 726 missing.Users = append(missing.Users, user) 727 } else if err != nil { 728 return fmt.Errorf("failed to add reviewer to PR. Status code: %d, errmsg: %v", statusCode, err) 729 } 730 } 731 if len(missing.Users) > 0 { 732 return missing 733 } 734 return nil 735 } 736 return err 737 } 738 739 // UnrequestReview tries to remove the users listed in 'logins' from the requested reviewers of the 740 // specified PR. The github API treats deletions of review requests differently than creations. Specifically, if 741 // 'logins' contains a user that isn't a requested reviewer, other users that are valid are still removed. 742 // Furthermore, the API response lists the set of requested reviewers after the deletion (unlike request creations), 743 // so we can determine if each deletion was successful. 744 // The API responds with http status code 200 no matter what the content of 'logins' is. 745 func (c *Client) UnrequestReview(org, repo string, number int, logins []string) error { 746 c.log("UnrequestReview", org, repo, number, logins) 747 var pr PullRequest 748 _, err := c.request(&request{ 749 method: http.MethodDelete, 750 path: fmt.Sprintf("%s/repos/%s/%s/pulls/%d/requested_reviewers", c.base, org, repo, number), 751 accept: "application/vnd.github.black-cat-preview+json", 752 requestBody: map[string][]string{"reviewers": logins}, 753 exitCodes: []int{http.StatusOK /*200*/}, 754 }, &pr) 755 if err != nil { 756 return err 757 } 758 extras := ExtraUsers{action: "remove the PR review request for"} 759 for _, user := range pr.RequestedReviewers { 760 found := false 761 for _, toDelete := range logins { 762 if NormLogin(user.Login) == NormLogin(toDelete) { 763 found = true 764 break 765 } 766 } 767 if found { 768 extras.Users = append(extras.Users, user.Login) 769 } 770 } 771 if len(extras.Users) > 0 { 772 return extras 773 } 774 return nil 775 } 776 777 // CloseIssue closes the existing, open issue provided 778 func (c *Client) CloseIssue(org, repo string, number int) error { 779 c.log("CloseIssue", org, repo, number) 780 _, err := c.request(&request{ 781 method: http.MethodPatch, 782 path: fmt.Sprintf("%s/repos/%s/%s/issues/%d", c.base, org, repo, number), 783 requestBody: map[string]string{"state": "closed"}, 784 exitCodes: []int{200}, 785 }, nil) 786 return err 787 } 788 789 // ReopenIssue re-opens the existing, closed issue provided 790 func (c *Client) ReopenIssue(org, repo string, number int) error { 791 c.log("ReopenIssue", org, repo, number) 792 _, err := c.request(&request{ 793 method: http.MethodPatch, 794 path: fmt.Sprintf("%s/repos/%s/%s/issues/%d", c.base, org, repo, number), 795 requestBody: map[string]string{"state": "open"}, 796 exitCodes: []int{200}, 797 }, nil) 798 return err 799 } 800 801 // ClosePR closes the existing, open PR provided 802 func (c *Client) ClosePR(org, repo string, number int) error { 803 c.log("ClosePR", org, repo, number) 804 _, err := c.request(&request{ 805 method: http.MethodPatch, 806 path: fmt.Sprintf("%s/repos/%s/%s/pulls/%d", c.base, org, repo, number), 807 requestBody: map[string]string{"state": "closed"}, 808 exitCodes: []int{200}, 809 }, nil) 810 return err 811 } 812 813 // ReopenPR re-opens the existing, closed PR provided 814 func (c *Client) ReopenPR(org, repo string, number int) error { 815 c.log("ReopenPR", org, repo, number) 816 _, err := c.request(&request{ 817 method: http.MethodPatch, 818 path: fmt.Sprintf("%s/repos/%s/%s/pulls/%d", c.base, org, repo, number), 819 requestBody: map[string]string{"state": "open"}, 820 exitCodes: []int{200}, 821 }, nil) 822 return err 823 } 824 825 // GetRef returns the SHA of the given ref, such as "heads/master". 826 func (c *Client) GetRef(org, repo, ref string) (string, error) { 827 c.log("GetRef", org, repo, ref) 828 var res struct { 829 Object map[string]string `json:"object"` 830 } 831 _, err := c.request(&request{ 832 method: http.MethodGet, 833 path: fmt.Sprintf("%s/repos/%s/%s/git/refs/%s", c.base, org, repo, ref), 834 exitCodes: []int{200}, 835 }, &res) 836 return res.Object["sha"], err 837 } 838 839 // FindIssues uses the github search API to find issues which match a particular query. 840 // 841 // Input query the same way you would into the website. 842 // Order returned results with sort (usually "updated"). 843 // Control whether oldest/newest is first with asc. 844 // 845 // See https://help.github.com/articles/searching-issues-and-pull-requests/ for details. 846 func (c *Client) FindIssues(query, sort string, asc bool) ([]Issue, error) { 847 c.log("FindIssues", query) 848 path := fmt.Sprintf("%s/search/issues?q=%s", c.base, url.QueryEscape(query)) 849 if sort != "" { 850 path += "&sort=" + url.QueryEscape(sort) 851 if asc { 852 path += "&order=asc" 853 } 854 } 855 var issSearchResult IssuesSearchResult 856 _, err := c.request(&request{ 857 method: http.MethodGet, 858 path: path, 859 exitCodes: []int{200}, 860 }, &issSearchResult) 861 return issSearchResult.Issues, err 862 } 863 864 type FileNotFound struct { 865 org, repo, path, commit string 866 } 867 868 func (e *FileNotFound) Error() string { 869 return fmt.Sprintf("%s/%s/%s @ %s not found", e.org, e.repo, e.path, e.commit) 870 } 871 872 // GetFile uses github repo contents API to retrieve the content of a file with commit sha. 873 // If commit is empty, it will grab content from repo's default branch, usually master. 874 // TODO(krzyzacy): Support retrieve a directory 875 func (c *Client) GetFile(org, repo, filepath, commit string) ([]byte, error) { 876 c.log("GetFile", org, repo, filepath, commit) 877 878 url := fmt.Sprintf("%s/repos/%s/%s/contents/%s", c.base, org, repo, filepath) 879 if commit != "" { 880 url = fmt.Sprintf("%s?ref=%s", url, commit) 881 } 882 883 var res Content 884 code, err := c.request(&request{ 885 method: http.MethodGet, 886 path: url, 887 exitCodes: []int{200, 404}, 888 }, &res) 889 890 if err != nil { 891 return nil, err 892 } 893 894 if code == 404 { 895 return nil, &FileNotFound{ 896 org: org, 897 repo: repo, 898 path: filepath, 899 commit: commit, 900 } 901 } 902 903 decoded, err := base64.StdEncoding.DecodeString(res.Content) 904 if err != nil { 905 return nil, fmt.Errorf("error decoding %s : %v", res.Content, err) 906 } 907 908 return decoded, nil 909 } 910 911 // Query runs a GraphQL query using shurcooL/githubql's client. 912 func (c *Client) Query(ctx context.Context, q interface{}, vars map[string]interface{}) error { 913 c.log("Query", q, vars) 914 return c.gqlc.Query(ctx, q, vars) 915 } 916 917 // ListTeams gets a list of teams for the given org 918 func (c *Client) ListTeams(org string) ([]Team, error) { 919 c.log("ListTeams", org) 920 if c.fake { 921 return nil, nil 922 } 923 path := fmt.Sprintf("/orgs/%s/teams", org) 924 var teams []Team 925 err := c.readPaginatedResults(path, 926 func() interface{} { 927 return &[]Team{} 928 }, 929 func(obj interface{}) { 930 teams = append(teams, *(obj.(*[]Team))...) 931 }, 932 ) 933 if err != nil { 934 return nil, err 935 } 936 return teams, nil 937 } 938 939 // ListTeamMembers gets a list of team members for the given team id 940 func (c *Client) ListTeamMembers(id int) ([]TeamMember, error) { 941 c.log("ListTeamMembers", id) 942 if c.fake { 943 return nil, nil 944 } 945 path := fmt.Sprintf("/teams/%d/members", id) 946 var teamMembers []TeamMember 947 err := c.readPaginatedResults(path, 948 func() interface{} { 949 return &[]TeamMember{} 950 }, 951 func(obj interface{}) { 952 teamMembers = append(teamMembers, *(obj.(*[]TeamMember))...) 953 }, 954 ) 955 if err != nil { 956 return nil, err 957 } 958 return teamMembers, nil 959 } 960 961 // MergeDetails contains desired properties of the merge. 962 // See https://developer.github.com/v3/pulls/#merge-a-pull-request-merge-button 963 type MergeDetails struct { 964 // CommitTitle defaults to the automatic message. 965 CommitTitle string `json:"commit_title,omitempty"` 966 // CommitMessage defaults to the automatic message. 967 CommitMessage string `json:"commit_message,omitempty"` 968 // The PR HEAD must match this to prevent races. 969 SHA string `json:"sha,omitempty"` 970 // Can be "merge", "squash", or "rebase". Defaults to merge. 971 MergeMethod string `json:"merge_method,omitempty"` 972 } 973 974 type ModifiedHeadError string 975 976 func (e ModifiedHeadError) Error() string { return string(e) } 977 978 type UnmergablePRError string 979 980 func (e UnmergablePRError) Error() string { return string(e) } 981 982 // Merge merges a PR. 983 func (c *Client) Merge(org, repo string, pr int, details MergeDetails) error { 984 c.log("Merge", org, repo, pr, details) 985 var res struct { 986 Message string `json:"message"` 987 } 988 ec, err := c.request(&request{ 989 method: http.MethodPut, 990 path: fmt.Sprintf("%s/repos/%s/%s/pulls/%d/merge", c.base, org, repo, pr), 991 requestBody: &details, 992 exitCodes: []int{200, 405, 409}, 993 }, &res) 994 if err != nil { 995 return err 996 } 997 if ec == 405 { 998 return UnmergablePRError(res.Message) 999 } else if ec == 409 { 1000 return ModifiedHeadError(res.Message) 1001 } 1002 1003 return nil 1004 }