feat(github): implement PRReader + FileReader client (#80) #93
@@ -27,9 +27,10 @@ const (
|
||||
// It carries the status code so callers can distinguish between
|
||||
// different failure modes (e.g. 404 vs 500).
|
||||
//
|
||||
// Note: Error() includes up to 200 bytes of the response body for debugging.
|
||||
// Callers should avoid logging raw error messages in production if the upstream
|
||||
// server may return sensitive details in error responses.
|
||||
// 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
|
||||
@@ -87,8 +88,9 @@ func AllowInsecureHTTP() ClientOption {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
@@ -141,9 +143,15 @@ func NewClient(token, baseURL string, opts ...ClientOption) *Client {
|
||||
}
|
||||
|
||||
// SetHTTPClient sets the underlying HTTP client used for requests.
|
||||
|
|
||||
// This is intended for testing to inject mock transports.
|
||||
// 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 + 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{
|
||||
@@ -155,6 +163,7 @@ func (c *Client) SetHTTPClient(hc *http.Client) {
|
||||
}
|
||||
|
||||
// SetRetryBackoff configures the retry backoff durations for testing.
|
||||
// It must be called before any goroutines issue requests.
|
||||
// In production the default {1s, 2s} applies.
|
||||
func (c *Client) SetRetryBackoff(d []time.Duration) {
|
||||
c.retryBackoff = d
|
||||
@@ -175,7 +184,10 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
||||
backoff = []time.Duration{1 * time.Second, 2 * time.Second}
|
||||
}
|
||||
|
||||
const maxErrorBodyBytes = 64 * 1024
|
||||
// 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 {
|
||||
@@ -199,7 +211,7 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Backoff elapsed, proceed with retry.
|
||||
timer.Stop() // no-op after fire, releases runtime resources promptly
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
|
||||
@@ -42,6 +42,8 @@ func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]
|
||||
|
||||
// The GitHub contents API returns an array for directories and an object
|
||||
// for single files. Try array first (common case), then fall back to object.
|
||||
// An empty array ([]) is valid — it represents an empty directory — and
|
||||
// results in a zero-length slice returned without error.
|
||||
var entries []entry
|
||||
if err := json.Unmarshal(body, &entries); err != nil {
|
||||
var single entry
|
||||
@@ -69,11 +71,12 @@ func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]
|
||||
|
||||
// escapePath escapes each segment of a relative file path for use in URLs.
|
||||
// Slashes are preserved as path separators; other special characters are escaped.
|
||||
// Dot-segments ("." and "..") are silently removed to prevent path traversal.
|
||||
// This is intentional: callers may receive a different path than requested without
|
||||
// error. The function is package-private, and all callers (GetFileContentAtRef,
|
||||
// ListContents) already handle missing-file errors from the API if the cleaned
|
||||
// path doesn't match what the caller intended.
|
||||
// Dot-segments ("." and "..") and empty segments (from consecutive slashes like
|
||||
|
sonnet-review-bot
commented
[MINOR] The dual-unmarshal fallback for array vs. object in **[MINOR]** The dual-unmarshal fallback for array vs. object in `ListContents` is correct, but the error variable shadowing with `err` and `err2` is slightly awkward. The outer `err` from array unmarshal is passed into the error string but `err2` is wrapped with `%w` — this means only the object-unmarshal error is unwrappable. Consider wrapping both or using a different format. Low impact since callers only check for nil.
|
||||
// "a//b") are silently removed to prevent path traversal and produce canonical
|
||||
// paths. This is intentional: callers may receive a different path than requested
|
||||
// without error. The function is package-private, and all callers
|
||||
// (GetFileContentAtRef, ListContents) already handle missing-file errors from the
|
||||
// API if the cleaned path doesn't match what the caller intended.
|
||||
|
sonnet-review-bot
commented
[MINOR] The fallback logic for **[MINOR]** The fallback logic for `ListContents` when the array unmarshal fails uses the error from the object unmarshal (`err2`) as the returned error, discarding the original array unmarshal error. This could produce a confusing error message if the response was genuinely a malformed array rather than a single-file object. The original `err` might be more informative in the pure malformed-JSON case.
|
||||
func escapePath(p string) string {
|
||||
parts := strings.Split(p, "/")
|
||||
var clean []string
|
||||
|
||||
@@ -89,6 +89,8 @@ const maxPages = 100
|
||||
|
||||
// GetPullRequestFiles fetches the list of files changed in a PR.
|
||||
// Paginates through all pages (100 per page) to collect all files.
|
||||
// Returns nil (not an empty slice) when the PR has no changed files.
|
||||
// Callers can safely range over or check len() on a nil slice.
|
||||
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]vcs.ChangedFile, error) {
|
||||
var allFiles []vcs.ChangedFile
|
||||
|
||||
@@ -156,6 +158,8 @@ func (c *Client) GetFileContentAtRef(ctx context.Context, owner, repo, path, ref
|
||||
|
||||
// GetCommitStatuses fetches both commit statuses and check runs for a SHA,
|
||||
// merging them into a unified []vcs.CommitStatus slice.
|
||||
// If the commit statuses endpoint fails (e.g. 404 for an unknown SHA), the
|
||||
// function returns immediately without attempting the check-runs endpoint.
|
||||
func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]vcs.CommitStatus, error) {
|
||||
var result []vcs.CommitStatus
|
||||
|
||||
|
||||
[MINOR] SetHTTPClient allows replacing the underlying http.Client without enforcing a safe CheckRedirect policy, which could cause Authorization to be forwarded to untrusted hosts on redirects. The docstring warns about this, but adding a runtime safeguard (e.g., rejecting clients with nil CheckRedirect or wrapping it) would further reduce misuse risk.