mirror of
https://github.com/google/go-github.git
synced 2026-10-29 00:44:52 +08:00
BREAKING CHANGE: `BasicAuthTransport`, `UnauthenticatedRateLimitedTransport` & `WithHTTPClient` have been removed as they do not fit with the required behaviour to only authenticate requests for specific endpoints. `CheckRedirect` and `Jar` are no longer supported.
2384 lines
83 KiB
Go
2384 lines
83 KiB
Go
// 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-iterators.go
|
|
//go:generate go run gen-stringify-test.go
|
|
//go:generate sh ../script/metadata.sh update-go
|
|
|
|
package github
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/go-querystring/query"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
const (
|
|
Version = "v93.0.0"
|
|
|
|
HeaderRateLimit = "X-Ratelimit-Limit"
|
|
HeaderRateRemaining = "X-Ratelimit-Remaining"
|
|
HeaderRateReset = "X-Ratelimit-Reset"
|
|
HeaderRateResource = "X-Ratelimit-Resource"
|
|
HeaderRateUsed = "X-Ratelimit-Used"
|
|
HeaderRequestID = "X-Github-Request-Id"
|
|
|
|
// https://docs.github.com/en/rest/about-the-rest-api/api-versions#about-api-versioning
|
|
api20221128 = "2022-11-28"
|
|
api20260310 = "2026-03-10"
|
|
|
|
defaultBaseURL = "https://api.github.com/"
|
|
defaultUserAgent = "go-github" + "/" + Version
|
|
uploadBaseURL = "https://uploads.github.com/"
|
|
|
|
headerAPIVersion = "X-Github-Api-Version"
|
|
headerOTP = "X-Github-Otp"
|
|
headerRetryAfter = "Retry-After"
|
|
|
|
headerTokenExpiration = "Github-Authentication-Token-Expiration"
|
|
|
|
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"
|
|
mediaTypeIssueImportAPI = "application/vnd.github.golden-comet-preview+json"
|
|
mediaTypeStarring = "application/vnd.github.star+json"
|
|
mediaTypeSCIM = "application/scim+json"
|
|
|
|
// Media Type values to access preview APIs.
|
|
// These media types will be added to the API request as headers
|
|
// and used to enable particular features on GitHub API that are still in preview.
|
|
// After some time, specific media types will be promoted (to a "stable" state).
|
|
// From then on, the preview headers are not required anymore to activate the additional
|
|
// feature on GitHub.com's API. However, this API header might still be needed for users
|
|
// to run a GitHub Enterprise Server on-premises.
|
|
// It's not uncommon for GitHub Enterprise Server customers to run older versions which
|
|
// would probably rely on the preview headers for some time.
|
|
// While the header promotion is going out for GitHub.com, it may be some time before it
|
|
// even arrives in GitHub Enterprise Server.
|
|
// We keep those preview headers around to avoid breaking older GitHub Enterprise Server
|
|
// versions. Additionally, non-functional (preview) headers don't create any side effects
|
|
// on GitHub Cloud version.
|
|
//
|
|
// See https://github.com/google/go-github/pull/2125 and https://github.com/google/go-github/pull/2188 for full context.
|
|
|
|
// 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/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-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-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-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://docs.github.com/rest/previews/#repository-creation-permissions
|
|
mediaTypeMemberAllowedRepoCreationTypePreview = "application/vnd.github.surtur-preview+json"
|
|
|
|
// https://docs.github.com/rest/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"
|
|
|
|
// https://developer.github.com/changes/2018-12-10-content-attachments-api/
|
|
mediaTypeContentAttachmentsPreview = "application/vnd.github.corsair-preview+json"
|
|
)
|
|
|
|
// ErrPathForbidden is returned when a URL path contains ".." as a path
|
|
// segment, which could allow path traversal attacks.
|
|
var ErrPathForbidden = errors.New("path must not contain '..' due to auth vulnerability issue")
|
|
|
|
// conditionalAuthTransport is an http.RoundTripper that conditionally
|
|
// uses an auth transport for URLs that are checked by a given function.
|
|
type conditionalAuthTransport struct {
|
|
baseTransport http.RoundTripper
|
|
authTransport http.RoundTripper
|
|
checkURLAuthFunc func(url *url.URL) bool
|
|
}
|
|
|
|
// RoundTrip implements the http.RoundTripper interface for conditionalAuthTransport.
|
|
func (t *conditionalAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
// Check if the URL should be authenticated
|
|
if t.checkURLAuthFunc(req.URL) {
|
|
// Use the auth transport for URLs that match the pattern
|
|
return t.authTransport.RoundTrip(req)
|
|
}
|
|
|
|
// Don't use the auth transport for URLs that don't match the pattern
|
|
return t.baseTransport.RoundTrip(req)
|
|
}
|
|
|
|
// A Client manages communication with the GitHub API.
|
|
type Client struct {
|
|
client *http.Client // HTTP client used to communicate with the API.
|
|
|
|
// tokenSource is the token source configured by [WithAuthToken],
|
|
// [WithBasicAuth] or [WithTokenSource], used to obtain access tokens for
|
|
// authenticated requests.
|
|
tokenSource oauth2.TokenSource
|
|
|
|
// baseTransport is the transport the credential wrapper was installed on
|
|
// top of, so that [Client.Clone] can rebase onto it rather than layering a
|
|
// second wrapper over the first.
|
|
baseTransport http.RoundTripper
|
|
|
|
// 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
|
|
|
|
// Default API version to set in the X-Github-Api-Version header.
|
|
apiVersionDefault string
|
|
// Minimum API version that the client can use.
|
|
apiVersionMin string
|
|
// Maximum API version that the client can use.
|
|
apiVersionMax string
|
|
|
|
// User agent used when communicating with the GitHub API.
|
|
userAgent string
|
|
|
|
// disableRateLimitCheck stops the client checking for rate limits or tracking
|
|
// them. This is different to setting BypassRateLimitCheck in the context,
|
|
// as that still tracks the rate limits.
|
|
disableRateLimitCheck bool
|
|
|
|
rateMu sync.Mutex
|
|
rateLimits [Categories]Rate // Rate limits for the client as determined by the most recent API calls.
|
|
secondaryRateLimitReset time.Time // Secondary rate limit reset for the client as determined by the most recent API calls.
|
|
|
|
// If specified, Client will block requests for at most this duration in case of reaching a secondary
|
|
// rate limit.
|
|
maxSecondaryRateLimitRetryAfterDuration time.Duration
|
|
|
|
// Whether to respect rate limit headers on endpoints that return 302 redirections to artifacts.
|
|
rateLimitRedirectionalEndpoints bool
|
|
|
|
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
|
|
AgentTasks *AgentTasksService
|
|
Agents *AgentsService
|
|
Apps *AppsService
|
|
Authorizations *AuthorizationsService
|
|
Billing *BillingService
|
|
Checks *ChecksService
|
|
Classroom *ClassroomService
|
|
CodeQuality *CodeQualityService
|
|
CodeScanning *CodeScanningService
|
|
CodesOfConduct *CodesOfConductService
|
|
Codespaces *CodespacesService
|
|
Copilot *CopilotService
|
|
Credentials *CredentialsService
|
|
Dependabot *DependabotService
|
|
DependencyGraph *DependencyGraphService
|
|
Emojis *EmojisService
|
|
Enterprise *EnterpriseService
|
|
Gists *GistsService
|
|
Git *GitService
|
|
Gitignores *GitignoresService
|
|
Interactions *InteractionsService
|
|
IssueImport *IssueImportService
|
|
Issues *IssuesService
|
|
Licenses *LicensesService
|
|
Markdown *MarkdownService
|
|
Marketplace *MarketplaceService
|
|
Meta *MetaService
|
|
Migrations *MigrationService
|
|
Organizations *OrganizationsService
|
|
PrivateRegistries *PrivateRegistriesService
|
|
Projects *ProjectsService
|
|
PullRequests *PullRequestsService
|
|
RateLimit *RateLimitService
|
|
Reactions *ReactionsService
|
|
Repositories *RepositoriesService
|
|
SCIM *SCIMService
|
|
Search *SearchService
|
|
SecretScanning *SecretScanningService
|
|
SecurityAdvisories *SecurityAdvisoriesService
|
|
SubIssue *SubIssueService
|
|
Teams *TeamsService
|
|
Users *UsersService
|
|
}
|
|
|
|
type service struct {
|
|
client *Client
|
|
}
|
|
|
|
// Client returns the http.Client used by this GitHub client.
|
|
// The token source configured by [WithAuthToken], [WithBasicAuth] or
|
|
// [WithTokenSource] is attached only to requests to this client's configured
|
|
// API and upload origins, so the returned client may also be used for requests
|
|
// to other hosts without leaking the token.
|
|
func (c *Client) Client() *http.Client {
|
|
clientCopy := *c.client
|
|
return &clientCopy
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// For paginated result sets, the number of results per page (max 100), starting from the first matching result.
|
|
// This parameter must not be used in combination with last.
|
|
First int `url:"first,omitempty"`
|
|
|
|
// For paginated result sets, the number of results per page (max 100), starting from the last matching result.
|
|
// This parameter must not be used in combination with first.
|
|
Last int `url:"last,omitempty"`
|
|
|
|
// A cursor, as given in the Link header. If specified, the query only searches for events after this cursor.
|
|
After string `url:"after,omitempty"`
|
|
|
|
// A cursor, as given in the Link header. If specified, the query only searches for events before this cursor.
|
|
Before string `url:"before,omitempty"`
|
|
|
|
// A cursor, as given in the Link header. If specified, the query continues the search using this cursor.
|
|
Cursor string `url:"cursor,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
|
|
}
|
|
|
|
type structPtr[T any] interface{ *T }
|
|
|
|
// 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[P structPtr[T], T any](s string, opts P) (string, error) {
|
|
if opts == nil {
|
|
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
|
|
}
|
|
|
|
// errUninitialized is returned when an uninitialized Client is used.
|
|
var errUninitialized = errors.New("client is not initialized")
|
|
|
|
// clientOptions holds the configuration options for a Client.
|
|
type clientOptions struct {
|
|
transport http.RoundTripper
|
|
timeout *time.Duration
|
|
apiVersionMin *string
|
|
apiVersionMax *string
|
|
userAgent *string
|
|
envProxy bool
|
|
tokenSource oauth2.TokenSource
|
|
baseURL *url.URL
|
|
uploadURL *url.URL
|
|
disableRateLimitCheck bool
|
|
rateLimitRedirectionalEndpoints bool
|
|
maxSecondaryRateLimitRetryAfterDuration *time.Duration
|
|
marketplaceStubbed bool
|
|
}
|
|
|
|
// ClientOptionsFunc is a functional option for providing configuration options
|
|
// to a Client.
|
|
type ClientOptionsFunc func(*clientOptions) error
|
|
|
|
// WithTransport returns a ClientOptionsFunc that sets the http.RoundTripper
|
|
// for a Client. If not set, the default http.RoundTripper will be used.
|
|
// Do not use this to authenticate requests; use [WithTokenSource],
|
|
// [WithBasicAuth] or [WithAuthToken] instead.
|
|
func WithTransport(transport http.RoundTripper) ClientOptionsFunc {
|
|
return func(o *clientOptions) error {
|
|
if transport == nil {
|
|
return errors.New("transport must not be nil")
|
|
}
|
|
|
|
o.transport = transport
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithTimeout returns a ClientOptionsFunc that sets the timeout for a Client.
|
|
// If not set, the default http.Client with no timeout will be used.
|
|
// It is recommended to provide a timeout for production environments.
|
|
func WithTimeout(timeout time.Duration) ClientOptionsFunc {
|
|
return func(o *clientOptions) error {
|
|
if timeout < 0 {
|
|
return errors.New("timeout must not be negative")
|
|
}
|
|
|
|
o.timeout = &timeout
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithUserAgent returns a ClientOptionsFunc that sets the User-Agent header
|
|
// for a Client. If not set, a default User-Agent will be used.
|
|
func WithUserAgent(userAgent string) ClientOptionsFunc {
|
|
return func(o *clientOptions) error {
|
|
o.userAgent = &userAgent
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithEnvProxy returns a ClientOptionsFunc that configures the Client to use
|
|
// the HTTP proxy settings from the environment variables
|
|
// (e.g., HTTP_PROXY, HTTPS_PROXY, NO_PROXY).
|
|
// If not set, the client will not use environment proxy settings.
|
|
func WithEnvProxy() ClientOptionsFunc {
|
|
return func(o *clientOptions) error {
|
|
o.envProxy = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithAuthToken returns a ClientOptionsFunc that sets the token source
|
|
// for a Client to use the provided authentication token.
|
|
func WithAuthToken(token string) ClientOptionsFunc {
|
|
return func(o *clientOptions) error {
|
|
if token == "" {
|
|
return errors.New("token must not be empty")
|
|
}
|
|
|
|
o.tokenSource = oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithBasicAuth returns a ClientOptionsFunc that sets the token source
|
|
// for a Client to use basic authentication with the provided username and password.
|
|
func WithBasicAuth(username, password string) ClientOptionsFunc {
|
|
return func(o *clientOptions) error {
|
|
if username == "" || password == "" {
|
|
return errors.New("username and password must not be empty")
|
|
}
|
|
|
|
creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
|
|
|
o.tokenSource = oauth2.StaticTokenSource(&oauth2.Token{TokenType: "Basic", AccessToken: creds})
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithTokenSource returns a ClientOptionsFunc that sets the token source
|
|
// for a Client. If a token source is not set, the client will operate
|
|
// unauthenticated.
|
|
func WithTokenSource(tokenSource oauth2.TokenSource) ClientOptionsFunc {
|
|
return func(o *clientOptions) error {
|
|
if tokenSource == nil {
|
|
return errors.New("tokenSource must not be nil")
|
|
}
|
|
|
|
o.tokenSource = oauth2.ReuseTokenSource(nil, tokenSource)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithURLs returns a ClientOptionsFunc that sets the base and upload URLs
|
|
// while only validating the URL format. Nil values will be ignored and default
|
|
// URLs will be used.
|
|
func WithURLs(baseURL, uploadURL *string) ClientOptionsFunc {
|
|
return func(o *clientOptions) error {
|
|
if baseURL != nil {
|
|
b, err := parseURL(*baseURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid base url: %w", err)
|
|
}
|
|
|
|
o.baseURL = b
|
|
}
|
|
|
|
if uploadURL != nil {
|
|
u, err := parseURL(*uploadURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid upload url: %w", err)
|
|
}
|
|
|
|
o.uploadURL = u
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithEnterpriseURLs returns a ClientOptionsFunc that sets the base and upload
|
|
// URLs for a Client.
|
|
func WithEnterpriseURLs(baseURL, uploadURL string) ClientOptionsFunc {
|
|
return func(o *clientOptions) error {
|
|
b, err := parseURL(baseURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid base url: %w", err)
|
|
}
|
|
|
|
if !strings.HasSuffix(b.Path, "/api/v3/") &&
|
|
!strings.HasPrefix(b.Host, "api.") &&
|
|
!strings.Contains(b.Host, ".api.") {
|
|
b.Path += "api/v3/"
|
|
}
|
|
|
|
o.baseURL = b
|
|
|
|
u, err := parseURL(uploadURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid upload url: %w", err)
|
|
}
|
|
|
|
if !strings.HasSuffix(u.Path, "/api/uploads/") &&
|
|
!strings.HasPrefix(u.Host, "api.") &&
|
|
!strings.Contains(u.Host, ".api.") &&
|
|
!strings.HasPrefix(u.Host, "uploads.") {
|
|
u.Path += "api/uploads/"
|
|
}
|
|
|
|
o.uploadURL = u
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithDisableRateLimitCheck returns a ClientOptionsFunc that disables rate
|
|
// limit checking for a Client. If not set, the client will check for rate
|
|
// limits and track them.
|
|
func WithDisableRateLimitCheck() ClientOptionsFunc {
|
|
return func(o *clientOptions) error {
|
|
o.disableRateLimitCheck = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithRateLimitRedirectionalEndpoints returns a ClientOptionsFunc that
|
|
// configures the Client to respect rate limit headers on endpoints that
|
|
// return 302 redirection to artifacts. If not set, the client will not
|
|
// respect rate limit headers on these endpoints.
|
|
func WithRateLimitRedirectionalEndpoints() ClientOptionsFunc {
|
|
return func(o *clientOptions) error {
|
|
o.rateLimitRedirectionalEndpoints = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithMaxSecondaryRateLimitRetryAfterDuration returns a ClientOptionsFunc
|
|
// that configures the Client secondary rate limit max retry after duration.
|
|
func WithMaxSecondaryRateLimitRetryAfterDuration(duration time.Duration) ClientOptionsFunc {
|
|
return func(o *clientOptions) error {
|
|
o.maxSecondaryRateLimitRetryAfterDuration = &duration
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// NewClient returns a new GitHub API client configured with the provided
|
|
// options. The default configuration is suitable for making unauthenticated
|
|
// requests to the public GitHub API.
|
|
//
|
|
// For GitHub Enterprise, use [WithEnterpriseURLs] to set the base and upload
|
|
// URLs.
|
|
//
|
|
// To make authenticated requests, use [WithAuthToken], [WithBasicAuth] or
|
|
// [WithTokenSource] to provide a token source.
|
|
//
|
|
// For production usage it is recommended to provide a timeout using
|
|
// [WithTimeout].
|
|
func NewClient(opts ...ClientOptionsFunc) (*Client, error) {
|
|
o := clientOptions{}
|
|
for _, opt := range opts {
|
|
if err := opt(&o); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return newClient(o)
|
|
}
|
|
|
|
// newClient creates a new Client with the provided options. This is an internal
|
|
// helper function that is called by [NewClient] and [Client.Clone].
|
|
func newClient(opts clientOptions) (*Client, error) {
|
|
c := &Client{
|
|
apiVersionDefault: api20221128,
|
|
apiVersionMin: api20221128,
|
|
apiVersionMax: api20260310,
|
|
client: &http.Client{},
|
|
}
|
|
|
|
if opts.transport != nil {
|
|
c.client.Transport = opts.transport
|
|
}
|
|
|
|
if opts.timeout != nil {
|
|
c.client.Timeout = *opts.timeout
|
|
}
|
|
|
|
if opts.envProxy {
|
|
transport := c.client.Transport
|
|
if transport == nil {
|
|
transport = http.DefaultTransport
|
|
}
|
|
|
|
t, ok := transport.(*http.Transport)
|
|
if !ok {
|
|
return nil, errors.New("cannot set environment proxy on non-http transport")
|
|
}
|
|
|
|
t2 := t.Clone()
|
|
t2.Proxy = http.ProxyFromEnvironment
|
|
c.client.Transport = t2
|
|
}
|
|
|
|
c.baseTransport = c.client.Transport
|
|
|
|
if opts.tokenSource != nil {
|
|
if c.baseTransport == nil {
|
|
c.baseTransport = http.DefaultTransport
|
|
}
|
|
|
|
c.tokenSource = opts.tokenSource
|
|
|
|
t := &conditionalAuthTransport{
|
|
baseTransport: c.baseTransport,
|
|
authTransport: &oauth2.Transport{
|
|
Source: c.tokenSource,
|
|
Base: c.baseTransport,
|
|
},
|
|
checkURLAuthFunc: c.shouldAuthorizeRequest,
|
|
}
|
|
|
|
c.client.Transport = t
|
|
}
|
|
|
|
if opts.apiVersionMin != nil {
|
|
c.apiVersionMin = *opts.apiVersionMin
|
|
}
|
|
|
|
if opts.apiVersionMax != nil {
|
|
c.apiVersionMax = *opts.apiVersionMax
|
|
}
|
|
|
|
if opts.userAgent != nil {
|
|
c.userAgent = *opts.userAgent
|
|
} else {
|
|
c.userAgent = defaultUserAgent
|
|
}
|
|
|
|
if opts.baseURL != nil {
|
|
c.baseURL = opts.baseURL
|
|
} else {
|
|
c.baseURL, _ = url.Parse(defaultBaseURL)
|
|
}
|
|
|
|
if opts.uploadURL != nil {
|
|
c.uploadURL = opts.uploadURL
|
|
} else {
|
|
c.uploadURL, _ = url.Parse(uploadBaseURL)
|
|
}
|
|
|
|
c.disableRateLimitCheck = opts.disableRateLimitCheck
|
|
|
|
if !c.disableRateLimitCheck {
|
|
c.rateLimitRedirectionalEndpoints = opts.rateLimitRedirectionalEndpoints
|
|
|
|
if opts.maxSecondaryRateLimitRetryAfterDuration != nil {
|
|
c.maxSecondaryRateLimitRetryAfterDuration = *opts.maxSecondaryRateLimitRetryAfterDuration
|
|
}
|
|
}
|
|
|
|
c.common.client = c
|
|
c.Actions = (*ActionsService)(&c.common)
|
|
c.Activity = (*ActivityService)(&c.common)
|
|
c.Admin = (*AdminService)(&c.common)
|
|
c.AgentTasks = (*AgentTasksService)(&c.common)
|
|
c.Agents = (*AgentsService)(&c.common)
|
|
c.Apps = (*AppsService)(&c.common)
|
|
c.Authorizations = (*AuthorizationsService)(&c.common)
|
|
c.Billing = (*BillingService)(&c.common)
|
|
c.Checks = (*ChecksService)(&c.common)
|
|
c.Classroom = (*ClassroomService)(&c.common)
|
|
c.CodeQuality = (*CodeQualityService)(&c.common)
|
|
c.CodeScanning = (*CodeScanningService)(&c.common)
|
|
c.Codespaces = (*CodespacesService)(&c.common)
|
|
c.CodesOfConduct = (*CodesOfConductService)(&c.common)
|
|
c.Copilot = (*CopilotService)(&c.common)
|
|
c.Credentials = (*CredentialsService)(&c.common)
|
|
c.Dependabot = (*DependabotService)(&c.common)
|
|
c.DependencyGraph = (*DependencyGraphService)(&c.common)
|
|
c.Emojis = (*EmojisService)(&c.common)
|
|
c.Enterprise = (*EnterpriseService)(&c.common)
|
|
c.Gists = (*GistsService)(&c.common)
|
|
c.Git = (*GitService)(&c.common)
|
|
c.Gitignores = (*GitignoresService)(&c.common)
|
|
c.Interactions = (*InteractionsService)(&c.common)
|
|
c.IssueImport = (*IssueImportService)(&c.common)
|
|
c.Issues = (*IssuesService)(&c.common)
|
|
c.Licenses = (*LicensesService)(&c.common)
|
|
c.Markdown = (*MarkdownService)(&c.common)
|
|
c.Marketplace = &MarketplaceService{client: c, Stubbed: opts.marketplaceStubbed}
|
|
c.Meta = (*MetaService)(&c.common)
|
|
c.Migrations = (*MigrationService)(&c.common)
|
|
c.Organizations = (*OrganizationsService)(&c.common)
|
|
c.PrivateRegistries = (*PrivateRegistriesService)(&c.common)
|
|
c.Projects = (*ProjectsService)(&c.common)
|
|
c.PullRequests = (*PullRequestsService)(&c.common)
|
|
c.RateLimit = (*RateLimitService)(&c.common)
|
|
c.Reactions = (*ReactionsService)(&c.common)
|
|
c.Repositories = (*RepositoriesService)(&c.common)
|
|
c.SCIM = (*SCIMService)(&c.common)
|
|
c.Search = (*SearchService)(&c.common)
|
|
c.SecretScanning = (*SecretScanningService)(&c.common)
|
|
c.SecurityAdvisories = (*SecurityAdvisoriesService)(&c.common)
|
|
c.SubIssue = (*SubIssueService)(&c.common)
|
|
c.Teams = (*TeamsService)(&c.common)
|
|
c.Users = (*UsersService)(&c.common)
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// A request's destination is governed by two rules, and they deliberately differ
|
|
// because a credential and a payload fail in opposite ways.
|
|
//
|
|
// Credentials — the token source configured by [WithAuthToken], [WithBasicAuth]
|
|
// or [WithTokenSource] — are sent only to origins the caller
|
|
// has configured. Any other destination, including a redirect hop, goes out
|
|
// with no credentials attached. Such a request is never rejected and the
|
|
// credentials are never forwarded: it is simply sent unauthenticated, so that
|
|
// a caller cannot leak a token by handing the client a URL whose host it does
|
|
// not control. Withholding a credential is safe to do silently because the
|
|
// request can still succeed without it — a pre-signed download URL handed back
|
|
// by the API is the ordinary case.
|
|
//
|
|
// A payload cannot be withheld that way, because a request whose body is dropped
|
|
// cannot succeed at all, and the caller would be told a request succeeded when
|
|
// nothing was stored. The rule for a body is therefore absolute: the two
|
|
// constructors that build one — [Client.NewUploadRequest] and
|
|
// [Client.NewFormRequest] — refuse to aim it at an origin this client was not
|
|
// configured for, so the caller's bytes are never sent to a host the caller did
|
|
// not choose. See [ErrUntrustedDestination].
|
|
//
|
|
// Within a Client both rules read the same two configured origins, BaseURL and
|
|
// UploadURL, which is where [WithEnterpriseURLs] and [WithURLs] put them; a
|
|
// GitHub Enterprise or proxy deployment whose upload host differs from its API
|
|
// host must therefore configure both. sameOrigin is the one predicate behind
|
|
// both rules. Every place that decides whether a destination may receive
|
|
// credentials — [WithAuthToken], [WithBasicAuth], [WithTokenSource], and
|
|
// the redirect guards — must use it, so the answer cannot differ depending on
|
|
// which code path a request happens to take.
|
|
|
|
// ErrUntrustedDestination is returned by [Client.NewUploadRequest] and
|
|
// [Client.NewFormRequest] when a request they build would send its body to an
|
|
// origin this client was not configured for.
|
|
//
|
|
// The URL for such a request is routinely read out of an API response — a
|
|
// release's UploadURL is the usual case — so the host in one is chosen by
|
|
// whoever answered the request rather than by the caller. A response naming a
|
|
// foreign host must not be able to take the caller's bytes, and an error is the
|
|
// only safe answer: sending the payload unauthenticated, the way a credential is
|
|
// withheld, would report success for a request that never reached GitHub.
|
|
//
|
|
// Configure the destination with [WithURLs] or [WithEnterpriseURLs] if a request
|
|
// legitimately belongs on a host other than BaseURL or UploadURL.
|
|
var ErrUntrustedDestination = errors.New("refusing to send a request body to a destination the client is not configured for")
|
|
|
|
// sameOrigin reports whether u and base share an origin: the same scheme, the
|
|
// same hostname, and the same port once the scheme's default port is implied. A
|
|
// nil argument never matches.
|
|
func sameOrigin(u, base *url.URL) bool {
|
|
if u == nil || base == nil {
|
|
return false
|
|
}
|
|
|
|
return strings.EqualFold(u.Scheme, base.Scheme) &&
|
|
strings.EqualFold(u.Hostname(), base.Hostname()) &&
|
|
normalizedPort(u) == normalizedPort(base)
|
|
}
|
|
|
|
// normalizedPort returns u's explicit port, or the default port for u's scheme
|
|
// so that "https://ghe.example.com" and "https://ghe.example.com:443" compare
|
|
// equal. It returns "" for a scheme with no default port.
|
|
func normalizedPort(u *url.URL) string {
|
|
if port := u.Port(); port != "" {
|
|
return port
|
|
}
|
|
|
|
switch strings.ToLower(u.Scheme) {
|
|
case "http":
|
|
return "80"
|
|
case "https":
|
|
return "443"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// shouldAuthorizeRequest reports whether the credentials configured on c may be
|
|
// sent to u. The client's own origins are read here, at request time, rather
|
|
// than captured when the wrapper was installed: GitHub Enterprise installs and
|
|
// test setups assign baseURL/uploadURL directly, and the rest of the request
|
|
// plumbing (NewRequest, BaseURL) reads them the same way.
|
|
func (c *Client) shouldAuthorizeRequest(u *url.URL) bool {
|
|
return sameOrigin(u, c.baseURL) || sameOrigin(u, c.uploadURL)
|
|
}
|
|
|
|
// checkBodyDestination returns [ErrUntrustedDestination] when u is not an origin
|
|
// this client may send a request body to.
|
|
//
|
|
// Such a request carries the caller's payload to a URL that a response usually
|
|
// chose, and unlike a credential a body cannot be withheld and then have the
|
|
// request still mean anything. So the destination is refused outright rather
|
|
// than quietly sent unauthenticated, and the refusal happens where the request
|
|
// is built, before any byte of the body is written.
|
|
func (c *Client) checkBodyDestination(u *url.URL) error {
|
|
if c.shouldAuthorizeRequest(u) {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("%w: %v", ErrUntrustedDestination, u.Redacted())
|
|
}
|
|
|
|
// UserAgent returns the User-Agent header value for the client.
|
|
func (c *Client) UserAgent() string {
|
|
return c.userAgent
|
|
}
|
|
|
|
// BaseURL returns the base URL for API requests.
|
|
func (c *Client) BaseURL() string {
|
|
if c.baseURL == nil {
|
|
return ""
|
|
}
|
|
return c.baseURL.String()
|
|
}
|
|
|
|
// UploadURL returns the base URL for upload API requests.
|
|
func (c *Client) UploadURL() string {
|
|
if c.uploadURL == nil {
|
|
return ""
|
|
}
|
|
return c.uploadURL.String()
|
|
}
|
|
|
|
// Clone returns a copy of the client with the same configuration and services.
|
|
// The returned client has its own http.Client but shares the client
|
|
// configuration such as transport and timeout. The returned client starts with
|
|
// the same rate limit information as the original client, but it is not
|
|
// updated when the original client's rate limit information is updated.
|
|
// The returned client is independent of the original client and can be
|
|
// modified without affecting the original client. Any token configured by
|
|
// [WithAuthToken], [WithBasicAuth] or [WithTokenSource] carries over to the
|
|
// clone, and is re-scoped to the clone's own API and upload origins.
|
|
func (c *Client) Clone(opts ...ClientOptionsFunc) (*Client, error) {
|
|
if c.client == nil {
|
|
return nil, errUninitialized
|
|
}
|
|
|
|
o := clientOptions{
|
|
transport: c.baseTransport,
|
|
timeout: &c.client.Timeout,
|
|
apiVersionMin: &c.apiVersionMin,
|
|
apiVersionMax: &c.apiVersionMax,
|
|
userAgent: &c.userAgent,
|
|
baseURL: new(*c.baseURL),
|
|
uploadURL: new(*c.uploadURL),
|
|
tokenSource: c.tokenSource,
|
|
disableRateLimitCheck: c.disableRateLimitCheck,
|
|
rateLimitRedirectionalEndpoints: c.rateLimitRedirectionalEndpoints,
|
|
maxSecondaryRateLimitRetryAfterDuration: &c.maxSecondaryRateLimitRetryAfterDuration,
|
|
}
|
|
|
|
if c.Marketplace != nil {
|
|
o.marketplaceStubbed = c.Marketplace.Stubbed
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
if err := opt(&o); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
clone, err := newClient(o)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !clone.disableRateLimitCheck {
|
|
c.rateMu.Lock()
|
|
clone.rateLimits = c.rateLimits
|
|
clone.secondaryRateLimitReset = c.secondaryRateLimitReset
|
|
c.rateMu.Unlock()
|
|
}
|
|
|
|
return clone, nil
|
|
}
|
|
|
|
// RequestOption represents an option that can modify an http.Request.
|
|
type RequestOption func(req *http.Request)
|
|
|
|
// WithVersion overrides the GitHub v3 API version for this individual request.
|
|
// For more information, see:
|
|
// https://github.blog/2022-11-28-to-infinity-and-beyond-enabling-the-future-of-githubs-rest-api-with-api-versioning/
|
|
func WithVersion(version string) RequestOption {
|
|
return func(req *http.Request) {
|
|
req.Header.Set(headerAPIVersion, version)
|
|
}
|
|
}
|
|
|
|
// requestBufferPool pools response read buffers so that Client.Do can decode
|
|
// JSON payloads without allocating a growing buffer per call.
|
|
var requestBufferPool = sync.Pool{
|
|
New: func() any {
|
|
return new(bytes.Buffer)
|
|
},
|
|
}
|
|
|
|
// maxPooledBufferCap is the largest buffer capacity that putRequestBuffer
|
|
// returns to requestBufferPool. Buffers that grew beyond it while reading an
|
|
// unusually large response are dropped so they do not pin memory in the pool.
|
|
const maxPooledBufferCap = 1 << 20
|
|
|
|
// putRequestBuffer resets buf and returns it to requestBufferPool, dropping
|
|
// oversized buffers. It reports whether buf was pooled.
|
|
func putRequestBuffer(buf *bytes.Buffer) bool {
|
|
if buf.Cap() > maxPooledBufferCap {
|
|
return false
|
|
}
|
|
buf.Reset()
|
|
requestBufferPool.Put(buf)
|
|
return true
|
|
}
|
|
|
|
// 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(ctx context.Context, method, urlStr string, body any, opts ...RequestOption) (*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)
|
|
}
|
|
|
|
if err := checkURLPathTraversal(urlStr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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.NewRequestWithContext(ctx, 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)
|
|
}
|
|
req.Header.Set(headerAPIVersion, c.apiVersionDefault)
|
|
|
|
for _, opt := range opts {
|
|
opt(req)
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
// NewFormRequest 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.
|
|
// Body is sent with Content-Type: application/x-www-form-urlencoded.
|
|
//
|
|
// An absolute urlStr replaces the BaseURL, so the request is refused with
|
|
// [ErrUntrustedDestination] unless it targets an origin this Client was
|
|
// configured for. This constructor carries a body, and a body cannot be withheld
|
|
// the way a credential can: see the destination rules on [ErrUntrustedDestination].
|
|
func (c *Client) NewFormRequest(ctx context.Context, urlStr string, body io.Reader, opts ...RequestOption) (*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)
|
|
}
|
|
|
|
if err := checkURLPathTraversal(urlStr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
u, err := c.baseURL.Parse(urlStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The same gate as NewUploadRequest, for the same reason: this builds a
|
|
// request that carries the caller's bytes, and an absolute urlStr is a
|
|
// destination a response could have supplied. Today's only caller passes a
|
|
// relative path, so this is a guard against the next one rather than a fix.
|
|
if err := c.checkBodyDestination(u); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", u.String(), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", mediaTypeV3)
|
|
if c.userAgent != "" {
|
|
req.Header.Set("User-Agent", c.userAgent)
|
|
}
|
|
req.Header.Set(headerAPIVersion, c.apiVersionDefault)
|
|
|
|
for _, opt := range opts {
|
|
opt(req)
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
// checkURLPathTraversal returns ErrPathForbidden if urlStr contains ".." as a
|
|
// path segment (e.g. "a/../b"), preventing path traversal attacks. Percent-
|
|
// encoded equivalents such as "%2e%2e" are also rejected because url.Parse
|
|
// decodes them to ".." before the check runs. It does not match ".." embedded
|
|
// within a segment (e.g. "file..txt"). The check is performed only on the path
|
|
// portion of the URL, ignoring any query string or fragment.
|
|
func checkURLPathTraversal(urlStr string) error {
|
|
u, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if slices.Contains(strings.Split(u.Path, "/"), "..") {
|
|
return ErrPathForbidden
|
|
}
|
|
return 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.
|
|
//
|
|
// An absolute urlStr replaces the Client's UploadURL, and the host in one of
|
|
// those is usually chosen by an API response rather than by the caller — a
|
|
// release's UploadURL is the usual case. The request is therefore refused with
|
|
// [ErrUntrustedDestination] unless it targets an origin this Client was
|
|
// configured for, so that a response cannot redirect the caller's bytes to a
|
|
// host the caller did not choose.
|
|
func (c *Client) NewUploadRequest(ctx context.Context, urlStr string, reader io.Reader, size int64, mediaType string, opts ...RequestOption) (*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)
|
|
}
|
|
|
|
if err := checkURLPathTraversal(urlStr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
u, err := c.uploadURL.Parse(urlStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The same gate as NewFormRequest, for the same reason: this builds a
|
|
// request that carries the caller's bytes, and an absolute urlStr is a
|
|
// destination a response could have supplied.
|
|
if err := c.checkBodyDestination(u); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
requestBody := reader
|
|
if reader != nil {
|
|
// Wrap the provided reader so transport code does not observe concrete body types
|
|
// (for example *os.File) and switch to platform-specific sendfile fast paths.
|
|
//
|
|
// Why this exists:
|
|
// race-enabled test runs on Windows have surfaced data races in the sendfile path
|
|
// while request read/write loops run concurrently. Hiding concrete type information
|
|
// keeps uploads on the generic io.Reader copy path, which is race-stable and preserves
|
|
// request semantics (same bytes, same headers, same content length).
|
|
requestBody = uploadRequestBodyReader{Reader: reader}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", u.String(), requestBody)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.ContentLength = size
|
|
|
|
// Set GetBody so HTTP/2 transports can retry the request when a stream is
|
|
// refused (REFUSED_STREAM / GOAWAY) after the body has been written. Without
|
|
// GetBody, net/http2 fails with "cannot retry ... after Request.Body was
|
|
// written". See https://github.com/google/go-github/issues/2113.
|
|
//
|
|
// http.Request.GetBody must return an independent copy of the body, so we
|
|
// require io.ReaderAt (to read without moving the original body's position)
|
|
// in addition to io.Seeker (to record the body's starting offset). This is
|
|
// satisfied by *os.File, *bytes.Reader and *strings.Reader. Readers that
|
|
// cannot provide an independent, rewindable view are left without GetBody so
|
|
// that large uploads are never buffered in memory.
|
|
seeker, isSeeker := reader.(io.Seeker)
|
|
readerAt, isReaderAt := reader.(io.ReaderAt)
|
|
if isSeeker && isReaderAt {
|
|
offset, err := seeker.Seek(0, io.SeekCurrent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.GetBody = func() (io.ReadCloser, error) {
|
|
return io.NopCloser(uploadRequestBodyReader{Reader: io.NewSectionReader(readerAt, offset, size)}), nil
|
|
}
|
|
}
|
|
|
|
if mediaType == "" {
|
|
mediaType = defaultMediaType
|
|
}
|
|
req.Header.Set("Content-Type", mediaType)
|
|
req.Header.Set("Accept", mediaTypeV3)
|
|
req.Header.Set("User-Agent", c.userAgent)
|
|
req.Header.Set(headerAPIVersion, c.apiVersionDefault)
|
|
|
|
for _, opt := range opts {
|
|
opt(req)
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
// uploadRequestBodyReader intentionally wraps an io.Reader to hide concrete reader types.
|
|
// See NewUploadRequest for why this prevents race-prone transport optimizations.
|
|
type uploadRequestBodyReader struct {
|
|
io.Reader
|
|
}
|
|
|
|
// 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
|
|
|
|
// For APIs that support cursor pagination, such as RepositoriesService.ListHookDeliveries,
|
|
// the following field will be populated to point to the next page.
|
|
// Set ListCursorOptions.Cursor to this value when calling the endpoint again.
|
|
Cursor string
|
|
|
|
// For APIs that support before/after pagination, such as OrganizationsService.AuditLog.
|
|
Before string
|
|
After string
|
|
|
|
// Explicitly specify the Rate type so Rate's String() receiver doesn't
|
|
// propagate to Response.
|
|
Rate Rate
|
|
|
|
// token's expiration date. Timestamp is 0001-01-01 when token doesn't expire.
|
|
// So it is valid for TokenExpiration.Equal(Timestamp{}) or TokenExpiration.Time.After(time.Now())
|
|
TokenExpiration Timestamp
|
|
}
|
|
|
|
// 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)
|
|
response.TokenExpiration = parseTokenExpiration(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.SplitSeq(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
|
|
}
|
|
|
|
q := url.Query()
|
|
|
|
if cursor := q.Get("cursor"); cursor != "" {
|
|
for _, segment := range segments[1:] {
|
|
if strings.TrimSpace(segment) == `rel="next"` {
|
|
r.Cursor = cursor
|
|
}
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
page := q.Get("page")
|
|
since := q.Get("since")
|
|
before := q.Get("before")
|
|
after := q.Get("after")
|
|
|
|
if page == "" && before == "" && after == "" && since == "" {
|
|
continue
|
|
}
|
|
|
|
if since != "" && page == "" {
|
|
page = since
|
|
}
|
|
|
|
for _, segment := range segments[1:] {
|
|
switch strings.TrimSpace(segment) {
|
|
case `rel="next"`:
|
|
if r.NextPage, err = strconv.Atoi(page); err != nil {
|
|
r.NextPageToken = page
|
|
}
|
|
r.After = after
|
|
case `rel="prev"`:
|
|
r.PrevPage, _ = strconv.Atoi(page)
|
|
r.Before = before
|
|
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 used := r.Header.Get(HeaderRateUsed); used != "" {
|
|
rate.Used, _ = strconv.Atoi(used)
|
|
}
|
|
if reset := r.Header.Get(HeaderRateReset); reset != "" {
|
|
if v, _ := strconv.ParseInt(reset, 10, 64); v != 0 {
|
|
rate.Reset = Timestamp{time.Unix(v, 0)}
|
|
}
|
|
}
|
|
if resource := r.Header.Get(HeaderRateResource); resource != "" {
|
|
rate.Resource = resource
|
|
}
|
|
return rate
|
|
}
|
|
|
|
// parseSecondaryRate parses the secondary rate related headers,
|
|
// and returns the time to retry after.
|
|
func parseSecondaryRate(r *http.Response) *time.Duration {
|
|
// 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.
|
|
if v := r.Header.Get(headerRetryAfter); v != "" {
|
|
retryAfterSeconds, _ := strconv.ParseInt(v, 10, 64) // Error handling is noop.
|
|
retryAfter := time.Duration(retryAfterSeconds) * time.Second
|
|
return &retryAfter
|
|
}
|
|
|
|
// According to GitHub support, endpoints might return x-ratelimit-reset instead,
|
|
// as an integer which represents the number of seconds since epoch UTC,
|
|
// representing the time to resume making requests.
|
|
if v := r.Header.Get(HeaderRateReset); v != "" {
|
|
secondsSinceEpoch, _ := strconv.ParseInt(v, 10, 64) // Error handling is noop.
|
|
retryAfter := time.Until(time.Unix(secondsSinceEpoch, 0))
|
|
return &retryAfter
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseTokenExpiration parses the TokenExpiration related headers.
|
|
// Returns 0001-01-01 if the header is not defined or could not be parsed.
|
|
func parseTokenExpiration(r *http.Response) Timestamp {
|
|
if v := r.Header.Get(headerTokenExpiration); v != "" {
|
|
if t, err := time.Parse("2006-01-02 15:04:05 MST", v); err == nil {
|
|
return Timestamp{t.Local()}
|
|
}
|
|
// Some tokens include the timezone offset instead of the timezone.
|
|
// https://github.com/google/go-github/issues/2649
|
|
if t, err := time.Parse("2006-01-02 15:04:05 -0700", v); err == nil {
|
|
return Timestamp{t.Local()}
|
|
}
|
|
}
|
|
return Timestamp{} // 0001-01-01 00:00:00
|
|
}
|
|
|
|
type requestContext uint8
|
|
|
|
const (
|
|
// BypassRateLimitCheck prevents a pre-emptive check for exceeded primary rate limits
|
|
// Specify this by providing a context with this key, e.g.
|
|
// context.WithValue(context.Background(), github.BypassRateLimitCheck, true)
|
|
BypassRateLimitCheck requestContext = iota
|
|
|
|
SleepUntilPrimaryRateLimitResetWhenRateLimited
|
|
)
|
|
|
|
// maxErrorBodySize is the maximum number of bytes read from an HTTP error
|
|
// response body. Limits memory allocation when a server returns an
|
|
// unexpectedly large error body.
|
|
const maxErrorBodySize = 1 * 1024 * 1024 // 1 MiB
|
|
|
|
// ErrUnsupportedAPIVersion is returned when the API version specified in the
|
|
// request is not supported by the client.
|
|
var ErrUnsupportedAPIVersion = errors.New("unsupported api version")
|
|
|
|
// checkRequestAPIVersionBeforeDo checks if the API version specified in the
|
|
// request is supported by the client before making the API call. If the
|
|
// version is not supported, it returns [ErrUnsupportedAPIVersion]. If the
|
|
// version is empty it returns nil.
|
|
func (c *Client) checkRequestAPIVersionBeforeDo(req *http.Request) error {
|
|
reqAPIVersion := req.Header.Get(headerAPIVersion)
|
|
|
|
if reqAPIVersion == "" {
|
|
return nil
|
|
}
|
|
|
|
if reqAPIVersion < c.apiVersionMin || reqAPIVersion > c.apiVersionMax {
|
|
return ErrUnsupportedAPIVersion
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// bareDo sends an API request using `caller` http.Client passed in the parameters
|
|
// and lets you handle the api response. If an error or API Error occurs, the error
|
|
// will contain more information. Otherwise, you are supposed to read and close the
|
|
// response's Body. If rate limit is exceeded and reset time is in the future,
|
|
// bareDo returns *RateLimitError immediately without making a network API call.
|
|
func (c *Client) bareDo(caller *http.Client, req *http.Request) (*Response, error) {
|
|
ctx := req.Context()
|
|
|
|
if err := c.checkRequestAPIVersionBeforeDo(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rateLimitCategory := CoreCategory
|
|
|
|
if !c.disableRateLimitCheck {
|
|
rateLimitCategory = GetRateLimitCategory(req.Method, c.apiPath(req.URL))
|
|
|
|
if bypass := ctx.Value(BypassRateLimitCheck); bypass == nil {
|
|
// 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
|
|
}
|
|
|
|
// If we've hit a secondary rate limit, don't make further requests before Retry After.
|
|
if err := c.checkSecondaryRateLimitBeforeDo(req); err != nil {
|
|
return &Response{
|
|
Response: err.Response,
|
|
}, err
|
|
}
|
|
}
|
|
}
|
|
|
|
resp, err := caller.Do(req)
|
|
var response *Response
|
|
if resp != nil {
|
|
response = newResponse(resp)
|
|
}
|
|
|
|
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 response, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// If the error type is *url.Error, sanitize its URL before returning.
|
|
if e, ok := errors.AsType[*url.Error](err); ok {
|
|
if url, err := url.Parse(e.URL); err == nil {
|
|
e.URL = sanitizeURL(url).String()
|
|
return response, e
|
|
}
|
|
}
|
|
|
|
return response, err
|
|
}
|
|
|
|
// Don't update the rate limits if the client has rate limits disabled or if
|
|
// this was a cached response. The X-From-Cache is set by
|
|
// https://github.com/bartventer/httpcache if it's enabled.
|
|
if !c.disableRateLimitCheck && response.Header.Get("X-From-Cache") == "" {
|
|
c.rateMu.Lock()
|
|
c.rateLimits[rateLimitCategory] = response.Rate
|
|
c.rateMu.Unlock()
|
|
}
|
|
|
|
// CheckResponse substitutes r.Body with a re-readable copy on error
|
|
// responses, so capture the network body first: it is the one that must
|
|
// be closed.
|
|
origBody := resp.Body
|
|
err = CheckResponse(resp)
|
|
if err != nil {
|
|
defer origBody.Close()
|
|
// Special case for AcceptedErrors. If an AcceptedError
|
|
// has been encountered, the response's payload will be
|
|
// added to the AcceptedError and returned.
|
|
//
|
|
// Issue #1022
|
|
if aerr, ok := errors.AsType[*AcceptedError](err); ok {
|
|
b, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
|
|
if readErr != nil {
|
|
return response, readErr
|
|
}
|
|
|
|
aerr.Raw = b
|
|
err = aerr
|
|
}
|
|
|
|
var rateLimitError *RateLimitError
|
|
if errors.As(err, &rateLimitError) &&
|
|
ctx.Value(SleepUntilPrimaryRateLimitResetWhenRateLimited) != nil {
|
|
if err := sleepUntilResetWithBuffer(ctx, rateLimitError.Rate.Reset.Time); err != nil {
|
|
return response, err
|
|
}
|
|
// retry the request now the rate limit should have been reset
|
|
newReq := req.Clone(context.WithValue(ctx, SleepUntilPrimaryRateLimitResetWhenRateLimited, nil))
|
|
return c.bareDo(caller, newReq)
|
|
}
|
|
|
|
// Update the secondary rate limit if we hit it.
|
|
var rerr *AbuseRateLimitError
|
|
if errors.As(err, &rerr) && rerr.RetryAfter != nil {
|
|
// if a max duration is specified, make sure that we are waiting at most this duration
|
|
if c.maxSecondaryRateLimitRetryAfterDuration > 0 && rerr.GetRetryAfter() > c.maxSecondaryRateLimitRetryAfterDuration {
|
|
rerr.RetryAfter = &c.maxSecondaryRateLimitRetryAfterDuration
|
|
}
|
|
c.rateMu.Lock()
|
|
c.secondaryRateLimitReset = time.Now().Add(*rerr.RetryAfter)
|
|
c.rateMu.Unlock()
|
|
}
|
|
}
|
|
return response, err
|
|
}
|
|
|
|
// apiPath returns the path of u relative to the client's BaseURL, keeping the
|
|
// leading slash, so that "/api/v3/search/code" on a GitHub Enterprise Server
|
|
// client becomes "/search/code". Paths outside BaseURL are returned unchanged.
|
|
func (c *Client) apiPath(u *url.URL) string {
|
|
if c.baseURL == nil {
|
|
return u.Path
|
|
}
|
|
prefix := strings.TrimSuffix(c.baseURL.Path, "/")
|
|
if path, ok := strings.CutPrefix(u.Path, prefix); ok && strings.HasPrefix(path, "/") {
|
|
return path
|
|
}
|
|
return u.Path
|
|
}
|
|
|
|
// BareDo sends an API request and lets you handle the api response. If an error
|
|
// or API Error occurs, the error will contain more information. Otherwise, you
|
|
// are supposed to read and close the response's Body. If rate limit is exceeded
|
|
// and reset time is in the future, BareDo returns *RateLimitError immediately
|
|
// without making a network API call.
|
|
func (c *Client) BareDo(req *http.Request) (*Response, error) {
|
|
return c.bareDo(c.client, req)
|
|
}
|
|
|
|
// bareDoIgnoreRedirects has the exact same behavior as BareDo but stops at the first
|
|
// redirection code returned by the API. If a redirection is returned by the api, bareDoIgnoreRedirects
|
|
// returns a *RedirectionError.
|
|
func (c *Client) bareDoIgnoreRedirects(req *http.Request) (*Response, error) {
|
|
cc := &http.Client{
|
|
Transport: c.client.Transport,
|
|
Timeout: c.client.Timeout,
|
|
Jar: c.client.Jar,
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
}
|
|
|
|
return c.bareDo(cc, req)
|
|
}
|
|
|
|
var errInvalidLocation = errors.New("invalid or empty Location header in redirection response")
|
|
|
|
// bareDoUntilFound has the exact same behavior as BareDo but only follows 301s, up to maxRedirects times. If it receives
|
|
// a 302, it will parse the Location header into a *url.URL and return that.
|
|
// This is useful for endpoints that return a 302 in successful cases but still might return 301s for
|
|
// permanent redirections.
|
|
func (c *Client) bareDoUntilFound(req *http.Request, maxRedirects int) (*url.URL, *Response, error) {
|
|
response, err := c.bareDoIgnoreRedirects(req)
|
|
if err != nil {
|
|
if rerr, ok := errors.AsType[*RedirectionError](err); ok {
|
|
// If we receive a 302, transform potential relative locations into absolute and return it.
|
|
if rerr.StatusCode == http.StatusFound {
|
|
if rerr.Location == nil {
|
|
return nil, nil, errInvalidLocation
|
|
}
|
|
newURL := c.baseURL.ResolveReference(rerr.Location)
|
|
return newURL, response, nil
|
|
}
|
|
// If permanent redirect response is returned, follow it
|
|
if maxRedirects > 0 && rerr.StatusCode == http.StatusMovedPermanently {
|
|
if rerr.Location == nil {
|
|
return nil, nil, errInvalidLocation
|
|
}
|
|
newURL := c.baseURL.ResolveReference(rerr.Location)
|
|
// Refuse to follow a permanent redirect outside the origins
|
|
// this client may send credentials to: the auth transport
|
|
// attaches them on every hop, so a cross-host target would
|
|
// leak them. This uses the same predicate as the transport so
|
|
// that the two cannot disagree about which origin is allowed.
|
|
if !c.shouldAuthorizeRequest(newURL) {
|
|
return nil, response, fmt.Errorf("refusing to follow cross-host redirect from %q to %q", c.baseURL.Host, newURL.Host)
|
|
}
|
|
newRequest := req.Clone(req.Context())
|
|
newRequest.URL = newURL
|
|
return c.bareDoUntilFound(newRequest, maxRedirects-1)
|
|
}
|
|
// If we reached the maximum amount of redirections, return an error
|
|
if maxRedirects <= 0 && rerr.StatusCode == http.StatusMovedPermanently {
|
|
return nil, response, fmt.Errorf("reached the maximum amount of redirections: %w", err)
|
|
}
|
|
return nil, response, fmt.Errorf("unexpected redirection response: %w", err)
|
|
}
|
|
}
|
|
|
|
// If we don't receive a redirection, forward the response and potential error
|
|
return nil, response, err
|
|
}
|
|
|
|
// 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 v is nil, and no error happens, the response is returned as is.
|
|
// If rate limit is exceeded and reset time is in the future, Do returns
|
|
// *RateLimitError immediately without making a network API call.
|
|
func (c *Client) Do(req *http.Request, v any) (*Response, error) {
|
|
resp, err := c.BareDo(req)
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
switch v := v.(type) {
|
|
case nil:
|
|
case io.Writer:
|
|
_, err = io.Copy(v, resp.Body)
|
|
default:
|
|
respBuf := requestBufferPool.Get().(*bytes.Buffer)
|
|
defer putRequestBuffer(respBuf)
|
|
|
|
_, readErr := respBuf.ReadFrom(resp.Body)
|
|
if readErr != nil {
|
|
err = readErr
|
|
} else if respBuf.Len() > 0 {
|
|
b := respBuf.Bytes()
|
|
decErr := json.Unmarshal(b, v)
|
|
if decErr != nil && len(bytes.TrimSpace(b)) == 0 {
|
|
decErr = nil // ignore errors caused by whitespace-only response body
|
|
}
|
|
if decErr != nil {
|
|
err = decErr
|
|
}
|
|
}
|
|
}
|
|
return resp, 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 {
|
|
ctx := req.Context()
|
|
|
|
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: io.NopCloser(strings.NewReader("")),
|
|
}
|
|
|
|
if ctx.Value(SleepUntilPrimaryRateLimitResetWhenRateLimited) != nil {
|
|
if err := sleepUntilResetWithBuffer(ctx, rate.Reset.Time); err == nil {
|
|
return nil
|
|
}
|
|
return &RateLimitError{
|
|
Rate: rate,
|
|
Response: resp,
|
|
Message: fmt.Sprintf("Context cancelled while waiting for rate limit to reset until %v, not making remote request.", rate.Reset.Time),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// checkSecondaryRateLimitBeforeDo does not make any network calls, but uses existing knowledge from
|
|
// current client state in order to quickly check if *AbuseRateLimitError 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) checkSecondaryRateLimitBeforeDo(req *http.Request) *AbuseRateLimitError {
|
|
c.rateMu.Lock()
|
|
secondary := c.secondaryRateLimitReset
|
|
c.rateMu.Unlock()
|
|
if !secondary.IsZero() && time.Now().Before(secondary) {
|
|
// Create a fake response.
|
|
resp := &http.Response{
|
|
Status: http.StatusText(http.StatusForbidden),
|
|
StatusCode: http.StatusForbidden,
|
|
Request: req,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader("")),
|
|
}
|
|
|
|
retryAfter := time.Until(secondary)
|
|
return &AbuseRateLimitError{
|
|
Response: resp,
|
|
Message: fmt.Sprintf("API secondary rate limit exceeded until %v, not making remote request.", secondary),
|
|
RetryAfter: &retryAfter,
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// compareHTTPResponse returns whether two http.Response objects are equal or not.
|
|
// Currently, only StatusCode is checked. This function is used when implementing the
|
|
// Is(error) bool interface for the custom error types in this package.
|
|
func compareHTTPResponse(r1, r2 *http.Response) bool {
|
|
if r1 == nil && r2 == nil {
|
|
return true
|
|
}
|
|
|
|
if r1 != nil && r2 != nil {
|
|
return r1.StatusCode == r2.StatusCode
|
|
}
|
|
return false
|
|
}
|
|
|
|
/*
|
|
An ErrorResponse reports one or more errors caused by an API request.
|
|
|
|
GitHub API docs: https://docs.github.com/rest?apiVersion=2022-11-28#client-errors
|
|
*/
|
|
type ErrorResponse struct {
|
|
Response *http.Response `json:"-"` // HTTP response that caused this error
|
|
Message string `json:"message"` // error message
|
|
//nolint:sliceofpointers
|
|
Errors []Error `json:"errors"` // more detail on individual errors
|
|
// Block is only populated on certain types of errors such as code 451.
|
|
Block *ErrorBlock `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://docs.github.com/rest?apiVersion=2022-11-28#client-errors
|
|
DocumentationURL string `json:"documentation_url,omitempty"`
|
|
}
|
|
|
|
// UnmarshalJSON implements the json.Unmarshaler interface.
|
|
func (r *ErrorResponse) UnmarshalJSON(data []byte) error {
|
|
type aliasErrorResponse ErrorResponse // avoid infinite recursion by using type alias.
|
|
response := struct {
|
|
Errors json.RawMessage `json:"errors"`
|
|
*aliasErrorResponse
|
|
}{
|
|
aliasErrorResponse: (*aliasErrorResponse)(r),
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &response); err != nil {
|
|
return err
|
|
}
|
|
if len(response.Errors) == 0 || string(response.Errors) == "null" {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(response.Errors, &r.Errors); err == nil {
|
|
return nil
|
|
}
|
|
|
|
var message string
|
|
if err := json.Unmarshal(response.Errors, &message); err != nil {
|
|
return err
|
|
}
|
|
//nolint:sliceofpointers
|
|
r.Errors = []Error{{Message: message}}
|
|
return nil
|
|
}
|
|
|
|
// ErrorBlock contains a further explanation for the reason of an error.
|
|
// See https://developer.github.com/changes/2016-03-17-the-451-status-code-is-now-supported/
|
|
// for more information.
|
|
type ErrorBlock struct {
|
|
Reason string `json:"reason,omitempty"`
|
|
CreatedAt *Timestamp `json:"created_at,omitempty"`
|
|
}
|
|
|
|
func (r *ErrorResponse) Error() string {
|
|
if r.Response != nil && r.Response.Request != nil {
|
|
return fmt.Sprintf("%v %v: %v %v %+v",
|
|
r.Response.Request.Method, sanitizeURL(r.Response.Request.URL),
|
|
r.Response.StatusCode, r.Message, r.Errors)
|
|
}
|
|
|
|
if r.Response != nil {
|
|
return fmt.Sprintf("%v %v %+v", r.Response.StatusCode, r.Message, r.Errors)
|
|
}
|
|
|
|
return fmt.Sprintf("%v %+v", r.Message, r.Errors)
|
|
}
|
|
|
|
// Is returns whether the provided error equals this error.
|
|
func (r *ErrorResponse) Is(target error) bool {
|
|
var v *ErrorResponse
|
|
if !errors.As(target, &v) {
|
|
return false
|
|
}
|
|
|
|
if r.Message != v.Message || (r.DocumentationURL != v.DocumentationURL) ||
|
|
!compareHTTPResponse(r.Response, v.Response) {
|
|
return false
|
|
}
|
|
|
|
// Compare Errors.
|
|
if len(r.Errors) != len(v.Errors) {
|
|
return false
|
|
}
|
|
for idx := range r.Errors {
|
|
if r.Errors[idx] != v.Errors[idx] {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Compare Block.
|
|
if (r.Block != nil && v.Block == nil) || (r.Block == nil && v.Block != nil) {
|
|
return false
|
|
}
|
|
if r.Block != nil && v.Block != nil {
|
|
if r.Block.Reason != v.Block.Reason {
|
|
return false
|
|
}
|
|
if (r.Block.CreatedAt != nil && v.Block.CreatedAt == nil) || (r.Block.CreatedAt ==
|
|
nil && v.Block.CreatedAt != nil) {
|
|
return false
|
|
}
|
|
if r.Block.CreatedAt != nil && v.Block.CreatedAt != nil {
|
|
if *r.Block.CreatedAt != *v.Block.CreatedAt {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// 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: %v %v %v",
|
|
r.Response.Request.Method, sanitizeURL(r.Response.Request.URL),
|
|
r.Response.StatusCode, r.Message, formatRateReset(time.Until(r.Rate.Reset.Time)))
|
|
}
|
|
|
|
// Is returns whether the provided error equals this error.
|
|
func (r *RateLimitError) Is(target error) bool {
|
|
var v *RateLimitError
|
|
if !errors.As(target, &v) {
|
|
return false
|
|
}
|
|
|
|
return r.Rate == v.Rate &&
|
|
r.Message == v.Message &&
|
|
compareHTTPResponse(r.Response, v.Response)
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
// Is returns whether the provided error equals this error.
|
|
func (ae *AcceptedError) Is(target error) bool {
|
|
var v *AcceptedError
|
|
if !errors.As(target, &v) {
|
|
return false
|
|
}
|
|
return bytes.Equal(ae.Raw, v.Raw)
|
|
}
|
|
|
|
// AbuseRateLimitError occurs when GitHub returns 403 Forbidden response with the
|
|
// "documentation_url" field value equal to "https://docs.github.com/rest/overview/rate-limits-for-the-rest-api?apiVersion=2022-11-28#about-secondary-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 {
|
|
retryInfo := ""
|
|
if r.RetryAfter != nil && *r.RetryAfter > 0 {
|
|
retryInfo = fmt.Sprintf(" [retry after %v]", r.RetryAfter.Round(time.Second))
|
|
}
|
|
return fmt.Sprintf("%v %v: %v %v%v",
|
|
r.Response.Request.Method, sanitizeURL(r.Response.Request.URL),
|
|
r.Response.StatusCode, r.Message, retryInfo)
|
|
}
|
|
|
|
// Is returns whether the provided error equals this error.
|
|
func (r *AbuseRateLimitError) Is(target error) bool {
|
|
var v *AbuseRateLimitError
|
|
if !errors.As(target, &v) {
|
|
return false
|
|
}
|
|
|
|
return r.Message == v.Message &&
|
|
equalDurationPtr(r.RetryAfter, v.RetryAfter) &&
|
|
compareHTTPResponse(r.Response, v.Response)
|
|
}
|
|
|
|
func equalDurationPtr(a, b *time.Duration) bool {
|
|
if a == nil || b == nil {
|
|
return a == b
|
|
}
|
|
return *a == *b
|
|
}
|
|
|
|
// RedirectionError represents a response that returned a redirect status code:
|
|
//
|
|
// 301 (Moved Permanently)
|
|
// 302 (Found)
|
|
// 303 (See Other)
|
|
// 307 (Temporary Redirect)
|
|
// 308 (Permanent Redirect)
|
|
//
|
|
// If there was a valid Location header included, it will be parsed to a URL. You should use
|
|
// `BaseURL.ResolveReference()` to enrich it with the correct hostname where needed.
|
|
type RedirectionError struct {
|
|
Response *http.Response // HTTP response that caused this error
|
|
StatusCode int
|
|
Location *url.URL // location header of the redirection if present
|
|
}
|
|
|
|
func (r *RedirectionError) Error() string {
|
|
return fmt.Sprintf("%v %v: %v location %v",
|
|
r.Response.Request.Method, sanitizeURL(r.Response.Request.URL),
|
|
r.StatusCode, sanitizeURL(r.Location))
|
|
}
|
|
|
|
// Is returns whether the provided error equals this error.
|
|
func (r *RedirectionError) Is(target error) bool {
|
|
var v *RedirectionError
|
|
if !errors.As(target, &v) {
|
|
return false
|
|
}
|
|
|
|
return r.StatusCode == v.StatusCode &&
|
|
(r.Location == v.Location || // either both locations are nil or exactly the same pointer
|
|
r.Location != nil && v.Location != nil && r.Location.String() == v.Location.String()) // or they are both not nil and marshaled identically
|
|
}
|
|
|
|
var sensitiveParams = []string{"client_secret", "access_token", "token"}
|
|
|
|
// sanitizeURL redacts sensitive parameters 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()
|
|
var redacted bool
|
|
for _, p := range sensitiveParams {
|
|
if len(params.Get(p)) > 0 {
|
|
params.Set(p, "REDACTED")
|
|
redacted = true
|
|
}
|
|
}
|
|
if 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://docs.github.com/rest?apiVersion=2022-11-28#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)
|
|
}
|
|
|
|
// UnmarshalJSON implements the json.Unmarshaler interface.
|
|
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].
|
|
//
|
|
// On error responses other than 202 Accepted, CheckResponse consumes r.Body
|
|
// and replaces it with an in-memory copy so that the error body can be
|
|
// re-read. Closing r.Body after CheckResponse returns therefore closes only
|
|
// the copy: to release the original body and its underlying connection,
|
|
// capture r.Body before the call and close the captured body instead.
|
|
//
|
|
// The error type will be *[RateLimitError] for rate limit exceeded errors,
|
|
// *[AcceptedError] for 202 Accepted status codes,
|
|
// *[TwoFactorAuthError] for two-factor authentication errors,
|
|
// and *[RedirectionError] for redirect status codes (only happens when ignoring redirections).
|
|
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 := io.ReadAll(io.LimitReader(r.Body, maxErrorBodySize))
|
|
if err == nil && data != nil {
|
|
err = json.Unmarshal(data, errorResponse)
|
|
if err != nil {
|
|
// reset the response as if this never happened
|
|
errorResponse = &ErrorResponse{Response: r}
|
|
}
|
|
}
|
|
// Re-populate error response body because GitHub error responses are often
|
|
// undocumented and inconsistent.
|
|
// Issue #1136, #540.
|
|
r.Body = io.NopCloser(bytes.NewBuffer(data))
|
|
switch {
|
|
case r.StatusCode == http.StatusUnauthorized && strings.HasPrefix(r.Header.Get(headerOTP), "required"):
|
|
return (*TwoFactorAuthError)(errorResponse)
|
|
// Primary rate limit exceeded: GitHub returns 403 or 429 with X-RateLimit-Remaining: 0
|
|
// See: https://docs.github.com/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28
|
|
case (r.StatusCode == http.StatusForbidden || r.StatusCode == http.StatusTooManyRequests) &&
|
|
r.Header.Get(HeaderRateRemaining) == "0":
|
|
return &RateLimitError{
|
|
Rate: parseRate(r),
|
|
Response: errorResponse.Response,
|
|
Message: errorResponse.Message,
|
|
}
|
|
// Secondary rate limit exceeded: GitHub returns 403 or 429 with specific documentation_url
|
|
// See: https://docs.github.com/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#about-secondary-rate-limits
|
|
case (r.StatusCode == http.StatusForbidden || r.StatusCode == http.StatusTooManyRequests) &&
|
|
(strings.HasSuffix(errorResponse.DocumentationURL, "#abuse-rate-limits") ||
|
|
strings.HasSuffix(errorResponse.DocumentationURL, "secondary-rate-limits")):
|
|
abuseRateLimitError := &AbuseRateLimitError{
|
|
Response: errorResponse.Response,
|
|
Message: errorResponse.Message,
|
|
}
|
|
if retryAfter := parseSecondaryRate(r); retryAfter != nil {
|
|
abuseRateLimitError.RetryAfter = retryAfter
|
|
}
|
|
return abuseRateLimitError
|
|
// Check that the status code is a redirection and return a sentinel error that can be used to handle special cases
|
|
// where 302 is considered a successful result.
|
|
// This should never happen with the default `CheckRedirect`, because it would return a `url.Error` that should be handled upstream.
|
|
case r.StatusCode == http.StatusMovedPermanently ||
|
|
r.StatusCode == http.StatusFound ||
|
|
r.StatusCode == http.StatusSeeOther ||
|
|
r.StatusCode == http.StatusTemporaryRedirect ||
|
|
r.StatusCode == http.StatusPermanentRedirect:
|
|
|
|
locationStr := r.Header.Get("Location")
|
|
var location *url.URL
|
|
if locationStr != "" {
|
|
location, _ = url.Parse(locationStr)
|
|
}
|
|
return &RedirectionError{
|
|
Response: errorResponse.Response,
|
|
StatusCode: r.StatusCode,
|
|
Location: location,
|
|
}
|
|
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
|
|
}
|
|
|
|
var rerr *ErrorResponse
|
|
if errors.As(err, &rerr) && rerr.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
|
|
}
|
|
|
|
// RateLimitCategory represents the enumeration of rate limit categories.
|
|
type RateLimitCategory uint8
|
|
|
|
const (
|
|
CoreCategory RateLimitCategory = iota
|
|
SearchCategory
|
|
GraphqlCategory
|
|
IntegrationManifestCategory
|
|
SourceImportCategory
|
|
CodeScanningUploadCategory
|
|
ActionsRunnerRegistrationCategory
|
|
ScimCategory
|
|
DependencySnapshotsCategory
|
|
CodeSearchCategory
|
|
AuditLogCategory
|
|
DependencySBOMCategory
|
|
|
|
Categories // An array of this length will be able to contain all rate limit categories.
|
|
)
|
|
|
|
// GetRateLimitCategory returns the rate limit RateLimitCategory of the endpoint, determined by HTTP method and Request.URL.Path.
|
|
func GetRateLimitCategory(method, path string) RateLimitCategory {
|
|
switch {
|
|
// https://docs.github.com/rest/rate-limit?apiVersion=2022-11-28#about-rate-limits
|
|
default:
|
|
// NOTE: coreCategory is returned for actionsRunnerRegistrationCategory too,
|
|
// because no API found for this category.
|
|
return CoreCategory
|
|
|
|
// https://docs.github.com/rest/search/search?apiVersion=2022-11-28#search-code
|
|
case strings.HasPrefix(path, "/search/code") &&
|
|
method == "GET":
|
|
return CodeSearchCategory
|
|
|
|
case strings.HasPrefix(path, "/search/"):
|
|
return SearchCategory
|
|
case path == "/graphql":
|
|
return GraphqlCategory
|
|
case strings.HasPrefix(path, "/app-manifests/") &&
|
|
strings.HasSuffix(path, "/conversions") &&
|
|
method == "POST":
|
|
return IntegrationManifestCategory
|
|
|
|
// https://docs.github.com/rest/migrations/source-imports?apiVersion=2022-11-28#start-an-import
|
|
case strings.HasPrefix(path, "/repos/") &&
|
|
strings.HasSuffix(path, "/import") &&
|
|
method == "PUT":
|
|
return SourceImportCategory
|
|
|
|
// https://docs.github.com/rest/code-scanning?apiVersion=2022-11-28#upload-an-analysis-as-sarif-data
|
|
case strings.HasSuffix(path, "/code-scanning/sarifs"):
|
|
return CodeScanningUploadCategory
|
|
|
|
// https://docs.github.com/enterprise-cloud@latest/rest/scim?apiVersion=2022-11-28
|
|
case strings.HasPrefix(path, "/scim/"):
|
|
return ScimCategory
|
|
|
|
// https://docs.github.com/rest/dependency-graph/dependency-submission?apiVersion=2022-11-28#create-a-snapshot-of-dependencies-for-a-repository
|
|
case strings.HasPrefix(path, "/repos/") &&
|
|
strings.HasSuffix(path, "/dependency-graph/snapshots") &&
|
|
method == "POST":
|
|
return DependencySnapshotsCategory
|
|
|
|
// https://docs.github.com/enterprise-cloud@latest/rest/orgs/orgs?apiVersion=2022-11-28#get-the-audit-log-for-an-organization
|
|
case strings.HasSuffix(path, "/audit-log"):
|
|
return AuditLogCategory
|
|
|
|
// https://docs.github.com/rest/dependency-graph/sboms?apiVersion=2022-11-28
|
|
case strings.HasPrefix(path, "/repos/") &&
|
|
(strings.HasSuffix(path, "/dependency-graph/sbom") ||
|
|
strings.HasSuffix(path, "/dependency-graph/sbom/generate-report")):
|
|
return DependencySBOMCategory
|
|
}
|
|
}
|
|
|
|
// RateLimits returns the rate limits for the current client.
|
|
//
|
|
// Deprecated: Use RateLimitService.Get instead.
|
|
func (c *Client) RateLimits(ctx context.Context) (*RateLimits, *Response, error) {
|
|
return c.RateLimit.Get(ctx)
|
|
}
|
|
|
|
// 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("%vm%02ds", minutes, seconds)
|
|
} else {
|
|
timeString = fmt.Sprintf("%vs", seconds)
|
|
}
|
|
|
|
if isNegative {
|
|
return fmt.Sprintf("[rate limit was reset %v ago]", timeString)
|
|
}
|
|
return fmt.Sprintf("[rate reset in %v]", timeString)
|
|
}
|
|
|
|
func sleepUntilResetWithBuffer(ctx context.Context, reset time.Time) error {
|
|
buffer := time.Second
|
|
timer := time.NewTimer(time.Until(reset) + buffer)
|
|
select {
|
|
case <-ctx.Done():
|
|
if !timer.Stop() {
|
|
<-timer.C
|
|
}
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// When using roundTripWithOptionalFollowRedirect, note that it
|
|
// is the responsibility of the caller to close the response body.
|
|
func (c *Client) roundTripWithOptionalFollowRedirect(ctx context.Context, u string, maxRedirects int, opts ...RequestOption) (*http.Response, error) {
|
|
req, err := c.NewRequest(ctx, "GET", u, nil, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp *http.Response
|
|
// Use http.DefaultTransport if no custom Transport is configured
|
|
if c.client.Transport == nil {
|
|
resp, err = http.DefaultTransport.RoundTrip(req)
|
|
} else {
|
|
resp, err = c.client.Transport.RoundTrip(req)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If redirect response is returned, follow it
|
|
if maxRedirects > 0 && resp.StatusCode == http.StatusMovedPermanently {
|
|
_ = resp.Body.Close()
|
|
u = resp.Header.Get("Location")
|
|
if err := c.checkRedirectHost(u); err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err = c.roundTripWithOptionalFollowRedirect(ctx, u, maxRedirects-1, opts...)
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
// checkRedirectHost returns an error if the redirect target is outside the
|
|
// origins this client may send credentials to. The auth transport attaches
|
|
// credentials on every hop, so a cross-origin Location header would otherwise
|
|
// carry them to a host the caller never configured, when a compromised or
|
|
// malicious API response supplies one. An empty Location is also rejected.
|
|
func (c *Client) checkRedirectHost(location string) error {
|
|
if location == "" {
|
|
return errInvalidLocation
|
|
}
|
|
target, err := url.Parse(location)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid redirect location %q: %w", location, err)
|
|
}
|
|
// Resolve relative locations against BaseURL so relative paths are allowed.
|
|
target = c.baseURL.ResolveReference(target)
|
|
if !c.shouldAuthorizeRequest(target) {
|
|
return fmt.Errorf("refusing to follow cross-host redirect from %q to %q", c.baseURL.Host, target.Host)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Ptr is a helper routine that allocates a new T value
|
|
// to store v and returns a pointer to it.
|
|
//
|
|
// Deprecated: use the new builtin instead.
|
|
//
|
|
//go:fix inline
|
|
func Ptr[T any](v T) *T {
|
|
return new(v)
|
|
}
|
|
|
|
// Bool is a helper routine that allocates a new bool value
|
|
// to store v and returns a pointer to it.
|
|
//
|
|
// Deprecated: use Ptr instead.
|
|
//
|
|
//go:fix inline
|
|
func Bool(v bool) *bool { return new(v) }
|
|
|
|
// Int is a helper routine that allocates a new int value
|
|
// to store v and returns a pointer to it.
|
|
//
|
|
// Deprecated: use Ptr instead.
|
|
//
|
|
//go:fix inline
|
|
func Int(v int) *int { return new(v) }
|
|
|
|
// Int64 is a helper routine that allocates a new int64 value
|
|
// to store v and returns a pointer to it.
|
|
//
|
|
// Deprecated: use Ptr instead.
|
|
//
|
|
//go:fix inline
|
|
func Int64(v int64) *int64 { return new(v) }
|
|
|
|
// String is a helper routine that allocates a new string value
|
|
// to store v and returns a pointer to it.
|
|
//
|
|
// Deprecated: use Ptr instead.
|
|
//
|
|
//go:fix inline
|
|
func String(v string) *string { return new(v) }
|
|
|
|
// roundTripperFunc creates a RoundTripper (transport).
|
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (fn roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
return fn(r)
|
|
}
|
|
|
|
var runIDFromURLRE = regexp.MustCompile(`repos/.*/actions/runs/(\d+)/deployment_protection_rule$`)
|
|
|
|
// GetRunID is a Helper Function used to extract the workflow RunID from the *DeploymentProtectionRuleEvent.DeploymentCallBackURL.
|
|
func (e *DeploymentProtectionRuleEvent) GetRunID() (int64, error) {
|
|
match := runIDFromURLRE.FindStringSubmatch(*e.DeploymentCallbackURL)
|
|
if len(match) != 2 {
|
|
return -1, errors.New("no match")
|
|
}
|
|
runID, err := strconv.ParseInt(match[1], 10, 64)
|
|
if err != nil {
|
|
return -1, err
|
|
}
|
|
return runID, nil
|
|
}
|