64c9d551ba
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 24s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 32s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m14s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m8s
- Restore timer.Stop() no-op in case <-timer.C for symmetry with ctx.Done - Add missing blank line between TestNoInsecureOption_RejectsHTTP and TestNoInsecureOption_RejectsUppercaseHTTP - Remove double blank line before TestAllowInsecureHTTP_WithoutEnvVar_Rejected Resolves review comments from sonnet-review-bot on PR #113.
388 lines
12 KiB
Go
388 lines
12 KiB
Go
// Package github provides a client for the GitHub API.
|
|
// It supports pull request operations, file content retrieval,
|
|
// and review submission for both github.com and GitHub Enterprise.
|
|
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultBaseURL = "https://api.github.com"
|
|
|
|
// maxRetryAttempts is the number of times doRequest will attempt a request.
|
|
maxRetryAttempts = 3
|
|
|
|
// maxRetryAfter caps the maximum delay from a Retry-After header to prevent
|
|
// a server from stalling the client indefinitely.
|
|
maxRetryAfter = 60 * time.Second
|
|
|
|
// maxErrorBodyBytes limits how much of an error response body we read
|
|
// to protect against malicious servers sending unbounded data.
|
|
maxErrorBodyBytes = 64 * 1024 // 64 KB
|
|
|
|
// maxResponseBodyBytes limits how much of a successful response body we read
|
|
// for defense-in-depth against servers returning excessively large payloads.
|
|
maxResponseBodyBytes = 10 * 1024 * 1024 // 10 MB
|
|
)
|
|
|
|
// 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).
|
|
//
|
|
// The Body field stores up to 64 KiB of the raw response for programmatic
|
|
// inspection. Error() truncates to 200 bytes for safe logging, but callers
|
|
// should avoid logging or propagating Body directly in production since it may
|
|
// contain sensitive details from the upstream server.
|
|
type APIError struct {
|
|
StatusCode int
|
|
Body string
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
body := e.Body
|
|
if len(body) > 200 {
|
|
body = body[:200] + "...(truncated)"
|
|
}
|
|
// Sanitize newlines to prevent log injection from upstream response bodies.
|
|
body = strings.ReplaceAll(body, "\n", " ")
|
|
body = strings.ReplaceAll(body, "\r", " ")
|
|
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
|
|
}
|
|
|
|
// Client interacts with the GitHub API.
|
|
// A Client is safe for concurrent use by multiple goroutines.
|
|
// SetHTTPClient and SetRetryBackoff are intended for test setup only and must
|
|
// be called before any goroutines issue requests; they have no synchronization.
|
|
type Client struct {
|
|
// TODO: baseURL is populated by NewClient but not yet consumed by doRequest/doGet.
|
|
// Higher-level exported methods (GetPullRequest, etc.) will use it to
|
|
// construct request URLs; remove this field if those methods end up
|
|
// accepting full URLs instead.
|
|
baseURL string
|
|
token string
|
|
httpClient *http.Client
|
|
|
|
// allowInsecureHTTP permits requests to HTTP (non-TLS) endpoints.
|
|
// When false, doRequest rejects URLs with an http:// scheme.
|
|
allowInsecureHTTP bool
|
|
|
|
// 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}.
|
|
retryBackoff []time.Duration
|
|
|
|
// now returns the current time. Defaults to time.Now.
|
|
// Override in tests to control HTTP-date Retry-After calculations.
|
|
now func() time.Time
|
|
}
|
|
|
|
// defaultCheckRedirect is the redirect policy used by NewClient.
|
|
// NOTE: This function is intentionally duplicated in gitea/client.go (and vice versa)
|
|
// because the packages are separate. Changes here must be mirrored there.
|
|
// It rejects HTTPS->HTTP protocol downgrades (to prevent plaintext leakage)
|
|
// and cross-host redirects (to prevent following responses from untrusted
|
|
// endpoints). Same-host, same-or-upgraded-scheme redirects are allowed.
|
|
func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 10 {
|
|
return fmt.Errorf("stopped after 10 redirects")
|
|
}
|
|
// Guard for direct invocation in tests and any future callers;
|
|
// net/http guarantees len(via) >= 1 during actual redirects.
|
|
if len(via) == 0 {
|
|
return nil
|
|
}
|
|
prev := via[len(via)-1]
|
|
// Reject protocol downgrade: HTTPS->HTTP leaks request metadata over plaintext.
|
|
if prev.URL.Scheme == "https" && req.URL.Scheme == "http" {
|
|
return fmt.Errorf("refusing redirect: HTTPS to HTTP downgrade (%s -> %s)", prev.URL.Host, req.URL.Host)
|
|
}
|
|
// Reject cross-host redirect entirely to avoid consuming responses
|
|
// from untrusted endpoints.
|
|
if req.URL.Host != prev.URL.Host {
|
|
return fmt.Errorf("refusing redirect: cross-host (%s -> %s)", prev.URL.Host, req.URL.Host)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClientOption configures optional behavior of a Client.
|
|
type ClientOption func(*clientConfig)
|
|
|
|
type clientConfig struct {
|
|
allowInsecureHTTP bool
|
|
insecureIsTestBypass bool
|
|
}
|
|
|
|
// AllowInsecureHTTP permits sending credentials over plaintext HTTP connections.
|
|
// In production, this option is gated by the REVIEW_BOT_ALLOW_INSECURE=1
|
|
// environment variable. Without the env var set, the option is silently ignored
|
|
// and a warning is logged.
|
|
//
|
|
// For tests, prefer AllowInsecureHTTPForTest which bypasses the env gate.
|
|
func AllowInsecureHTTP() ClientOption {
|
|
return func(cfg *clientConfig) {
|
|
cfg.allowInsecureHTTP = true
|
|
}
|
|
}
|
|
|
|
// AllowInsecureHTTPForTest permits sending credentials over plaintext HTTP
|
|
// without requiring the REVIEW_BOT_ALLOW_INSECURE environment variable.
|
|
// This is intended exclusively for test code using httptest.Server.
|
|
func AllowInsecureHTTPForTest() ClientOption {
|
|
return func(cfg *clientConfig) {
|
|
cfg.allowInsecureHTTP = true
|
|
cfg.insecureIsTestBypass = true
|
|
}
|
|
}
|
|
|
|
// 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).
|
|
func NewClient(token, baseURL string, opts ...ClientOption) *Client {
|
|
if baseURL == "" {
|
|
baseURL = defaultBaseURL
|
|
}
|
|
|
|
var cfg clientConfig
|
|
for _, opt := range opts {
|
|
opt(&cfg)
|
|
}
|
|
|
|
if cfg.allowInsecureHTTP && !cfg.insecureIsTestBypass {
|
|
if os.Getenv("REVIEW_BOT_ALLOW_INSECURE") != "1" {
|
|
slog.Warn("AllowInsecureHTTP ignored: set REVIEW_BOT_ALLOW_INSECURE=1 to enable")
|
|
cfg.allowInsecureHTTP = false
|
|
} else {
|
|
slog.Warn("AllowInsecureHTTP enabled — credentials may be sent over plaintext",
|
|
"env", "REVIEW_BOT_ALLOW_INSECURE=1")
|
|
}
|
|
}
|
|
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
token: token,
|
|
allowInsecureHTTP: cfg.allowInsecureHTTP,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: defaultCheckRedirect,
|
|
},
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
// SetHTTPClient sets the underlying HTTP client used for requests.
|
|
// This is intended for test setup only to inject mock transports; it must be
|
|
// called before any goroutines issue requests.
|
|
//
|
|
// Passing nil restores the default client (30s timeout + redirect-rejecting
|
|
// CheckRedirect policy matching NewClient).
|
|
//
|
|
// Callers providing a non-nil client are responsible for configuring a safe
|
|
// CheckRedirect policy. Without one, the default net/http behavior will follow
|
|
// redirects and may forward the Authorization header to untrusted hosts.
|
|
func (c *Client) SetHTTPClient(hc *http.Client) {
|
|
if hc == nil {
|
|
hc = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: defaultCheckRedirect,
|
|
}
|
|
}
|
|
c.httpClient = hc
|
|
}
|
|
|
|
// SetRetryBackoff sets the delays between retry attempts.
|
|
// This is intended for testing to speed up retry tests.
|
|
//
|
|
// Note: if an empty non-nil slice is provided, Retry-After delays parsed from
|
|
// server responses will be computed and capped but not applied (because
|
|
// attempt < len(backoff) is always false). This is acceptable for the
|
|
// test-only use case but callers should be aware of this edge case.
|
|
func (c *Client) SetRetryBackoff(backoff []time.Duration) {
|
|
c.retryBackoff = backoff
|
|
}
|
|
|
|
// parseRetryAfter parses a Retry-After header value, supporting both integer
|
|
// seconds (e.g. "120") and HTTP-date format (e.g. "Thu, 01 Dec 2025 16:00:00 GMT")
|
|
// as specified in RFC 7231 §7.1.3.
|
|
//
|
|
// For integer values, it returns the duration directly.
|
|
// For HTTP-date values, it computes the delay as the difference between the
|
|
// parsed time and now. If the date is in the past, it returns 0.
|
|
//
|
|
// Returns (0, false) if the value cannot be parsed as either format.
|
|
func (c *Client) parseRetryAfter(value string) (time.Duration, bool) {
|
|
value = strings.TrimSpace(value)
|
|
|
|
// Try integer seconds first (most common from GitHub).
|
|
// RFC 7231 allows delta-seconds of 0 to indicate immediate retry.
|
|
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
|
|
return time.Duration(seconds) * time.Second, true
|
|
}
|
|
|
|
// Try HTTP-date format (RFC 7231 §7.1.3).
|
|
// http.ParseTime handles RFC 1123, RFC 850, and ASCTIME formats.
|
|
if retryAt, err := http.ParseTime(value); err == nil {
|
|
delay := retryAt.Sub(c.now())
|
|
if delay < 0 {
|
|
delay = 0
|
|
}
|
|
return delay, true
|
|
}
|
|
|
|
return 0, false
|
|
}
|
|
|
|
// redactURL redacts sensitive components from a URL for safe inclusion in error
|
|
// messages and log output. It removes userinfo (e.g., user:pass@) and replaces
|
|
// query parameters with a placeholder.
|
|
func redactURL(rawURL string) string {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return "<unparseable URL>"
|
|
}
|
|
if u.User != nil {
|
|
u.User = nil
|
|
}
|
|
if u.RawQuery != "" {
|
|
u.RawQuery = "<redacted>"
|
|
}
|
|
u.Fragment = ""
|
|
return u.String()
|
|
}
|
|
|
|
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
|
// It respects the Retry-After header when present, supporting both integer
|
|
// seconds and HTTP-date formats (capped at maxRetryAfter).
|
|
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
|
if !c.allowInsecureHTTP {
|
|
parsed, err := url.Parse(reqURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse request URL: %w", err)
|
|
}
|
|
if strings.EqualFold(parsed.Scheme, "http") {
|
|
return nil, fmt.Errorf("refusing HTTP request to %s: use HTTPS or set AllowInsecureHTTP option", redactURL(reqURL))
|
|
}
|
|
}
|
|
|
|
var backoff []time.Duration
|
|
if c.retryBackoff != nil {
|
|
backoff = append([]time.Duration(nil), c.retryBackoff...)
|
|
} else {
|
|
backoff = []time.Duration{1 * time.Second, 2 * time.Second}
|
|
}
|
|
|
|
var lastErr error
|
|
for attempt := 0; attempt < maxRetryAttempts; 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.Stop() // no-op after fire; kept for symmetry with the ctx.Done case
|
|
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)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
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, maxResponseBodyBytes))
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response body: %w", err)
|
|
}
|
|
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 < maxRetryAttempts-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 delay, ok := c.parseRetryAfter(ra); ok {
|
|
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, url string) ([]byte, error) {
|
|
return c.doRequest(ctx, http.MethodGet, url, "")
|
|
}
|