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  }