feat(github): implement PRReader + FileReader client (#80) #93

Closed
rodin wants to merge 16 commits from review-bot-issue-80 into feature/github-support
3 changed files with 32 additions and 13 deletions
Showing only changes of commit 80af5037b2 - Show all commits
+20 -8
View File
1
@@ -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
9
@@ -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
4
@@ -141,9 +143,15 @@ func NewClient(token, baseURL string, opts ...ClientOption) *Client {
}
// SetHTTPClient sets the underlying HTTP client used for requests.
Review

[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.

**[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.
// 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 {
1
@@ -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()
9
+8 -5
View File
4
@@ -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
1
@@ -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
Review

[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.

**[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.
Review

[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.

**[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
1
+4
View File
6
@@ -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
9
@@ -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
7