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 }