mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-26 13:29:12 -05:00
b3e06523c1
* upgrate go-github client to v32.1.0 * migrate
1076 lines
36 KiB
Go
Vendored
1076 lines
36 KiB
Go
Vendored
// Copyright 2013 The go-github AUTHORS. All rights reserved.
|
|
//
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
//go:generate go run gen-accessors.go
|
|
//go:generate go run gen-stringify-test.go
|
|
|
|
package github
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/go-querystring/query"
|
|
)
|
|
|
|
const (
|
|
defaultBaseURL = "https://api.github.com/"
|
|
uploadBaseURL = "https://uploads.github.com/"
|
|
userAgent = "go-github"
|
|
|
|
headerRateLimit = "X-RateLimit-Limit"
|
|
headerRateRemaining = "X-RateLimit-Remaining"
|
|
headerRateReset = "X-RateLimit-Reset"
|
|
headerOTP = "X-GitHub-OTP"
|
|
|
|
mediaTypeV3 = "application/vnd.github.v3+json"
|
|
defaultMediaType = "application/octet-stream"
|
|
mediaTypeV3SHA = "application/vnd.github.v3.sha"
|
|
mediaTypeV3Diff = "application/vnd.github.v3.diff"
|
|
mediaTypeV3Patch = "application/vnd.github.v3.patch"
|
|
mediaTypeOrgPermissionRepo = "application/vnd.github.v3.repository+json"
|
|
|
|
// Media Type values to access preview APIs
|
|
|
|
// https://developer.github.com/changes/2014-12-09-new-attributes-for-stars-api/
|
|
mediaTypeStarringPreview = "application/vnd.github.v3.star+json"
|
|
|
|
// https://help.github.com/enterprise/2.4/admin/guides/migrations/exporting-the-github-com-organization-s-repositories/
|
|
mediaTypeMigrationsPreview = "application/vnd.github.wyandotte-preview+json"
|
|
|
|
// https://developer.github.com/changes/2016-04-06-deployment-and-deployment-status-enhancements/
|
|
mediaTypeDeploymentStatusPreview = "application/vnd.github.ant-man-preview+json"
|
|
|
|
// https://developer.github.com/changes/2018-10-16-deployments-environments-states-and-auto-inactive-updates/
|
|
mediaTypeExpandDeploymentStatusPreview = "application/vnd.github.flash-preview+json"
|
|
|
|
// https://developer.github.com/changes/2016-05-12-reactions-api-preview/
|
|
mediaTypeReactionsPreview = "application/vnd.github.squirrel-girl-preview"
|
|
|
|
// https://developer.github.com/changes/2016-05-23-timeline-preview-api/
|
|
mediaTypeTimelinePreview = "application/vnd.github.mockingbird-preview+json"
|
|
|
|
// https://developer.github.com/changes/2016-09-14-projects-api/
|
|
mediaTypeProjectsPreview = "application/vnd.github.inertia-preview+json"
|
|
|
|
// https://developer.github.com/changes/2016-09-14-Integrations-Early-Access/
|
|
mediaTypeIntegrationPreview = "application/vnd.github.machine-man-preview+json"
|
|
|
|
// https://developer.github.com/changes/2017-01-05-commit-search-api/
|
|
mediaTypeCommitSearchPreview = "application/vnd.github.cloak-preview+json"
|
|
|
|
// https://developer.github.com/changes/2017-02-28-user-blocking-apis-and-webhook/
|
|
mediaTypeBlockUsersPreview = "application/vnd.github.giant-sentry-fist-preview+json"
|
|
|
|
// https://developer.github.com/changes/2017-02-09-community-health/
|
|
mediaTypeRepositoryCommunityHealthMetricsPreview = "application/vnd.github.black-panther-preview+json"
|
|
|
|
// https://developer.github.com/changes/2017-05-23-coc-api/
|
|
mediaTypeCodesOfConductPreview = "application/vnd.github.scarlet-witch-preview+json"
|
|
|
|
// https://developer.github.com/changes/2017-07-17-update-topics-on-repositories/
|
|
mediaTypeTopicsPreview = "application/vnd.github.mercy-preview+json"
|
|
|
|
// https://developer.github.com/changes/2018-03-16-protected-branches-required-approving-reviews/
|
|
mediaTypeRequiredApprovingReviewsPreview = "application/vnd.github.luke-cage-preview+json"
|
|
|
|
// https://developer.github.com/changes/2018-01-10-lock-reason-api-preview/
|
|
mediaTypeLockReasonPreview = "application/vnd.github.sailor-v-preview+json"
|
|
|
|
// https://developer.github.com/changes/2018-05-07-new-checks-api-public-beta/
|
|
mediaTypeCheckRunsPreview = "application/vnd.github.antiope-preview+json"
|
|
|
|
// https://developer.github.com/enterprise/2.13/v3/repos/pre_receive_hooks/
|
|
mediaTypePreReceiveHooksPreview = "application/vnd.github.eye-scream-preview"
|
|
|
|
// https://developer.github.com/changes/2018-02-22-protected-branches-required-signatures/
|
|
mediaTypeSignaturePreview = "application/vnd.github.zzzax-preview+json"
|
|
|
|
// https://developer.github.com/changes/2018-09-05-project-card-events/
|
|
mediaTypeProjectCardDetailsPreview = "application/vnd.github.starfox-preview+json"
|
|
|
|
// https://developer.github.com/changes/2018-12-18-interactions-preview/
|
|
mediaTypeInteractionRestrictionsPreview = "application/vnd.github.sombra-preview+json"
|
|
|
|
// https://developer.github.com/changes/2019-03-14-enabling-disabling-pages/
|
|
mediaTypeEnablePagesAPIPreview = "application/vnd.github.switcheroo-preview+json"
|
|
|
|
// https://developer.github.com/changes/2019-04-24-vulnerability-alerts/
|
|
mediaTypeRequiredVulnerabilityAlertsPreview = "application/vnd.github.dorian-preview+json"
|
|
|
|
// https://developer.github.com/changes/2019-06-04-automated-security-fixes/
|
|
mediaTypeRequiredAutomatedSecurityFixesPreview = "application/vnd.github.london-preview+json"
|
|
|
|
// https://developer.github.com/changes/2019-05-29-update-branch-api/
|
|
mediaTypeUpdatePullRequestBranchPreview = "application/vnd.github.lydian-preview+json"
|
|
|
|
// https://developer.github.com/changes/2019-04-11-pulls-branches-for-commit/
|
|
mediaTypeListPullsOrBranchesForCommitPreview = "application/vnd.github.groot-preview+json"
|
|
|
|
// https://developer.github.com/v3/previews/#repository-creation-permissions
|
|
mediaTypeMemberAllowedRepoCreationTypePreview = "application/vnd.github.surtur-preview+json"
|
|
|
|
// https://developer.github.com/v3/previews/#create-and-use-repository-templates
|
|
mediaTypeRepositoryTemplatePreview = "application/vnd.github.baptiste-preview+json"
|
|
|
|
// https://developer.github.com/changes/2019-10-03-multi-line-comments/
|
|
mediaTypeMultiLineCommentsPreview = "application/vnd.github.comfort-fade-preview+json"
|
|
|
|
// https://developer.github.com/changes/2019-11-05-deprecated-passwords-and-authorizations-api/
|
|
mediaTypeOAuthAppPreview = "application/vnd.github.doctor-strange-preview+json"
|
|
|
|
// https://developer.github.com/changes/2019-12-03-internal-visibility-changes/
|
|
mediaTypeRepositoryVisibilityPreview = "application/vnd.github.nebula-preview+json"
|
|
)
|
|
|
|
// A Client manages communication with the GitHub API.
|
|
type Client struct {
|
|
clientMu sync.Mutex // clientMu protects the client during calls that modify the CheckRedirect func.
|
|
client *http.Client // HTTP client used to communicate with the API.
|
|
|
|
// Base URL for API requests. Defaults to the public GitHub API, but can be
|
|
// set to a domain endpoint to use with GitHub Enterprise. BaseURL should
|
|
// always be specified with a trailing slash.
|
|
BaseURL *url.URL
|
|
|
|
// Base URL for uploading files.
|
|
UploadURL *url.URL
|
|
|
|
// User agent used when communicating with the GitHub API.
|
|
UserAgent string
|
|
|
|
rateMu sync.Mutex
|
|
rateLimits [categories]Rate // Rate limits for the client as determined by the most recent API calls.
|
|
|
|
common service // Reuse a single struct instead of allocating one for each service on the heap.
|
|
|
|
// Services used for talking to different parts of the GitHub API.
|
|
Actions *ActionsService
|
|
Activity *ActivityService
|
|
Admin *AdminService
|
|
Apps *AppsService
|
|
Authorizations *AuthorizationsService
|
|
Checks *ChecksService
|
|
CodeScanning *CodeScanningService
|
|
Gists *GistsService
|
|
Git *GitService
|
|
Gitignores *GitignoresService
|
|
Interactions *InteractionsService
|
|
Issues *IssuesService
|
|
Licenses *LicensesService
|
|
Marketplace *MarketplaceService
|
|
Migrations *MigrationService
|
|
Organizations *OrganizationsService
|
|
Projects *ProjectsService
|
|
PullRequests *PullRequestsService
|
|
Reactions *ReactionsService
|
|
Repositories *RepositoriesService
|
|
Search *SearchService
|
|
Teams *TeamsService
|
|
Users *UsersService
|
|
}
|
|
|
|
type service struct {
|
|
client *Client
|
|
}
|
|
|
|
// ListOptions specifies the optional parameters to various List methods that
|
|
// support offset pagination.
|
|
type ListOptions struct {
|
|
// For paginated result sets, page of results to retrieve.
|
|
Page int `url:"page,omitempty"`
|
|
|
|
// For paginated result sets, the number of results to include per page.
|
|
PerPage int `url:"per_page,omitempty"`
|
|
}
|
|
|
|
// ListCursorOptions specifies the optional parameters to various List methods that
|
|
// support cursor pagination.
|
|
type ListCursorOptions struct {
|
|
// For paginated result sets, page of results to retrieve.
|
|
Page string `url:"page,omitempty"`
|
|
|
|
// For paginated result sets, the number of results to include per page.
|
|
PerPage int `url:"per_page,omitempty"`
|
|
}
|
|
|
|
// UploadOptions specifies the parameters to methods that support uploads.
|
|
type UploadOptions struct {
|
|
Name string `url:"name,omitempty"`
|
|
Label string `url:"label,omitempty"`
|
|
MediaType string `url:"-"`
|
|
}
|
|
|
|
// RawType represents type of raw format of a request instead of JSON.
|
|
type RawType uint8
|
|
|
|
const (
|
|
// Diff format.
|
|
Diff RawType = 1 + iota
|
|
// Patch format.
|
|
Patch
|
|
)
|
|
|
|
// RawOptions specifies parameters when user wants to get raw format of
|
|
// a response instead of JSON.
|
|
type RawOptions struct {
|
|
Type RawType
|
|
}
|
|
|
|
// addOptions adds the parameters in opts as URL query parameters to s. opts
|
|
// must be a struct whose fields may contain "url" tags.
|
|
func addOptions(s string, opts interface{}) (string, error) {
|
|
v := reflect.ValueOf(opts)
|
|
if v.Kind() == reflect.Ptr && v.IsNil() {
|
|
return s, nil
|
|
}
|
|
|
|
u, err := url.Parse(s)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
|
|
qs, err := query.Values(opts)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
|
|
u.RawQuery = qs.Encode()
|
|
return u.String(), nil
|
|
}
|
|
|
|
// NewClient returns a new GitHub API client. If a nil httpClient is
|
|
// provided, a new http.Client will be used. To use API methods which require
|
|
// authentication, provide an http.Client that will perform the authentication
|
|
// for you (such as that provided by the golang.org/x/oauth2 library).
|
|
func NewClient(httpClient *http.Client) *Client {
|
|
if httpClient == nil {
|
|
httpClient = &http.Client{}
|
|
}
|
|
baseURL, _ := url.Parse(defaultBaseURL)
|
|
uploadURL, _ := url.Parse(uploadBaseURL)
|
|
|
|
c := &Client{client: httpClient, BaseURL: baseURL, UserAgent: userAgent, UploadURL: uploadURL}
|
|
c.common.client = c
|
|
c.Actions = (*ActionsService)(&c.common)
|
|
c.Activity = (*ActivityService)(&c.common)
|
|
c.Admin = (*AdminService)(&c.common)
|
|
c.Apps = (*AppsService)(&c.common)
|
|
c.Authorizations = (*AuthorizationsService)(&c.common)
|
|
c.Checks = (*ChecksService)(&c.common)
|
|
c.CodeScanning = (*CodeScanningService)(&c.common)
|
|
c.Gists = (*GistsService)(&c.common)
|
|
c.Git = (*GitService)(&c.common)
|
|
c.Gitignores = (*GitignoresService)(&c.common)
|
|
c.Interactions = (*InteractionsService)(&c.common)
|
|
c.Issues = (*IssuesService)(&c.common)
|
|
c.Licenses = (*LicensesService)(&c.common)
|
|
c.Marketplace = &MarketplaceService{client: c}
|
|
c.Migrations = (*MigrationService)(&c.common)
|
|
c.Organizations = (*OrganizationsService)(&c.common)
|
|
c.Projects = (*ProjectsService)(&c.common)
|
|
c.PullRequests = (*PullRequestsService)(&c.common)
|
|
c.Reactions = (*ReactionsService)(&c.common)
|
|
c.Repositories = (*RepositoriesService)(&c.common)
|
|
c.Search = (*SearchService)(&c.common)
|
|
c.Teams = (*TeamsService)(&c.common)
|
|
c.Users = (*UsersService)(&c.common)
|
|
return c
|
|
}
|
|
|
|
// NewEnterpriseClient returns a new GitHub API client with provided
|
|
// base URL and upload URL (often is your GitHub Enterprise hostname).
|
|
// If the base URL does not have the suffix "/api/v3/", it will be added automatically.
|
|
// If the upload URL does not have the suffix "/api/uploads", it will be added automatically.
|
|
// If a nil httpClient is provided, a new http.Client will be used.
|
|
//
|
|
// Note that NewEnterpriseClient is a convenience helper only;
|
|
// its behavior is equivalent to using NewClient, followed by setting
|
|
// the BaseURL and UploadURL fields.
|
|
//
|
|
// Another important thing is that by default, the GitHub Enterprise URL format
|
|
// should be http(s)://[hostname]/api/v3/ or you will always receive the 406 status code.
|
|
// The upload URL format should be http(s)://[hostname]/api/uploads/.
|
|
func NewEnterpriseClient(baseURL, uploadURL string, httpClient *http.Client) (*Client, error) {
|
|
baseEndpoint, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !strings.HasSuffix(baseEndpoint.Path, "/") {
|
|
baseEndpoint.Path += "/"
|
|
}
|
|
if !strings.HasSuffix(baseEndpoint.Path, "/api/v3/") {
|
|
baseEndpoint.Path += "api/v3/"
|
|
}
|
|
|
|
uploadEndpoint, err := url.Parse(uploadURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !strings.HasSuffix(uploadEndpoint.Path, "/") {
|
|
uploadEndpoint.Path += "/"
|
|
}
|
|
if !strings.HasSuffix(uploadEndpoint.Path, "/api/uploads/") {
|
|
uploadEndpoint.Path += "api/uploads/"
|
|
}
|
|
|
|
c := NewClient(httpClient)
|
|
c.BaseURL = baseEndpoint
|
|
c.UploadURL = uploadEndpoint
|
|
return c, nil
|
|
}
|
|
|
|
// NewRequest creates an API request. A relative URL can be provided in urlStr,
|
|
// in which case it is resolved relative to the BaseURL of the Client.
|
|
// Relative URLs should always be specified without a preceding slash. If
|
|
// specified, the value pointed to by body is JSON encoded and included as the
|
|
// request body.
|
|
func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Request, error) {
|
|
if !strings.HasSuffix(c.BaseURL.Path, "/") {
|
|
return nil, fmt.Errorf("BaseURL must have a trailing slash, but %q does not", c.BaseURL)
|
|
}
|
|
u, err := c.BaseURL.Parse(urlStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var buf io.ReadWriter
|
|
if body != nil {
|
|
buf = &bytes.Buffer{}
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
err := enc.Encode(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequest(method, u.String(), buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", mediaTypeV3)
|
|
if c.UserAgent != "" {
|
|
req.Header.Set("User-Agent", c.UserAgent)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// NewUploadRequest creates an upload request. A relative URL can be provided in
|
|
// urlStr, in which case it is resolved relative to the UploadURL of the Client.
|
|
// Relative URLs should always be specified without a preceding slash.
|
|
func (c *Client) NewUploadRequest(urlStr string, reader io.Reader, size int64, mediaType string) (*http.Request, error) {
|
|
if !strings.HasSuffix(c.UploadURL.Path, "/") {
|
|
return nil, fmt.Errorf("UploadURL must have a trailing slash, but %q does not", c.UploadURL)
|
|
}
|
|
u, err := c.UploadURL.Parse(urlStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", u.String(), reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.ContentLength = size
|
|
|
|
if mediaType == "" {
|
|
mediaType = defaultMediaType
|
|
}
|
|
req.Header.Set("Content-Type", mediaType)
|
|
req.Header.Set("Accept", mediaTypeV3)
|
|
req.Header.Set("User-Agent", c.UserAgent)
|
|
return req, nil
|
|
}
|
|
|
|
// Response is a GitHub API response. This wraps the standard http.Response
|
|
// returned from GitHub and provides convenient access to things like
|
|
// pagination links.
|
|
type Response struct {
|
|
*http.Response
|
|
|
|
// These fields provide the page values for paginating through a set of
|
|
// results. Any or all of these may be set to the zero value for
|
|
// responses that are not part of a paginated set, or for which there
|
|
// are no additional pages.
|
|
//
|
|
// These fields support what is called "offset pagination" and should
|
|
// be used with the ListOptions struct.
|
|
NextPage int
|
|
PrevPage int
|
|
FirstPage int
|
|
LastPage int
|
|
|
|
// Additionally, some APIs support "cursor pagination" instead of offset.
|
|
// This means that a token points directly to the next record which
|
|
// can lead to O(1) performance compared to O(n) performance provided
|
|
// by offset pagination.
|
|
//
|
|
// For APIs that support cursor pagination (such as
|
|
// TeamsService.ListIDPGroupsInOrganization), the following field
|
|
// will be populated to point to the next page.
|
|
//
|
|
// To use this token, set ListCursorOptions.Page to this value before
|
|
// calling the endpoint again.
|
|
NextPageToken string
|
|
|
|
// Explicitly specify the Rate type so Rate's String() receiver doesn't
|
|
// propagate to Response.
|
|
Rate Rate
|
|
}
|
|
|
|
// newResponse creates a new Response for the provided http.Response.
|
|
// r must not be nil.
|
|
func newResponse(r *http.Response) *Response {
|
|
response := &Response{Response: r}
|
|
response.populatePageValues()
|
|
response.Rate = parseRate(r)
|
|
return response
|
|
}
|
|
|
|
// populatePageValues parses the HTTP Link response headers and populates the
|
|
// various pagination link values in the Response.
|
|
func (r *Response) populatePageValues() {
|
|
if links, ok := r.Response.Header["Link"]; ok && len(links) > 0 {
|
|
for _, link := range strings.Split(links[0], ",") {
|
|
segments := strings.Split(strings.TrimSpace(link), ";")
|
|
|
|
// link must at least have href and rel
|
|
if len(segments) < 2 {
|
|
continue
|
|
}
|
|
|
|
// ensure href is properly formatted
|
|
if !strings.HasPrefix(segments[0], "<") || !strings.HasSuffix(segments[0], ">") {
|
|
continue
|
|
}
|
|
|
|
// try to pull out page parameter
|
|
url, err := url.Parse(segments[0][1 : len(segments[0])-1])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
page := url.Query().Get("page")
|
|
if page == "" {
|
|
continue
|
|
}
|
|
|
|
for _, segment := range segments[1:] {
|
|
switch strings.TrimSpace(segment) {
|
|
case `rel="next"`:
|
|
if r.NextPage, err = strconv.Atoi(page); err != nil {
|
|
r.NextPageToken = page
|
|
}
|
|
case `rel="prev"`:
|
|
r.PrevPage, _ = strconv.Atoi(page)
|
|
case `rel="first"`:
|
|
r.FirstPage, _ = strconv.Atoi(page)
|
|
case `rel="last"`:
|
|
r.LastPage, _ = strconv.Atoi(page)
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseRate parses the rate related headers.
|
|
func parseRate(r *http.Response) Rate {
|
|
var rate Rate
|
|
if limit := r.Header.Get(headerRateLimit); limit != "" {
|
|
rate.Limit, _ = strconv.Atoi(limit)
|
|
}
|
|
if remaining := r.Header.Get(headerRateRemaining); remaining != "" {
|
|
rate.Remaining, _ = strconv.Atoi(remaining)
|
|
}
|
|
if reset := r.Header.Get(headerRateReset); reset != "" {
|
|
if v, _ := strconv.ParseInt(reset, 10, 64); v != 0 {
|
|
rate.Reset = Timestamp{time.Unix(v, 0)}
|
|
}
|
|
}
|
|
return rate
|
|
}
|
|
|
|
// Do sends an API request and returns the API response. The API response is
|
|
// JSON decoded and stored in the value pointed to by v, or returned as an
|
|
// error if an API error has occurred. If v implements the io.Writer
|
|
// interface, the raw response body will be written to v, without attempting to
|
|
// first decode it. If rate limit is exceeded and reset time is in the future,
|
|
// Do returns *RateLimitError immediately without making a network API call.
|
|
//
|
|
// The provided ctx must be non-nil, if it is nil an error is returned. If it is canceled or times out,
|
|
// ctx.Err() will be returned.
|
|
func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*Response, error) {
|
|
if ctx == nil {
|
|
return nil, errors.New("context must be non-nil")
|
|
}
|
|
req = withContext(ctx, req)
|
|
|
|
rateLimitCategory := category(req.URL.Path)
|
|
|
|
// If we've hit rate limit, don't make further requests before Reset time.
|
|
if err := c.checkRateLimitBeforeDo(req, rateLimitCategory); err != nil {
|
|
return &Response{
|
|
Response: err.Response,
|
|
Rate: err.Rate,
|
|
}, err
|
|
}
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
// If we got an error, and the context has been canceled,
|
|
// the context's error is probably more useful.
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// If the error type is *url.Error, sanitize its URL before returning.
|
|
if e, ok := err.(*url.Error); ok {
|
|
if url, err := url.Parse(e.URL); err == nil {
|
|
e.URL = sanitizeURL(url).String()
|
|
return nil, e
|
|
}
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
response := newResponse(resp)
|
|
|
|
c.rateMu.Lock()
|
|
c.rateLimits[rateLimitCategory] = response.Rate
|
|
c.rateMu.Unlock()
|
|
|
|
err = CheckResponse(resp)
|
|
if err != nil {
|
|
// Special case for AcceptedErrors. If an AcceptedError
|
|
// has been encountered, the response's payload will be
|
|
// added to the AcceptedError and returned.
|
|
//
|
|
// Issue #1022
|
|
aerr, ok := err.(*AcceptedError)
|
|
if ok {
|
|
b, readErr := ioutil.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return response, readErr
|
|
}
|
|
|
|
aerr.Raw = b
|
|
return response, aerr
|
|
}
|
|
|
|
return response, err
|
|
}
|
|
|
|
if v != nil {
|
|
if w, ok := v.(io.Writer); ok {
|
|
io.Copy(w, resp.Body)
|
|
} else {
|
|
decErr := json.NewDecoder(resp.Body).Decode(v)
|
|
if decErr == io.EOF {
|
|
decErr = nil // ignore EOF errors caused by empty response body
|
|
}
|
|
if decErr != nil {
|
|
err = decErr
|
|
}
|
|
}
|
|
}
|
|
|
|
return response, err
|
|
}
|
|
|
|
// checkRateLimitBeforeDo does not make any network calls, but uses existing knowledge from
|
|
// current client state in order to quickly check if *RateLimitError can be immediately returned
|
|
// from Client.Do, and if so, returns it so that Client.Do can skip making a network API call unnecessarily.
|
|
// Otherwise it returns nil, and Client.Do should proceed normally.
|
|
func (c *Client) checkRateLimitBeforeDo(req *http.Request, rateLimitCategory rateLimitCategory) *RateLimitError {
|
|
c.rateMu.Lock()
|
|
rate := c.rateLimits[rateLimitCategory]
|
|
c.rateMu.Unlock()
|
|
if !rate.Reset.Time.IsZero() && rate.Remaining == 0 && time.Now().Before(rate.Reset.Time) {
|
|
// Create a fake response.
|
|
resp := &http.Response{
|
|
Status: http.StatusText(http.StatusForbidden),
|
|
StatusCode: http.StatusForbidden,
|
|
Request: req,
|
|
Header: make(http.Header),
|
|
Body: ioutil.NopCloser(strings.NewReader("")),
|
|
}
|
|
return &RateLimitError{
|
|
Rate: rate,
|
|
Response: resp,
|
|
Message: fmt.Sprintf("API rate limit of %v still exceeded until %v, not making remote request.", rate.Limit, rate.Reset.Time),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/*
|
|
An ErrorResponse reports one or more errors caused by an API request.
|
|
|
|
GitHub API docs: https://developer.github.com/v3/#client-errors
|
|
*/
|
|
type ErrorResponse struct {
|
|
Response *http.Response // HTTP response that caused this error
|
|
Message string `json:"message"` // error message
|
|
Errors []Error `json:"errors"` // more detail on individual errors
|
|
// Block is only populated on certain types of errors such as code 451.
|
|
// See https://developer.github.com/changes/2016-03-17-the-451-status-code-is-now-supported/
|
|
// for more information.
|
|
Block *struct {
|
|
Reason string `json:"reason,omitempty"`
|
|
CreatedAt *Timestamp `json:"created_at,omitempty"`
|
|
} `json:"block,omitempty"`
|
|
// Most errors will also include a documentation_url field pointing
|
|
// to some content that might help you resolve the error, see
|
|
// https://developer.github.com/v3/#client-errors
|
|
DocumentationURL string `json:"documentation_url,omitempty"`
|
|
}
|
|
|
|
func (r *ErrorResponse) Error() string {
|
|
return fmt.Sprintf("%v %v: %d %v %+v",
|
|
r.Response.Request.Method, sanitizeURL(r.Response.Request.URL),
|
|
r.Response.StatusCode, r.Message, r.Errors)
|
|
}
|
|
|
|
// TwoFactorAuthError occurs when using HTTP Basic Authentication for a user
|
|
// that has two-factor authentication enabled. The request can be reattempted
|
|
// by providing a one-time password in the request.
|
|
type TwoFactorAuthError ErrorResponse
|
|
|
|
func (r *TwoFactorAuthError) Error() string { return (*ErrorResponse)(r).Error() }
|
|
|
|
// RateLimitError occurs when GitHub returns 403 Forbidden response with a rate limit
|
|
// remaining value of 0.
|
|
type RateLimitError struct {
|
|
Rate Rate // Rate specifies last known rate limit for the client
|
|
Response *http.Response // HTTP response that caused this error
|
|
Message string `json:"message"` // error message
|
|
}
|
|
|
|
func (r *RateLimitError) Error() string {
|
|
return fmt.Sprintf("%v %v: %d %v %v",
|
|
r.Response.Request.Method, sanitizeURL(r.Response.Request.URL),
|
|
r.Response.StatusCode, r.Message, formatRateReset(time.Until(r.Rate.Reset.Time)))
|
|
}
|
|
|
|
// AcceptedError occurs when GitHub returns 202 Accepted response with an
|
|
// empty body, which means a job was scheduled on the GitHub side to process
|
|
// the information needed and cache it.
|
|
// Technically, 202 Accepted is not a real error, it's just used to
|
|
// indicate that results are not ready yet, but should be available soon.
|
|
// The request can be repeated after some time.
|
|
type AcceptedError struct {
|
|
// Raw contains the response body.
|
|
Raw []byte
|
|
}
|
|
|
|
func (*AcceptedError) Error() string {
|
|
return "job scheduled on GitHub side; try again later"
|
|
}
|
|
|
|
// AbuseRateLimitError occurs when GitHub returns 403 Forbidden response with the
|
|
// "documentation_url" field value equal to "https://developer.github.com/v3/#abuse-rate-limits".
|
|
type AbuseRateLimitError struct {
|
|
Response *http.Response // HTTP response that caused this error
|
|
Message string `json:"message"` // error message
|
|
|
|
// RetryAfter is provided with some abuse rate limit errors. If present,
|
|
// it is the amount of time that the client should wait before retrying.
|
|
// Otherwise, the client should try again later (after an unspecified amount of time).
|
|
RetryAfter *time.Duration
|
|
}
|
|
|
|
func (r *AbuseRateLimitError) Error() string {
|
|
return fmt.Sprintf("%v %v: %d %v",
|
|
r.Response.Request.Method, sanitizeURL(r.Response.Request.URL),
|
|
r.Response.StatusCode, r.Message)
|
|
}
|
|
|
|
// sanitizeURL redacts the client_secret parameter from the URL which may be
|
|
// exposed to the user.
|
|
func sanitizeURL(uri *url.URL) *url.URL {
|
|
if uri == nil {
|
|
return nil
|
|
}
|
|
params := uri.Query()
|
|
if len(params.Get("client_secret")) > 0 {
|
|
params.Set("client_secret", "REDACTED")
|
|
uri.RawQuery = params.Encode()
|
|
}
|
|
return uri
|
|
}
|
|
|
|
/*
|
|
An Error reports more details on an individual error in an ErrorResponse.
|
|
These are the possible validation error codes:
|
|
|
|
missing:
|
|
resource does not exist
|
|
missing_field:
|
|
a required field on a resource has not been set
|
|
invalid:
|
|
the formatting of a field is invalid
|
|
already_exists:
|
|
another resource has the same valid as this field
|
|
custom:
|
|
some resources return this (e.g. github.User.CreateKey()), additional
|
|
information is set in the Message field of the Error
|
|
|
|
GitHub error responses structure are often undocumented and inconsistent.
|
|
Sometimes error is just a simple string (Issue #540).
|
|
In such cases, Message represents an error message as a workaround.
|
|
|
|
GitHub API docs: https://developer.github.com/v3/#client-errors
|
|
*/
|
|
type Error struct {
|
|
Resource string `json:"resource"` // resource on which the error occurred
|
|
Field string `json:"field"` // field on which the error occurred
|
|
Code string `json:"code"` // validation error code
|
|
Message string `json:"message"` // Message describing the error. Errors with Code == "custom" will always have this set.
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
return fmt.Sprintf("%v error caused by %v field on %v resource",
|
|
e.Code, e.Field, e.Resource)
|
|
}
|
|
|
|
func (e *Error) UnmarshalJSON(data []byte) error {
|
|
type aliasError Error // avoid infinite recursion by using type alias.
|
|
if err := json.Unmarshal(data, (*aliasError)(e)); err != nil {
|
|
return json.Unmarshal(data, &e.Message) // data can be json string.
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CheckResponse checks the API response for errors, and returns them if
|
|
// present. A response is considered an error if it has a status code outside
|
|
// the 200 range or equal to 202 Accepted.
|
|
// API error responses are expected to have response
|
|
// body, and a JSON response body that maps to ErrorResponse.
|
|
//
|
|
// The error type will be *RateLimitError for rate limit exceeded errors,
|
|
// *AcceptedError for 202 Accepted status codes,
|
|
// and *TwoFactorAuthError for two-factor authentication errors.
|
|
func CheckResponse(r *http.Response) error {
|
|
if r.StatusCode == http.StatusAccepted {
|
|
return &AcceptedError{}
|
|
}
|
|
if c := r.StatusCode; 200 <= c && c <= 299 {
|
|
return nil
|
|
}
|
|
errorResponse := &ErrorResponse{Response: r}
|
|
data, err := ioutil.ReadAll(r.Body)
|
|
if err == nil && data != nil {
|
|
json.Unmarshal(data, errorResponse)
|
|
}
|
|
// Re-populate error response body because GitHub error responses are often
|
|
// undocumented and inconsistent.
|
|
// Issue #1136, #540.
|
|
r.Body = ioutil.NopCloser(bytes.NewBuffer(data))
|
|
switch {
|
|
case r.StatusCode == http.StatusUnauthorized && strings.HasPrefix(r.Header.Get(headerOTP), "required"):
|
|
return (*TwoFactorAuthError)(errorResponse)
|
|
case r.StatusCode == http.StatusForbidden && r.Header.Get(headerRateRemaining) == "0":
|
|
return &RateLimitError{
|
|
Rate: parseRate(r),
|
|
Response: errorResponse.Response,
|
|
Message: errorResponse.Message,
|
|
}
|
|
case r.StatusCode == http.StatusForbidden && strings.HasSuffix(errorResponse.DocumentationURL, "/v3/#abuse-rate-limits"):
|
|
abuseRateLimitError := &AbuseRateLimitError{
|
|
Response: errorResponse.Response,
|
|
Message: errorResponse.Message,
|
|
}
|
|
if v := r.Header["Retry-After"]; len(v) > 0 {
|
|
// According to GitHub support, the "Retry-After" header value will be
|
|
// an integer which represents the number of seconds that one should
|
|
// wait before resuming making requests.
|
|
retryAfterSeconds, _ := strconv.ParseInt(v[0], 10, 64) // Error handling is noop.
|
|
retryAfter := time.Duration(retryAfterSeconds) * time.Second
|
|
abuseRateLimitError.RetryAfter = &retryAfter
|
|
}
|
|
return abuseRateLimitError
|
|
default:
|
|
return errorResponse
|
|
}
|
|
}
|
|
|
|
// parseBoolResponse determines the boolean result from a GitHub API response.
|
|
// Several GitHub API methods return boolean responses indicated by the HTTP
|
|
// status code in the response (true indicated by a 204, false indicated by a
|
|
// 404). This helper function will determine that result and hide the 404
|
|
// error if present. Any other error will be returned through as-is.
|
|
func parseBoolResponse(err error) (bool, error) {
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
|
|
if err, ok := err.(*ErrorResponse); ok && err.Response.StatusCode == http.StatusNotFound {
|
|
// Simply false. In this one case, we do not pass the error through.
|
|
return false, nil
|
|
}
|
|
|
|
// some other real error occurred
|
|
return false, err
|
|
}
|
|
|
|
// Rate represents the rate limit for the current client.
|
|
type Rate struct {
|
|
// The number of requests per hour the client is currently limited to.
|
|
Limit int `json:"limit"`
|
|
|
|
// The number of remaining requests the client can make this hour.
|
|
Remaining int `json:"remaining"`
|
|
|
|
// The time at which the current rate limit will reset.
|
|
Reset Timestamp `json:"reset"`
|
|
}
|
|
|
|
func (r Rate) String() string {
|
|
return Stringify(r)
|
|
}
|
|
|
|
// RateLimits represents the rate limits for the current client.
|
|
type RateLimits struct {
|
|
// The rate limit for non-search API requests. Unauthenticated
|
|
// requests are limited to 60 per hour. Authenticated requests are
|
|
// limited to 5,000 per hour.
|
|
//
|
|
// GitHub API docs: https://developer.github.com/v3/#rate-limiting
|
|
Core *Rate `json:"core"`
|
|
|
|
// The rate limit for search API requests. Unauthenticated requests
|
|
// are limited to 10 requests per minutes. Authenticated requests are
|
|
// limited to 30 per minute.
|
|
//
|
|
// GitHub API docs: https://developer.github.com/v3/search/#rate-limit
|
|
Search *Rate `json:"search"`
|
|
}
|
|
|
|
func (r RateLimits) String() string {
|
|
return Stringify(r)
|
|
}
|
|
|
|
type rateLimitCategory uint8
|
|
|
|
const (
|
|
coreCategory rateLimitCategory = iota
|
|
searchCategory
|
|
|
|
categories // An array of this length will be able to contain all rate limit categories.
|
|
)
|
|
|
|
// category returns the rate limit category of the endpoint, determined by Request.URL.Path.
|
|
func category(path string) rateLimitCategory {
|
|
switch {
|
|
default:
|
|
return coreCategory
|
|
case strings.HasPrefix(path, "/search/"):
|
|
return searchCategory
|
|
}
|
|
}
|
|
|
|
// RateLimits returns the rate limits for the current client.
|
|
func (c *Client) RateLimits(ctx context.Context) (*RateLimits, *Response, error) {
|
|
req, err := c.NewRequest("GET", "rate_limit", nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
response := new(struct {
|
|
Resources *RateLimits `json:"resources"`
|
|
})
|
|
resp, err := c.Do(ctx, req, response)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if response.Resources != nil {
|
|
c.rateMu.Lock()
|
|
if response.Resources.Core != nil {
|
|
c.rateLimits[coreCategory] = *response.Resources.Core
|
|
}
|
|
if response.Resources.Search != nil {
|
|
c.rateLimits[searchCategory] = *response.Resources.Search
|
|
}
|
|
c.rateMu.Unlock()
|
|
}
|
|
|
|
return response.Resources, resp, nil
|
|
}
|
|
|
|
func setCredentialsAsHeaders(req *http.Request, id, secret string) *http.Request {
|
|
// To set extra headers, we must make a copy of the Request so
|
|
// that we don't modify the Request we were given. This is required by the
|
|
// specification of http.RoundTripper.
|
|
//
|
|
// Since we are going to modify only req.Header here, we only need a deep copy
|
|
// of req.Header.
|
|
convertedRequest := new(http.Request)
|
|
*convertedRequest = *req
|
|
convertedRequest.Header = make(http.Header, len(req.Header))
|
|
|
|
for k, s := range req.Header {
|
|
convertedRequest.Header[k] = append([]string(nil), s...)
|
|
}
|
|
convertedRequest.SetBasicAuth(id, secret)
|
|
return convertedRequest
|
|
}
|
|
|
|
/*
|
|
UnauthenticatedRateLimitedTransport allows you to make unauthenticated calls
|
|
that need to use a higher rate limit associated with your OAuth application.
|
|
|
|
t := &github.UnauthenticatedRateLimitedTransport{
|
|
ClientID: "your app's client ID",
|
|
ClientSecret: "your app's client secret",
|
|
}
|
|
client := github.NewClient(t.Client())
|
|
|
|
This will add the client id and secret as a base64-encoded string in the format
|
|
ClientID:ClientSecret and apply it as an "Authorization": "Basic" header.
|
|
|
|
See https://developer.github.com/v3/#unauthenticated-rate-limited-requests for
|
|
more information.
|
|
*/
|
|
type UnauthenticatedRateLimitedTransport struct {
|
|
// ClientID is the GitHub OAuth client ID of the current application, which
|
|
// can be found by selecting its entry in the list at
|
|
// https://github.com/settings/applications.
|
|
ClientID string
|
|
|
|
// ClientSecret is the GitHub OAuth client secret of the current
|
|
// application.
|
|
ClientSecret string
|
|
|
|
// Transport is the underlying HTTP transport to use when making requests.
|
|
// It will default to http.DefaultTransport if nil.
|
|
Transport http.RoundTripper
|
|
}
|
|
|
|
// RoundTrip implements the RoundTripper interface.
|
|
func (t *UnauthenticatedRateLimitedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
if t.ClientID == "" {
|
|
return nil, errors.New("t.ClientID is empty")
|
|
}
|
|
if t.ClientSecret == "" {
|
|
return nil, errors.New("t.ClientSecret is empty")
|
|
}
|
|
|
|
req2 := setCredentialsAsHeaders(req, t.ClientID, t.ClientSecret)
|
|
// Make the HTTP request.
|
|
return t.transport().RoundTrip(req2)
|
|
}
|
|
|
|
// Client returns an *http.Client that makes requests which are subject to the
|
|
// rate limit of your OAuth application.
|
|
func (t *UnauthenticatedRateLimitedTransport) Client() *http.Client {
|
|
return &http.Client{Transport: t}
|
|
}
|
|
|
|
func (t *UnauthenticatedRateLimitedTransport) transport() http.RoundTripper {
|
|
if t.Transport != nil {
|
|
return t.Transport
|
|
}
|
|
return http.DefaultTransport
|
|
}
|
|
|
|
// BasicAuthTransport is an http.RoundTripper that authenticates all requests
|
|
// using HTTP Basic Authentication with the provided username and password. It
|
|
// additionally supports users who have two-factor authentication enabled on
|
|
// their GitHub account.
|
|
type BasicAuthTransport struct {
|
|
Username string // GitHub username
|
|
Password string // GitHub password
|
|
OTP string // one-time password for users with two-factor auth enabled
|
|
|
|
// Transport is the underlying HTTP transport to use when making requests.
|
|
// It will default to http.DefaultTransport if nil.
|
|
Transport http.RoundTripper
|
|
}
|
|
|
|
// RoundTrip implements the RoundTripper interface.
|
|
func (t *BasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
req2 := setCredentialsAsHeaders(req, t.Username, t.Password)
|
|
if t.OTP != "" {
|
|
req2.Header.Set(headerOTP, t.OTP)
|
|
}
|
|
return t.transport().RoundTrip(req2)
|
|
}
|
|
|
|
// Client returns an *http.Client that makes requests that are authenticated
|
|
// using HTTP Basic Authentication.
|
|
func (t *BasicAuthTransport) Client() *http.Client {
|
|
return &http.Client{Transport: t}
|
|
}
|
|
|
|
func (t *BasicAuthTransport) transport() http.RoundTripper {
|
|
if t.Transport != nil {
|
|
return t.Transport
|
|
}
|
|
return http.DefaultTransport
|
|
}
|
|
|
|
// formatRateReset formats d to look like "[rate reset in 2s]" or
|
|
// "[rate reset in 87m02s]" for the positive durations. And like "[rate limit was reset 87m02s ago]"
|
|
// for the negative cases.
|
|
func formatRateReset(d time.Duration) string {
|
|
isNegative := d < 0
|
|
if isNegative {
|
|
d *= -1
|
|
}
|
|
secondsTotal := int(0.5 + d.Seconds())
|
|
minutes := secondsTotal / 60
|
|
seconds := secondsTotal - minutes*60
|
|
|
|
var timeString string
|
|
if minutes > 0 {
|
|
timeString = fmt.Sprintf("%dm%02ds", minutes, seconds)
|
|
} else {
|
|
timeString = fmt.Sprintf("%ds", seconds)
|
|
}
|
|
|
|
if isNegative {
|
|
return fmt.Sprintf("[rate limit was reset %v ago]", timeString)
|
|
}
|
|
return fmt.Sprintf("[rate reset in %v]", timeString)
|
|
}
|
|
|
|
// Bool is a helper routine that allocates a new bool value
|
|
// to store v and returns a pointer to it.
|
|
func Bool(v bool) *bool { return &v }
|
|
|
|
// Int is a helper routine that allocates a new int value
|
|
// to store v and returns a pointer to it.
|
|
func Int(v int) *int { return &v }
|
|
|
|
// Int64 is a helper routine that allocates a new int64 value
|
|
// to store v and returns a pointer to it.
|
|
func Int64(v int64) *int64 { return &v }
|
|
|
|
// String is a helper routine that allocates a new string value
|
|
// to store v and returns a pointer to it.
|
|
func String(v string) *string { return &v }
|