Files
review-bot/github/client.go
T
claw 4b55e33a49
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 35s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 2m13s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m21s
feat(github): implement PRReader + FileReader client (#80)
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
2026-05-12 15:16:33 -07:00

197 lines
5.0 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"
"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 ok := errorAs(err, &target); ok {
return target, true
}
return nil, false
}
// errorAs is a type-safe wrapper for errors.As to avoid import cycle issues.
func errorAs(err error, target interface{}) bool {
switch t := target.(type) {
case **APIError:
for err != nil {
if e, ok := err.(*APIError); ok {
*t = e
return true
}
// Try unwrapping
if u, ok := err.(interface{ Unwrap() error }); ok {
err = u.Unwrap()
} else {
return false
}
}
}
return 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, "")
}