github.com/billybanfield/evergreen@v0.0.0-20170525200750-eeee692790f7/thirdparty/github.go (about)

     1  package thirdparty
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io/ioutil"
     8  	"net/http"
     9  	"strconv"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/evergreen-ci/evergreen"
    14  	"github.com/evergreen-ci/evergreen/util"
    15  	"github.com/mongodb/grip"
    16  	"github.com/mongodb/grip/level"
    17  	"github.com/pkg/errors"
    18  )
    19  
    20  const (
    21  	GithubBase          = "https://github.com"
    22  	NumGithubRetries    = 5
    23  	GithubSleepTimeSecs = 1
    24  	GithubAPIBase       = "https://api.github.com"
    25  	GithubStatusBase    = "https://status.github.com"
    26  
    27  	GithubAPIStatusMinor = "minor"
    28  	GithubAPIStatusMajor = "major"
    29  	GithubAPIStatusGood  = "good"
    30  )
    31  
    32  type GithubUser struct {
    33  	Active       bool   `json:"active"`
    34  	DispName     string `json:"display-name"`
    35  	EmailAddress string `json:"email"`
    36  	FirstName    string `json:"first-name"`
    37  	LastName     string `json:"last-name"`
    38  	Name         string `json:"name"`
    39  }
    40  
    41  // GetGithubCommits returns a slice of GithubCommit objects from
    42  // the given commitsURL when provided a valid oauth token
    43  func GetGithubCommits(oauthToken, commitsURL string) (
    44  	githubCommits []GithubCommit, header http.Header, err error) {
    45  	resp, err := tryGithubGet(oauthToken, commitsURL)
    46  	if resp != nil {
    47  		defer resp.Body.Close()
    48  	}
    49  	if resp == nil {
    50  		errMsg := fmt.Sprintf("nil response from url '%v'", commitsURL)
    51  		grip.Error(errMsg)
    52  		return nil, nil, APIResponseError{errMsg}
    53  	}
    54  	if err != nil {
    55  		errMsg := fmt.Sprintf("error querying '%v': %v", commitsURL, err)
    56  		grip.Error(errMsg)
    57  		return nil, nil, APIResponseError{errMsg}
    58  	}
    59  
    60  	header = resp.Header
    61  	respBody, err := ioutil.ReadAll(resp.Body)
    62  	if err != nil {
    63  		return nil, nil, ResponseReadError{err.Error()}
    64  	}
    65  
    66  	grip.Debugf("Github API response: %s. %d bytes", resp.Status, len(respBody))
    67  
    68  	if resp.StatusCode != http.StatusOK {
    69  		requestError := APIRequestError{}
    70  		if err = json.Unmarshal(respBody, &requestError); err != nil {
    71  			return nil, nil, APIRequestError{Message: string(respBody)}
    72  		}
    73  		return nil, nil, requestError
    74  	}
    75  
    76  	if err = json.Unmarshal(respBody, &githubCommits); err != nil {
    77  		return nil, nil, APIUnmarshalError{string(respBody), err.Error()}
    78  	}
    79  	return
    80  }
    81  
    82  func GetGithubAPIStatus() (string, error) {
    83  	req, err := http.NewRequest(evergreen.MethodGet, fmt.Sprintf("%v/api/status.json", GithubStatusBase), nil)
    84  	if err != nil {
    85  		return "", err
    86  	}
    87  
    88  	req.Header.Add("Content-Type", "application/json")
    89  	req.Header.Add("Accept", "application/json")
    90  	client := &http.Client{}
    91  	resp, err := client.Do(req)
    92  	if err != nil {
    93  		return "", errors.Wrap(err, "github request failed")
    94  	}
    95  
    96  	gitStatus := struct {
    97  		Status      string    `json:"status"`
    98  		LastUpdated time.Time `json:"last_updated"`
    99  	}{}
   100  
   101  	err = util.ReadJSONInto(resp.Body, &gitStatus)
   102  	if err != nil {
   103  		return "", errors.Wrap(err, "json read failed")
   104  	}
   105  
   106  	return gitStatus.Status, nil
   107  }
   108  
   109  // GetGithubFile returns a struct that contains the contents of files within
   110  // a repository as Base64 encoded content.
   111  func GetGithubFile(oauthToken, fileURL string) (githubFile *GithubFile, err error) {
   112  	resp, err := tryGithubGet(oauthToken, fileURL)
   113  	if resp == nil {
   114  		errMsg := fmt.Sprintf("nil response from url '%v'", fileURL)
   115  		grip.Error(errMsg)
   116  		return nil, APIResponseError{errMsg}
   117  	}
   118  	defer resp.Body.Close()
   119  
   120  	if err != nil {
   121  		errMsg := fmt.Sprintf("error querying '%v': %v", fileURL, err)
   122  		grip.Error(errMsg)
   123  		return nil, APIResponseError{errMsg}
   124  	}
   125  
   126  	if resp.StatusCode != http.StatusOK {
   127  		grip.Errorf("Github API response: ā€˜%s’", resp.Status)
   128  		if resp.StatusCode == http.StatusNotFound {
   129  			return nil, FileNotFoundError{fileURL}
   130  		}
   131  		return nil, errors.Errorf("github API returned status '%v'", resp.Status)
   132  	}
   133  
   134  	respBody, err := ioutil.ReadAll(resp.Body)
   135  	if err != nil {
   136  		return nil, ResponseReadError{err.Error()}
   137  	}
   138  
   139  	grip.Debugf("Github API response: %s. %d bytes", resp.Status, len(respBody))
   140  
   141  	if resp.StatusCode != http.StatusOK {
   142  		requestError := APIRequestError{}
   143  		if err = json.Unmarshal(respBody, &requestError); err != nil {
   144  			return nil, APIRequestError{Message: string(respBody)}
   145  		}
   146  		return nil, requestError
   147  	}
   148  
   149  	if err = json.Unmarshal(respBody, &githubFile); err != nil {
   150  		return nil, APIUnmarshalError{string(respBody), err.Error()}
   151  	}
   152  	return
   153  }
   154  
   155  func GetGitHubMergeBaseRevision(oauthToken, repoOwner, repo, baseRevision string, currentCommit *GithubCommit) (string, error) {
   156  	if currentCommit == nil {
   157  		return "", errors.New("no recent commit found")
   158  	}
   159  	url := fmt.Sprintf("%v/repos/%v/%v/compare/%v:%v...%v:%v",
   160  		GithubAPIBase,
   161  		repoOwner,
   162  		repo,
   163  		repoOwner,
   164  		baseRevision,
   165  		repoOwner,
   166  		currentCommit.SHA)
   167  
   168  	resp, err := tryGithubGet(oauthToken, url)
   169  	if resp != nil {
   170  		defer resp.Body.Close()
   171  	}
   172  	if err != nil {
   173  		errMsg := fmt.Sprintf("error getting merge base commit response for url, %v: %v", url, err)
   174  		grip.Error(errMsg)
   175  		return "", APIResponseError{errMsg}
   176  	}
   177  	respBody, err := ioutil.ReadAll(resp.Body)
   178  	if err != nil {
   179  		return "", ResponseReadError{err.Error()}
   180  	}
   181  	if resp.StatusCode != http.StatusOK {
   182  		requestError := APIRequestError{}
   183  		if err = json.Unmarshal(respBody, &requestError); err != nil {
   184  			return "", APIRequestError{Message: string(respBody)}
   185  		}
   186  		return "", requestError
   187  	}
   188  	compareResponse := &GitHubCompareResponse{}
   189  	if err = json.Unmarshal(respBody, compareResponse); err != nil {
   190  		return "", APIUnmarshalError{string(respBody), err.Error()}
   191  	}
   192  	return compareResponse.MergeBaseCommit.SHA, nil
   193  }
   194  
   195  func GetCommitEvent(oauthToken, repoOwner, repo, githash string) (*CommitEvent,
   196  	error) {
   197  	commitURL := fmt.Sprintf("%v/repos/%v/%v/commits/%v",
   198  		GithubAPIBase,
   199  		repoOwner,
   200  		repo,
   201  		githash,
   202  	)
   203  
   204  	grip.Errorln("requesting github commit from url:", commitURL)
   205  
   206  	resp, err := tryGithubGet(oauthToken, commitURL)
   207  	if resp != nil {
   208  		defer resp.Body.Close()
   209  	}
   210  	if err != nil {
   211  		errMsg := fmt.Sprintf("error querying '%v': %v", commitURL, err)
   212  		grip.Error(errMsg)
   213  		return nil, APIResponseError{errMsg}
   214  	}
   215  
   216  	respBody, err := ioutil.ReadAll(resp.Body)
   217  	if err != nil {
   218  		return nil, ResponseReadError{err.Error()}
   219  	}
   220  	grip.Debugf("Github API response: %s. %d bytes", resp.Status, len(respBody))
   221  
   222  	if resp.StatusCode != http.StatusOK {
   223  		requestError := APIRequestError{}
   224  		if err = json.Unmarshal(respBody, &requestError); err != nil {
   225  			return nil, APIRequestError{Message: string(respBody)}
   226  		}
   227  		return nil, requestError
   228  	}
   229  
   230  	commitEvent := &CommitEvent{}
   231  	if err = json.Unmarshal(respBody, commitEvent); err != nil {
   232  		return nil, APIUnmarshalError{string(respBody), err.Error()}
   233  	}
   234  	return commitEvent, nil
   235  }
   236  
   237  // GetBranchEvent gets the head of the a given branch via an API call to GitHub
   238  func GetBranchEvent(oauthToken, repoOwner, repo, branch string) (*BranchEvent,
   239  	error) {
   240  	branchURL := fmt.Sprintf("%v/repos/%v/%v/branches/%v",
   241  		GithubAPIBase,
   242  		repoOwner,
   243  		repo,
   244  		branch,
   245  	)
   246  
   247  	grip.Errorln("requesting github commit from url:", branchURL)
   248  
   249  	resp, err := tryGithubGet(oauthToken, branchURL)
   250  	if resp != nil {
   251  		defer resp.Body.Close()
   252  	}
   253  
   254  	if err != nil {
   255  		errMsg := fmt.Sprintf("error querying '%v': %v", branchURL, err)
   256  		grip.Error(errMsg)
   257  		return nil, APIResponseError{errMsg}
   258  	}
   259  
   260  	respBody, err := ioutil.ReadAll(resp.Body)
   261  	if err != nil {
   262  		return nil, ResponseReadError{err.Error()}
   263  	}
   264  	grip.Debugf("Github API response: %s. %d bytes", resp.Status, len(respBody))
   265  
   266  	if resp.StatusCode != http.StatusOK {
   267  		requestError := APIRequestError{}
   268  		if err = json.Unmarshal(respBody, &requestError); err != nil {
   269  			return nil, APIRequestError{Message: string(respBody)}
   270  		}
   271  		return nil, requestError
   272  	}
   273  
   274  	branchEvent := &BranchEvent{}
   275  	if err = json.Unmarshal(respBody, branchEvent); err != nil {
   276  		return nil, APIUnmarshalError{string(respBody), err.Error()}
   277  	}
   278  	return branchEvent, nil
   279  }
   280  
   281  // githubRequest performs the specified http request. If the oauth token field is empty it will not use oauth
   282  func githubRequest(method string, url string, oauthToken string, data interface{}) (*http.Response, error) {
   283  	req, err := http.NewRequest(method, url, nil)
   284  	if err != nil {
   285  		return nil, err
   286  	}
   287  
   288  	// if there is data, add it to the body of the request
   289  	if data != nil {
   290  		jsonBytes, err := json.Marshal(data)
   291  		if err != nil {
   292  			return nil, err
   293  		}
   294  		req.Body = ioutil.NopCloser(bytes.NewReader(jsonBytes))
   295  	}
   296  
   297  	// check if there is an oauth token, if there is make sure it is a valid oauthtoken
   298  	if len(oauthToken) > 0 {
   299  		if !strings.HasPrefix(oauthToken, "token ") {
   300  			return nil, errors.New("Invalid oauth token given")
   301  		}
   302  		req.Header.Add("Authorization", oauthToken)
   303  	}
   304  
   305  	req.Header.Add("Content-Type", "application/json")
   306  	req.Header.Add("Accept", "application/json")
   307  	client := &http.Client{}
   308  	return client.Do(req)
   309  }
   310  
   311  func tryGithubGet(oauthToken, url string) (resp *http.Response, err error) {
   312  	grip.Debugf("Attempting GitHub API call at '%s'", url)
   313  	retriableGet := util.RetriableFunc(
   314  		func() error {
   315  			resp, err = githubRequest("GET", url, oauthToken, nil)
   316  			if err != nil {
   317  				grip.Errorf("failed trying to call github GET on %s: %+v", url, err)
   318  				return util.RetriableError{err}
   319  			}
   320  			if resp.StatusCode == http.StatusUnauthorized {
   321  				err = errors.Errorf("Calling github GET on %v failed: got 'unauthorized' response", url)
   322  				grip.Error(err)
   323  				return err
   324  			}
   325  			if resp.StatusCode != http.StatusOK {
   326  				err = errors.Errorf("Calling github GET on %v got a bad response code: %v", url, resp.StatusCode)
   327  			}
   328  			// read the results
   329  			rateMessage, _ := getGithubRateLimit(resp.Header)
   330  			grip.Debugf("Github API response: %s. %s", resp.Status, rateMessage)
   331  			return nil
   332  		},
   333  	)
   334  
   335  	retryFail, err := util.Retry(retriableGet, NumGithubRetries, GithubSleepTimeSecs*time.Second)
   336  	if err != nil {
   337  		// couldn't get it
   338  		if retryFail {
   339  			grip.Errorf("Github GET on %v used up all retries.", err)
   340  		}
   341  		return nil, errors.WithStack(err)
   342  	}
   343  
   344  	return
   345  }
   346  
   347  // tryGithubPost posts the data to the Github api endpoint with the url given
   348  func tryGithubPost(url string, oauthToken string, data interface{}) (resp *http.Response, err error) {
   349  	grip.Errorf("Attempting GitHub API POST at ā€˜%s’", url)
   350  	retriableGet := util.RetriableFunc(
   351  		func() (retryError error) {
   352  			resp, err = githubRequest("POST", url, oauthToken, data)
   353  			if err != nil {
   354  				grip.Errorf("failed trying to call github POST on %s: %+v", url, err)
   355  				return util.RetriableError{err}
   356  			}
   357  			if resp.StatusCode == http.StatusUnauthorized {
   358  				err = errors.Errorf("Calling github POST on %v failed: got 'unauthorized' response", url)
   359  				grip.Error(err)
   360  				return err
   361  			}
   362  			if resp.StatusCode != http.StatusOK {
   363  				err = errors.Errorf("Calling github POST on %v got a bad response code: %v", url, resp.StatusCode)
   364  			}
   365  			// read the results
   366  			rateMessage, loglevel := getGithubRateLimit(resp.Header)
   367  
   368  			grip.Logf(loglevel, "Github API response: %v. %v", resp.Status, rateMessage)
   369  			return nil
   370  		},
   371  	)
   372  
   373  	retryFail, err := util.Retry(retriableGet, NumGithubRetries, GithubSleepTimeSecs*time.Second)
   374  	if err != nil {
   375  		// couldn't post it
   376  		if retryFail {
   377  			grip.Errorf("Github POST to '%s' used up all retries.", url)
   378  		}
   379  		return nil, errors.WithStack(err)
   380  	}
   381  
   382  	return
   383  }
   384  
   385  // GetGithubFileURL returns a URL that locates a github file given the owner,
   386  // repo,remote path and revision
   387  func GetGithubFileURL(owner, repo, remotePath, revision string) string {
   388  	return fmt.Sprintf("https://api.github.com/repos/%v/%v/contents/%v?ref=%v",
   389  		owner,
   390  		repo,
   391  		remotePath,
   392  		revision,
   393  	)
   394  }
   395  
   396  // NextPageLink returns the link to the next page for a given header's "Link"
   397  // key based on http://developer.github.com/v3/#pagination
   398  // For full details see http://tools.ietf.org/html/rfc5988
   399  func NextGithubPageLink(header http.Header) string {
   400  	hlink, ok := header["Link"]
   401  	if !ok {
   402  		return ""
   403  	}
   404  
   405  	for _, s := range hlink {
   406  		ix := strings.Index(s, `; rel="next"`)
   407  		if ix > -1 {
   408  			t := s[:ix]
   409  			op := strings.Index(t, "<")
   410  			po := strings.Index(t, ">")
   411  			u := t[op+1 : po]
   412  			return u
   413  		}
   414  	}
   415  	return ""
   416  }
   417  
   418  // getGithubRateLimit interprets the limit headers, and produces an increasingly
   419  // alarmed message (for the caller to log) as we get closer and closer
   420  func getGithubRateLimit(header http.Header) (message string, loglevel level.Priority) {
   421  	h := (map[string][]string)(header)
   422  	limStr, okLim := h["X-Ratelimit-Limit"]
   423  	remStr, okRem := h["X-Ratelimit-Remaining"]
   424  
   425  	// ensure that we were able to read the rate limit header
   426  	if !okLim || !okRem || len(limStr) == 0 || len(remStr) == 0 {
   427  		loglevel = level.Warning
   428  		message = "Could not get rate limit data"
   429  		return
   430  	}
   431  
   432  	// parse the rate limits
   433  	lim, limErr := strconv.ParseInt(limStr[0], 10, 0) // parse in decimal to int
   434  	rem, remErr := strconv.ParseInt(remStr[0], 10, 0)
   435  
   436  	// ensure we successfully parsed the rate limits
   437  	if limErr != nil || remErr != nil {
   438  		loglevel = level.Warning
   439  		message = fmt.Sprintf("Could not parse rate limit data: limit=%q, rate=%t",
   440  			limStr, okLim)
   441  		return
   442  	}
   443  
   444  	// We're in good shape
   445  	if rem > int64(0.1*float32(lim)) {
   446  		loglevel = level.Info
   447  		message = fmt.Sprintf("Rate limit: %v/%v", rem, lim)
   448  		return
   449  	}
   450  
   451  	// we're running short
   452  	if rem > 20 {
   453  		loglevel = level.Warning
   454  		message = fmt.Sprintf("Rate limit significantly low: %v/%v", rem, lim)
   455  		return
   456  	}
   457  
   458  	// we're in trouble
   459  	loglevel = level.Error
   460  	message = fmt.Sprintf("Throttling required - rate limit almost exhausted: %v/%v", rem, lim)
   461  	return
   462  }
   463  
   464  // GithubAuthenticate does a POST to github with the code that it received, the ClientId, ClientSecret
   465  // And returns the response which contains the accessToken associated with the user.
   466  func GithubAuthenticate(code, clientId, clientSecret string) (githubResponse *GithubAuthResponse, err error) {
   467  	accessUrl := "https://github.com/login/oauth/access_token"
   468  	authParameters := GithubAuthParameters{
   469  		ClientId:     clientId,
   470  		ClientSecret: clientSecret,
   471  		Code:         code,
   472  	}
   473  	resp, err := tryGithubPost(accessUrl, "", authParameters)
   474  	if resp != nil {
   475  		defer resp.Body.Close()
   476  	}
   477  	if err != nil {
   478  		return nil, errors.Wrap(err, "could not authenticate for token")
   479  	}
   480  	if resp == nil {
   481  		return nil, errors.New("invalid github response")
   482  	}
   483  	respBody, err := ioutil.ReadAll(resp.Body)
   484  	if err != nil {
   485  		return nil, ResponseReadError{err.Error()}
   486  	}
   487  	grip.Debugf("GitHub API response: %s. %d bytes", resp.Status, len(respBody))
   488  
   489  	if err = json.Unmarshal(respBody, &githubResponse); err != nil {
   490  		return nil, APIUnmarshalError{string(respBody), err.Error()}
   491  	}
   492  	return
   493  }
   494  
   495  // GetGithubUser does a GET from GitHub for the user, email, and organizations information and
   496  // returns the GithubLoginUser and its associated GithubOrganizations after authentication
   497  func GetGithubUser(token string) (githubUser *GithubLoginUser, githubOrganizations []GithubOrganization, err error) {
   498  	userUrl := fmt.Sprintf("%v/user", GithubAPIBase)
   499  	orgUrl := fmt.Sprintf("%v/user/orgs", GithubAPIBase)
   500  	t := fmt.Sprintf("token %v", token)
   501  	// get the user
   502  	resp, err := tryGithubGet(t, userUrl)
   503  	if resp != nil {
   504  		defer resp.Body.Close()
   505  	}
   506  	if err != nil {
   507  		return nil, nil, errors.WithStack(err)
   508  	}
   509  	respBody, err := ioutil.ReadAll(resp.Body)
   510  	if err != nil {
   511  		return nil, nil, ResponseReadError{err.Error()}
   512  	}
   513  
   514  	grip.Debugf("Github API response: %s. %d bytes", resp.Status, len(respBody))
   515  
   516  	if err = json.Unmarshal(respBody, &githubUser); err != nil {
   517  		return nil, nil, APIUnmarshalError{string(respBody), err.Error()}
   518  	}
   519  
   520  	// get the user's organizations
   521  	resp, err = tryGithubGet(t, orgUrl)
   522  	if resp != nil {
   523  		defer resp.Body.Close()
   524  	}
   525  	if err != nil {
   526  		return nil, nil, errors.Wrapf(err, "Could not get user from token")
   527  	}
   528  	respBody, err = ioutil.ReadAll(resp.Body)
   529  	if err != nil {
   530  		return nil, nil, ResponseReadError{err.Error()}
   531  	}
   532  
   533  	grip.Debugf("Github API response: %s. %d bytes", resp.Status, len(respBody))
   534  
   535  	if err = json.Unmarshal(respBody, &githubOrganizations); err != nil {
   536  		return nil, nil, APIUnmarshalError{string(respBody), err.Error()}
   537  	}
   538  	return
   539  }
   540  
   541  // verifyGithubAPILimitHeader parses a Github API header to find the number of requests remaining
   542  func verifyGithubAPILimitHeader(header http.Header) (int64, error) {
   543  	h := (map[string][]string)(header)
   544  	limStr, okLim := h["X-Ratelimit-Limit"]
   545  	remStr, okRem := h["X-Ratelimit-Remaining"]
   546  
   547  	if !okLim || !okRem || len(limStr) == 0 || len(remStr) == 0 {
   548  		return 0, errors.New("Could not get rate limit data")
   549  	}
   550  
   551  	rem, err := strconv.ParseInt(remStr[0], 10, 0)
   552  	if err != nil {
   553  		return 0, errors.Errorf("Could not parse rate limit data: limit=%q, rate=%t", limStr, okLim)
   554  	}
   555  
   556  	return rem, nil
   557  }
   558  
   559  // CheckGithubAPILimit queries Github for the number of API requests remaining
   560  func CheckGithubAPILimit(token string) (int64, error) {
   561  	url := fmt.Sprintf("%v/rate_limit", GithubAPIBase)
   562  	resp, err := githubRequest("GET", url, token, nil)
   563  	if err != nil {
   564  		grip.Errorf("github GET rate limit failed on %s: %+v", url, err)
   565  		return 0, err
   566  	}
   567  	rem, err := verifyGithubAPILimitHeader(resp.Header)
   568  	if err != nil {
   569  		grip.Errorf("Error getting rate limit: %s", err)
   570  		return 0, err
   571  	}
   572  	return rem, nil
   573  }