feat(github): implement GitHub API client foundation #101
@@ -0,0 +1,344 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
// maxRetryAttempts is the number of times doRequest will attempt a request.
|
||||||
|
// The retry backoff slice must have length maxRetryAttempts-1.
|
||||||
|
maxRetryAttempts = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 4 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
sonnet-review-bot
commented
[NIT] The **[NIT]** The `allowInsecureHTTP` field is stored on both `clientConfig` and `Client`. While this is a deliberate copy (the option is read once at construction and stored for use in `doRequest`), adding a brief comment on the `Client.allowInsecureHTTP` field noting it is set from `ClientOption` at construction time would make the data flow clearer for future maintainers.
|
|||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultCheckRedirect is the redirect policy used by NewClient and SetHTTPClient(nil).
|
||||||
|
// It rejects HTTPS→HTTP protocol downgrades (to prevent plaintext leakage) and strips
|
||||||
|
// the Authorization header on cross-host redirects to prevent credential leakage to
|
||||||
|
// third-party hosts (e.g. CDN redirects from GitHub).
|
||||||
|
sonnet-review-bot
commented
[MINOR] The **[MINOR]** The `SetHTTPClient` and `SetRetryBackoff` methods are documented as 'test setup only' and 'must be called before any goroutines issue requests'. Consider either unexported test helpers via an export_test.go bridge (following the stdlib export_test.go pattern documented in the testing patterns) to avoid exposing these mutation methods on the public API surface, or at minimum adding a `//nolint` or similar signal. As-is, exported mutation methods on a type documented as 'safe for concurrent use' is a mild API design tension.
|
|||||||
|
func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return fmt.Errorf("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
sonnet-review-bot
commented
[NIT] The **[NIT]** The `defaultCheckRedirect` function uses `len(via) >= 10` to cap redirects, matching net/http's own default of 10. However, the stdlib's default `CheckRedirect` returns `ErrUseLastResponse`-based behavior; this returns a plain `fmt.Errorf`. This is functionally equivalent (both cause the redirect to fail) but the error message won't be recognized as a redirect limit error by callers using `errors.Is`. Since this is internal behavior and unlikely to be checked by callers, it's a minor style point only.
|
|||||||
|
// Guard: net/http guarantees len(via) >= 1 but this is undocumented;
|
||||||
|
// defend against zero-length to avoid panic on index out of range.
|
||||||
|
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 from HTTPS to HTTP (%s → %s)", prev.URL.Host, req.URL.Host)
|
||||||
|
}
|
||||||
|
// Strip Authorization on cross-host redirect to avoid leaking credentials
|
||||||
|
// to third-party hosts (GitHub legitimately redirects to CDN hosts).
|
||||||
|
if req.URL.Host != prev.URL.Host {
|
||||||
|
req.Header.Del("Authorization")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{
|
||||||
|
gpt-review-bot
commented
[MINOR] The strict coupling between maxAttempts and backoff length could be relaxed. Consider validating the slice in SetRetryBackoff (returning an error) or tolerating mismatched lengths by using available entries and treating missing entries as zero delays, instead of enforcing equality at request time. **[MINOR]** The strict coupling between maxAttempts and backoff length could be relaxed. Consider validating the slice in SetRetryBackoff (returning an error) or tolerating mismatched lengths by using available entries and treating missing entries as zero delays, instead of enforcing equality at request time.
|
|||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
allowInsecureHTTP: cfg.allowInsecureHTTP,
|
||||||
|
token: token,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
CheckRedirect: defaultCheckRedirect,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
gpt-review-bot
commented
[MAJOR] doRequest panics if the computed backoff length does not equal maxAttempts-1. Repository conventions require returning errors and never panicking; this should be handled gracefully (e.g., defaulting, validation at setter time, or returning an error). **[MAJOR]** doRequest panics if the computed backoff length does not equal maxAttempts-1. Repository conventions require returning errors and never panicking; this should be handled gracefully (e.g., defaulting, validation at setter time, or returning an error).
|
|||||||
|
// Passing nil restores the default client (30s timeout + auth-stripping
|
||||||
|
// 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 configures the retry backoff durations for testing.
|
||||||
|
// It must be called before any goroutines issue requests.
|
||||||
|
// The slice must have exactly maxRetryAttempts-1 entries (one delay per retry gap).
|
||||||
|
// In production the default {1s, 2s} applies.
|
||||||
|
func (c *Client) SetRetryBackoff(d []time.Duration) error {
|
||||||
|
if len(d) != maxRetryAttempts-1 {
|
||||||
|
return fmt.Errorf("github: backoff length %d does not match maxRetryAttempts-1 (%d)", len(d), maxRetryAttempts-1)
|
||||||
|
}
|
||||||
|
c.retryBackoff = d
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
sonnet-review-bot
commented
[MINOR] The panic on backoff length mismatch is a deliberate 'catch misconfiguration in tests' guard, but panicking in a library function violates the repo convention ('Return errors; never panic'). The comment justifies it as a programming error caught early, but it means any test that calls SetRetryBackoff with the wrong length (e.g., during future refactoring that changes maxAttempts) will panic rather than getting a useful error. Consider returning an error from doRequest or validating at SetRetryBackoff time instead. **[MINOR]** The panic on backoff length mismatch is a deliberate 'catch misconfiguration in tests' guard, but panicking in a library function violates the repo convention ('Return errors; never panic'). The comment justifies it as a programming error caught early, but it means any test that calls SetRetryBackoff with the wrong length (e.g., during future refactoring that changes maxAttempts) will panic rather than getting a useful error. Consider returning an error from doRequest or validating at SetRetryBackoff time instead.
|
|||||||
|
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
||||||
|
const maxRetryAfter = 120 * time.Second
|
||||||
|
|
||||||
|
sonnet-review-bot
commented
[NIT] The **[NIT]** The `defaultBackoff` local variable is declared but serves only as a template for the else branch. The variable name and the copy-pattern are fine, but it could be simplified slightly: the `if c.retryBackoff != nil && len(c.retryBackoff) == maxRetryAttempts-1` guard duplicates the invariant already enforced by `SetRetryBackoff`. A comment noting that the length check here is a defensive guard against direct struct construction (bypassing the setter) would clarify the intent.
|
|||||||
|
// backoff holds per-attempt delays: backoff[i] is the delay before attempt i+1.
|
||||||
|
// Length must be maxRetryAttempts-1 (one entry per retry gap).
|
||||||
|
// SetRetryBackoff validates at configuration time; the default is always valid.
|
||||||
|
defaultBackoff := []time.Duration{1 * time.Second, 2 * time.Second}
|
||||||
|
var backoff []time.Duration
|
||||||
|
if c.retryBackoff != nil && len(c.retryBackoff) == maxRetryAttempts-1 {
|
||||||
|
backoff = make([]time.Duration, len(c.retryBackoff))
|
||||||
|
copy(backoff, c.retryBackoff)
|
||||||
|
} else {
|
||||||
|
backoff = make([]time.Duration, len(defaultBackoff))
|
||||||
|
copy(backoff, defaultBackoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxErrorBodyBytes limits how much of an error response body is stored.
|
||||||
|
// Kept small (4 KiB) to reduce the risk of sensitive data leakage if callers
|
||||||
|
// log APIError.Body directly. Error() further truncates to 200 bytes.
|
||||||
|
const maxErrorBodyBytes = 4 * 1024
|
||||||
|
|
||||||
|
// Reject non-HTTPS URLs early since the URL is immutable across retries.
|
||||||
|
if c.token != "" && !c.allowInsecureHTTP {
|
||||||
|
[MINOR] doRequest validates HTTPS when a token is present but does not ensure the request host matches the configured baseURL. If future internal callers accidentally pass an arbitrary URL, the Authorization header could be sent to an unintended host. Consider enforcing that reqURL's host equals the client's baseURL host (or only accept relative paths) to prevent credential leakage by misuse. **[MINOR]** doRequest validates HTTPS when a token is present but does not ensure the request host matches the configured baseURL. If future internal callers accidentally pass an arbitrary URL, the Authorization header could be sent to an unintended host. Consider enforcing that reqURL's host equals the client's baseURL host (or only accept relative paths) to prevent credential leakage by misuse.
|
|||||||
|
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)
|
||||||
|
[NIT] The error message for refusing non-HTTPS includes the full URL. If this error is logged upstream, URL query strings could leak sensitive parameters. Consider eliding or redacting query components in error messages. **[NIT]** The error message for refusing non-HTTPS includes the full URL. If this error is logged upstream, URL query strings could leak sensitive parameters. Consider eliding or redacting query components in error messages.
|
|||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
sonnet-review-bot
commented
[NIT] The timer pattern uses **[NIT]** The timer pattern uses `timer.Stop()` after `<-timer.C` with a comment 'no-op after fire; kept for symmetry'. The standard Go idiom from the docs recommends this for correctness in case of concurrent resets, but for a non-reset timer this is indeed a no-op. The comment explains the intent — this is fine as written.
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sonnet-review-bot
commented
[MINOR] When **[MINOR]** When `delay == 0` (e.g., a past HTTP-date Retry-After), the timer block is skipped entirely due to the `if delay > 0` guard, which is correct. However, if `attempt-1 >= len(backoff)` (impossible given the length check, but defensive readers will note it), delay stays 0 and is also skipped. The logic is correct but the `attempt-1 < len(backoff)` guard (line 245) is now dead code because the panic above guarantees `len(backoff) == maxAttempts-1 == 2` and `attempt` ranges 1..2, so `attempt-1` is always 0 or 1, always in bounds. The dead branch is harmless but slightly confusing.
|
|||||||
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
sonnet-review-bot
commented
[MINOR] After **[MINOR]** After `handleResponse` returns `done=false` (the 429 case that will be retried), `resp` is already closed by `handleResponse`'s defer. The subsequent `resp.StatusCode == http.StatusTooManyRequests` check on line 258 reads from a closed response, but this is safe because `resp` is a struct value (not a stream) and `StatusCode` was already populated. However, the design where `handleResponse` owns the response lifecycle (via defer) but the caller still needs to inspect `resp.StatusCode` afterward is a subtle coupling. The caller reads `resp.StatusCode` on line 258 after `handleResponse` has already closed the body — this works but is fragile. Consider returning the status code from `handleResponse` or restructuring to make the ownership clearer.
|
|||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
sonnet-review-bot
commented
[MINOR] The retry condition **[MINOR]** The retry condition `attempt < maxAttempts-1` is evaluated after `handleResponse` returns `done=false`. This means on the final attempt (attempt==2), a 429 response falls through to `return nil, lastErr` correctly. However, the `continue` on a 429 means the loop increments `attempt` and hits the backoff sleep at the top — but `backoff` has only 2 entries for 3 attempts. The indexing `backoff[attempt-1]` for attempt=1 gives backoff[0] and for attempt=2 gives backoff[1], which is correct. But the guard `attempt < maxAttempts-1` (i.e., attempt < 2) means we only continue for attempts 0 and 1, never attempt 2. On attempt 2 a 429 hits `return nil, lastErr`. This is correct behavior but the interaction between the backoff slice length and maxAttempts is implicit and could silently break if maxAttempts is changed without updating the default backoff.
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture response metadata before handleResponse takes body ownership.
|
||||||
|
sonnet-review-bot
commented
[NIT] Transport errors from **[NIT]** Transport errors from `c.httpClient.Do(req)` are returned immediately without setting `lastErr`, meaning if a transport error occurs on attempt > 0 after successful 429 retries, the function correctly returns the error — but if the intent is to return the *last* meaningful error in all cases, the transport error path bypasses `lastErr`. This is fine as-is since transport errors are not retried per the documented contract, but a brief inline comment noting 'transport errors are non-retryable, return immediately' would improve clarity.
|
|||||||
|
respStatus := resp.StatusCode
|
||||||
|
retryAfterHeader := resp.Header.Get("Retry-After")
|
||||||
|
|
||||||
|
body, done, err := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
|
||||||
|
if done {
|
||||||
|
return body, err
|
||||||
|
gpt-review-bot
commented
[MINOR] The error message references "(truncated)" for oversized success responses, but the function returns an error without returning a truncated body. Consider rewording to avoid implying truncated data was returned. **[MINOR]** The error message references "(truncated)" for oversized success responses, but the function returns an error without returning a truncated body. Consider rewording to avoid implying truncated data was returned.
|
|||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
|
||||||
|
// Retry on 429 rate limit
|
||||||
|
if respStatus == 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 := retryAfterHeader; 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 retryAt, err := http.ParseTime(ra); err == nil {
|
||||||
|
delay := time.Until(retryAt)
|
||||||
|
if delay < 0 {
|
||||||
|
delay = 0
|
||||||
|
}
|
||||||
|
sonnet-review-bot
commented
[NIT] Minor timer resource leak: when **[NIT]** Minor timer resource leak: when `delay == 0`, the timer is never created but `timer.Stop()` is only called after the select fires. This is fine. However, in the `case <-timer.C` branch, `timer.Stop()` is called after the channel has already fired — the comment acknowledges it's a no-op. This is correct per Go docs but the symmetry comment is slightly misleading; `timer.Stop()` after drain is genuinely a no-op and serves no purpose beyond visual symmetry. Not a bug.
|
|||||||
|
if delay > maxRetryAfter {
|
||||||
|
delay = maxRetryAfter
|
||||||
|
}
|
||||||
|
if attempt < len(backoff) {
|
||||||
|
backoff[attempt] = delay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't retry other errors
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResponse reads and closes the response body, returning the result.
|
||||||
|
// It uses defer to ensure the body is always closed regardless of code path.
|
||||||
|
// Returns (body, done, err) where done=true means the caller should return immediately.
|
||||||
|
func (c *Client) handleResponse(resp *http.Response, maxRespBytes int, maxErrBytes int) ([]byte, bool, error) {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxRespBytes)+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
if len(body) > maxRespBytes {
|
||||||
|
return nil, true, fmt.Errorf("response body exceeded %d bytes", maxRespBytes)
|
||||||
|
}
|
||||||
|
return body, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
errBody, readErr := io.ReadAll(io.LimitReader(resp.Body, int64(maxErrBytes)))
|
||||||
|
if readErr != nil && len(errBody) == 0 {
|
||||||
|
errBody = []byte(fmt.Sprintf("[error reading response body: %v]", readErr))
|
||||||
|
}
|
||||||
|
return nil, false, &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, "")
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewClient_DefaultBaseURL(t *testing.T) {
|
||||||
|
c := NewClient("test-token", "")
|
||||||
|
if c.baseURL != "https://api.github.com" {
|
||||||
|
t.Errorf("expected default base URL, got %q", c.baseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClient_CustomBaseURL(t *testing.T) {
|
||||||
|
c := NewClient("test-token", "https://github.concur.com/api/v3")
|
||||||
|
if c.baseURL != "https://github.concur.com/api/v3" {
|
||||||
|
t.Errorf("expected custom base URL, got %q", c.baseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClient_TrimsTrailingSlash(t *testing.T) {
|
||||||
|
c := NewClient("test-token", "https://github.concur.com/api/v3/")
|
||||||
|
if c.baseURL != "https://github.concur.com/api/v3" {
|
||||||
|
t.Errorf("expected trailing slash trimmed, got %q", c.baseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_SetsAuthHeader(t *testing.T) {
|
||||||
|
var gotAuth string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte("{}"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("my-token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
_, _ = c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
|
||||||
|
if gotAuth != "Bearer my-token" {
|
||||||
|
t.Errorf("expected Bearer auth, got %q", gotAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_SetsDefaultAcceptHeader(t *testing.T) {
|
||||||
|
var gotAccept string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAccept = r.Header.Get("Accept")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte("{}"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
_, _ = c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
|
||||||
|
if gotAccept != "application/vnd.github+json" {
|
||||||
|
t.Errorf("expected default Accept header, got %q", gotAccept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_429Retry(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
w.WriteHeader(429)
|
||||||
|
w.Write([]byte(`{"message":"rate limit"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
if err := c.SetRetryBackoff([]time.Duration{10 * time.Millisecond, 10 * time.Millisecond}); err != nil {
|
||||||
|
t.Fatalf("SetRetryBackoff: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != `{"ok":true}` {
|
||||||
|
t.Errorf("unexpected body: %s", body)
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Errorf("expected 2 attempts, got %d", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_429ExhaustsRetries(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
w.WriteHeader(429)
|
||||||
|
w.Write([]byte(`{"message":"rate limit"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
|
||||||
|
t.Fatalf("SetRetryBackoff: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error after exhausting retries")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 429 {
|
||||||
|
t.Errorf("expected 429, got %d", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
if attempts != 3 {
|
||||||
|
t.Errorf("expected 3 attempts, got %d", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_404NoRetry(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
w.WriteHeader(404)
|
||||||
|
w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 404")
|
||||||
|
}
|
||||||
|
if attempts != 1 {
|
||||||
|
t.Errorf("expected 1 attempt (no retry on 404), got %d", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_401NoRetry(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
w.WriteHeader(401)
|
||||||
|
w.Write([]byte(`{"message":"bad credentials"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
sonnet-review-bot
commented
[NIT] TestDoRequest_429ExhaustsRetries uses a direct type assertion **[NIT]** TestDoRequest_429ExhaustsRetries uses a direct type assertion `err.(*APIError)` rather than `errors.As`. While this works since doRequest returns *APIError unwrapped, using `errors.As` would be more idiomatic per the error-handling patterns and would remain correct if error wrapping is added later.
|
|||||||
|
t.Fatal("expected error for 401")
|
||||||
|
}
|
||||||
|
if attempts != 1 {
|
||||||
|
t.Errorf("expected 1 attempt (no retry on 401), got %d", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsNotFound(t *testing.T) {
|
||||||
|
err := &APIError{StatusCode: 404, Body: "not found"}
|
||||||
|
if !IsNotFound(err) {
|
||||||
|
t.Error("expected IsNotFound to return true for 404")
|
||||||
|
}
|
||||||
|
sonnet-review-bot
commented
[NIT] The test **[NIT]** The test `TestDoRequest_RetryAfterDoesNotMutateBackoff` skips in short mode with `t.Skip("skipping slow retry test in short mode")` but the test sets a `Retry-After: 1` header which causes a ~1 second delay. This is intentional for the actual retry test, but the 'does not mutate' property could be verified without the real delay by using `RetryAfter: 0` or a past date. Minor since the skip guard is appropriate.
|
|||||||
|
err2 := &APIError{StatusCode: 500, Body: "server error"}
|
||||||
|
if IsNotFound(err2) {
|
||||||
|
t.Error("expected IsNotFound to return false for 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsUnauthorized(t *testing.T) {
|
||||||
|
err := &APIError{StatusCode: 401, Body: "bad credentials"}
|
||||||
|
if !IsUnauthorized(err) {
|
||||||
|
t.Error("expected IsUnauthorized to return true for 401")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIError_SanitizesNewlines(t *testing.T) {
|
||||||
|
err := &APIError{StatusCode: 500, Body: "line1\ninjected\rmore"}
|
||||||
|
msg := err.Error()
|
||||||
|
sonnet-review-bot
commented
[NIT] **[NIT]** `TestDoRequest_RetryAfterDoesNotMutateBackoff` accesses `c.retryBackoff` directly (a private field) from within the `github` package test — this works because the test is in `package github` (white-box test). This is intentional and correct given the test file is in the same package.
|
|||||||
|
if strings.Contains(msg, "\n") || strings.Contains(msg, "\r") {
|
||||||
|
t.Errorf("expected newlines to be sanitized, got: %q", msg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, "line1 injected more") {
|
||||||
|
t.Errorf("expected sanitized body, got: %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_429RetryAfterHeader(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping slow retry test in short mode")
|
||||||
|
}
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
w.Header().Set("Retry-After", "1")
|
||||||
|
w.WriteHeader(429)
|
||||||
|
w.Write([]byte(`{"message":"rate limit"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
// Use short backoff; Retry-After should override
|
||||||
|
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
|
||||||
|
t.Fatalf("SetRetryBackoff: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != `{"ok":true}` {
|
||||||
|
t.Errorf("unexpected body: %s", body)
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Errorf("expected 2 attempts, got %d", attempts)
|
||||||
|
}
|
||||||
|
// Retry-After: 1 means at least 1 second delay
|
||||||
|
if elapsed < 900*time.Millisecond {
|
||||||
|
t.Errorf("expected ~1s delay from Retry-After, got %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_RetryAfterDoesNotMutateBackoff(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping slow retry test in short mode")
|
||||||
|
}
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
w.Header().Set("Retry-After", "1")
|
||||||
|
w.WriteHeader(429)
|
||||||
|
w.Write([]byte(`{"message":"rate limit"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
|
||||||
|
t.Fatalf("SetRetryBackoff: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the original retryBackoff slice was not mutated
|
||||||
|
if c.retryBackoff[0] != 1*time.Millisecond {
|
||||||
|
t.Errorf("retryBackoff[0] was mutated: got %v, want 1ms", c.retryBackoff[0])
|
||||||
|
}
|
||||||
|
if c.retryBackoff[1] != 1*time.Millisecond {
|
||||||
|
t.Errorf("retryBackoff[1] was mutated: got %v, want 1ms", c.retryBackoff[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_429RetryAfterHTTPDate(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping slow Retry-After HTTP-date test in short mode")
|
||||||
|
}
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
// Use HTTP-date format (RFC 7231) — a time 2 seconds in the future.
|
||||||
|
future := time.Now().Add(2 * time.Second).UTC()
|
||||||
|
w.Header().Set("Retry-After", future.Format(http.TimeFormat))
|
||||||
|
w.WriteHeader(429)
|
||||||
|
w.Write([]byte(`{"message":"rate limit"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
|
||||||
|
t.Fatalf("SetRetryBackoff: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != `{"ok":true}` {
|
||||||
|
t.Errorf("unexpected body: %s", body)
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Errorf("expected 2 attempts, got %d", attempts)
|
||||||
|
}
|
||||||
|
// HTTP-date was ~2s in the future; by the time client processes it,
|
||||||
|
// time.Until gives ~1-2s. Verify it's meaningfully delayed (not instant).
|
||||||
|
if elapsed < 500*time.Millisecond {
|
||||||
|
t.Errorf("expected meaningful delay from HTTP-date Retry-After, got %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_429RetryAfterHTTPDateInPast(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
// Use a time in the past — should result in zero/immediate retry.
|
||||||
|
past := time.Now().Add(-10 * time.Second).UTC()
|
||||||
|
w.Header().Set("Retry-After", past.Format(http.TimeFormat))
|
||||||
|
w.WriteHeader(429)
|
||||||
|
w.Write([]byte(`{"message":"rate limit"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
if err := c.SetRetryBackoff([]time.Duration{5 * time.Second, 5 * time.Second}); err != nil {
|
||||||
|
t.Fatalf("SetRetryBackoff: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Errorf("expected 2 attempts, got %d", attempts)
|
||||||
|
}
|
||||||
|
// Past date should override the 5s backoff to ~0
|
||||||
|
if elapsed > 500*time.Millisecond {
|
||||||
|
t.Errorf("expected near-instant retry for past HTTP-date, got %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_SetsUserAgentHeader(t *testing.T) {
|
||||||
|
var gotUA string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotUA = r.Header.Get("User-Agent")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte("{}"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
_, _ = c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
|
||||||
|
if gotUA != "review-bot/1.0" {
|
||||||
|
t.Errorf("expected User-Agent 'review-bot/1.0', got %q", gotUA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_LimitsResponseBody(t *testing.T) {
|
||||||
|
// Verify that oversized responses return an error rather than silently truncating.
|
||||||
|
bigBody := strings.Repeat("x", maxResponseBytes+1024)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(bigBody))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for oversized response body")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "exceeded") {
|
||||||
|
t.Errorf("expected truncation error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_AcceptsExactlyAtLimit(t *testing.T) {
|
||||||
|
// A response body exactly equal to maxResponseBytes should succeed (not error).
|
||||||
|
exactBody := strings.Repeat("x", maxResponseBytes)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(exactBody))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for exactly-at-limit body: %v", err)
|
||||||
|
}
|
||||||
|
if len(body) != maxResponseBytes {
|
||||||
|
t.Errorf("expected body length %d, got %d", maxResponseBytes, len(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_SkipsAuthWhenTokenEmpty(t *testing.T) {
|
||||||
|
var gotAuth string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte("{}"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("", srv.URL, AllowInsecureHTTP()) // empty token
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
_, _ = c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
|
||||||
|
if gotAuth != "" {
|
||||||
|
t.Errorf("expected no Authorization header with empty token, got %q", gotAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClient_CheckRedirectStripsAuthOnCrossHost(t *testing.T) {
|
||||||
|
// Verify the CheckRedirect function is configured
|
||||||
|
c := NewClient("secret-token", "https://api.github.com")
|
||||||
|
if c.httpClient.CheckRedirect == nil {
|
||||||
|
t.Fatal("expected CheckRedirect to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsHTTPSToHTTP(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "http", Host: "api.github.com", Path: "/foo"},
|
||||||
|
Header: http.Header{"Authorization": []string{"Bearer token"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on HTTPS→HTTP redirect")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "refusing redirect from HTTPS to HTTP") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_StripsAuthOnCrossHost(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "https", Host: "objects.githubusercontent.com", Path: "/bar"},
|
||||||
|
Header: http.Header{"Authorization": []string{"Bearer token"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||||
|
t.Errorf("expected Authorization header to be stripped, got %q", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_PreservesAuthOnSameHost(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/bar"},
|
||||||
|
Header: http.Header{"Authorization": []string{"Bearer token"}},
|
||||||
|
sonnet-review-bot
commented
[NIT] Double blank line between **[NIT]** Double blank line between `TestSetHTTPClient_NilRestoresDefault` and `TestSetRetryBackoff_RejectsInvalidLength`. Minor formatting — `gofmt` would normalize this to a single blank line.
|
|||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if auth := req.Header.Get("Authorization"); auth != "Bearer token" {
|
||||||
|
t.Errorf("expected Authorization to be preserved, got %q", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_RejectsHTTPWithToken(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte("{}"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Without AllowInsecureHTTP, should refuse to send token over HTTP
|
||||||
|
c := NewClient("secret-token", srv.URL)
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when sending token over HTTP")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "refusing to send credentials") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_AllowsHTTPWithoutToken(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Without token, HTTP should be fine (no credentials to leak)
|
||||||
|
c := NewClient("", srv.URL)
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != `{"ok":true}` {
|
||||||
|
t.Errorf("unexpected body: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_AllowsHTTPWithInsecureOption(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("secret-token", srv.URL, AllowInsecureHTTP())
|
||||||
|
c.SetHTTPClient(srv.Client())
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != `{"ok":true}` {
|
||||||
|
t.Errorf("unexpected body: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
|
||||||
|
c := NewClient("token", "https://api.github.com")
|
||||||
|
c.SetHTTPClient(nil)
|
||||||
|
if c.httpClient == nil {
|
||||||
|
t.Fatal("expected non-nil httpClient after SetHTTPClient(nil)")
|
||||||
|
}
|
||||||
|
if c.httpClient.Timeout != 30*time.Second {
|
||||||
|
t.Errorf("expected 30s timeout, got %v", c.httpClient.Timeout)
|
||||||
|
}
|
||||||
|
if c.httpClient.CheckRedirect == nil {
|
||||||
|
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestSetRetryBackoff_RejectsInvalidLength(t *testing.T) {
|
||||||
|
c := NewClient("token", "https://api.github.com")
|
||||||
|
|
||||||
|
// Too short
|
||||||
|
err := c.SetRetryBackoff([]time.Duration{1 * time.Second})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for backoff length 1")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "backoff length 1") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Too long
|
||||||
|
err = c.SetRetryBackoff([]time.Duration{1 * time.Second, 2 * time.Second, 3 * time.Second})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for backoff length 3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct length succeeds
|
||||||
|
err = c.SetRetryBackoff([]time.Duration{1 * time.Second, 2 * time.Second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for valid backoff: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ type PullRequest struct {
|
|||||||
type ChangedFile struct {
|
type ChangedFile struct {
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
Patch string `json:"patch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentEntry represents a file or directory entry from the contents API.
|
// ContentEntry represents a file or directory entry from the contents API.
|
||||||
|
|||||||
[MINOR] Functional options are used for a single AllowInsecureHTTP option. While acceptable if you plan to add more options soon, the documented configuration patterns suggest avoiding functional options for <3 options unless growth is expected.