github.com/abayer/test-infra@v0.0.5/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 "errors" 25 "fmt" 26 "io" 27 "io/ioutil" 28 "net/http" 29 "net/url" 30 "regexp" 31 "strconv" 32 "strings" 33 "sync" 34 "sync/atomic" 35 "time" 36 37 "github.com/shurcooL/githubv4" 38 "github.com/sirupsen/logrus" 39 "golang.org/x/oauth2" 40 41 "k8s.io/test-infra/prow/errorutil" 42 ) 43 44 type timeClient interface { 45 Sleep(time.Duration) 46 Until(time.Time) time.Duration 47 } 48 49 type standardTime struct{} 50 51 func (s *standardTime) Sleep(d time.Duration) { 52 time.Sleep(d) 53 } 54 func (s *standardTime) Until(t time.Time) time.Duration { 55 return time.Until(t) 56 } 57 58 // Client interacts with the github api. 59 type Client struct { 60 // If logger is non-nil, log all method calls with it. 61 logger *logrus.Entry 62 time timeClient 63 64 gqlc gqlClient 65 client httpClient 66 bases []string 67 dry bool 68 fake bool 69 throttle throttler 70 getToken func() []byte 71 72 mut sync.Mutex // protects botName and email 73 botName string 74 email string 75 } 76 77 var ( 78 maxRetries = 8 79 max404Retries = 2 80 maxSleepTime = 2 * time.Minute 81 initialDelay = 2 * time.Second 82 teamRe = regexp.MustCompile(`^(.*)/(.*)$`) 83 ) 84 85 const ( 86 acceptNone = "" 87 ) 88 89 // Interface for how prow interacts with the http client, which we may throttle. 90 type httpClient interface { 91 Do(req *http.Request) (*http.Response, error) 92 } 93 94 // Interface for how prow interacts with the graphql client, which we may throttle. 95 type gqlClient interface { 96 Query(ctx context.Context, q interface{}, vars map[string]interface{}) error 97 } 98 99 // throttler sets a ceiling on the rate of GitHub requests. 100 // Configure with Client.Throttle() 101 type throttler struct { 102 ticker *time.Ticker 103 throttle chan time.Time 104 http httpClient 105 graph gqlClient 106 slow int32 // Helps log once when requests start/stop being throttled 107 lock sync.RWMutex 108 } 109 110 func (t *throttler) Wait() { 111 log := logrus.WithFields(logrus.Fields{"client": "github", "throttled": true}) 112 t.lock.RLock() 113 defer t.lock.RUnlock() 114 var more bool 115 select { 116 case _, more = <-t.throttle: 117 // If we were throttled and the channel is now somewhat (25%+) full, note this 118 if len(t.throttle) > cap(t.throttle)/4 && atomic.CompareAndSwapInt32(&t.slow, 1, 0) { 119 log.Debug("Unthrottled") 120 } 121 if !more { 122 log.Debug("Throttle channel closed") 123 } 124 return 125 default: // Do not wait if nothing is available right now 126 } 127 // If this is the first time we are waiting, note this 128 if slow := atomic.SwapInt32(&t.slow, 1); slow == 0 { 129 log.Debug("Throttled") 130 } 131 _, more = <-t.throttle 132 if !more { 133 log.Debug("Throttle channel closed") 134 } 135 } 136 137 func (t *throttler) Do(req *http.Request) (*http.Response, error) { 138 t.Wait() 139 return t.http.Do(req) 140 } 141 142 func (t *throttler) Query(ctx context.Context, q interface{}, vars map[string]interface{}) error { 143 t.Wait() 144 return t.graph.Query(ctx, q, vars) 145 } 146 147 // Throttle client to a rate of at most hourlyTokens requests per hour, 148 // allowing burst tokens. 149 func (c *Client) Throttle(hourlyTokens, burst int) { 150 c.log("Throttle", hourlyTokens, burst) 151 c.throttle.lock.Lock() 152 defer c.throttle.lock.Unlock() 153 previouslyThrottled := c.throttle.ticker != nil 154 if hourlyTokens <= 0 || burst <= 0 { // Disable throttle 155 if previouslyThrottled { // Unwrap clients if necessary 156 c.client = c.throttle.http 157 c.gqlc = c.throttle.graph 158 c.throttle.ticker.Stop() 159 c.throttle.ticker = nil 160 } 161 return 162 } 163 rate := time.Hour / time.Duration(hourlyTokens) 164 ticker := time.NewTicker(rate) 165 throttle := make(chan time.Time, burst) 166 for i := 0; i < burst; i++ { // Fill up the channel 167 throttle <- time.Now() 168 } 169 go func() { 170 // Refill the channel 171 for t := range ticker.C { 172 select { 173 case throttle <- t: 174 default: 175 } 176 } 177 }() 178 if !previouslyThrottled { // Wrap clients if we haven't already 179 c.throttle.http = c.client 180 c.throttle.graph = c.gqlc 181 c.client = &c.throttle 182 c.gqlc = &c.throttle 183 } 184 c.throttle.ticker = ticker 185 c.throttle.throttle = throttle 186 } 187 188 // NewClient creates a new fully operational GitHub client. 189 // 'getToken' is a generator for the GitHub access token to use. 190 // 'bases' is a variadic slice of endpoints to use in order of preference. 191 // An endpoint is used when all preceding endpoints have returned a conn err. 192 // This should be used when using the ghproxy GitHub proxy cache to allow 193 // this client to bypass the cache if it is temporarily unavailable. 194 func NewClient(getToken func() []byte, bases ...string) *Client { 195 return &Client{ 196 logger: logrus.WithField("client", "github"), 197 time: &standardTime{}, 198 gqlc: githubv4.NewClient(oauth2.NewClient(context.Background(), 199 oauth2.StaticTokenSource(&oauth2.Token{AccessToken: string(getToken())}))), 200 client: &http.Client{}, 201 bases: bases, 202 getToken: getToken, 203 dry: false, 204 } 205 } 206 207 // NewDryRunClient creates a new client that will not perform mutating actions 208 // such as setting statuses or commenting, but it will still query GitHub and 209 // use up API tokens. 210 // 'getToken' is a generator the GitHub access token to use. 211 // 'bases' is a variadic slice of endpoints to use in order of preference. 212 // An endpoint is used when all preceding endpoints have returned a conn err. 213 // This should be used when using the ghproxy GitHub proxy cache to allow 214 // this client to bypass the cache if it is temporarily unavailable. 215 func NewDryRunClient(getToken func() []byte, bases ...string) *Client { 216 return &Client{ 217 logger: logrus.WithField("client", "github"), 218 time: &standardTime{}, 219 gqlc: githubv4.NewClient(oauth2.NewClient(context.Background(), 220 oauth2.StaticTokenSource(&oauth2.Token{AccessToken: string(getToken())}))), 221 client: &http.Client{}, 222 bases: bases, 223 getToken: getToken, 224 dry: true, 225 } 226 } 227 228 // NewFakeClient creates a new client that will not perform any actions at all. 229 func NewFakeClient() *Client { 230 return &Client{ 231 logger: logrus.WithField("client", "github"), 232 time: &standardTime{}, 233 fake: true, 234 dry: true, 235 } 236 } 237 238 func (c *Client) log(methodName string, args ...interface{}) { 239 if c.logger == nil { 240 return 241 } 242 var as []string 243 for _, arg := range args { 244 as = append(as, fmt.Sprintf("%v", arg)) 245 } 246 c.logger.Infof("%s(%s)", methodName, strings.Join(as, ", ")) 247 } 248 249 type request struct { 250 method string 251 path string 252 accept string 253 requestBody interface{} 254 exitCodes []int 255 } 256 257 type requestError struct { 258 ClientError 259 ErrorString string 260 } 261 262 func (r requestError) Error() string { 263 return r.ErrorString 264 } 265 266 // Make a request with retries. If ret is not nil, unmarshal the response body 267 // into it. Returns an error if the exit code is not one of the provided codes. 268 func (c *Client) request(r *request, ret interface{}) (int, error) { 269 statusCode, b, err := c.requestRaw(r) 270 if err != nil { 271 return statusCode, err 272 } 273 if ret != nil { 274 if err := json.Unmarshal(b, ret); err != nil { 275 return statusCode, err 276 } 277 } 278 return statusCode, nil 279 } 280 281 // requestRaw makes a request with retries and returns the response body. 282 // Returns an error if the exit code is not one of the provided codes. 283 func (c *Client) requestRaw(r *request) (int, []byte, error) { 284 if c.fake || (c.dry && r.method != http.MethodGet) { 285 return r.exitCodes[0], nil, nil 286 } 287 resp, err := c.requestRetry(r.method, r.path, r.accept, r.requestBody) 288 if err != nil { 289 return 0, nil, err 290 } 291 defer resp.Body.Close() 292 b, err := ioutil.ReadAll(resp.Body) 293 if err != nil { 294 return 0, nil, err 295 } 296 var okCode bool 297 for _, code := range r.exitCodes { 298 if code == resp.StatusCode { 299 okCode = true 300 break 301 } 302 } 303 if !okCode { 304 clientError := ClientError{} 305 if err := json.Unmarshal(b, &clientError); err != nil { 306 return resp.StatusCode, b, err 307 } 308 return resp.StatusCode, b, requestError{ 309 ClientError: clientError, 310 ErrorString: fmt.Sprintf("status code %d not one of %v, body: %s", resp.StatusCode, r.exitCodes, string(b)), 311 } 312 } 313 return resp.StatusCode, b, nil 314 } 315 316 // Retry on transport failures. Retries on 500s, retries after sleep on 317 // ratelimit exceeded, and retries 404s a couple times. 318 // This function closes the response body iff it also returns an error. 319 func (c *Client) requestRetry(method, path, accept string, body interface{}) (*http.Response, error) { 320 var hostIndex int 321 var resp *http.Response 322 var err error 323 backoff := initialDelay 324 for retries := 0; retries < maxRetries; retries++ { 325 if retries > 0 && resp != nil { 326 resp.Body.Close() 327 } 328 resp, err = c.doRequest(method, c.bases[hostIndex]+path, accept, body) 329 if err == nil { 330 if resp.StatusCode == 404 && retries < max404Retries { 331 // Retry 404s a couple times. Sometimes GitHub is inconsistent in 332 // the sense that they send us an event such as "PR opened" but an 333 // immediate request to GET the PR returns 404. We don't want to 334 // retry more than a couple times in this case, because a 404 may 335 // be caused by a bad API call and we'll just burn through API 336 // tokens. 337 c.time.Sleep(backoff) 338 backoff *= 2 339 } else if resp.StatusCode == 403 { 340 if resp.Header.Get("X-RateLimit-Remaining") == "0" { 341 // If we are out of API tokens, sleep first. The X-RateLimit-Reset 342 // header tells us the time at which we can request again. 343 var t int 344 if t, err = strconv.Atoi(resp.Header.Get("X-RateLimit-Reset")); err == nil { 345 // Sleep an extra second plus how long GitHub wants us to 346 // sleep. If it's going to take too long, then break. 347 sleepTime := c.time.Until(time.Unix(int64(t), 0)) + time.Second 348 if sleepTime < maxSleepTime { 349 c.time.Sleep(sleepTime) 350 } else { 351 err = fmt.Errorf("sleep time for token reset exceeds max sleep time (%v > %v)", sleepTime, maxSleepTime) 352 resp.Body.Close() 353 break 354 } 355 } else { 356 err = fmt.Errorf("failed to parse rate limit reset unix time %q: %v", resp.Header.Get("X-RateLimit-Reset"), err) 357 resp.Body.Close() 358 break 359 } 360 } else if oauthScopes := resp.Header.Get("X-Accepted-OAuth-Scopes"); len(oauthScopes) > 0 { 361 err = fmt.Errorf("is the account using at least one of the following oauth scopes?: %s", oauthScopes) 362 resp.Body.Close() 363 break 364 } 365 } else if resp.StatusCode < 500 { 366 // Normal, happy case. 367 break 368 } else { 369 // Retry 500 after a break. 370 c.time.Sleep(backoff) 371 backoff *= 2 372 } 373 } else { 374 // Connection problem. Try a different host. 375 hostIndex = (hostIndex + 1) % len(c.bases) 376 c.time.Sleep(backoff) 377 backoff *= 2 378 } 379 } 380 return resp, err 381 } 382 383 func (c *Client) doRequest(method, path, accept string, body interface{}) (*http.Response, error) { 384 var buf io.Reader 385 if body != nil { 386 b, err := json.Marshal(body) 387 if err != nil { 388 return nil, err 389 } 390 buf = bytes.NewBuffer(b) 391 } 392 req, err := http.NewRequest(method, path, buf) 393 if err != nil { 394 return nil, err 395 } 396 req.Header.Set("Authorization", "Token "+string(c.getToken())) 397 if accept == acceptNone { 398 req.Header.Add("Accept", "application/vnd.github.v3+json") 399 } else { 400 req.Header.Add("Accept", accept) 401 } 402 // Disable keep-alive so that we don't get flakes when GitHub closes the 403 // connection prematurely. 404 // https://go-review.googlesource.com/#/c/3210/ fixed it for GET, but not 405 // for POST. 406 req.Close = true 407 return c.client.Do(req) 408 } 409 410 // Not thread-safe - callers need to hold c.mut. 411 func (c *Client) getUserData() error { 412 var u User 413 _, err := c.request(&request{ 414 method: http.MethodGet, 415 path: "/user", 416 exitCodes: []int{200}, 417 }, &u) 418 if err != nil { 419 return err 420 } 421 c.botName = u.Login 422 // email needs to be publicly accessible via the profile 423 // of the current account. Read below for more info 424 // https://developer.github.com/v3/users/#get-a-single-user 425 c.email = u.Email 426 return nil 427 } 428 429 // BotName returns the login of the authenticated identity. 430 // 431 // See https://developer.github.com/v3/users/#get-the-authenticated-user 432 func (c *Client) BotName() (string, error) { 433 c.mut.Lock() 434 defer c.mut.Unlock() 435 if c.botName == "" { 436 if err := c.getUserData(); err != nil { 437 return "", fmt.Errorf("fetching bot name from GitHub: %v", err) 438 } 439 } 440 return c.botName, nil 441 } 442 443 // Email returns the user-configured email for the authenticated identity. 444 // 445 // See https://developer.github.com/v3/users/#get-the-authenticated-user 446 func (c *Client) Email() (string, error) { 447 c.mut.Lock() 448 defer c.mut.Unlock() 449 if c.email == "" { 450 if err := c.getUserData(); err != nil { 451 return "", fmt.Errorf("fetching e-mail from GitHub: %v", err) 452 } 453 } 454 return c.email, nil 455 } 456 457 // IsMember returns whether or not the user is a member of the org. 458 // 459 // See https://developer.github.com/v3/orgs/members/#check-membership 460 func (c *Client) IsMember(org, user string) (bool, error) { 461 c.log("IsMember", org, user) 462 if org == user { 463 // Make it possible to run a couple of plugins on personal repos. 464 return true, nil 465 } 466 code, err := c.request(&request{ 467 method: http.MethodGet, 468 path: fmt.Sprintf("/orgs/%s/members/%s", org, user), 469 exitCodes: []int{204, 404, 302}, 470 }, nil) 471 if err != nil { 472 return false, err 473 } 474 if code == 204 { 475 return true, nil 476 } else if code == 404 { 477 return false, nil 478 } else if code == 302 { 479 return false, fmt.Errorf("requester is not %s org member", org) 480 } 481 // Should be unreachable. 482 return false, fmt.Errorf("unexpected status: %d", code) 483 } 484 485 // GetOrg returns current metadata for the org 486 // 487 // https://developer.github.com/v3/orgs/#get-an-organization 488 func (c *Client) GetOrg(name string) (*Organization, error) { 489 c.log("GetOrg", name) 490 var retOrg Organization 491 _, err := c.request(&request{ 492 method: http.MethodGet, 493 path: fmt.Sprintf("/orgs/%s", name), 494 exitCodes: []int{200}, 495 }, &retOrg) 496 if err != nil { 497 return nil, err 498 } 499 return &retOrg, nil 500 } 501 502 // EditOrg will update the metadata for this org. 503 // 504 // https://developer.github.com/v3/orgs/#edit-an-organization 505 func (c *Client) EditOrg(name string, config Organization) (*Organization, error) { 506 c.log("EditOrg", name, config) 507 if c.dry { 508 return &config, nil 509 } 510 var retOrg Organization 511 _, err := c.request(&request{ 512 method: http.MethodPatch, 513 path: fmt.Sprintf("/orgs/%s", name), 514 exitCodes: []int{200}, 515 requestBody: &config, 516 }, &retOrg) 517 if err != nil { 518 return nil, err 519 } 520 return &retOrg, nil 521 } 522 523 // ListOrgInvitations lists pending invitations to th org. 524 // 525 // https://developer.github.com/v3/orgs/members/#list-pending-organization-invitations 526 func (c *Client) ListOrgInvitations(org string) ([]OrgInvitation, error) { 527 c.log("ListOrgInvitations", org) 528 if c.fake { 529 return nil, nil 530 } 531 path := fmt.Sprintf("/orgs/%s/invitations", org) 532 var ret []OrgInvitation 533 err := c.readPaginatedResults( 534 path, 535 acceptNone, 536 func() interface{} { 537 return &[]OrgInvitation{} 538 }, 539 func(obj interface{}) { 540 ret = append(ret, *(obj.(*[]OrgInvitation))...) 541 }, 542 ) 543 if err != nil { 544 return nil, err 545 } 546 return ret, nil 547 } 548 549 // ListOrgMembers list all users who are members of an organization. If the authenticated 550 // user is also a member of this organization then both concealed and public members 551 // will be returned. 552 // 553 // Role options are "all", "admin" and "member" 554 // 555 // https://developer.github.com/v3/orgs/members/#members-list 556 func (c *Client) ListOrgMembers(org, role string) ([]TeamMember, error) { 557 c.log("ListOrgMembers", org, role) 558 if c.fake { 559 return nil, nil 560 } 561 path := fmt.Sprintf("/orgs/%s/members", org) 562 var teamMembers []TeamMember 563 err := c.readPaginatedResultsWithValues( 564 path, 565 url.Values{ 566 "per_page": []string{"100"}, 567 "role": []string{role}, 568 }, 569 acceptNone, 570 func() interface{} { 571 return &[]TeamMember{} 572 }, 573 func(obj interface{}) { 574 teamMembers = append(teamMembers, *(obj.(*[]TeamMember))...) 575 }, 576 ) 577 if err != nil { 578 return nil, err 579 } 580 return teamMembers, nil 581 } 582 583 // HasPermission returns true if GetUserPermission() returns any of the roles. 584 func (c *Client) HasPermission(org, repo, user string, roles ...string) (bool, error) { 585 perm, err := c.GetUserPermission(org, repo, user) 586 if err != nil { 587 return false, err 588 } 589 for _, r := range roles { 590 if r == perm { 591 return true, nil 592 } 593 } 594 return false, nil 595 } 596 597 // GetUserPermission returns the user's permission level for a repo 598 // 599 // https://developer.github.com/v3/repos/collaborators/#review-a-users-permission-level 600 func (c *Client) GetUserPermission(org, repo, user string) (string, error) { 601 c.log("GetUserPermission", org, repo, user) 602 603 var perm struct { 604 Perm string `json:"permission"` 605 } 606 _, err := c.request(&request{ 607 method: http.MethodGet, 608 path: fmt.Sprintf("/repos/%s/%s/collaborators/%s/permission", org, repo, user), 609 exitCodes: []int{200}, 610 }, &perm) 611 if err != nil { 612 return "", err 613 } 614 return perm.Perm, nil 615 } 616 617 // UpdateOrgMembership invites a user to the org and/or updates their permission level. 618 // 619 // If the user is not already a member, this will invite them. 620 // This will also change the role to/from admin, on either the invitation or membership setting. 621 // 622 // https://developer.github.com/v3/orgs/members/#add-or-update-organization-membership 623 func (c *Client) UpdateOrgMembership(org, user string, admin bool) (*OrgMembership, error) { 624 c.log("UpdateOrgMembership", org, user, admin) 625 om := OrgMembership{} 626 if admin { 627 om.Role = RoleAdmin 628 } else { 629 om.Role = RoleMember 630 } 631 if c.dry { 632 return &om, nil 633 } 634 635 _, err := c.request(&request{ 636 method: http.MethodPut, 637 path: fmt.Sprintf("/orgs/%s/memberships/%s", org, user), 638 requestBody: &om, 639 exitCodes: []int{200}, 640 }, &om) 641 return &om, err 642 } 643 644 // RemoveOrgMembership removes the user from the org. 645 // 646 // https://developer.github.com/v3/orgs/members/#remove-organization-membership 647 func (c *Client) RemoveOrgMembership(org, user string) error { 648 c.log("RemoveOrgMembership", org, user) 649 _, err := c.request(&request{ 650 method: http.MethodDelete, 651 path: fmt.Sprintf("/orgs/%s/memberships/%s", org, user), 652 exitCodes: []int{204}, 653 }, nil) 654 return err 655 } 656 657 // CreateComment creates a comment on the issue. 658 // 659 // See https://developer.github.com/v3/issues/comments/#create-a-comment 660 func (c *Client) CreateComment(org, repo string, number int, comment string) error { 661 c.log("CreateComment", org, repo, number, comment) 662 ic := IssueComment{ 663 Body: comment, 664 } 665 _, err := c.request(&request{ 666 method: http.MethodPost, 667 path: fmt.Sprintf("/repos/%s/%s/issues/%d/comments", org, repo, number), 668 requestBody: &ic, 669 exitCodes: []int{201}, 670 }, nil) 671 return err 672 } 673 674 // DeleteComment deletes the comment. 675 // 676 // See https://developer.github.com/v3/issues/comments/#delete-a-comment 677 func (c *Client) DeleteComment(org, repo string, id int) error { 678 c.log("DeleteComment", org, repo, id) 679 _, err := c.request(&request{ 680 method: http.MethodDelete, 681 path: fmt.Sprintf("/repos/%s/%s/issues/comments/%d", org, repo, id), 682 exitCodes: []int{204}, 683 }, nil) 684 return err 685 } 686 687 // EditComment changes the body of comment id in org/repo. 688 // 689 // See https://developer.github.com/v3/issues/comments/#edit-a-comment 690 func (c *Client) EditComment(org, repo string, id int, comment string) error { 691 c.log("EditComment", org, repo, id, comment) 692 ic := IssueComment{ 693 Body: comment, 694 } 695 _, err := c.request(&request{ 696 method: http.MethodPatch, 697 path: fmt.Sprintf("/repos/%s/%s/issues/comments/%d", org, repo, id), 698 requestBody: &ic, 699 exitCodes: []int{200}, 700 }, nil) 701 return err 702 } 703 704 // CreateCommentReaction responds emotionally to comment id in org/repo. 705 // 706 // See https://developer.github.com/v3/reactions/#create-reaction-for-an-issue-comment 707 func (c *Client) CreateCommentReaction(org, repo string, id int, reaction string) error { 708 c.log("CreateCommentReaction", org, repo, id, reaction) 709 r := Reaction{Content: reaction} 710 _, err := c.request(&request{ 711 method: http.MethodPost, 712 path: fmt.Sprintf("/repos/%s/%s/issues/comments/%d/reactions", org, repo, id), 713 accept: "application/vnd.github.squirrel-girl-preview", 714 exitCodes: []int{201}, 715 requestBody: &r, 716 }, nil) 717 return err 718 } 719 720 // CreateIssueReaction responds emotionally to org/repo#id 721 // 722 // See https://developer.github.com/v3/reactions/#create-reaction-for-an-issue 723 func (c *Client) CreateIssueReaction(org, repo string, id int, reaction string) error { 724 c.log("CreateIssueReaction", org, repo, id, reaction) 725 r := Reaction{Content: reaction} 726 _, err := c.request(&request{ 727 method: http.MethodPost, 728 path: fmt.Sprintf("/repos/%s/%s/issues/%d/reactions", org, repo, id), 729 accept: "application/vnd.github.squirrel-girl-preview", 730 requestBody: &r, 731 exitCodes: []int{200, 201}, 732 }, nil) 733 return err 734 } 735 736 // DeleteStaleComments iterates over comments on an issue/PR, deleting those which the 'isStale' 737 // function identifies as stale. If 'comments' is nil, the comments will be fetched from GitHub. 738 func (c *Client) DeleteStaleComments(org, repo string, number int, comments []IssueComment, isStale func(IssueComment) bool) error { 739 var err error 740 if comments == nil { 741 comments, err = c.ListIssueComments(org, repo, number) 742 if err != nil { 743 return fmt.Errorf("failed to list comments while deleting stale comments. err: %v", err) 744 } 745 } 746 for _, comment := range comments { 747 if isStale(comment) { 748 if err := c.DeleteComment(org, repo, comment.ID); err != nil { 749 return fmt.Errorf("failed to delete stale comment with ID '%d'", comment.ID) 750 } 751 } 752 } 753 return nil 754 } 755 756 // readPaginatedResults iterates over all objects in the paginated result indicated by the given url. 757 // 758 // newObj() should return a new slice of the expected type 759 // accumulate() should accept that populated slice for each page of results. 760 // 761 // Returns an error any call to GitHub or object marshalling fails. 762 func (c *Client) readPaginatedResults(path, accept string, newObj func() interface{}, accumulate func(interface{})) error { 763 values := url.Values{ 764 "per_page": []string{"100"}, 765 } 766 return c.readPaginatedResultsWithValues(path, values, accept, newObj, accumulate) 767 } 768 769 // readPaginatedResultsWithValues is an override that allows control over the query string. 770 func (c *Client) readPaginatedResultsWithValues(path string, values url.Values, accept string, newObj func() interface{}, accumulate func(interface{})) error { 771 pagedPath := path 772 if len(values) > 0 { 773 pagedPath += "?" + values.Encode() 774 } 775 for { 776 resp, err := c.requestRetry(http.MethodGet, pagedPath, accept, nil) 777 if err != nil { 778 return err 779 } 780 defer resp.Body.Close() 781 if resp.StatusCode < 200 || resp.StatusCode > 299 { 782 return fmt.Errorf("return code not 2XX: %s", resp.Status) 783 } 784 785 b, err := ioutil.ReadAll(resp.Body) 786 if err != nil { 787 return err 788 } 789 790 obj := newObj() 791 if err := json.Unmarshal(b, obj); err != nil { 792 return err 793 } 794 795 accumulate(obj) 796 797 link := parseLinks(resp.Header.Get("Link"))["next"] 798 if link == "" { 799 break 800 } 801 u, err := url.Parse(link) 802 if err != nil { 803 return fmt.Errorf("failed to parse 'next' link: %v", err) 804 } 805 pagedPath = u.RequestURI() 806 } 807 return nil 808 } 809 810 // ListIssueComments returns all comments on an issue. 811 // 812 // Each page of results consumes one API token. 813 // 814 // See https://developer.github.com/v3/issues/comments/#list-comments-on-an-issue 815 func (c *Client) ListIssueComments(org, repo string, number int) ([]IssueComment, error) { 816 c.log("ListIssueComments", org, repo, number) 817 if c.fake { 818 return nil, nil 819 } 820 path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", org, repo, number) 821 var comments []IssueComment 822 err := c.readPaginatedResults( 823 path, 824 acceptNone, 825 func() interface{} { 826 return &[]IssueComment{} 827 }, 828 func(obj interface{}) { 829 comments = append(comments, *(obj.(*[]IssueComment))...) 830 }, 831 ) 832 if err != nil { 833 return nil, err 834 } 835 return comments, nil 836 } 837 838 // GetPullRequest gets a pull request. 839 // 840 // See https://developer.github.com/v3/pulls/#get-a-single-pull-request 841 func (c *Client) GetPullRequest(org, repo string, number int) (*PullRequest, error) { 842 c.log("GetPullRequest", org, repo, number) 843 var pr PullRequest 844 _, err := c.request(&request{ 845 method: http.MethodGet, 846 path: fmt.Sprintf("/repos/%s/%s/pulls/%d", org, repo, number), 847 exitCodes: []int{200}, 848 }, &pr) 849 return &pr, err 850 } 851 852 // GetPullRequestPatch gets the patch version of a pull request. 853 // 854 // See https://developer.github.com/v3/media/#commits-commit-comparison-and-pull-requests 855 func (c *Client) GetPullRequestPatch(org, repo string, number int) ([]byte, error) { 856 c.log("GetPullRequestPatch", org, repo, number) 857 _, patch, err := c.requestRaw(&request{ 858 accept: "application/vnd.github.VERSION.patch", 859 method: http.MethodGet, 860 path: fmt.Sprintf("/repos/%s/%s/pulls/%d", org, repo, number), 861 exitCodes: []int{200}, 862 }) 863 return patch, err 864 } 865 866 // CreatePullRequest creates a new pull request and returns its number if 867 // the creation is successful, otherwise any error that is encountered. 868 // 869 // See https://developer.github.com/v3/pulls/#create-a-pull-request 870 func (c *Client) CreatePullRequest(org, repo, title, body, head, base string, canModify bool) (int, error) { 871 c.log("CreatePullRequest", org, repo, title) 872 data := struct { 873 Title string `json:"title"` 874 Body string `json:"body"` 875 Head string `json:"head"` 876 Base string `json:"base"` 877 // MaintainerCanModify allows maintainers of the repo to modify this 878 // pull request, eg. push changes to it before merging. 879 MaintainerCanModify bool `json:"maintainer_can_modify"` 880 }{ 881 Title: title, 882 Body: body, 883 Head: head, 884 Base: base, 885 886 MaintainerCanModify: canModify, 887 } 888 var resp struct { 889 Num int `json:"number"` 890 } 891 _, err := c.request(&request{ 892 method: http.MethodPost, 893 path: fmt.Sprintf("/repos/%s/%s/pulls", org, repo), 894 requestBody: &data, 895 exitCodes: []int{201}, 896 }, &resp) 897 if err != nil { 898 return 0, err 899 } 900 return resp.Num, nil 901 } 902 903 // GetPullRequestChanges gets a list of files modified in a pull request. 904 // 905 // See https://developer.github.com/v3/pulls/#list-pull-requests-files 906 func (c *Client) GetPullRequestChanges(org, repo string, number int) ([]PullRequestChange, error) { 907 c.log("GetPullRequestChanges", org, repo, number) 908 if c.fake { 909 return []PullRequestChange{}, nil 910 } 911 path := fmt.Sprintf("/repos/%s/%s/pulls/%d/files", org, repo, number) 912 var changes []PullRequestChange 913 err := c.readPaginatedResults( 914 path, 915 acceptNone, 916 func() interface{} { 917 return &[]PullRequestChange{} 918 }, 919 func(obj interface{}) { 920 changes = append(changes, *(obj.(*[]PullRequestChange))...) 921 }, 922 ) 923 if err != nil { 924 return nil, err 925 } 926 return changes, nil 927 } 928 929 // ListPullRequestComments returns all *review* comments on a pull request. 930 // 931 // Multiple-pages of comments consumes multiple API tokens. 932 // 933 // See https://developer.github.com/v3/pulls/comments/#list-comments-on-a-pull-request 934 func (c *Client) ListPullRequestComments(org, repo string, number int) ([]ReviewComment, error) { 935 c.log("ListPullRequestComments", org, repo, number) 936 if c.fake { 937 return nil, nil 938 } 939 path := fmt.Sprintf("/repos/%s/%s/pulls/%d/comments", org, repo, number) 940 var comments []ReviewComment 941 err := c.readPaginatedResults( 942 path, 943 acceptNone, 944 func() interface{} { 945 return &[]ReviewComment{} 946 }, 947 func(obj interface{}) { 948 comments = append(comments, *(obj.(*[]ReviewComment))...) 949 }, 950 ) 951 if err != nil { 952 return nil, err 953 } 954 return comments, nil 955 } 956 957 // ListReviews returns all reviews on a pull request. 958 // 959 // Multiple-pages of results consumes multiple API tokens. 960 // 961 // See https://developer.github.com/v3/pulls/reviews/#list-reviews-on-a-pull-request 962 func (c *Client) ListReviews(org, repo string, number int) ([]Review, error) { 963 c.log("ListReviews", org, repo, number) 964 if c.fake { 965 return nil, nil 966 } 967 path := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", org, repo, number) 968 var reviews []Review 969 err := c.readPaginatedResults( 970 path, 971 acceptNone, 972 func() interface{} { 973 return &[]Review{} 974 }, 975 func(obj interface{}) { 976 reviews = append(reviews, *(obj.(*[]Review))...) 977 }, 978 ) 979 if err != nil { 980 return nil, err 981 } 982 return reviews, nil 983 } 984 985 // CreateStatus creates or updates the status of a commit. 986 // 987 // See https://developer.github.com/v3/repos/statuses/#create-a-status 988 func (c *Client) CreateStatus(org, repo, sha string, s Status) error { 989 c.log("CreateStatus", org, repo, sha, s) 990 _, err := c.request(&request{ 991 method: http.MethodPost, 992 path: fmt.Sprintf("/repos/%s/%s/statuses/%s", org, repo, sha), 993 requestBody: &s, 994 exitCodes: []int{201}, 995 }, nil) 996 return err 997 } 998 999 // ListStatuses gets commit statuses for a given ref. 1000 // 1001 // See https://developer.github.com/v3/repos/statuses/#list-statuses-for-a-specific-ref 1002 func (c *Client) ListStatuses(org, repo, ref string) ([]Status, error) { 1003 c.log("ListStatuses", org, repo, ref) 1004 var statuses []Status 1005 _, err := c.request(&request{ 1006 method: http.MethodGet, 1007 path: fmt.Sprintf("/repos/%s/%s/statuses/%s", org, repo, ref), 1008 exitCodes: []int{200}, 1009 }, &statuses) 1010 return statuses, err 1011 } 1012 1013 // GetRepo returns the repo for the provided owner/name combination. 1014 // 1015 // See https://developer.github.com/v3/repos/#get 1016 func (c *Client) GetRepo(owner, name string) (Repo, error) { 1017 c.log("GetRepo", owner, name) 1018 1019 var repo Repo 1020 _, err := c.request(&request{ 1021 method: http.MethodGet, 1022 path: fmt.Sprintf("/repos/%s/%s", owner, name), 1023 exitCodes: []int{200}, 1024 }, &repo) 1025 return repo, err 1026 } 1027 1028 // GetRepos returns all repos in an org. 1029 // 1030 // This call uses multiple API tokens when results are paginated. 1031 // 1032 // See https://developer.github.com/v3/repos/#list-organization-repositories 1033 func (c *Client) GetRepos(org string, isUser bool) ([]Repo, error) { 1034 c.log("GetRepos", org, isUser) 1035 var ( 1036 repos []Repo 1037 nextURL string 1038 ) 1039 if c.fake { 1040 return repos, nil 1041 } 1042 if isUser { 1043 nextURL = fmt.Sprintf("/users/%s/repos", org) 1044 } else { 1045 nextURL = fmt.Sprintf("/orgs/%s/repos", org) 1046 } 1047 err := c.readPaginatedResults( 1048 nextURL, // path 1049 acceptNone, // accept 1050 func() interface{} { // newObj 1051 return &[]Repo{} 1052 }, 1053 func(obj interface{}) { // accumulate 1054 repos = append(repos, *(obj.(*[]Repo))...) 1055 }, 1056 ) 1057 if err != nil { 1058 return nil, err 1059 } 1060 return repos, nil 1061 } 1062 1063 // GetBranches returns all branches in the repo. 1064 // 1065 // If onlyProtected is true it will only return repos with protection enabled, 1066 // and branch.Protected will be true. Otherwise Protected is the default value (false). 1067 // 1068 // This call uses multiple API tokens when results are paginated. 1069 // 1070 // See https://developer.github.com/v3/repos/branches/#list-branches 1071 func (c *Client) GetBranches(org, repo string, onlyProtected bool) ([]Branch, error) { 1072 c.log("GetBranches", org, repo) 1073 var branches []Branch 1074 err := c.readPaginatedResultsWithValues( 1075 fmt.Sprintf("/repos/%s/%s/branches", org, repo), 1076 url.Values{ 1077 "protected": []string{strconv.FormatBool(onlyProtected)}, 1078 "per_page": []string{"100"}, 1079 }, 1080 acceptNone, 1081 func() interface{} { // newObj 1082 return &[]Branch{} 1083 }, 1084 func(obj interface{}) { 1085 branches = append(branches, *(obj.(*[]Branch))...) 1086 }, 1087 ) 1088 if err != nil { 1089 return nil, err 1090 } 1091 return branches, nil 1092 } 1093 1094 // RemoveBranchProtection unprotects org/repo=branch. 1095 // 1096 // See https://developer.github.com/v3/repos/branches/#remove-branch-protection 1097 func (c *Client) RemoveBranchProtection(org, repo, branch string) error { 1098 c.log("RemoveBranchProtection", org, repo, branch) 1099 _, err := c.request(&request{ 1100 method: http.MethodDelete, 1101 path: fmt.Sprintf("/repos/%s/%s/branches/%s/protection", org, repo, branch), 1102 exitCodes: []int{204}, 1103 }, nil) 1104 return err 1105 } 1106 1107 // UpdateBranchProtection configures org/repo=branch. 1108 // 1109 // See https://developer.github.com/v3/repos/branches/#update-branch-protection 1110 func (c *Client) UpdateBranchProtection(org, repo, branch string, config BranchProtectionRequest) error { 1111 c.log("UpdateBranchProtection", org, repo, branch, config) 1112 _, err := c.request(&request{ 1113 accept: "application/vnd.github.luke-cage-preview+json", // for required_approving_review_count 1114 method: http.MethodPut, 1115 path: fmt.Sprintf("/repos/%s/%s/branches/%s/protection", org, repo, branch), 1116 requestBody: config, 1117 exitCodes: []int{200}, 1118 }, nil) 1119 return err 1120 } 1121 1122 // AddRepoLabel adds a defined label given org/repo 1123 // 1124 // See https://developer.github.com/v3/issues/labels/#create-a-label 1125 func (c *Client) AddRepoLabel(org, repo, label, description, color string) error { 1126 c.log("AddRepoLabel", org, repo, label, description, color) 1127 _, err := c.request(&request{ 1128 method: http.MethodPost, 1129 path: fmt.Sprintf("/repos/%s/%s/labels", org, repo), 1130 accept: "application/vnd.github.symmetra-preview+json", // allow the description field -- https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 1131 requestBody: Label{Name: label, Description: description, Color: color}, 1132 exitCodes: []int{201}, 1133 }, nil) 1134 return err 1135 } 1136 1137 // UpdateRepoLabel updates a org/repo label to new name, description, and color 1138 // 1139 // See https://developer.github.com/v3/issues/labels/#update-a-label 1140 func (c *Client) UpdateRepoLabel(org, repo, label, newName, description, color string) error { 1141 c.log("UpdateRepoLabel", org, repo, label, newName, color) 1142 _, err := c.request(&request{ 1143 method: http.MethodPatch, 1144 path: fmt.Sprintf("/repos/%s/%s/labels/%s", org, repo, label), 1145 accept: "application/vnd.github.symmetra-preview+json", // allow the description field -- https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 1146 requestBody: Label{Name: newName, Description: description, Color: color}, 1147 exitCodes: []int{200}, 1148 }, nil) 1149 return err 1150 } 1151 1152 // DeleteRepoLabel deletes a label in org/repo 1153 // 1154 // See https://developer.github.com/v3/issues/labels/#delete-a-label 1155 func (c *Client) DeleteRepoLabel(org, repo, label string) error { 1156 c.log("DeleteRepoLabel", org, repo, label) 1157 _, err := c.request(&request{ 1158 method: http.MethodDelete, 1159 accept: "application/vnd.github.symmetra-preview+json", // allow the description field -- https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 1160 path: fmt.Sprintf("/repos/%s/%s/labels/%s", org, repo, label), 1161 requestBody: Label{Name: label}, 1162 exitCodes: []int{204}, 1163 }, nil) 1164 return err 1165 } 1166 1167 // GetCombinedStatus returns the latest statuses for a given ref. 1168 // 1169 // See https://developer.github.com/v3/repos/statuses/#get-the-combined-status-for-a-specific-ref 1170 func (c *Client) GetCombinedStatus(org, repo, ref string) (*CombinedStatus, error) { 1171 c.log("GetCombinedStatus", org, repo, ref) 1172 var combinedStatus CombinedStatus 1173 _, err := c.request(&request{ 1174 method: http.MethodGet, 1175 path: fmt.Sprintf("/repos/%s/%s/commits/%s/status", org, repo, ref), 1176 exitCodes: []int{200}, 1177 }, &combinedStatus) 1178 return &combinedStatus, err 1179 } 1180 1181 // getLabels is a helper function that retrieves a paginated list of labels from a github URI path. 1182 func (c *Client) getLabels(path string) ([]Label, error) { 1183 var labels []Label 1184 if c.fake { 1185 return labels, nil 1186 } 1187 err := c.readPaginatedResults( 1188 path, 1189 "application/vnd.github.symmetra-preview+json", // allow the description field -- https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 1190 func() interface{} { 1191 return &[]Label{} 1192 }, 1193 func(obj interface{}) { 1194 labels = append(labels, *(obj.(*[]Label))...) 1195 }, 1196 ) 1197 if err != nil { 1198 return nil, err 1199 } 1200 return labels, nil 1201 } 1202 1203 // GetRepoLabels returns the list of labels accessible to org/repo. 1204 // 1205 // See https://developer.github.com/v3/issues/labels/#list-all-labels-for-this-repository 1206 func (c *Client) GetRepoLabels(org, repo string) ([]Label, error) { 1207 c.log("GetRepoLabels", org, repo) 1208 return c.getLabels(fmt.Sprintf("/repos/%s/%s/labels", org, repo)) 1209 } 1210 1211 // GetIssueLabels returns the list of labels currently on issue org/repo#number. 1212 // 1213 // See https://developer.github.com/v3/issues/labels/#list-labels-on-an-issue 1214 func (c *Client) GetIssueLabels(org, repo string, number int) ([]Label, error) { 1215 c.log("GetIssueLabels", org, repo, number) 1216 return c.getLabels(fmt.Sprintf("/repos/%s/%s/issues/%d/labels", org, repo, number)) 1217 } 1218 1219 // AddLabel adds label to org/repo#number, returning an error on a bad response code. 1220 // 1221 // See https://developer.github.com/v3/issues/labels/#add-labels-to-an-issue 1222 func (c *Client) AddLabel(org, repo string, number int, label string) error { 1223 c.log("AddLabel", org, repo, number, label) 1224 _, err := c.request(&request{ 1225 method: http.MethodPost, 1226 path: fmt.Sprintf("/repos/%s/%s/issues/%d/labels", org, repo, number), 1227 requestBody: []string{label}, 1228 exitCodes: []int{200}, 1229 }, nil) 1230 return err 1231 } 1232 1233 // LabelNotFound indicates that a label is not attached to an issue. For example, removing a 1234 // label from an issue, when the issue does not have that label. 1235 type LabelNotFound struct { 1236 Owner, Repo string 1237 Number int 1238 Label string 1239 } 1240 1241 func (e *LabelNotFound) Error() string { 1242 return fmt.Sprintf("label %q does not exist on %s/%s/%d", e.Label, e.Owner, e.Repo, e.Number) 1243 } 1244 1245 type githubError struct { 1246 Message string `json:"message,omitempty"` 1247 } 1248 1249 // RemoveLabel removes label from org/repo#number, returning an error on any failure. 1250 // 1251 // See https://developer.github.com/v3/issues/labels/#remove-a-label-from-an-issue 1252 func (c *Client) RemoveLabel(org, repo string, number int, label string) error { 1253 c.log("RemoveLabel", org, repo, number, label) 1254 code, body, err := c.requestRaw(&request{ 1255 method: http.MethodDelete, 1256 path: fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%s", org, repo, number, label), 1257 // GitHub sometimes returns 200 for this call, which is a bug on their end. 1258 // Do not expect a 404 exit code and handle it separately because we need 1259 // to introspect the request's response body. 1260 exitCodes: []int{200, 204}, 1261 }) 1262 1263 switch { 1264 case code == 200 || code == 204: 1265 // If our code was 200 or 204, no error info. 1266 return nil 1267 case code == 404: 1268 // continue 1269 case err != nil: 1270 return err 1271 default: 1272 return fmt.Errorf("unexpected status code: %v", code) 1273 } 1274 1275 ge := &githubError{} 1276 if err := json.Unmarshal(body, ge); err != nil { 1277 return err 1278 } 1279 1280 // If the error was because the label was not found, annotate that error with type information. 1281 if ge.Message == "Label does not exist" { 1282 return &LabelNotFound{ 1283 Owner: org, 1284 Repo: repo, 1285 Number: number, 1286 Label: label, 1287 } 1288 } 1289 1290 // Otherwise we got some other 404 error. 1291 return fmt.Errorf("deleting label 404: %s", ge.Message) 1292 } 1293 1294 // MissingUsers is an error specifying the users that could not be unassigned. 1295 type MissingUsers struct { 1296 Users []string 1297 action string 1298 } 1299 1300 func (m MissingUsers) Error() string { 1301 return fmt.Sprintf("could not %s the following user(s): %s.", m.action, strings.Join(m.Users, ", ")) 1302 } 1303 1304 // AssignIssue adds logins to org/repo#number, returning an error if any login is missing after making the call. 1305 // 1306 // See https://developer.github.com/v3/issues/assignees/#add-assignees-to-an-issue 1307 func (c *Client) AssignIssue(org, repo string, number int, logins []string) error { 1308 c.log("AssignIssue", org, repo, number, logins) 1309 assigned := make(map[string]bool) 1310 var i Issue 1311 _, err := c.request(&request{ 1312 method: http.MethodPost, 1313 path: fmt.Sprintf("/repos/%s/%s/issues/%d/assignees", org, repo, number), 1314 requestBody: map[string][]string{"assignees": logins}, 1315 exitCodes: []int{201}, 1316 }, &i) 1317 if err != nil { 1318 return err 1319 } 1320 for _, assignee := range i.Assignees { 1321 assigned[NormLogin(assignee.Login)] = true 1322 } 1323 missing := MissingUsers{action: "assign"} 1324 for _, login := range logins { 1325 if !assigned[NormLogin(login)] { 1326 missing.Users = append(missing.Users, login) 1327 } 1328 } 1329 if len(missing.Users) > 0 { 1330 return missing 1331 } 1332 return nil 1333 } 1334 1335 // ExtraUsers is an error specifying the users that could not be unassigned. 1336 type ExtraUsers struct { 1337 Users []string 1338 action string 1339 } 1340 1341 func (e ExtraUsers) Error() string { 1342 return fmt.Sprintf("could not %s the following user(s): %s.", e.action, strings.Join(e.Users, ", ")) 1343 } 1344 1345 // UnassignIssue removes logins from org/repo#number, returns an error if any login remains assigned. 1346 // 1347 // See https://developer.github.com/v3/issues/assignees/#remove-assignees-from-an-issue 1348 func (c *Client) UnassignIssue(org, repo string, number int, logins []string) error { 1349 c.log("UnassignIssue", org, repo, number, logins) 1350 assigned := make(map[string]bool) 1351 var i Issue 1352 _, err := c.request(&request{ 1353 method: http.MethodDelete, 1354 path: fmt.Sprintf("/repos/%s/%s/issues/%d/assignees", org, repo, number), 1355 requestBody: map[string][]string{"assignees": logins}, 1356 exitCodes: []int{200}, 1357 }, &i) 1358 if err != nil { 1359 return err 1360 } 1361 for _, assignee := range i.Assignees { 1362 assigned[NormLogin(assignee.Login)] = true 1363 } 1364 extra := ExtraUsers{action: "unassign"} 1365 for _, login := range logins { 1366 if assigned[NormLogin(login)] { 1367 extra.Users = append(extra.Users, login) 1368 } 1369 } 1370 if len(extra.Users) > 0 { 1371 return extra 1372 } 1373 return nil 1374 } 1375 1376 // CreateReview creates a review using the draft. 1377 // 1378 // https://developer.github.com/v3/pulls/reviews/#create-a-pull-request-review 1379 func (c *Client) CreateReview(org, repo string, number int, r DraftReview) error { 1380 c.log("CreateReview", org, repo, number, r) 1381 _, err := c.request(&request{ 1382 method: http.MethodPost, 1383 path: fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", org, repo, number), 1384 accept: "application/vnd.github.black-cat-preview+json", 1385 requestBody: r, 1386 exitCodes: []int{200}, 1387 }, nil) 1388 return err 1389 } 1390 1391 // prepareReviewersBody separates reviewers from team_reviewers and prepares a map 1392 // { 1393 // "reviewers": [ 1394 // "octocat", 1395 // "hubot", 1396 // "other_user" 1397 // ], 1398 // "team_reviewers": [ 1399 // "justice-league" 1400 // ] 1401 // } 1402 // 1403 // https://developer.github.com/v3/pulls/review_requests/#create-a-review-request 1404 func prepareReviewersBody(logins []string, org string) (map[string][]string, error) { 1405 body := map[string][]string{} 1406 var errors []error 1407 for _, login := range logins { 1408 mat := teamRe.FindStringSubmatch(login) 1409 if mat == nil { 1410 if _, exists := body["reviewers"]; !exists { 1411 body["reviewers"] = []string{} 1412 } 1413 body["reviewers"] = append(body["reviewers"], login) 1414 } else if mat[1] == org { 1415 if _, exists := body["team_reviewers"]; !exists { 1416 body["team_reviewers"] = []string{} 1417 } 1418 body["team_reviewers"] = append(body["team_reviewers"], mat[2]) 1419 } else { 1420 errors = append(errors, fmt.Errorf("team %s is not part of %s org", login, org)) 1421 } 1422 } 1423 return body, errorutil.NewAggregate(errors...) 1424 } 1425 1426 func (c *Client) tryRequestReview(org, repo string, number int, logins []string) (int, error) { 1427 c.log("RequestReview", org, repo, number, logins) 1428 var pr PullRequest 1429 body, err := prepareReviewersBody(logins, org) 1430 if err != nil { 1431 // At least one team not in org, 1432 // let RequestReview handle retries and alerting for each login. 1433 return http.StatusUnprocessableEntity, err 1434 } 1435 return c.request(&request{ 1436 method: http.MethodPost, 1437 path: fmt.Sprintf("/repos/%s/%s/pulls/%d/requested_reviewers", org, repo, number), 1438 accept: "application/vnd.github.symmetra-preview+json", 1439 requestBody: body, 1440 exitCodes: []int{http.StatusCreated /*201*/}, 1441 }, &pr) 1442 } 1443 1444 // RequestReview tries to add the users listed in 'logins' as requested reviewers of the specified PR. 1445 // If any user in the 'logins' slice is not a contributor of the repo, the entire POST will fail 1446 // without adding any reviewers. The GitHub API response does not specify which user(s) were invalid 1447 // so if we fail to request reviews from the members of 'logins' we try to request reviews from 1448 // each member individually. We try first with all users in 'logins' for efficiency in the common case. 1449 // 1450 // See https://developer.github.com/v3/pulls/review_requests/#create-a-review-request 1451 func (c *Client) RequestReview(org, repo string, number int, logins []string) error { 1452 statusCode, err := c.tryRequestReview(org, repo, number, logins) 1453 if err != nil && statusCode == http.StatusUnprocessableEntity /*422*/ { 1454 // Failed to set all members of 'logins' as reviewers, try individually. 1455 missing := MissingUsers{action: "request a PR review from"} 1456 for _, user := range logins { 1457 statusCode, err = c.tryRequestReview(org, repo, number, []string{user}) 1458 if err != nil && statusCode == http.StatusUnprocessableEntity /*422*/ { 1459 // User is not a contributor, or team not in org. 1460 missing.Users = append(missing.Users, user) 1461 } else if err != nil { 1462 return fmt.Errorf("failed to add reviewer to PR. Status code: %d, errmsg: %v", statusCode, err) 1463 } 1464 } 1465 if len(missing.Users) > 0 { 1466 return missing 1467 } 1468 return nil 1469 } 1470 return err 1471 } 1472 1473 // UnrequestReview tries to remove the users listed in 'logins' from the requested reviewers of the 1474 // specified PR. The GitHub API treats deletions of review requests differently than creations. Specifically, if 1475 // 'logins' contains a user that isn't a requested reviewer, other users that are valid are still removed. 1476 // Furthermore, the API response lists the set of requested reviewers after the deletion (unlike request creations), 1477 // so we can determine if each deletion was successful. 1478 // The API responds with http status code 200 no matter what the content of 'logins' is. 1479 // 1480 // See https://developer.github.com/v3/pulls/review_requests/#delete-a-review-request 1481 func (c *Client) UnrequestReview(org, repo string, number int, logins []string) error { 1482 c.log("UnrequestReview", org, repo, number, logins) 1483 var pr PullRequest 1484 body, err := prepareReviewersBody(logins, org) 1485 if len(body) == 0 { 1486 // No point in doing request for none, 1487 // if some logins didn't make it to body, extras.Users will catch them. 1488 return err 1489 } 1490 _, err = c.request(&request{ 1491 method: http.MethodDelete, 1492 path: fmt.Sprintf("/repos/%s/%s/pulls/%d/requested_reviewers", org, repo, number), 1493 accept: "application/vnd.github.symmetra-preview+json", 1494 requestBody: body, 1495 exitCodes: []int{http.StatusOK /*200*/}, 1496 }, &pr) 1497 if err != nil { 1498 return err 1499 } 1500 extras := ExtraUsers{action: "remove the PR review request for"} 1501 for _, user := range pr.RequestedReviewers { 1502 found := false 1503 for _, toDelete := range logins { 1504 if NormLogin(user.Login) == NormLogin(toDelete) { 1505 found = true 1506 break 1507 } 1508 } 1509 if found { 1510 extras.Users = append(extras.Users, user.Login) 1511 } 1512 } 1513 if len(extras.Users) > 0 { 1514 return extras 1515 } 1516 return nil 1517 } 1518 1519 // CloseIssue closes the existing, open issue provided 1520 // 1521 // See https://developer.github.com/v3/issues/#edit-an-issue 1522 func (c *Client) CloseIssue(org, repo string, number int) error { 1523 c.log("CloseIssue", org, repo, number) 1524 _, err := c.request(&request{ 1525 method: http.MethodPatch, 1526 path: fmt.Sprintf("/repos/%s/%s/issues/%d", org, repo, number), 1527 requestBody: map[string]string{"state": "closed"}, 1528 exitCodes: []int{200}, 1529 }, nil) 1530 return err 1531 } 1532 1533 // StateCannotBeChanged represents the "custom" GitHub API 1534 // error that occurs when a resource cannot be changed 1535 type StateCannotBeChanged struct { 1536 Message string 1537 } 1538 1539 func (s StateCannotBeChanged) Error() string { 1540 return s.Message 1541 } 1542 1543 // StateCannotBeChanged implements error 1544 var _ error = (*StateCannotBeChanged)(nil) 1545 1546 // convert to a StateCannotBeChanged if appropriate or else return the original error 1547 func stateCannotBeChangedOrOriginalError(err error) error { 1548 requestErr, ok := err.(requestError) 1549 if ok && len(requestErr.Errors) > 0 { 1550 for _, subErr := range requestErr.Errors { 1551 if strings.Contains(subErr.Message, stateCannotBeChangedMessagePrefix) { 1552 return StateCannotBeChanged{ 1553 Message: subErr.Message, 1554 } 1555 } 1556 } 1557 } 1558 return err 1559 } 1560 1561 // ReopenIssue re-opens the existing, closed issue provided 1562 // 1563 // See https://developer.github.com/v3/issues/#edit-an-issue 1564 func (c *Client) ReopenIssue(org, repo string, number int) error { 1565 c.log("ReopenIssue", org, repo, number) 1566 _, err := c.request(&request{ 1567 method: http.MethodPatch, 1568 path: fmt.Sprintf("/repos/%s/%s/issues/%d", org, repo, number), 1569 requestBody: map[string]string{"state": "open"}, 1570 exitCodes: []int{200}, 1571 }, nil) 1572 return stateCannotBeChangedOrOriginalError(err) 1573 } 1574 1575 // ClosePR closes the existing, open PR provided 1576 // TODO: Rename to ClosePullRequest 1577 // 1578 // See https://developer.github.com/v3/pulls/#update-a-pull-request 1579 func (c *Client) ClosePR(org, repo string, number int) error { 1580 c.log("ClosePR", org, repo, number) 1581 _, err := c.request(&request{ 1582 method: http.MethodPatch, 1583 path: fmt.Sprintf("/repos/%s/%s/pulls/%d", org, repo, number), 1584 requestBody: map[string]string{"state": "closed"}, 1585 exitCodes: []int{200}, 1586 }, nil) 1587 return err 1588 } 1589 1590 // ReopenPR re-opens the existing, closed PR provided 1591 // TODO: Rename to ReopenPullRequest 1592 // 1593 // See https://developer.github.com/v3/pulls/#update-a-pull-request 1594 func (c *Client) ReopenPR(org, repo string, number int) error { 1595 c.log("ReopenPR", org, repo, number) 1596 _, err := c.request(&request{ 1597 method: http.MethodPatch, 1598 path: fmt.Sprintf("/repos/%s/%s/pulls/%d", org, repo, number), 1599 requestBody: map[string]string{"state": "open"}, 1600 exitCodes: []int{200}, 1601 }, nil) 1602 return stateCannotBeChangedOrOriginalError(err) 1603 } 1604 1605 // GetRef returns the SHA of the given ref, such as "heads/master". 1606 // 1607 // See https://developer.github.com/v3/git/refs/#get-a-reference 1608 func (c *Client) GetRef(org, repo, ref string) (string, error) { 1609 c.log("GetRef", org, repo, ref) 1610 var res struct { 1611 Object map[string]string `json:"object"` 1612 } 1613 _, err := c.request(&request{ 1614 method: http.MethodGet, 1615 path: fmt.Sprintf("/repos/%s/%s/git/refs/%s", org, repo, ref), 1616 exitCodes: []int{200}, 1617 }, &res) 1618 return res.Object["sha"], err 1619 } 1620 1621 // FindIssues uses the GitHub search API to find issues which match a particular query. 1622 // 1623 // Input query the same way you would into the website. 1624 // Order returned results with sort (usually "updated"). 1625 // Control whether oldest/newest is first with asc. 1626 // 1627 // See https://help.github.com/articles/searching-issues-and-pull-requests/ for details. 1628 func (c *Client) FindIssues(query, sort string, asc bool) ([]Issue, error) { 1629 c.log("FindIssues", query) 1630 path := fmt.Sprintf("/search/issues?q=%s", url.QueryEscape(query)) 1631 if sort != "" { 1632 path += "&sort=" + url.QueryEscape(sort) 1633 if asc { 1634 path += "&order=asc" 1635 } 1636 } 1637 var issSearchResult IssuesSearchResult 1638 _, err := c.request(&request{ 1639 method: http.MethodGet, 1640 path: path, 1641 exitCodes: []int{200}, 1642 }, &issSearchResult) 1643 return issSearchResult.Issues, err 1644 } 1645 1646 // FileNotFound happens when github cannot find the file requested by GetFile(). 1647 type FileNotFound struct { 1648 org, repo, path, commit string 1649 } 1650 1651 func (e *FileNotFound) Error() string { 1652 return fmt.Sprintf("%s/%s/%s @ %s not found", e.org, e.repo, e.path, e.commit) 1653 } 1654 1655 // GetFile uses GitHub repo contents API to retrieve the content of a file with commit sha. 1656 // If commit is empty, it will grab content from repo's default branch, usually master. 1657 // TODO(krzyzacy): Support retrieve a directory 1658 // 1659 // See https://developer.github.com/v3/repos/contents/#get-contents 1660 func (c *Client) GetFile(org, repo, filepath, commit string) ([]byte, error) { 1661 c.log("GetFile", org, repo, filepath, commit) 1662 1663 url := fmt.Sprintf("/repos/%s/%s/contents/%s", org, repo, filepath) 1664 if commit != "" { 1665 url = fmt.Sprintf("%s?ref=%s", url, commit) 1666 } 1667 1668 var res Content 1669 code, err := c.request(&request{ 1670 method: http.MethodGet, 1671 path: url, 1672 exitCodes: []int{200, 404}, 1673 }, &res) 1674 1675 if err != nil { 1676 return nil, err 1677 } 1678 1679 if code == 404 { 1680 return nil, &FileNotFound{ 1681 org: org, 1682 repo: repo, 1683 path: filepath, 1684 commit: commit, 1685 } 1686 } 1687 1688 decoded, err := base64.StdEncoding.DecodeString(res.Content) 1689 if err != nil { 1690 return nil, fmt.Errorf("error decoding %s : %v", res.Content, err) 1691 } 1692 1693 return decoded, nil 1694 } 1695 1696 // Query runs a GraphQL query using shurcooL/githubv4's client. 1697 func (c *Client) Query(ctx context.Context, q interface{}, vars map[string]interface{}) error { 1698 // Don't log query here because Query is typically called multiple times to get all pages. 1699 // Instead log once per search and include total search cost. 1700 return c.gqlc.Query(ctx, q, vars) 1701 } 1702 1703 // CreateTeam adds a team with name to the org, returning a struct with the new ID. 1704 // 1705 // See https://developer.github.com/v3/teams/#create-team 1706 func (c *Client) CreateTeam(org string, team Team) (*Team, error) { 1707 c.log("CreateTeam", org, team) 1708 if team.Name == "" { 1709 return nil, errors.New("team.Name must be non-empty") 1710 } 1711 if c.fake { 1712 return nil, nil 1713 } else if c.dry { 1714 return &team, nil 1715 } 1716 path := fmt.Sprintf("/orgs/%s/teams", org) 1717 var retTeam Team 1718 _, err := c.request(&request{ 1719 method: http.MethodPost, 1720 path: path, 1721 // This accept header enables the nested teams preview. 1722 // https://developer.github.com/changes/2017-08-30-preview-nested-teams/ 1723 accept: "application/vnd.github.hellcat-preview+json", 1724 requestBody: &team, 1725 exitCodes: []int{201}, 1726 }, &retTeam) 1727 return &retTeam, err 1728 } 1729 1730 // EditTeam patches team.ID to contain the specified other values. 1731 // 1732 // See https://developer.github.com/v3/teams/#edit-team 1733 func (c *Client) EditTeam(t Team) (*Team, error) { 1734 c.log("EditTeam", t) 1735 if t.ID == 0 { 1736 return nil, errors.New("team.ID must be non-zero") 1737 } 1738 if c.dry { 1739 return &t, nil 1740 } 1741 id := t.ID 1742 t.ID = 0 1743 // Need to send parent_team_id: null 1744 team := struct { 1745 Team 1746 ParentTeamID *int `json:"parent_team_id"` 1747 }{ 1748 Team: t, 1749 ParentTeamID: t.ParentTeamID, 1750 } 1751 var retTeam Team 1752 path := fmt.Sprintf("/teams/%d", id) 1753 _, err := c.request(&request{ 1754 method: http.MethodPatch, 1755 path: path, 1756 // This accept header enables the nested teams preview. 1757 // https://developer.github.com/changes/2017-08-30-preview-nested-teams/ 1758 accept: "application/vnd.github.hellcat-preview+json", 1759 requestBody: &team, 1760 exitCodes: []int{200, 201}, 1761 }, &retTeam) 1762 return &retTeam, err 1763 } 1764 1765 // DeleteTeam removes team.ID from GitHub. 1766 // 1767 // See https://developer.github.com/v3/teams/#delete-team 1768 func (c *Client) DeleteTeam(id int) error { 1769 c.log("DeleteTeam", id) 1770 path := fmt.Sprintf("/teams/%d", id) 1771 _, err := c.request(&request{ 1772 method: http.MethodDelete, 1773 path: path, 1774 exitCodes: []int{204}, 1775 }, nil) 1776 return err 1777 } 1778 1779 // ListTeams gets a list of teams for the given org 1780 // 1781 // See https://developer.github.com/v3/teams/#list-teams 1782 func (c *Client) ListTeams(org string) ([]Team, error) { 1783 c.log("ListTeams", org) 1784 if c.fake { 1785 return nil, nil 1786 } 1787 path := fmt.Sprintf("/orgs/%s/teams", org) 1788 var teams []Team 1789 err := c.readPaginatedResults( 1790 path, 1791 // This accept header enables the nested teams preview. 1792 // https://developer.github.com/changes/2017-08-30-preview-nested-teams/ 1793 "application/vnd.github.hellcat-preview+json", 1794 func() interface{} { 1795 return &[]Team{} 1796 }, 1797 func(obj interface{}) { 1798 teams = append(teams, *(obj.(*[]Team))...) 1799 }, 1800 ) 1801 if err != nil { 1802 return nil, err 1803 } 1804 return teams, nil 1805 } 1806 1807 // UpdateTeamMembership adds the user to the team and/or updates their role in that team. 1808 // 1809 // If the user is not a member of the org, GitHub will invite them to become an outside collaborator, setting their status to pending. 1810 // 1811 // https://developer.github.com/v3/teams/members/#add-or-update-team-membership 1812 func (c *Client) UpdateTeamMembership(id int, user string, maintainer bool) (*TeamMembership, error) { 1813 c.log("UpdateTeamMembership", id, user, maintainer) 1814 if c.fake { 1815 return nil, nil 1816 } 1817 tm := TeamMembership{} 1818 if maintainer { 1819 tm.Role = RoleMaintainer 1820 } else { 1821 tm.Role = RoleMember 1822 } 1823 1824 if c.dry { 1825 return &tm, nil 1826 } 1827 1828 _, err := c.request(&request{ 1829 method: http.MethodPut, 1830 path: fmt.Sprintf("/teams/%d/memberships/%s", id, user), 1831 requestBody: &tm, 1832 exitCodes: []int{200}, 1833 }, &tm) 1834 return &tm, err 1835 } 1836 1837 // RemoveTeamMembership removes the user from the team (but not the org). 1838 // 1839 // https://developer.github.com/v3/teams/members/#remove-team-member 1840 func (c *Client) RemoveTeamMembership(id int, user string) error { 1841 c.log("RemoveTeamMembership", id, user) 1842 if c.fake { 1843 return nil 1844 } 1845 _, err := c.request(&request{ 1846 method: http.MethodDelete, 1847 path: fmt.Sprintf("/teams/%d/memberships/%s", id, user), 1848 exitCodes: []int{204}, 1849 }, nil) 1850 return err 1851 } 1852 1853 // ListTeamMembers gets a list of team members for the given team id 1854 // 1855 // Role options are "all", "maintainer" and "member" 1856 // 1857 // https://developer.github.com/v3/teams/members/#list-team-members 1858 func (c *Client) ListTeamMembers(id int, role string) ([]TeamMember, error) { 1859 c.log("ListTeamMembers", id, role) 1860 if c.fake { 1861 return nil, nil 1862 } 1863 path := fmt.Sprintf("/teams/%d/members", id) 1864 var teamMembers []TeamMember 1865 err := c.readPaginatedResultsWithValues( 1866 path, 1867 url.Values{ 1868 "per_page": []string{"100"}, 1869 "role": []string{role}, 1870 }, 1871 // This accept header enables the nested teams preview. 1872 // https://developer.github.com/changes/2017-08-30-preview-nested-teams/ 1873 "application/vnd.github.hellcat-preview+json", 1874 func() interface{} { 1875 return &[]TeamMember{} 1876 }, 1877 func(obj interface{}) { 1878 teamMembers = append(teamMembers, *(obj.(*[]TeamMember))...) 1879 }, 1880 ) 1881 if err != nil { 1882 return nil, err 1883 } 1884 return teamMembers, nil 1885 } 1886 1887 // MergeDetails contains desired properties of the merge. 1888 // 1889 // See https://developer.github.com/v3/pulls/#merge-a-pull-request-merge-button 1890 type MergeDetails struct { 1891 // CommitTitle defaults to the automatic message. 1892 CommitTitle string `json:"commit_title,omitempty"` 1893 // CommitMessage defaults to the automatic message. 1894 CommitMessage string `json:"commit_message,omitempty"` 1895 // The PR HEAD must match this to prevent races. 1896 SHA string `json:"sha,omitempty"` 1897 // Can be "merge", "squash", or "rebase". Defaults to merge. 1898 MergeMethod string `json:"merge_method,omitempty"` 1899 } 1900 1901 // ModifiedHeadError happens when github refuses to merge a PR because the PR changed. 1902 type ModifiedHeadError string 1903 1904 func (e ModifiedHeadError) Error() string { return string(e) } 1905 1906 // UnmergablePRError happens when github refuses to merge a PR for other reasons (merge confclit). 1907 type UnmergablePRError string 1908 1909 func (e UnmergablePRError) Error() string { return string(e) } 1910 1911 // UnmergablePRBaseChangedError happens when github refuses merging a PR because the base changed. 1912 type UnmergablePRBaseChangedError string 1913 1914 func (e UnmergablePRBaseChangedError) Error() string { return string(e) } 1915 1916 // UnauthorizedToPushError happens when client is not allowed to push to github. 1917 type UnauthorizedToPushError string 1918 1919 func (e UnauthorizedToPushError) Error() string { return string(e) } 1920 1921 // Merge merges a PR. 1922 // 1923 // See https://developer.github.com/v3/pulls/#merge-a-pull-request-merge-button 1924 func (c *Client) Merge(org, repo string, pr int, details MergeDetails) error { 1925 c.log("Merge", org, repo, pr, details) 1926 ge := githubError{} 1927 ec, err := c.request(&request{ 1928 method: http.MethodPut, 1929 path: fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", org, repo, pr), 1930 requestBody: &details, 1931 exitCodes: []int{200, 405, 409}, 1932 }, &ge) 1933 if err != nil { 1934 return err 1935 } 1936 if ec == 405 { 1937 if strings.Contains(ge.Message, "Base branch was modified") { 1938 return UnmergablePRBaseChangedError(ge.Message) 1939 } 1940 if strings.Contains(ge.Message, "You're not authorized to push to this branch") { 1941 return UnauthorizedToPushError(ge.Message) 1942 } 1943 return UnmergablePRError(ge.Message) 1944 } else if ec == 409 { 1945 return ModifiedHeadError(ge.Message) 1946 } 1947 1948 return nil 1949 } 1950 1951 // IsCollaborator returns whether or not the user is a collaborator of the repo. 1952 // From GitHub's API reference: 1953 // For organization-owned repositories, the list of collaborators includes 1954 // outside collaborators, organization members that are direct collaborators, 1955 // organization members with access through team memberships, organization 1956 // members with access through default organization permissions, and 1957 // organization owners. 1958 // 1959 // See https://developer.github.com/v3/repos/collaborators/ 1960 func (c *Client) IsCollaborator(org, repo, user string) (bool, error) { 1961 c.log("IsCollaborator", org, user) 1962 if org == user { 1963 // Make it possible to run a couple of plugins on personal repos. 1964 return true, nil 1965 } 1966 code, err := c.request(&request{ 1967 method: http.MethodGet, 1968 // This accept header enables the nested teams preview. 1969 // https://developer.github.com/changes/2017-08-30-preview-nested-teams/ 1970 accept: "application/vnd.github.hellcat-preview+json", 1971 path: fmt.Sprintf("/repos/%s/%s/collaborators/%s", org, repo, user), 1972 exitCodes: []int{204, 404, 302}, 1973 }, nil) 1974 if err != nil { 1975 return false, err 1976 } 1977 if code == 204 { 1978 return true, nil 1979 } else if code == 404 { 1980 return false, nil 1981 } 1982 return false, fmt.Errorf("unexpected status: %d", code) 1983 } 1984 1985 // ListCollaborators gets a list of all users who have access to a repo (and 1986 // can become assignees or requested reviewers). 1987 // 1988 // See 'IsCollaborator' for more details. 1989 // See https://developer.github.com/v3/repos/collaborators/ 1990 func (c *Client) ListCollaborators(org, repo string) ([]User, error) { 1991 c.log("ListCollaborators", org, repo) 1992 if c.fake { 1993 return nil, nil 1994 } 1995 path := fmt.Sprintf("/repos/%s/%s/collaborators", org, repo) 1996 var users []User 1997 err := c.readPaginatedResults( 1998 path, 1999 // This accept header enables the nested teams preview. 2000 // https://developer.github.com/changes/2017-08-30-preview-nested-teams/ 2001 "application/vnd.github.hellcat-preview+json", 2002 func() interface{} { 2003 return &[]User{} 2004 }, 2005 func(obj interface{}) { 2006 users = append(users, *(obj.(*[]User))...) 2007 }, 2008 ) 2009 if err != nil { 2010 return nil, err 2011 } 2012 return users, nil 2013 } 2014 2015 // CreateFork creates a fork for the authenticated user. Forking a repository 2016 // happens asynchronously. Therefore, we may have to wait a short period before 2017 // accessing the git objects. If this takes longer than 5 minutes, Github 2018 // recommends contacting their support. 2019 // 2020 // See https://developer.github.com/v3/repos/forks/#create-a-fork 2021 func (c *Client) CreateFork(owner, repo string) error { 2022 c.log("CreateFork", owner, repo) 2023 _, err := c.request(&request{ 2024 method: http.MethodPost, 2025 path: fmt.Sprintf("/repos/%s/%s/forks", owner, repo), 2026 exitCodes: []int{202}, 2027 }, nil) 2028 return err 2029 } 2030 2031 // ListIssueEvents gets a list events from GitHub's events API that pertain to the specified issue. 2032 // The events that are returned have a different format than webhook events and certain event types 2033 // are excluded. 2034 // 2035 // See https://developer.github.com/v3/issues/events/ 2036 func (c *Client) ListIssueEvents(org, repo string, num int) ([]ListedIssueEvent, error) { 2037 c.log("ListIssueEvents", org, repo, num) 2038 if c.fake { 2039 return nil, nil 2040 } 2041 path := fmt.Sprintf("/repos/%s/%s/issues/%d/events", org, repo, num) 2042 var events []ListedIssueEvent 2043 err := c.readPaginatedResults( 2044 path, 2045 acceptNone, 2046 func() interface{} { 2047 return &[]ListedIssueEvent{} 2048 }, 2049 func(obj interface{}) { 2050 events = append(events, *(obj.(*[]ListedIssueEvent))...) 2051 }, 2052 ) 2053 if err != nil { 2054 return nil, err 2055 } 2056 return events, nil 2057 } 2058 2059 // IsMergeable determines if a PR can be merged. 2060 // Mergeability is calculated by a background job on GitHub and is not immediately available when 2061 // new commits are added so the PR must be polled until the background job completes. 2062 func (c *Client) IsMergeable(org, repo string, number int, sha string) (bool, error) { 2063 backoff := time.Second * 3 2064 maxTries := 3 2065 for try := 0; try < maxTries; try++ { 2066 pr, err := c.GetPullRequest(org, repo, number) 2067 if err != nil { 2068 return false, err 2069 } 2070 if pr.Head.SHA != sha { 2071 return false, fmt.Errorf("pull request head changed while checking mergeability (%s -> %s)", sha, pr.Head.SHA) 2072 } 2073 if pr.Merged { 2074 return false, errors.New("pull request was merged while checking mergeability") 2075 } 2076 if pr.Mergable != nil { 2077 return *pr.Mergable, nil 2078 } 2079 if try+1 < maxTries { 2080 c.time.Sleep(backoff) 2081 backoff *= 2 2082 } 2083 } 2084 return false, fmt.Errorf("reached maximum number of retries (%d) checking mergeability", maxTries) 2085 } 2086 2087 // ClearMilestone clears the milestone from the specified issue 2088 // 2089 // See https://developer.github.com/v3/issues/#edit-an-issue 2090 func (c *Client) ClearMilestone(org, repo string, num int) error { 2091 c.log("ClearMilestone", org, repo, num) 2092 2093 issue := &struct { 2094 // Clearing the milestone requires providing a null value, and 2095 // interface{} will serialize to null. 2096 Milestone interface{} `json:"milestone"` 2097 }{} 2098 _, err := c.request(&request{ 2099 method: http.MethodPatch, 2100 path: fmt.Sprintf("/repos/%v/%v/issues/%d", org, repo, num), 2101 requestBody: &issue, 2102 exitCodes: []int{200}, 2103 }, nil) 2104 return err 2105 } 2106 2107 // SetMilestone sets the milestone from the specified issue (if it is a valid milestone) 2108 // 2109 // See https://developer.github.com/v3/issues/#edit-an-issue 2110 func (c *Client) SetMilestone(org, repo string, issueNum, milestoneNum int) error { 2111 c.log("SetMilestone", org, repo, issueNum, milestoneNum) 2112 2113 issue := &struct { 2114 Milestone int `json:"milestone"` 2115 }{Milestone: milestoneNum} 2116 2117 _, err := c.request(&request{ 2118 method: http.MethodPatch, 2119 path: fmt.Sprintf("/repos/%v/%v/issues/%d", org, repo, issueNum), 2120 requestBody: &issue, 2121 exitCodes: []int{200}, 2122 }, nil) 2123 return err 2124 } 2125 2126 // ListMilestones list all milestones in a repo 2127 // 2128 // See https://developer.github.com/v3/issues/milestones/#list-milestones-for-a-repository/ 2129 func (c *Client) ListMilestones(org, repo string) ([]Milestone, error) { 2130 c.log("ListMilestones", org) 2131 if c.fake { 2132 return nil, nil 2133 } 2134 path := fmt.Sprintf("/repos/%s/%s/milestones", org, repo) 2135 var milestones []Milestone 2136 err := c.readPaginatedResults( 2137 path, 2138 acceptNone, 2139 func() interface{} { 2140 return &[]Milestone{} 2141 }, 2142 func(obj interface{}) { 2143 milestones = append(milestones, *(obj.(*[]Milestone))...) 2144 }, 2145 ) 2146 if err != nil { 2147 return nil, err 2148 } 2149 return milestones, nil 2150 }