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

Closed
rodin wants to merge 16 commits from review-bot-issue-80 into feature/github-support
6 changed files with 201 additions and 62 deletions
Showing only changes of commit 1bc3f206ba - Show all commits
+35 -7
View File
7
@@ -65,13 +65,30 @@ func asAPIError(err error) (*APIError, bool) {
return nil, false return nil, false
Review

[NIT] Comment states the Client is safe for concurrent use, but SetHTTPClient and SetRetryBackoff mutate configuration and are not concurrency-safe. Consider clarifying that configuration methods are for setup/testing and should not be called concurrently with requests.

**[NIT]** Comment states the Client is safe for concurrent use, but SetHTTPClient and SetRetryBackoff mutate configuration and are not concurrency-safe. Consider clarifying that configuration methods are for setup/testing and should not be called concurrently with requests.
} }
Review

[NIT] baseURL is configurable. While typically set to GitHub/GHE, if this were to be influenced by untrusted input it could be used for SSRF or to target internal services. Ensure at integration points that baseURL is sourced from a trusted allowlist and not user-controlled.

**[NIT]** baseURL is configurable. While typically set to GitHub/GHE, if this were to be influenced by untrusted input it could be used for SSRF or to target internal services. Ensure at integration points that baseURL is sourced from a trusted allowlist and not user-controlled.
// 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 {
Review

[MINOR] AllowInsecureHTTP option permits sending credentials over HTTP when enabled. Although documented for trusted/internal use, accidental enablement in production would expose tokens over cleartext. Consider additional safeguards (e.g., explicit environment gate or failing fast unless a dedicated test flag is present).

**[MINOR]** AllowInsecureHTTP option permits sending credentials over HTTP when enabled. Although documented for trusted/internal use, accidental enablement in production would expose tokens over cleartext. Consider additional safeguards (e.g., explicit environment gate or failing fast unless a dedicated test flag is present).
return func(c *clientConfig) {
c.allowInsecureHTTP = true
}
}
// Client interacts with the GitHub API. // Client interacts with the GitHub API.
// A Client is safe for concurrent use by multiple goroutines; // A Client is safe for concurrent use by multiple goroutines;
// however, SetHTTPClient and SetRetryBackoff must not be called concurrently with requests. // however, SetHTTPClient and SetRetryBackoff must not be called concurrently with requests.
type Client struct { type Client struct {
baseURL string baseURL string
token string token string
httpClient *http.Client allowInsecureHTTP bool
httpClient *http.Client
// retryBackoff defines the delays between retry attempts for 429 responses. // retryBackoff defines the delays between retry attempts for 429 responses.
// retryBackoff[i] is the delay before attempt i+1 (after attempt i fails). // retryBackoff[i] is the delay before attempt i+1 (after attempt i fails).
@@ -82,13 +99,20 @@ type Client struct {
// NewClient creates a new GitHub API client. // NewClient creates a new GitHub API client.
// If baseURL is empty, it defaults to https://api.github.com. // 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). // For GitHub Enterprise, pass the API base URL (e.g. https://github.concur.com/api/v3).
func NewClient(token, baseURL string) *Client { // 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 == "" { if baseURL == "" {
baseURL = defaultBaseURL baseURL = defaultBaseURL
} }
cfg := clientConfig{}
for _, o := range opts {
Review

[MINOR] defaultCheckRedirect allows cross-host redirects (with Authorization stripped). Although token leakage is mitigated, following cross-host redirects can facilitate SSRF-like behavior if baseURL is misconfigured or points to a compromised server. Consider rejecting cross-host redirects by default or enforcing an allowlist of trusted hosts.

**[MINOR]** defaultCheckRedirect allows cross-host redirects (with Authorization stripped). Although token leakage is mitigated, following cross-host redirects can facilitate SSRF-like behavior if baseURL is misconfigured or points to a compromised server. Consider rejecting cross-host redirects by default or enforcing an allowlist of trusted hosts.
o(&cfg)
}
return &Client{ return &Client{
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
token: token, allowInsecureHTTP: cfg.allowInsecureHTTP,
token: token,
Review

[MINOR] defaultCheckRedirect follows HTTPS→HTTP redirects after stripping Authorization. While credentials are protected, this still permits plaintext requests to proceed, which can leak metadata and expands attack surface if a misconfigured or compromised server issues such redirects. Prefer failing closed on protocol downgrades.

**[MINOR]** defaultCheckRedirect follows HTTPS→HTTP redirects after stripping Authorization. While credentials are protected, this still permits plaintext requests to proceed, which can leak metadata and expands attack surface if a misconfigured or compromised server issues such redirects. Prefer failing closed on protocol downgrades.
httpClient: &http.Client{ httpClient: &http.Client{
Review

[MINOR] The doc comment on defaultCheckRedirect says it "strips the Authorization header on cross-host redirects or protocol downgrades (HTTPS→HTTP) to prevent credential leakage, while still following the redirect." However, a protocol downgrade from HTTPS to HTTP is a genuine security issue — stripping the header and still following is debatable. Consider returning an error on HTTPS→HTTP downgrade rather than silently following. This is a design choice that has security implications, not a bug per se, but worth flagging.

**[MINOR]** The doc comment on `defaultCheckRedirect` says it "strips the Authorization header on cross-host redirects or protocol downgrades (HTTPS→HTTP) to prevent credential leakage, while still following the redirect." However, a protocol downgrade from HTTPS to HTTP is a genuine security issue — stripping the header and still following is debatable. Consider returning an error on HTTPS→HTTP downgrade rather than silently following. This is a design choice that has security implications, not a bug per se, but worth flagging.
Review

[MINOR] defaultCheckRedirect indexes via[len(via)-1] without guarding for len(via) == 0. net/http currently guarantees at least one prior request in via, but adding a len(via) check would make this more robust against misuse.

**[MINOR]** defaultCheckRedirect indexes via[len(via)-1] without guarding for len(via) == 0. net/http currently guarantees at least one prior request in via, but adding a len(via) check would make this more robust against misuse.
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
1
@@ -146,7 +170,7 @@ func (c *Client) doRequest(ctx context.Context, method, url string, accept strin
timer := time.NewTimer(delay) timer := time.NewTimer(delay)
select { select {
case <-timer.C: case <-timer.C:
timer.Stop() // Timer already fired; Stop() is a no-op here.
case <-ctx.Done(): case <-ctx.Done():
timer.Stop() timer.Stop()
return nil, ctx.Err() return nil, ctx.Err()
@@ -159,6 +183,10 @@ func (c *Client) doRequest(ctx context.Context, method, url string, accept strin
return nil, fmt.Errorf("create request: %w", err) return nil, fmt.Errorf("create request: %w", err)
} }
if c.token != "" { if c.token != "" {
// Refuse to send credentials over plaintext unless explicitly allowed.
if !c.allowInsecureHTTP && req.URL.Scheme != "https" {
return nil, fmt.Errorf("refusing to send credentials over non-HTTPS URL %q (use AllowInsecureHTTP option for trusted networks)", req.URL.Host)
}
req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Authorization", "Bearer "+c.token)
} }
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
8
+85 -15
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -38,7 +39,7 @@ func TestDoRequest_SetsAuthHeader(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("my-token", srv.URL) c := NewClient("my-token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, _ = c.doGet(context.Background(), srv.URL+"/test") _, _ = c.doGet(context.Background(), srv.URL+"/test")
@@ -56,7 +57,7 @@ func TestDoRequest_SetsDefaultAcceptHeader(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, _ = c.doGet(context.Background(), srv.URL+"/test") _, _ = c.doGet(context.Background(), srv.URL+"/test")
@@ -79,7 +80,7 @@ func TestDoRequest_429Retry(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{10 * time.Millisecond, 10 * time.Millisecond}) c.SetRetryBackoff([]time.Duration{10 * time.Millisecond, 10 * time.Millisecond})
@@ -104,7 +105,7 @@ func TestDoRequest_429ExhaustsRetries(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}) c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
1
@@ -133,7 +134,7 @@ func TestDoRequest_404NoRetry(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.doGet(context.Background(), srv.URL+"/test") _, err := c.doGet(context.Background(), srv.URL+"/test")
@@ -154,7 +155,7 @@ func TestDoRequest_401NoRetry(t *testing.T) {
})) }))
Review

[NIT] TestDoRequest_429RetryAfterHeader and TestDoRequest_RetryAfterDoesNotMutateBackoff are gated behind testing.Short() but TestDoRequest_429RetryAfterHTTPDate (which waits ~2 seconds) is not. This test will slow down non-short test runs. Consider adding testing.Short() skip to TestDoRequest_429RetryAfterHTTPDate as well for consistency.

**[NIT]** `TestDoRequest_429RetryAfterHeader` and `TestDoRequest_RetryAfterDoesNotMutateBackoff` are gated behind `testing.Short()` but `TestDoRequest_429RetryAfterHTTPDate` (which waits ~2 seconds) is not. This test will slow down non-short test runs. Consider adding `testing.Short()` skip to `TestDoRequest_429RetryAfterHTTPDate` as well for consistency.
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.doGet(context.Background(), srv.URL+"/test") _, err := c.doGet(context.Background(), srv.URL+"/test")
1
@@ -202,7 +203,7 @@ func TestDoRequest_429RetryAfterHeader(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
// Use short backoff; Retry-After should override // Use short backoff; Retry-After should override
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}) c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
@@ -244,7 +245,7 @@ func TestDoRequest_RetryAfterDoesNotMutateBackoff(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}) c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
@@ -271,7 +272,7 @@ func TestDoRequest_SetsUserAgentHeader(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, _ = c.doGet(context.Background(), srv.URL+"/test") _, _ = c.doGet(context.Background(), srv.URL+"/test")
@@ -281,11 +282,24 @@ func TestDoRequest_SetsUserAgentHeader(t *testing.T) {
} }
func TestDoRequest_LimitsResponseBody(t *testing.T) { func TestDoRequest_LimitsResponseBody(t *testing.T) {
// Verify that responses are read through a limit reader. // Verify that response body reading is actually bounded by maxResponseBytes.
// We can't easily test the 10 MiB limit without OOM risk, // Use a small custom limit to avoid allocating 10 MiB in tests.
// but we verify the constant is set correctly. bigBody := strings.Repeat("x", maxResponseBytes+1024)
if maxResponseBytes != 10*1024*1024 { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("expected maxResponseBytes = 10 MiB, got %d", maxResponseBytes) w.WriteHeader(200)
w.Write([]byte(bigBody))
}))
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: %v", err)
}
// LimitReader should cap the body at maxResponseBytes
if len(body) > maxResponseBytes {
t.Errorf("expected body <= %d bytes, got %d", maxResponseBytes, len(body))
} }
} }
@@ -298,7 +312,7 @@ func TestDoRequest_SkipsAuthWhenTokenEmpty(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("", srv.URL) // empty token c := NewClient("", srv.URL, AllowInsecureHTTP()) // empty token
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, _ = c.doGet(context.Background(), srv.URL+"/test") _, _ = c.doGet(context.Background(), srv.URL+"/test")
@@ -314,3 +328,59 @@ func TestNewClient_CheckRedirectStripsAuthOnCrossHost(t *testing.T) {
t.Fatal("expected CheckRedirect to be set") t.Fatal("expected CheckRedirect to be set")
} }
} }
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)
}
}
+20 -3
View File
4
@@ -19,6 +19,9 @@ func (c *Client) GetFileContent(ctx context.Context, owner, repo, path, ref stri
// ListContents lists files and directories at a given path in a repo. // ListContents lists files and directories at a given path in a repo.
// Returns the directory listing from the GitHub contents API. // Returns the directory listing from the GitHub contents API.
// If the path points to a single file (not a directory), the API returns
// a JSON object instead of an array; this is handled by returning a
// single-element slice.
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) { func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s", reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path)) c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path))
@@ -26,14 +29,24 @@ func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]
if err != nil { if err != nil {
return nil, fmt.Errorf("list contents %s: %w", path, err) return nil, fmt.Errorf("list contents %s: %w", path, err)
} }
var entries []struct {
type entry struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Type string `json:"type"` Type string `json:"type"`
} }
// 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.
var entries []entry
if err := json.Unmarshal(body, &entries); err != nil { if err := json.Unmarshal(body, &entries); err != nil {
return nil, fmt.Errorf("parse contents JSON: %w", err) var single entry
if err2 := json.Unmarshal(body, &single); err2 != nil {
return nil, fmt.Errorf("parse contents JSON: %w", err)
}
entries = []entry{single}
} }
result := make([]vcs.ContentEntry, len(entries)) result := make([]vcs.ContentEntry, len(entries))
for i, e := range entries { for i, e := range entries {
result[i] = vcs.ContentEntry{ result[i] = vcs.ContentEntry{
@@ -47,7 +60,11 @@ func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]
// escapePath escapes each segment of a relative file path for use in URLs. // escapePath escapes each segment of a relative file path for use in URLs.
// Slashes are preserved as path separators; other special characters are escaped. // Slashes are preserved as path separators; other special characters are escaped.
// Dot-segments ("." and "..") are removed to prevent path traversal. // Dot-segments ("." and "..") are silently removed to prevent path traversal.
// This is intentional: callers may receive a different path than requested without
Review

[NIT] The escapePath function removes dot-segments silently. The doc comment acknowledges this and explains it's intentional, which is good. But the test case {"../etc/passwd", "etc/passwd"} documents that a path traversal attempt is silently resolved to etc/passwd rather than returning an error. Depending on threat model, callers may want to know the path was modified. Since this is intentional and documented, this is a NIT-level observation for a future design consideration.

**[NIT]** The `escapePath` function removes dot-segments silently. The doc comment acknowledges this and explains it's intentional, which is good. But the test case `{"../etc/passwd", "etc/passwd"}` documents that a path traversal attempt is silently resolved to `etc/passwd` rather than returning an error. Depending on threat model, callers may want to know the path was modified. Since this is intentional and documented, this is a NIT-level observation for a future design consideration.
// 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.
func escapePath(p string) string { func escapePath(p string) string {
parts := strings.Split(p, "/") parts := strings.Split(p, "/")
var clean []string var clean []string
2
+36 -11
View File
@@ -20,7 +20,7 @@ func TestGetFileContent_DelegatesToGetFileContentAtRef(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
// Call with empty ref — should not include ref param // Call with empty ref — should not include ref param
@@ -47,7 +47,7 @@ func TestGetFileContent_WithRef(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "abc123") _, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "abc123")
@@ -66,7 +66,7 @@ func TestGetFileContent_404(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "missing.go", "") _, err := c.GetFileContent(context.Background(), "owner", "repo", "missing.go", "")
@@ -82,7 +82,7 @@ func TestGetFileContent_401(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "") _, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
@@ -107,7 +107,7 @@ func TestGetFileContent_429Retry(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond}) c.SetRetryBackoff([]time.Duration{1 * time.Millisecond})
@@ -130,7 +130,7 @@ func TestGetFileContent_MalformedJSON(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "") _, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
@@ -151,7 +151,7 @@ func TestListContents_HappyPath(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
entries, err := c.ListContents(context.Background(), "owner", "repo", "src") entries, err := c.ListContents(context.Background(), "owner", "repo", "src")
@@ -185,7 +185,7 @@ func TestListContents_404(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.ListContents(context.Background(), "owner", "repo", "missing") _, err := c.ListContents(context.Background(), "owner", "repo", "missing")
@@ -201,7 +201,7 @@ func TestListContents_401(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.ListContents(context.Background(), "owner", "repo", "src") _, err := c.ListContents(context.Background(), "owner", "repo", "src")
@@ -225,7 +225,7 @@ func TestListContents_429Retry(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond}) c.SetRetryBackoff([]time.Duration{1 * time.Millisecond})
@@ -248,7 +248,7 @@ func TestListContents_MalformedJSON(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.ListContents(context.Background(), "owner", "repo", "src") _, err := c.ListContents(context.Background(), "owner", "repo", "src")
@@ -307,3 +307,28 @@ func TestDecodeBase64Content_CRLF(t *testing.T) {
t.Errorf("expected 'hello world', got %q", decoded) t.Errorf("expected 'hello world', got %q", decoded)
} }
} }
func TestListContents_SingleFile(t *testing.T) {
// GitHub Contents API returns a JSON object (not array) for single-file paths
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{"name":"README.md","path":"README.md","type":"file"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
entries, err := c.ListContents(context.Background(), "owner", "repo", "README.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Name != "README.md" {
t.Errorf("expected name 'README.md', got %q", entries[0].Name)
}
if entries[0].Type != "file" {
t.Errorf("expected type 'file', got %q", entries[0].Type)
}
}
+1 -2
View File
@@ -44,8 +44,7 @@ type commitStatusResponse struct {
// checkRunsResponse is the GitHub check runs API response. // checkRunsResponse is the GitHub check runs API response.
type checkRunsResponse struct { type checkRunsResponse struct {
TotalCount int `json:"total_count"` CheckRuns []struct {
CheckRuns []struct {
Name string `json:"name"` Name string `json:"name"`
Conclusion *string `json:"conclusion"` Conclusion *string `json:"conclusion"`
Status string `json:"status"` Status string `json:"status"`
22
+24 -24
View File
@@ -26,7 +26,7 @@ func TestGetPullRequest_HappyPath(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
pr, err := c.GetPullRequest(context.Background(), "owner", "repo", 42) pr, err := c.GetPullRequest(context.Background(), "owner", "repo", 42)
@@ -60,7 +60,7 @@ func TestGetPullRequest_404(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequest(context.Background(), "owner", "repo", 999) _, err := c.GetPullRequest(context.Background(), "owner", "repo", 999)
@@ -79,7 +79,7 @@ func TestGetPullRequest_401(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequest(context.Background(), "owner", "repo", 1) _, err := c.GetPullRequest(context.Background(), "owner", "repo", 1)
@@ -110,7 +110,7 @@ func TestGetPullRequest_429Retry(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond}) c.SetRetryBackoff([]time.Duration{1 * time.Millisecond})
1
@@ -133,7 +133,7 @@ func TestGetPullRequest_MalformedJSON(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequest(context.Background(), "owner", "repo", 1) _, err := c.GetPullRequest(context.Background(), "owner", "repo", 1)
@@ -155,7 +155,7 @@ func TestGetPullRequestDiff_HappyPath(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
diff, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 42) diff, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 42)
@@ -177,7 +177,7 @@ func TestGetPullRequestDiff_404(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 999) _, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 999)
@@ -193,7 +193,7 @@ func TestGetPullRequestDiff_401(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 1) _, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 1)
@@ -211,7 +211,7 @@ func TestGetPullRequestFiles_HappyPath(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
files, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1) files, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
@@ -256,7 +256,7 @@ func TestGetPullRequestFiles_Pagination(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
files, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1) files, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
@@ -283,7 +283,7 @@ func TestGetPullRequestFiles_BinaryFile_NoPatch(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
files, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1) files, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
@@ -305,7 +305,7 @@ func TestGetPullRequestFiles_404(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 999) _, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 999)
@@ -321,7 +321,7 @@ func TestGetPullRequestFiles_MalformedJSON(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1) _, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
@@ -345,7 +345,7 @@ func TestGetFileContentAtRef_HappyPath(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
content, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "path/to/file.go", "abc123") content, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "path/to/file.go", "abc123")
@@ -369,7 +369,7 @@ func TestGetFileContentAtRef_EmptyRef(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
content, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "file.txt", "") content, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "file.txt", "")
@@ -388,7 +388,7 @@ func TestGetFileContentAtRef_404(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "missing.go", "main") _, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "missing.go", "main")
@@ -404,7 +404,7 @@ func TestGetFileContentAtRef_401(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "file.go", "main") _, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "file.go", "main")
@@ -420,7 +420,7 @@ func TestGetFileContentAtRef_MalformedJSON(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "file.go", "main") _, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "file.go", "main")
1
@@ -445,7 +445,7 @@ func TestGetFileContentAtRef_429Retry(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond}) c.SetRetryBackoff([]time.Duration{1 * time.Millisecond})
@@ -496,7 +496,7 @@ func TestGetCommitStatuses_HappyPath(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
statuses, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "abc123") statuses, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "abc123")
@@ -567,7 +567,7 @@ func TestGetCommitStatuses_CheckRunConclusions(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
statuses, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "sha1") statuses, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "sha1")
@@ -591,7 +591,7 @@ func TestGetCommitStatuses_404(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "badsha") _, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "badsha")
@@ -607,7 +607,7 @@ func TestGetCommitStatuses_401(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
_, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "sha") _, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "sha")
@@ -623,7 +623,7 @@ func TestGetCommitStatuses_MalformedJSON(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
c := NewClient("token", srv.URL) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
Review

[NIT] strPtr helper is defined in pr_test.go but the same helper could be needed in other test files. Since all files are in package github (internal tests), this is fine as-is but worth noting for future maintainability.

**[NIT]** strPtr helper is defined in pr_test.go but the same helper could be needed in other test files. Since all files are in package `github` (internal tests), this is fine as-is but worth noting for future maintainability.
Review

[NIT] stringPtr helper is defined in pr_test.go (package github) but TestGetCommitStatuses_CheckRunConclusions also uses it. Since tests are in the same package, this is fine, but the helper could be placed in a shared test helper file to avoid potential future duplication if more test files are added to the package.

**[NIT]** `stringPtr` helper is defined in `pr_test.go` (package `github`) but `TestGetCommitStatuses_CheckRunConclusions` also uses it. Since tests are in the same package, this is fine, but the helper could be placed in a shared test helper file to avoid potential future duplication if more test files are added to the package.
_, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "sha") _, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "sha")
5