github.com/shashidharatd/test-infra@v0.0.0-20171006011030-71304e1ca560/prow/github/client.go (about)

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