d1ef1e21e5
CI / test (pull_request) Successful in 18s
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 34s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m45s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m56s
Implement the GitHub API client with PRReader and FileReader interface conformance for both github.com and GitHub Enterprise. New files: - github/client.go: Client struct, NewClient with configurable base URL, HTTP helpers with 429 retry and Retry-After support - github/pr.go: GetPullRequest, GetPullRequestDiff (per-request Accept header), GetPullRequestFiles (paginated, populates Patch field), GetFileContentAtRef (base64 decode), GetCommitStatuses (merges commit statuses + check runs with conclusion mapping) - github/files.go: GetFileContent (delegates to GetFileContentAtRef), ListContents, escapePath, decodeBase64Content helpers Type changes: - vcs/types.go: Add Patch field to ChangedFile struct Tests cover: happy path, 404, 401, 429+retry, malformed response, pagination, binary files, check run conclusion mapping, base64 decoding. Compile-time checks: var _ vcs.PRReader = (*Client)(nil) var _ vcs.FileReader = (*Client)(nil) Exit criteria met: - go test ./github/... passes (all methods) - NewClient with empty baseURL uses https://api.github.com - NewClient with GHE URL targets correctly - GetFileContent delegates to GetFileContentAtRef with empty ref - GetPullRequestFiles paginates and populates Patch field - GetCommitStatuses merges both commit statuses and check-runs
178 lines
4.6 KiB
Go
178 lines
4.6 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"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const defaultBaseURL = "https://api.github.com"
|
|
|
|
// 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
|
|
}
|
|
|
|
// Client interacts with the GitHub API.
|
|
// A Client is safe for concurrent use by multiple goroutines.
|
|
type Client struct {
|
|
baseURL string
|
|
token string
|
|
http *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.
|
|
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).
|
|
func NewClient(token, baseURL string) *Client {
|
|
if baseURL == "" {
|
|
baseURL = defaultBaseURL
|
|
}
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
token: token,
|
|
http: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
// SetHTTPClient sets the underlying HTTP client used for requests.
|
|
// This is intended for testing to inject mock transports.
|
|
func (c *Client) SetHTTPClient(hc *http.Client) {
|
|
c.http = hc
|
|
}
|
|
|
|
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
|
// It respects the Retry-After header when present.
|
|
func (c *Client) doRequest(ctx context.Context, method, url string, accept string) ([]byte, error) {
|
|
const maxAttempts = 3
|
|
backoff := c.RetryBackoff
|
|
if backoff == nil {
|
|
backoff = []time.Duration{1 * time.Second, 2 * time.Second}
|
|
}
|
|
|
|
const maxErrorBodyBytes = 64 * 1024
|
|
|
|
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:
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, url, 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.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
body, err := io.ReadAll(resp.Body)
|
|
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 < maxAttempts-1 {
|
|
// Check for Retry-After header and override backoff if present
|
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
|
if seconds, err := strconv.Atoi(ra); err == nil && seconds > 0 {
|
|
if attempt < len(backoff) {
|
|
backoff[attempt] = time.Duration(seconds) * time.Second
|
|
}
|
|
}
|
|
}
|
|
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, "")
|
|
}
|