1fcc0b738a
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m30s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m8s
- SetHTTPClient(nil): preserve CheckRedirect auth-stripping policy instead of restoring a plain http.Client that loses cross-host protection. - Authorization header: add comment documenting why Bearer scheme is correct (OAuth2 standard, works for both classic PATs and fine-grained tokens). - Retry-After parsing: support HTTP-date format (RFC 7231) in addition to integer seconds. GitHub only sends integers today, but the implementation is now spec-compliant. - escapePath dot-segment removal: document the behavior in public API doc comments for ListContents and GetFileContentAtRef so callers are aware without reading the internal helper.
289 lines
8.4 KiB
Go
289 lines
8.4 KiB
Go
// Package github provides a client for the GitHub API.
|
|
// It supports pull request operations, file content retrieval, CI status checks,
|
|
// and directory listing for both github.com and GitHub Enterprise.
|
|
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultBaseURL = "https://api.github.com"
|
|
userAgent = "review-bot/1.0"
|
|
|
|
// maxResponseBytes limits successful response body reads to 10 MiB.
|
|
maxResponseBytes = 10 * 1024 * 1024
|
|
)
|
|
|
|
// APIError represents an HTTP error response from the GitHub API.
|
|
// It carries the status code so callers can distinguish between
|
|
// different failure modes (e.g. 404 vs 500).
|
|
type APIError struct {
|
|
StatusCode int
|
|
Body string
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
body := e.Body
|
|
if len(body) > 200 {
|
|
body = body[:200] + "...(truncated)"
|
|
}
|
|
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, body)
|
|
}
|
|
|
|
// IsNotFound reports whether an error is an API 404 response.
|
|
func IsNotFound(err error) bool {
|
|
if apiErr, ok := asAPIError(err); ok {
|
|
return apiErr.StatusCode == http.StatusNotFound
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsUnauthorized reports whether an error is an API 401 response.
|
|
func IsUnauthorized(err error) bool {
|
|
if apiErr, ok := asAPIError(err); ok {
|
|
return apiErr.StatusCode == http.StatusUnauthorized
|
|
}
|
|
return false
|
|
}
|
|
|
|
func asAPIError(err error) (*APIError, bool) {
|
|
if err == nil {
|
|
return nil, false
|
|
}
|
|
var target *APIError
|
|
if errors.As(err, &target) {
|
|
return target, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// clientConfig holds optional configuration for NewClient.
|
|
type clientConfig struct {
|
|
allowInsecureHTTP bool
|
|
}
|
|
|
|
// ClientOption configures optional behavior of NewClient.
|
|
type ClientOption func(*clientConfig)
|
|
|
|
// AllowInsecureHTTP permits the client to use HTTP (non-TLS) base URLs.
|
|
// This should only be used for trusted internal deployments or testing.
|
|
func AllowInsecureHTTP() ClientOption {
|
|
return func(c *clientConfig) {
|
|
c.allowInsecureHTTP = true
|
|
}
|
|
}
|
|
|
|
// Client interacts with the GitHub API.
|
|
// A Client is safe for concurrent use by multiple goroutines;
|
|
// however, SetHTTPClient and SetRetryBackoff must not be called concurrently with requests.
|
|
type Client struct {
|
|
baseURL string
|
|
token string
|
|
allowInsecureHTTP bool
|
|
httpClient *http.Client
|
|
|
|
// retryBackoff defines the delays between retry attempts for 429 responses.
|
|
// retryBackoff[i] is the delay before attempt i+1 (after attempt i fails).
|
|
// If nil, defaults to {1s, 2s}. Set to shorter durations in tests via SetRetryBackoff.
|
|
retryBackoff []time.Duration
|
|
}
|
|
|
|
// NewClient creates a new GitHub API client.
|
|
// If baseURL is empty, it defaults to https://api.github.com.
|
|
// For GitHub Enterprise, pass the API base URL (e.g. https://github.concur.com/api/v3).
|
|
// The baseURL must use HTTPS; pass AllowInsecureHTTP() as an option to permit HTTP
|
|
// for trusted internal deployments (e.g. local testing).
|
|
func NewClient(token, baseURL string, opts ...ClientOption) *Client {
|
|
if baseURL == "" {
|
|
baseURL = defaultBaseURL
|
|
}
|
|
cfg := clientConfig{}
|
|
for _, o := range opts {
|
|
o(&cfg)
|
|
}
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
allowInsecureHTTP: cfg.allowInsecureHTTP,
|
|
token: token,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 10 {
|
|
return fmt.Errorf("stopped after 10 redirects")
|
|
}
|
|
// Strip Authorization on cross-host redirect or protocol downgrade (https→http).
|
|
prev := via[len(via)-1]
|
|
if req.URL.Host != prev.URL.Host || (prev.URL.Scheme == "https" && req.URL.Scheme == "http") {
|
|
req.Header.Del("Authorization")
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// SetHTTPClient sets the underlying HTTP client used for requests.
|
|
// This is intended for testing to inject mock transports.
|
|
// Passing nil restores the default client (30s timeout + auth-stripping
|
|
// CheckRedirect policy matching NewClient).
|
|
func (c *Client) SetHTTPClient(hc *http.Client) {
|
|
if hc == nil {
|
|
hc = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 10 {
|
|
return fmt.Errorf("stopped after 10 redirects")
|
|
}
|
|
prev := via[len(via)-1]
|
|
if req.URL.Host != prev.URL.Host || (prev.URL.Scheme == "https" && req.URL.Scheme == "http") {
|
|
req.Header.Del("Authorization")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
c.httpClient = hc
|
|
}
|
|
|
|
// SetRetryBackoff configures the retry backoff durations for testing.
|
|
// In production the default {1s, 2s} applies.
|
|
func (c *Client) SetRetryBackoff(d []time.Duration) {
|
|
c.retryBackoff = d
|
|
}
|
|
|
|
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
|
// It respects the Retry-After header when present (capped at maxRetryAfter).
|
|
// Transport errors (network failures, context cancellation) are not retried.
|
|
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
|
const maxAttempts = 3
|
|
const maxRetryAfter = 120 * time.Second
|
|
|
|
var backoff []time.Duration
|
|
if c.retryBackoff != nil {
|
|
backoff = make([]time.Duration, len(c.retryBackoff))
|
|
copy(backoff, c.retryBackoff)
|
|
} else {
|
|
backoff = []time.Duration{1 * time.Second, 2 * time.Second}
|
|
}
|
|
|
|
const maxErrorBodyBytes = 64 * 1024
|
|
|
|
// Reject non-HTTPS URLs early since the URL is immutable across retries.
|
|
if c.token != "" && !c.allowInsecureHTTP {
|
|
parsed, err := url.Parse(reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse request URL: %w", err)
|
|
}
|
|
if !strings.EqualFold(parsed.Scheme, "https") {
|
|
return nil, fmt.Errorf("refusing to send credentials over non-HTTPS URL %q (use AllowInsecureHTTP option for trusted networks)", reqURL)
|
|
}
|
|
}
|
|
|
|
var lastErr error
|
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
|
if attempt > 0 {
|
|
var delay time.Duration
|
|
if attempt-1 < len(backoff) {
|
|
delay = backoff[attempt-1]
|
|
}
|
|
if delay > 0 {
|
|
timer := time.NewTimer(delay)
|
|
select {
|
|
case <-timer.C:
|
|
// Timer already fired; Stop() is a no-op here.
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
if c.token != "" {
|
|
// Bearer is the OAuth2 standard and is accepted by GitHub for both
|
|
// classic PATs and fine-grained tokens. The alternative "token" scheme
|
|
// is GitHub-specific and offers no additional compatibility.
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
req.Header.Set("User-Agent", userAgent)
|
|
if accept != "" {
|
|
req.Header.Set("Accept", accept)
|
|
} else {
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response body: %w", err)
|
|
}
|
|
if int64(len(body)) >= maxResponseBytes {
|
|
return nil, fmt.Errorf("response body exceeded %d bytes (truncated)", maxResponseBytes)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
|
|
resp.Body.Close()
|
|
|
|
lastErr = &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
|
|
|
|
// Retry on 429 rate limit
|
|
if resp.StatusCode == http.StatusTooManyRequests && attempt < maxAttempts-1 {
|
|
// Check for Retry-After header and override backoff if present.
|
|
// Supports both integer seconds (common) and HTTP-date format (RFC 7231).
|
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
|
if seconds, err := strconv.Atoi(ra); err == nil && seconds > 0 {
|
|
delay := time.Duration(seconds) * time.Second
|
|
if delay > maxRetryAfter {
|
|
delay = maxRetryAfter
|
|
}
|
|
if attempt < len(backoff) {
|
|
backoff[attempt] = delay
|
|
}
|
|
} else if t, err := http.ParseTime(ra); err == nil {
|
|
delay := time.Until(t)
|
|
if delay < 0 {
|
|
delay = 0
|
|
}
|
|
if delay > maxRetryAfter {
|
|
delay = maxRetryAfter
|
|
}
|
|
if attempt < len(backoff) {
|
|
backoff[attempt] = delay
|
|
}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Don't retry other errors
|
|
return nil, lastErr
|
|
}
|
|
|
|
return nil, lastErr
|
|
}
|
|
|
|
// doGet is a convenience wrapper for GET requests with the default Accept header.
|
|
func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
|
return c.doRequest(ctx, http.MethodGet, reqURL, "")
|
|
}
|