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

Closed
rodin wants to merge 16 commits from review-bot-issue-80 into feature/github-support
8 changed files with 2215 additions and 0 deletions
+327
View File
@@ -0,0 +1,327 @@
// Package github provides a client for the GitHub API.
// It supports pull request operations, file content retrieval, CI status checks,
// and directory listing for both github.com and GitHub Enterprise.
package github
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
Review

[NIT] Package comment mentions "review submission" but this PR doesn't include review endpoints. Consider updating the comment or adding a TODO/ref to avoid misleading users.

**[NIT]** Package comment mentions "review submission" but this PR doesn't include review endpoints. Consider updating the comment or adding a TODO/ref to avoid misleading users.
"time"
)
const (
Outdated
Review

[NIT] Three separate const declarations could be grouped into a single const (...) block per the style pattern, though this is a pure style nit and not a correctness issue.

**[NIT]** Three separate `const` declarations could be grouped into a single `const (...)` block per the style pattern, though this is a pure style nit and not a correctness issue.
defaultBaseURL = "https://api.github.com"
userAgent = "review-bot/1.0"
// maxResponseBytes limits successful response body reads to 10 MiB.
maxResponseBytes = 10 * 1024 * 1024
)
// 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).
//
// 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
Outdated
Review

[NIT] APIError.Error includes up to 200 bytes of the response body. If callers log errors verbatim, this could leak server-provided details (e.g., repository names). Consider further redaction or requiring callers to log status codes without bodies in production.

**[NIT]** APIError.Error includes up to 200 bytes of the response body. If callers log errors verbatim, this could leak server-provided details (e.g., repository names). Consider further redaction or requiring callers to log status codes without bodies in production.
// should avoid logging or propagating Body directly in production since it may
// contain sensitive details from the upstream server.
Outdated
Review

[NIT] APIError.Error includes up to 200 bytes of the response body. If callers log errors, this could surface sensitive server details. Consider reducing or masking returned body content, or clearly documenting that callers should avoid logging raw error messages in production.

**[NIT]** APIError.Error includes up to 200 bytes of the response body. If callers log errors, this could surface sensitive server details. Consider reducing or masking returned body content, or clearly documenting that callers should avoid logging raw error messages in production.
type APIError struct {
StatusCode int
Body string
}
Review

[MINOR] APIError.Error includes the response body content in the error string (truncated to 200 bytes). If callers log errors verbatim, this can leak information from upstream responses. Consider redacting or omitting bodies by default, or making inclusion configurable.

**[MINOR]** APIError.Error includes the response body content in the error string (truncated to 200 bytes). If callers log errors verbatim, this can leak information from upstream responses. Consider redacting or omitting bodies by default, or making inclusion configurable.
func (e *APIError) Error() string {
Review

[MINOR] APIError.Error() includes up to 200 characters of the upstream response body in the error message. While sanitized and truncated, this can still leak information into logs if generic error logging prints err.Error(). Consider omitting body content from Error() or making inclusion opt-in to reduce information exposure.

**[MINOR]** APIError.Error() includes up to 200 characters of the upstream response body in the error message. While sanitized and truncated, this can still leak information into logs if generic error logging prints err.Error(). Consider omitting body content from Error() or making inclusion opt-in to reduce information exposure.
body := e.Body
if len(body) > 200 {
body = body[:200] + "...(truncated)"
}
// Sanitize newlines to prevent log injection from upstream response bodies.
Outdated
Review

[MINOR] APIError.Error includes up to 200 bytes of upstream response body in the error string. If these errors are logged, this can leak sensitive details from upstream or allow log injection (newlines) if baseURL points to an untrusted endpoint. Consider sanitizing newlines and/or omitting body content from the error string.

**[MINOR]** APIError.Error includes up to 200 bytes of upstream response body in the error string. If these errors are logged, this can leak sensitive details from upstream or allow log injection (newlines) if baseURL points to an untrusted endpoint. Consider sanitizing newlines and/or omitting body content from the error string.
body = strings.ReplaceAll(body, "\n", " ")
body = strings.ReplaceAll(body, "\r", " ")
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, body)
}
// IsNotFound reports whether an error is an API 404 response.
Review

[NIT] The APIError.Body field is exported, meaning callers can access it directly. However, the Error() method truncates to 200 bytes while the Body field contains the full text. This asymmetry is intentional (truncation is for human-readable error messages) but could surprise callers who log err.Error() thinking they see the full body. The existing doc comment addresses this adequately; no code change needed, just noting the asymmetry.

**[NIT]** The `APIError.Body` field is exported, meaning callers can access it directly. However, the `Error()` method truncates to 200 bytes while the `Body` field contains the full text. This asymmetry is intentional (truncation is for human-readable error messages) but could surprise callers who log `err.Error()` thinking they see the full body. The existing doc comment addresses this adequately; no code change needed, just noting the asymmetry.
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 {
Review

[NIT] The APIError.Body field is exported (Body string) which means callers can directly access potentially sensitive upstream response content. The doc comment warns against this, but since APIError is a public type, there's no enforcement. This is acceptable given the documentation but worth noting as a conscious design trade-off.

**[NIT]** The `APIError.Body` field is exported (`Body string`) which means callers can directly access potentially sensitive upstream response content. The doc comment warns against this, but since `APIError` is a public type, there's no enforcement. This is acceptable given the documentation but worth noting as a conscious design trade-off.
if apiErr, ok := asAPIError(err); ok {
return apiErr.StatusCode == http.StatusUnauthorized
}
return false
Review

[MINOR] The exported RetryBackoff field is an exposed implementation detail intended primarily for testing. The doc comment says 'Set to shorter durations in tests', but exposing it publicly allows any caller to mutate it after construction. A cleaner approach for the production API would be a WithRetryBackoff([]time.Duration) option or making it unexported and providing a test helper. That said, the test TestDoRequest_RetryAfterDoesNotMutateBackoff correctly verifies the internal copy-on-use behavior, so the current design is safe — just not idiomatic for a public field that is primarily a test hook. Per configuration pattern #2 (Options Struct), test-only configuration hooks should typically not be part of the public API surface.

**[MINOR]** The exported `RetryBackoff` field is an exposed implementation detail intended primarily for testing. The doc comment says 'Set to shorter durations in tests', but exposing it publicly allows any caller to mutate it after construction. A cleaner approach for the production API would be a `WithRetryBackoff([]time.Duration)` option or making it unexported and providing a test helper. That said, the test `TestDoRequest_RetryAfterDoesNotMutateBackoff` correctly verifies the internal copy-on-use behavior, so the current design is safe — just not idiomatic for a public field that is primarily a test hook. Per configuration pattern #2 (Options Struct), test-only configuration hooks should typically not be part of the public API surface.
Review

[NIT] The struct field http *http.Client shadows the net/http package name within the struct definition. While Go allows this and the compiler handles it correctly, it's a minor readability concern. A name like httpClient or hc would avoid the shadowing.

**[NIT]** The struct field `http *http.Client` shadows the `net/http` package name within the struct definition. While Go allows this and the compiler handles it correctly, it's a minor readability concern. A name like `httpClient` or `hc` would avoid the shadowing.
}
Outdated
Review

[MINOR] The errorAs function is a hand-rolled reimplementation of errors.As. The comment says it's to 'avoid import cycle issues', but there are no obvious import cycles here — errors is a stdlib package with no dependencies. This should simply use errors.As(err, target) directly. The custom implementation handles the Unwrap() error interface but doesn't handle Unwrap() []error (multi-errors from errors.Join), so it's also less complete than the stdlib version.

**[MINOR]** The `errorAs` function is a hand-rolled reimplementation of `errors.As`. The comment says it's to 'avoid import cycle issues', but there are no obvious import cycles here — `errors` is a stdlib package with no dependencies. This should simply use `errors.As(err, target)` directly. The custom implementation handles the `Unwrap() error` interface but doesn't handle `Unwrap() []error` (multi-errors from `errors.Join`), so it's also less complete than the stdlib version.
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.
func asAPIError(err error) (*APIError, bool) {
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.
if err == nil {
return nil, false
Outdated
Review

[MINOR] RetryBackoff is an exported field on Client, which exposes an internal implementation detail and creates a testing surface that's somewhat awkward — callers might accidentally rely on mutation semantics. A more idiomatic approach would be a functional option (e.g. WithRetryBackoff) or keeping it unexported with a test helper. That said, the comment explicitly marks it as a test hook, which mitigates this concern.

**[MINOR]** RetryBackoff is an exported field on Client, which exposes an internal implementation detail and creates a testing surface that's somewhat awkward — callers might accidentally rely on mutation semantics. A more idiomatic approach would be a functional option (e.g. `WithRetryBackoff`) or keeping it unexported with a test helper. That said, the comment explicitly marks it as a test hook, which mitigates this concern.
Outdated
Review

[MINOR] RetryBackoff is an exported field on Client, which makes it part of the public API surface. This is used as a testing escape hatch ('Set to shorter durations in tests'), but the pattern documented in the codebase for HTTP client injection is SetHTTPClient(). Exposing RetryBackoff publicly means callers can set arbitrary production retry delays, which may be intentional but conflates test and production configuration. A SetRetryBackoff method or keeping it package-internal with a test-only setter via export_test.go would be cleaner, though the current approach is pragmatic.

**[MINOR]** RetryBackoff is an exported field on Client, which makes it part of the public API surface. This is used as a testing escape hatch ('Set to shorter durations in tests'), but the pattern documented in the codebase for HTTP client injection is SetHTTPClient(). Exposing RetryBackoff publicly means callers can set arbitrary production retry delays, which may be intentional but conflates test and production configuration. A SetRetryBackoff method or keeping it package-internal with a test-only setter via export_test.go would be cleaner, though the current approach is pragmatic.
}
var target *APIError
Outdated
Review

[MINOR] The default http.Client will follow redirects. If a redirect points to a different host, there is a risk (dependent on Go version/runtime behavior) of Authorization headers being forwarded, potentially leaking tokens. Consider setting CheckRedirect to disallow cross-host redirects or disable redirects (return http.ErrUseLastResponse), and/or ensure Authorization is stripped on host changes.

**[MINOR]** The default http.Client will follow redirects. If a redirect points to a different host, there is a risk (dependent on Go version/runtime behavior) of Authorization headers being forwarded, potentially leaking tokens. Consider setting CheckRedirect to disallow cross-host redirects or disable redirects (return http.ErrUseLastResponse), and/or ensure Authorization is stripped on host changes.
Outdated
Review

[MINOR] The Client struct has inconsistent field alignment — httpClient is not aligned with baseURL and token. While gofmt doesn't enforce struct field alignment, the blank line between token and httpClient with the comment block is slightly inconsistent with Go conventions. More substantively: SetHTTPClient and SetRetryBackoff are exported methods that mutate the struct, breaking the documented concurrency guarantee ('A Client is safe for concurrent use by multiple goroutines'). These mutation methods are clearly test-only helpers, but they're exported without any documentation caveat about not calling them concurrently with requests.

**[MINOR]** The Client struct has inconsistent field alignment — `httpClient` is not aligned with `baseURL` and `token`. While gofmt doesn't enforce struct field alignment, the blank line between `token` and `httpClient` with the comment block is slightly inconsistent with Go conventions. More substantively: `SetHTTPClient` and `SetRetryBackoff` are exported methods that mutate the struct, breaking the documented concurrency guarantee ('A Client is safe for concurrent use by multiple goroutines'). These mutation methods are clearly test-only helpers, but they're exported without any documentation caveat about not calling them concurrently with requests.
if errors.As(err, &target) {
return target, true
Outdated
Review

[MINOR] Functional options are used for a single boolean (AllowInsecureHTTP). Per configuration patterns, prefer a simpler parameter or a nil-opts struct when there are <3 stable options; consider YAGNI until more options appear.

**[MINOR]** Functional options are used for a single boolean (AllowInsecureHTTP). Per configuration patterns, prefer a simpler parameter or a nil-opts struct when there are <3 stable options; consider YAGNI until more options appear.
}
Outdated
Review

[NIT] The struct formatting has inconsistent spacing — baseURL and token fields have no space before the type, but httpClient has extra whitespace alignment. gofmt normally handles this, so it may be a display artifact, but worth verifying the file passes gofmt cleanly.

**[NIT]** The struct formatting has inconsistent spacing — `baseURL` and `token` fields have no space before the type, but `httpClient` has extra whitespace alignment. `gofmt` normally handles this, so it may be a display artifact, but worth verifying the file passes `gofmt` cleanly.
return nil, false
Outdated
Review

[MINOR] NewClient does not validate the baseURL scheme. If misconfigured to use http://, the token will be sent over plaintext. Consider enforcing https:// by default (or providing an explicit opt-out flag) to prevent accidental credential leakage.

**[MINOR]** NewClient does not validate the baseURL scheme. If misconfigured to use http://, the token will be sent over plaintext. Consider enforcing https:// by default (or providing an explicit opt-out flag) to prevent accidental credential leakage.
}
Outdated
Review

[MINOR] NewClient accepts arbitrary baseURL without validating scheme. If configured with an http:// URL, the Authorization token will be sent in plaintext over the network. Enforce HTTPS by default or require an explicit opt-in to allow HTTP for trusted internal deployments.

**[MINOR]** NewClient accepts arbitrary baseURL without validating scheme. If configured with an http:// URL, the Authorization token will be sent in plaintext over the network. Enforce HTTPS by default or require an explicit opt-in to allow HTTP for trusted internal deployments.
Outdated
Review

[MINOR] AllowInsecureHTTP() permits sending Authorization tokens over plaintext HTTP if enabled. While opt-in and documented for trusted environments/tests, it is a potential footgun if inadvertently enabled in production. Consider restricting to loopback/private ranges or emitting a prominent warning/telemetry when used.

**[MINOR]** AllowInsecureHTTP() permits sending Authorization tokens over plaintext HTTP if enabled. While opt-in and documented for trusted environments/tests, it is a potential footgun if inadvertently enabled in production. Consider restricting to loopback/private ranges or emitting a prominent warning/telemetry when used.
// clientConfig holds optional configuration for NewClient.
type clientConfig struct {
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).
allowInsecureHTTP bool
}
// ClientOption configures optional behavior of NewClient.
type ClientOption func(*clientConfig)
Outdated
Review

[MINOR] Authorization header is stripped only on cross-host redirects; it is still forwarded on same-host scheme downgrades (https -> http). This can leak the token over plaintext if the server issues a downgrade redirect. Consider also stripping Authorization when the scheme changes or when redirecting to non-HTTPS.

**[MINOR]** Authorization header is stripped only on cross-host redirects; it is still forwarded on same-host scheme downgrades (https -> http). This can leak the token over plaintext if the server issues a downgrade redirect. Consider also stripping Authorization when the scheme changes or when redirecting to non-HTTPS.
Outdated
Review

[MINOR] CheckRedirect compares req.URL.Host against via[0].URL.Host (the original request). If the redirect chain changes hosts on a later hop, comparing against the immediately previous host (via[len(via)-1]) is safer to ensure Authorization is stripped whenever the host changes at any step.

**[MINOR]** CheckRedirect compares req.URL.Host against via[0].URL.Host (the original request). If the redirect chain changes hosts on a later hop, comparing against the immediately previous host (via[len(via)-1]) is safer to ensure Authorization is stripped whenever the host changes at any step.
Outdated
Review

[NIT] The http field name shadows the net/http package import within method bodies. While Go resolves this correctly (field access via c.http vs package via http.MethodGet), using a field name that matches an imported package name reduces readability. Consider renaming to httpClient or hc.

**[NIT]** The `http` field name shadows the `net/http` package import within method bodies. While Go resolves this correctly (field access via `c.http` vs package via `http.MethodGet`), using a field name that matches an imported package name reduces readability. Consider renaming to `httpClient` or `hc`.
// AllowInsecureHTTP permits the client to use HTTP (non-TLS) base URLs.
// This should only be used for trusted internal deployments or testing.
Outdated
Review

[MINOR] The SetHTTPClient method is documented as 'intended for testing to inject mock transports', making it a test-only escape hatch on the public API. Per the package design pattern for internal/ packages, test-only hooks that are not part of the intended public contract ideally belong in export_test.go or a test helper. However, since this is a new package and the project may not yet have an export_test.go pattern established, this is a minor concern.

**[MINOR]** The `SetHTTPClient` method is documented as 'intended for testing to inject mock transports', making it a test-only escape hatch on the public API. Per the package design pattern for `internal/` packages, test-only hooks that are not part of the intended public contract ideally belong in `export_test.go` or a test helper. However, since this is a new package and the project may not yet have an `export_test.go` pattern established, this is a minor concern.
func AllowInsecureHTTP() ClientOption {
return func(c *clientConfig) {
c.allowInsecureHTTP = true
Outdated
Review

[MINOR] NewClient accepts any baseURL without enforcing HTTPS or validating against a trusted allowlist. If a misconfiguration allows an attacker-controlled baseURL, the client could send the Authorization token to an untrusted host or over plaintext HTTP.

**[MINOR]** NewClient accepts any baseURL without enforcing HTTPS or validating against a trusted allowlist. If a misconfiguration allows an attacker-controlled baseURL, the client could send the Authorization token to an untrusted host or over plaintext HTTP.
}
}
// Client interacts with the GitHub API.
// A Client is safe for concurrent use by multiple goroutines.
Outdated
Review

[NIT] The SetHTTPClient and SetRetryBackoff methods are exported as test-injection points but documented as unsafe for concurrent use with requests. The convention in the patterns (style.md) suggests using unexported helpers or the functional options pattern for test injection. The current design works but exposes mutation methods on what is documented as a concurrent-safe type. A //nolint or explicit doc note would make the intent clearer.

**[NIT]** The `SetHTTPClient` and `SetRetryBackoff` methods are exported as test-injection points but documented as unsafe for concurrent use with requests. The convention in the patterns (style.md) suggests using unexported helpers or the functional options pattern for test injection. The current design works but exposes mutation methods on what is documented as a concurrent-safe type. A `//nolint` or explicit doc note would make the intent clearer.
// 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
Outdated
Review

[MINOR] CheckRedirect strips Authorization on cross-host or https→http redirects but still follows the redirect. Following cross-host redirects can be an SSRF vector in misconfigured environments; consider blocking cross-host redirects entirely rather than proceeding without Authorization.

**[MINOR]** CheckRedirect strips Authorization on cross-host or https→http redirects but still follows the redirect. Following cross-host redirects can be an SSRF vector in misconfigured environments; consider blocking cross-host redirects entirely rather than proceeding without Authorization.
allowInsecureHTTP bool
httpClient *http.Client
Outdated
Review

[NIT] The parameter name 'url' in doRequest shadows the conceptual URL type used elsewhere (e.g., net/url in other files). Renaming to 'reqURL' could improve clarity, though this is purely stylistic.

**[NIT]** The parameter name 'url' in doRequest shadows the conceptual URL type used elsewhere (e.g., net/url in other files). Renaming to 'reqURL' could improve clarity, though this is purely stylistic.
// retryBackoff defines the delays between retry attempts for 429 responses.
// retryBackoff[i] is the delay before attempt i+1 (after attempt i fails).
Outdated
Review

[NIT] The doc comment for Client says SetHTTPClient and SetRetryBackoff must not be called concurrently with requests, but these are public methods and there's no enforcement or noCopy guard. Given the concurrent-use note, a brief comment in SetHTTPClient and SetRetryBackoff reiterating the constraint would help (though this matches the stdlib's tls.Config immutable-after-use convention, so it's acceptable as-is).

**[NIT]** The doc comment for `Client` says `SetHTTPClient` and `SetRetryBackoff` must not be called concurrently with requests, but these are public methods and there's no enforcement or `noCopy` guard. Given the concurrent-use note, a brief comment in `SetHTTPClient` and `SetRetryBackoff` reiterating the constraint would help (though this matches the stdlib's `tls.Config` immutable-after-use convention, so it's acceptable as-is).
// If nil, defaults to {1s, 2s}. Set to shorter durations in tests via SetRetryBackoff.
retryBackoff []time.Duration
}
Outdated
Review

[MINOR] The CheckRedirect handler allows following cross-host and HTTPS→HTTP redirects (while stripping Authorization). Although the token isn’t leaked, following cross-host redirects may contact untrusted hosts. Consider restricting redirects to same-host and HTTPS-only or fail on cross-host redirects to reduce SSRF-style risks.

**[MINOR]** The CheckRedirect handler allows following cross-host and HTTPS→HTTP redirects (while stripping Authorization). Although the token isn’t leaked, following cross-host redirects may contact untrusted hosts. Consider restricting redirects to same-host and HTTPS-only or fail on cross-host redirects to reduce SSRF-style risks.
// defaultCheckRedirect is the redirect policy used by NewClient and SetHTTPClient(nil).
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.
// It rejects HTTPS→HTTP protocol downgrades (to prevent plaintext leakage) and strips
Outdated
Review

[MINOR] Consider setting a User-Agent header on all requests. GitHub recommends identifying clients, and some enterprise installations enforce it. Add req.Header.Set("User-Agent", "review-bot/1.0") or similar.

**[MINOR]** Consider setting a User-Agent header on all requests. GitHub recommends identifying clients, and some enterprise installations enforce it. Add req.Header.Set("User-Agent", "review-bot/1.0") or similar.
// the Authorization header on cross-host redirects to prevent credential leakage to
// third-party hosts (e.g. CDN redirects from GitHub).
Outdated
Review

[MINOR] SetHTTPClient allows setting a nil *http.Client; subsequent use (c.httpClient.Do) would panic. Either guard against nil (return error or restore default client) or document that nil is invalid.

**[MINOR]** SetHTTPClient allows setting a nil *http.Client; subsequent use (c.httpClient.Do) would panic. Either guard against nil (return error or restore default client) or document that nil is invalid.
func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
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.
}
Outdated
Review

[MINOR] Authorization header is always set to "Bearer "+token even when token is empty. Consider only setting the header when token is non-empty to avoid sending an empty bearer token on unauthenticated requests.

**[MINOR]** Authorization header is always set to "Bearer "+token even when token is empty. Consider only setting the header when token is non-empty to avoid sending an empty bearer token on unauthenticated requests.
Outdated
Review

[NIT] GitHub classic PATs typically use the "token" scheme while fine-grained tokens use "Bearer". If supporting both is desired, consider documenting or adapting the auth scheme based on token type.

**[NIT]** GitHub classic PATs typically use the "token" scheme while fine-grained tokens use "Bearer". If supporting both is desired, consider documenting or adapting the auth scheme based on token type.
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.
// Guard: net/http guarantees len(via) >= 1 but this is undocumented;
// defend against zero-length to avoid panic on index out of range.
Outdated
Review

[NIT] The concurrency safety doc comment says "SetHTTPClient and SetRetryBackoff must not be called concurrently with requests" but these are exported methods with no enforcement (no mutex, no atomic). This is an acceptable design for a test-support method, but the comment could be stronger: e.g., "These methods are intended for test setup only and must be called before any goroutines issue requests."

**[NIT]** The concurrency safety doc comment says "SetHTTPClient and SetRetryBackoff must not be called concurrently with requests" but these are exported methods with no enforcement (no mutex, no atomic). This is an acceptable design for a test-support method, but the comment could be stronger: e.g., "These methods are intended for test setup only and must be called before any goroutines issue requests."
if len(via) == 0 {
return nil
}
Outdated
Review

[NIT] Parameter name "url" in doRequest shadows the common concept of URLs and can reduce readability given other files import net/url. Consider renaming the parameter to reqURL for clarity.

**[NIT]** Parameter name "url" in doRequest shadows the common concept of URLs and can reduce readability given other files import net/url. Consider renaming the parameter to reqURL for clarity.
Outdated
Review

[MINOR] Redirects to different hosts or to HTTP are allowed (Authorization is stripped), which can lead to consuming responses from untrusted or downgraded endpoints. While token leakage is prevented, consider rejecting cross-host redirects and HTTPS→HTTP downgrades entirely to avoid integrity/confidentiality risks.

**[MINOR]** Redirects to different hosts or to HTTP are allowed (Authorization is stripped), which can lead to consuming responses from untrusted or downgraded endpoints. While token leakage is prevented, consider rejecting cross-host redirects and HTTPS→HTTP downgrades entirely to avoid integrity/confidentiality risks.
prev := via[len(via)-1]
// Reject protocol downgrade: HTTPS→HTTP leaks request metadata over plaintext.
if prev.URL.Scheme == "https" && req.URL.Scheme == "http" {
return fmt.Errorf("refusing redirect from HTTPS to HTTP (%s → %s)", prev.URL.Host, req.URL.Host)
}
Outdated
Review

[MINOR] SetHTTPClient(nil) restores a basic http.Client with only Timeout set and drops the custom CheckRedirect policy configured in NewClient. This diverges from the initial default behavior and could unintentionally allow auth headers on redirects. Either preserve the CheckRedirect behavior when resetting or adjust the comment to clarify the change.

**[MINOR]** SetHTTPClient(nil) restores a basic http.Client with only Timeout set and drops the custom CheckRedirect policy configured in NewClient. This diverges from the initial default behavior and could unintentionally allow auth headers on redirects. Either preserve the CheckRedirect behavior when resetting or adjust the comment to clarify the change.
Outdated
Review

[MINOR] SetHTTPClient accepts an arbitrary *http.Client and does not enforce the safe redirect policy. If a caller supplies a client without a CheckRedirect that strips Authorization on cross-host or downgrade redirects, credentials could leak during redirects.

**[MINOR]** SetHTTPClient accepts an arbitrary *http.Client and does not enforce the safe redirect policy. If a caller supplies a client without a CheckRedirect that strips Authorization on cross-host or downgrade redirects, credentials could leak during redirects.
// Strip Authorization on cross-host redirect to avoid leaking credentials
// to third-party hosts (GitHub legitimately redirects to CDN hosts).
if req.URL.Host != prev.URL.Host {
req.Header.Del("Authorization")
Outdated
Review

[MINOR] The Retry-After override mutates the backoff slice in-place. When c.RetryBackoff is non-nil (set by tests or callers), backoff is a reference to the same slice. This means a Retry-After header on attempt 0 will permanently modify c.RetryBackoff[0] on the caller's struct, which is surprising shared-state mutation. Either copy the slice at the start of doRequest, or use a local variable for the overridden delay rather than mutating the slice.

**[MINOR]** The Retry-After override mutates the `backoff` slice in-place. When `c.RetryBackoff` is non-nil (set by tests or callers), `backoff` is a reference to the same slice. This means a Retry-After header on attempt 0 will permanently modify `c.RetryBackoff[0]` on the caller's struct, which is surprising shared-state mutation. Either copy the slice at the start of doRequest, or use a local variable for the overridden delay rather than mutating the slice.
Outdated
Review

[MINOR] Successful responses are read with io.ReadAll without a size limit. If the configured baseURL responds with a very large body, this could lead to memory exhaustion (DoS). Consider bounding successful response sizes or streaming with explicit limits.

**[MINOR]** Successful responses are read with io.ReadAll without a size limit. If the configured baseURL responds with a very large body, this could lead to memory exhaustion (DoS). Consider bounding successful response sizes or streaming with explicit limits.
}
Outdated
Review

[NIT] The doRequest parameter is named 'url', which can be confusing since 'url' is also a common package import name. Consider renaming to 'reqURL' for clarity and to avoid future shadowing if net/url is imported.

**[NIT]** The doRequest parameter is named 'url', which can be confusing since 'url' is also a common package import name. Consider renaming to 'reqURL' for clarity and to avoid future shadowing if net/url is imported.
return nil
}
Outdated
Review

[NIT] Calling timer.Stop() after the timer has already fired is unnecessary. You could simplify the delay logic with select { case <-time.After(delay): case <-ctx.Done(): } or only Stop in the ctx.Done branch.

**[NIT]** Calling timer.Stop() after the timer has already fired is unnecessary. You could simplify the delay logic with select { case <-time.After(delay): case <-ctx.Done(): } or only Stop in the ctx.Done branch.
// NewClient creates a new GitHub API client.
// If baseURL is empty, it defaults to https://api.github.com.
Outdated
Review

[MINOR] Retry-After is parsed only as delta-seconds; per RFC 7231 it may also be an HTTP-date. Consider falling back to parsing an HTTP-date when Atoi fails to honor server guidance more robustly.

**[MINOR]** Retry-After is parsed only as delta-seconds; per RFC 7231 it may also be an HTTP-date. Consider falling back to parsing an HTTP-date when Atoi fails to honor server guidance more robustly.
// For GitHub Enterprise, pass the API base URL (e.g. https://github.concur.com/api/v3).
Outdated
Review

[MINOR] Non-HTTPS rejection checks strings.HasPrefix(url, "https://"). This is case-sensitive and string-based; safer to parse with net/url and check URL.Scheme case-insensitively (e.g., u.Scheme == "https").

**[MINOR]** Non-HTTPS rejection checks strings.HasPrefix(url, "https://"). This is case-sensitive and string-based; safer to parse with net/url and check URL.Scheme case-insensitively (e.g., u.Scheme == "https").
// 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 == "" {
baseURL = defaultBaseURL
}
Outdated
Review

[NIT] The doRequest method signature uses url as a parameter name, which shadows the imported net/url package. While net/url is not imported in client.go (it's only in pr.go and files.go), this naming is a minor style concern since url as a local variable name is idiomatic but shadows the package name if ever imported. Not a bug here, just worth noting.

**[NIT]** The `doRequest` method signature uses `url` as a parameter name, which shadows the imported `net/url` package. While `net/url` is not imported in `client.go` (it's only in `pr.go` and `files.go`), this naming is a minor style concern since `url` as a local variable name is idiomatic but shadows the package name if ever imported. Not a bug here, just worth noting.
cfg := clientConfig{}
for _, o := range opts {
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.
o(&cfg)
Outdated
Review

[NIT] Authorization scheme is hardcoded to "Bearer". While modern GitHub tokens support this, some environments still use the "token" scheme. Consider documenting the expected token type or allowing the scheme to be configurable if needed.

**[NIT]** Authorization scheme is hardcoded to "Bearer". While modern GitHub tokens support this, some environments still use the "token" scheme. Consider documenting the expected token type or allowing the scheme to be configurable if needed.
}
return &Client{
Outdated
Review

[MINOR] Authorization header always uses the "Bearer" scheme. Some GitHub token types (classic PAT) historically use the "token" scheme. Consider making the auth scheme configurable or auto-detectable to maximize compatibility.

**[MINOR]** Authorization header always uses the "Bearer" scheme. Some GitHub token types (classic PAT) historically use the "token" scheme. Consider making the auth scheme configurable or auto-detectable to maximize compatibility.
baseURL: strings.TrimRight(baseURL, "/"),
Outdated
Review

[MINOR] Authorization header is sent to whatever baseURL is configured. If baseURL can be influenced by untrusted input, this could leak tokens to an attacker-controlled host (SSRF/token exfiltration). Ensure baseURL is treated as trusted configuration and consider allowlisting expected hosts at a higher layer.

**[MINOR]** Authorization header is sent to whatever baseURL is configured. If baseURL can be influenced by untrusted input, this could leak tokens to an attacker-controlled host (SSRF/token exfiltration). Ensure baseURL is treated as trusted configuration and consider allowlisting expected hosts at a higher layer.
allowInsecureHTTP: cfg.allowInsecureHTTP,
Outdated
Review

[MINOR] The CheckRedirect lambda is duplicated verbatim in both NewClient and SetHTTPClient(nil). Extract it to a package-level function (e.g., defaultCheckRedirect) to eliminate the duplication and ensure both code paths stay in sync when the policy changes.

**[MINOR]** The CheckRedirect lambda is duplicated verbatim in both NewClient and SetHTTPClient(nil). Extract it to a package-level function (e.g., `defaultCheckRedirect`) to eliminate the duplication and ensure both code paths stay in sync when the policy changes.
token: token,
Outdated
Review

[NIT] Retry-After parsing only handles delta-seconds via Atoi. RFC 7231 allows an HTTP-date format; optionally support parsing HTTP-date to fully respect server guidance.

**[NIT]** Retry-After parsing only handles delta-seconds via Atoi. RFC 7231 allows an HTTP-date format; optionally support parsing HTTP-date to fully respect server guidance.
httpClient: &http.Client{
Timeout: 30 * time.Second,
Outdated
Review

[MINOR] When c.http.Do(req) returns an error (network failure, context cancellation), the function returns immediately without retrying. For transient network errors, a retry could be valuable. The current behavior is reasonable for the stated scope (only retry on 429), but the comment says 'It respects the Retry-After header when present' without mentioning the no-retry-on-transport-error behavior. This is a documentation gap rather than a bug.

**[MINOR]** When `c.http.Do(req)` returns an error (network failure, context cancellation), the function returns immediately without retrying. For transient network errors, a retry could be valuable. The current behavior is reasonable for the stated scope (only retry on 429), but the comment says 'It respects the Retry-After header when present' without mentioning the no-retry-on-transport-error behavior. This is a documentation gap rather than a bug.
CheckRedirect: defaultCheckRedirect,
},
Outdated
Review

[MINOR] The security check if !c.allowInsecureHTTP && req.URL.Scheme != "https" is performed inside the retry loop, meaning it will fail on every retry attempt rather than being checked once before the loop starts. Since the URL doesn't change between retries, this is wasteful and the error message is slightly misleading (it mentions req.URL.Host but the real issue is the scheme). Moving the check before the retry loop or to NewClient would be cleaner.

**[MINOR]** The security check `if !c.allowInsecureHTTP && req.URL.Scheme != "https"` is performed inside the retry loop, meaning it will fail on every retry attempt rather than being checked once before the loop starts. Since the URL doesn't change between retries, this is wasteful and the error message is slightly misleading (it mentions `req.URL.Host` but the real issue is the scheme). Moving the check before the retry loop or to `NewClient` would be cleaner.
}
}
security-review-bot marked this conversation as resolved Outdated
Outdated
Review

[MINOR] Retry-After header is applied without an upper bound. If the API (or a malicious endpoint in a misconfigured environment) returns an excessively large value, the client may sleep for a very long time, enabling a denial-of-service style delay. Consider capping the duration to a sane maximum.

**[MINOR]** Retry-After header is applied without an upper bound. If the API (or a malicious endpoint in a misconfigured environment) returns an excessively large value, the client may sleep for a very long time, enabling a denial-of-service style delay. Consider capping the duration to a sane maximum.
Outdated
Review

[MINOR] The timer in the retry loop is not stopped in the happy path (when <-timer.C fires). After time.NewTimer fires via <-timer.C, the timer is already expired and GC will collect it, but the idiomatic Go pattern is to always call timer.Stop() after a select to be safe and clear. Pattern from concurrency.md: prefer explicit resource cleanup. Low risk here since the timer has already fired, but idiomatic code calls Stop() regardless.

**[MINOR]** The timer in the retry loop is not stopped in the happy path (when `<-timer.C` fires). After `time.NewTimer` fires via `<-timer.C`, the timer is already expired and GC will collect it, but the idiomatic Go pattern is to always call `timer.Stop()` after a select to be safe and clear. Pattern from concurrency.md: prefer explicit resource cleanup. Low risk here since the timer has already fired, but idiomatic code calls Stop() regardless.
// SetHTTPClient sets the underlying HTTP client used for requests.
// This is intended for test setup only to inject mock transports; it must be
security-review-bot marked this conversation as resolved Outdated
Outdated
Review

[MINOR] doRequest mutates the shared RetryBackoff slice based on Retry-After. Because Client is used concurrently, this shared-state mutation can cause a data race and persistently alter backoff behavior across goroutines. A malicious or misconfigured upstream could set very large Retry-After values, degrading performance for unrelated requests (potential DoS).

**[MINOR]** doRequest mutates the shared RetryBackoff slice based on Retry-After. Because Client is used concurrently, this shared-state mutation can cause a data race and persistently alter backoff behavior across goroutines. A malicious or misconfigured upstream could set very large Retry-After values, degrading performance for unrelated requests (potential DoS).
// called before any goroutines issue requests.
Outdated
Review

[MINOR] After the timer fires, timer.Stop() is called redundantly. When <-timer.C succeeds, the timer has already expired and Stop() returns false with no effect. It's harmless but misleading — the pattern from the Go docs is to call Stop() only in the cancellation case. Consider removing the redundant timer.Stop() on the success branch, or document why it's there.

**[MINOR]** After the timer fires, `timer.Stop()` is called redundantly. When `<-timer.C` succeeds, the timer has already expired and `Stop()` returns false with no effect. It's harmless but misleading — the pattern from the Go docs is to call Stop() only in the cancellation case. Consider removing the redundant `timer.Stop()` on the success branch, or document why it's there.
//
Outdated
Review

[MAJOR] Authorization header uses the "Bearer" scheme unconditionally (req.Header.Set("Authorization", "Bearer "+c.token)). GitHub REST v3 typically expects "token " for personal access tokens; using Bearer may cause 401s. Consider using "token" by default or making the scheme configurable.

**[MAJOR]** Authorization header uses the "Bearer" scheme unconditionally (req.Header.Set("Authorization", "Bearer "+c.token)). GitHub REST v3 typically expects "token <PAT>" for personal access tokens; using Bearer may cause 401s. Consider using "token" by default or making the scheme configurable.
// Passing nil restores the default client (30s timeout + auth-stripping
Outdated
Review

[MINOR] The Retry-After handling mutates the backoff slice in-place: backoff[attempt] = time.Duration(seconds) * time.Second. When c.RetryBackoff is non-nil (e.g. in tests), this modifies the caller's slice, which is surprising and could cause test pollution if the same slice is reused. A local copy should be made before mutation, or the mutation should only apply to the local backoff variable (which it does when RetryBackoff is nil since a new slice is allocated, but not when it's non-nil).

**[MINOR]** The Retry-After handling mutates the `backoff` slice in-place: `backoff[attempt] = time.Duration(seconds) * time.Second`. When `c.RetryBackoff` is non-nil (e.g. in tests), this modifies the caller's slice, which is surprising and could cause test pollution if the same slice is reused. A local copy should be made before mutation, or the mutation should only apply to the local `backoff` variable (which it does when `RetryBackoff` is nil since a new slice is allocated, but not when it's non-nil).
// 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.
Outdated
Review

[MINOR] After a successful response is read, resp.Body.Close() is called directly after io.ReadAll. If io.ReadAll returns an error (e.g. partial read), the body is still closed via the subsequent line, which is fine. However, the pattern is slightly inconsistent with the error path below it — consider using defer resp.Body.Close() paired with a drain before close on the error path for symmetry. This is purely stylistic; the current approach is correct.

**[MINOR]** After a successful response is read, `resp.Body.Close()` is called directly after `io.ReadAll`. If `io.ReadAll` returns an error (e.g. partial read), the body is still closed via the subsequent line, which is fine. However, the pattern is slightly inconsistent with the error path below it — consider using `defer resp.Body.Close()` paired with a drain before close on the error path for symmetry. This is purely stylistic; the current approach is correct.
func (c *Client) SetHTTPClient(hc *http.Client) {
if hc == nil {
Outdated
Review

[NIT] Authorization header uses the 'Bearer' scheme. GitHub PATs typically use the 'token' scheme; while 'Bearer' may work for some token types, consider aligning with GitHub’s documented scheme or making it configurable.

**[NIT]** Authorization header uses the 'Bearer' scheme. GitHub PATs typically use the 'token' scheme; while 'Bearer' may work for some token types, consider aligning with GitHub’s documented scheme or making it configurable.
hc = &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: defaultCheckRedirect,
Outdated
Review

[MINOR] The comment // Timer already fired; Stop() is a no-op here. is misleading. time.NewTimer creates a timer that fires after the duration; when the select case <-timer.C fires, the timer has already expired so Stop() does return false, but the comment conflates 'fired' with 'Stop is no-op'. More importantly, there's no defer timer.Stop() before the select — while technically fine here because the goroutine either drains the channel or returns, the pattern is fragile and deviates from the canonical defer timer.Stop() idiom. The standard pattern is: timer := time.NewTimer(delay); defer timer.Stop(); select { case <-timer.C: ... case <-ctx.Done(): ... }. The current code avoids the leak correctly but via an atypical path.

**[MINOR]** The comment `// Timer already fired; Stop() is a no-op here.` is misleading. `time.NewTimer` creates a timer that fires after the duration; when the select case `<-timer.C` fires, the timer has already expired so `Stop()` does return false, but the comment conflates 'fired' with 'Stop is no-op'. More importantly, there's no `defer timer.Stop()` before the select — while technically fine here because the goroutine either drains the channel or returns, the pattern is fragile and deviates from the canonical `defer timer.Stop()` idiom. The standard pattern is: `timer := time.NewTimer(delay); defer timer.Stop(); select { case <-timer.C: ... case <-ctx.Done(): ... }`. The current code avoids the leak correctly but via an atypical path.
}
}
c.httpClient = hc
Outdated
Review

[MINOR] Successful responses are truncated to maxResponseBytes without signaling truncation. If truncation is possible for some endpoints (e.g., very large diffs), consider documenting this behavior or returning an explicit error when the limit is reached.

**[MINOR]** Successful responses are truncated to maxResponseBytes without signaling truncation. If truncation is possible for some endpoints (e.g., very large diffs), consider documenting this behavior or returning an explicit error when the limit is reached.
}
Outdated
Review

[MINOR] Retry-After parsing only supports integer seconds; per RFC 7231, servers may send an HTTP-date. Consider supporting both seconds and HTTP-date formats for broader interoperability.

**[MINOR]** Retry-After parsing only supports integer seconds; per RFC 7231, servers may send an HTTP-date. Consider supporting both seconds and HTTP-date formats for broader interoperability.
// SetRetryBackoff configures the retry backoff durations for testing.
Outdated
Review

[NIT] Retry-After parsing only supports integer seconds. HTTP allows Retry-After to be an HTTP-date. Consider also parsing date format to be more standards-compliant.

**[NIT]** Retry-After parsing only supports integer seconds. HTTP allows Retry-After to be an HTTP-date. Consider also parsing date format to be more standards-compliant.
// 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
}
Outdated
Review

[NIT] The timer in the retry loop has a comment // Timer already fired; Stop() is a no-op here. — this is slightly inaccurate. Stop() on an already-fired timer is not a no-op; it returns false and the channel has already been drained by the case <-timer.C: branch, so there's no leak. The comment could be clearer: // Channel already drained; no need to call Stop().

**[NIT]** The timer in the retry loop has a comment `// Timer already fired; Stop() is a no-op here.` — this is slightly inaccurate. `Stop()` on an already-fired timer is not a no-op; it returns false and the channel has already been drained by the `case <-timer.C:` branch, so there's no leak. The comment could be clearer: `// Channel already drained; no need to call Stop().`
// doRequest performs an HTTP request with retry on 429 rate limit responses.
// It respects the Retry-After header when present (capped at maxRetryAfter).
// Transport errors (network failures, context cancellation) are not retried.
Outdated
Review

[MINOR] Authorization header uses the "Bearer" scheme unconditionally. GitHub REST v3 commonly expects the "token" scheme for classic/fine-grained PATs, while "Bearer" is used in some flows (e.g., OAuth or app tokens). Consider supporting both schemes or making the scheme configurable to maximize compatibility.

**[MINOR]** Authorization header uses the "Bearer" scheme unconditionally. GitHub REST v3 commonly expects the "token" scheme for classic/fine-grained PATs, while "Bearer" is used in some flows (e.g., OAuth or app tokens). Consider supporting both schemes or making the scheme configurable to maximize compatibility.
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
const maxAttempts = 3
const maxRetryAfter = 120 * time.Second
var backoff []time.Duration
Outdated
Review

[MINOR] Retry-After parsing only supports integer seconds. The HTTP spec allows an HTTP-date as well; consider parsing both forms to be more robust (fallback to date parsing when Atoi fails).

**[MINOR]** Retry-After parsing only supports integer seconds. The HTTP spec allows an HTTP-date as well; consider parsing both forms to be more robust (fallback to date parsing when Atoi fails).
if c.retryBackoff != nil {
backoff = make([]time.Duration, len(c.retryBackoff))
copy(backoff, c.retryBackoff)
Outdated
Review

[MINOR] The timer drain comment '// Timer already fired; Stop() is a no-op here.' is slightly misleading. When the timer fires and we receive from timer.C, Stop() returns false but calling it is still harmless — however the comment may cause confusion since we never call Stop() in this branch. More importantly, after receiving from timer.C, Stop() genuinely IS a no-op (the timer has already fired), but the code never calls Stop() here at all, which is correct. The comment is about something that isn't happening. This is fine but the comment could be clearer or removed.

**[MINOR]** The timer drain comment '// Timer already fired; Stop() is a no-op here.' is slightly misleading. When the timer fires and we receive from `timer.C`, `Stop()` returns false but calling it is still harmless — however the comment may cause confusion since we never call Stop() in this branch. More importantly, after receiving from `timer.C`, Stop() genuinely IS a no-op (the timer has already fired), but the code never calls Stop() here at all, which is correct. The comment is about something that isn't happening. This is fine but the comment could be clearer or removed.
Review

[NIT] The doRequest method signature uses a positional accept string parameter rather than a functional option or an options struct. For internal use only (called from doGet and GetPullRequestDiff) this is fine, but if the API surface grows (e.g. needing custom headers), this approach will require signature changes. No action needed now, but a comment noting it's intentionally internal would be helpful.

**[NIT]** The `doRequest` method signature uses a positional `accept string` parameter rather than a functional option or an options struct. For internal use only (called from `doGet` and `GetPullRequestDiff`) this is fine, but if the API surface grows (e.g. needing custom headers), this approach will require signature changes. No action needed now, but a comment noting it's intentionally internal would be helpful.
} else {
backoff = []time.Duration{1 * time.Second, 2 * time.Second}
}
Outdated
Review

[NIT] The doRequest method has accept string as the last parameter. Per Go idiom (style.md receiver naming / api-conventions), named parameters for Accept headers are fine, but the parameter name accept conflicts with the common Go pattern of using accept only in request context. Not a correctness issue — purely style.

**[NIT]** The `doRequest` method has `accept string` as the last parameter. Per Go idiom (style.md receiver naming / api-conventions), named parameters for Accept headers are fine, but the parameter name `accept` conflicts with the common Go pattern of using `accept` only in request context. Not a correctness issue — purely style.
// 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 {
Outdated
Review

[MINOR] The response body size check uses >= instead of >, so a response of exactly maxResponseBytes (10 MiB) will return an error even though it was fully read and not truncated. The io.LimitReader reads at most N bytes, so len(body) == maxResponseBytes means the response may have been truncated — the intent seems correct, but the error message says 'exceeded' which is misleading for an exact match. This is consistent with the test (maxResponseBytes+1024), but the boundary condition is ambiguous. Consider using > maxResponseBytes - 1 or reading one extra byte as a sentinel to confirm truncation actually occurred.

**[MINOR]** The response body size check uses `>=` instead of `>`, so a response of exactly `maxResponseBytes` (10 MiB) will return an error even though it was fully read and not truncated. The `io.LimitReader` reads at most N bytes, so `len(body) == maxResponseBytes` means the response *may* have been truncated — the intent seems correct, but the error message says 'exceeded' which is misleading for an exact match. This is consistent with the test (`maxResponseBytes+1024`), but the boundary condition is ambiguous. Consider using `> maxResponseBytes - 1` or reading one extra byte as a sentinel to confirm truncation actually occurred.
parsed, err := url.Parse(reqURL)
if err != nil {
return nil, fmt.Errorf("parse request URL: %w", err)
}
if !strings.EqualFold(parsed.Scheme, "https") {
return nil, fmt.Errorf("refusing to send credentials over non-HTTPS URL %q (use AllowInsecureHTTP option for trusted networks)", reqURL)
}
}
Outdated
Review

[NIT] Calling timer.Stop() after the timer has already fired is unnecessary. It’s harmless, but can be removed for clarity; only the ctx.Done() path needs Stop before return.

**[NIT]** Calling timer.Stop() after the timer has already fired is unnecessary. It’s harmless, but can be removed for clarity; only the ctx.Done() path needs Stop before return.
var lastErr error
Outdated
Review

[MINOR] Timer leak on the happy path of the retry loop. When timer.C fires normally (case <-timer.C), timer.Stop() is never called. The timer has already fired so this is not a resource leak in the traditional sense, but idiomatic Go calls timer.Stop() on all branches to release the internal runtime resources promptly. The fix: case <-timer.C: timer.Stop() or use defer timer.Stop() before the select.

**[MINOR]** Timer leak on the happy path of the retry loop. When `timer.C` fires normally (case `<-timer.C`), `timer.Stop()` is never called. The timer has already fired so this is not a resource leak in the traditional sense, but idiomatic Go calls `timer.Stop()` on all branches to release the internal runtime resources promptly. The fix: `case <-timer.C: timer.Stop()` or use `defer timer.Stop()` before the select.
for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 {
var delay time.Duration
Review

[NIT] The timer pattern timer.Stop() // no-op after fire; kept for symmetry in the case <-timer.C branch is slightly misleading. Stop() after a channel receive is indeed a no-op since the channel has already fired, but the comment could be clearer: timer.Stop() after <-timer.C does nothing and could simply be omitted. This is a readability nit, not a correctness issue.

**[NIT]** The timer pattern `timer.Stop() // no-op after fire; kept for symmetry` in the `case <-timer.C` branch is slightly misleading. `Stop()` after a channel receive is indeed a no-op since the channel has already fired, but the comment could be clearer: `timer.Stop()` after `<-timer.C` does nothing and could simply be omitted. This is a readability nit, not a correctness issue.
if attempt-1 < len(backoff) {
Review

[MINOR] APIError stores up to 64KB of error body in the Body field. While Error() truncates to 200 bytes, exposing the full Body increases the risk of sensitive data leakage if callers log or propagate it. Consider further limiting or redacting Body contents.

**[MINOR]** APIError stores up to 64KB of error body in the Body field. While Error() truncates to 200 bytes, exposing the full Body increases the risk of sensitive data leakage if callers log or propagate it. Consider further limiting or redacting Body contents.
delay = backoff[attempt-1]
}
Outdated
Review

[MINOR] Retry-After parsing handles only integer seconds. Per RFC 7231, Retry-After may be an HTTP-date as well. Supporting HTTP-date parsing would improve compliance with servers that return a date rather than seconds.

**[MINOR]** Retry-After parsing handles only integer seconds. Per RFC 7231, Retry-After may be an HTTP-date as well. Supporting HTTP-date parsing would improve compliance with servers that return a date rather than seconds.
if delay > 0 {
timer := time.NewTimer(delay)
select {
Outdated
Review

[MINOR] The timer leak on the happy path is benign but slightly untidy. When <-timer.C fires, timer.Stop() returns false and the channel is already drained, so it's a no-op — the comment acknowledges this. Consider using timer.Reset pattern or noting it more explicitly, but this is purely cosmetic.

**[MINOR]** The timer leak on the happy path is benign but slightly untidy. When `<-timer.C` fires, `timer.Stop()` returns false and the channel is already drained, so it's a no-op — the comment acknowledges this. Consider using `timer.Reset` pattern or noting it more explicitly, but this is purely cosmetic.
case <-timer.C:
timer.Stop() // no-op after fire; kept for symmetry with the ctx.Done case
Outdated
Review

[MINOR] The response body limit check uses >= instead of >. io.LimitReader reads at most maxResponseBytes bytes, so len(body) == maxResponseBytes could mean the response is exactly 10 MiB (not truncated) or it was truncated at the limit. Using > is impossible here since LimitReader caps at the limit. The check should be == maxResponseBytes to be semantically correct, or the limit should be maxResponseBytes+1 to distinguish exactly-limit from truncated. The current code will error on exactly 10 MiB legitimate responses.

**[MINOR]** The response body limit check uses `>=` instead of `>`. `io.LimitReader` reads at most `maxResponseBytes` bytes, so `len(body) == maxResponseBytes` could mean the response is exactly 10 MiB (not truncated) or it was truncated at the limit. Using `>` is impossible here since `LimitReader` caps at the limit. The check should be `== maxResponseBytes` to be semantically correct, or the limit should be `maxResponseBytes+1` to distinguish exactly-limit from truncated. The current code will error on exactly 10 MiB legitimate responses.
case <-ctx.Done():
timer.Stop()
Review

[MINOR] After resp.Body.Close() is called for a successful response, if io.ReadAll returns an error, the body is already closed which is correct, but the success path reads up to maxResponseBytes+1 bytes via io.LimitReader. This is the intentional over-read trick to detect exceeding the limit, which is correct. However, on the error path for non-2xx responses (line 245), errBody, _ := io.ReadAll(...) silently discards the read error. While acceptable in practice since it's for error body capture, the convention in this codebase is to check all error returns. This is minor since the worst case is an empty error body.

**[MINOR]** After `resp.Body.Close()` is called for a successful response, if `io.ReadAll` returns an error, the body is already closed which is correct, but the success path reads up to `maxResponseBytes+1` bytes via `io.LimitReader`. This is the intentional over-read trick to detect exceeding the limit, which is correct. However, on the error path for non-2xx responses (line 245), `errBody, _ := io.ReadAll(...)` silently discards the read error. While acceptable in practice since it's for error body capture, the convention in this codebase is to check all error returns. This is minor since the worst case is an empty error body.
return nil, ctx.Err()
}
}
Review

[MINOR] After a successful response read, resp.Body.Close() is called after io.ReadAll but not in a defer. If the code path between resp.Body.Close() and the size check were to grow (e.g., someone adds an early return), the body could leak. The pattern defer resp.Body.Close() immediately after c.httpClient.Do(req) is the idiomatic Go pattern per the style patterns (defer for resource cleanup). Currently safe but brittle.

**[MINOR]** After a successful response read, `resp.Body.Close()` is called after `io.ReadAll` but not in a `defer`. If the code path between `resp.Body.Close()` and the size check were to grow (e.g., someone adds an early return), the body could leak. The pattern `defer resp.Body.Close()` immediately after `c.httpClient.Do(req)` is the idiomatic Go pattern per the style patterns (defer for resource cleanup). Currently safe but brittle.
}
Outdated
Review

[MINOR] The shadow variable t on } else if t, err := http.ParseTime(ra); err == nil { shadows the outer t parameter name from the function signature — but this function has no t parameter, so there is no actual shadowing issue here. However, using t as a local variable for a time.Time is confusing since t conventionally means *testing.T in Go. Consider renaming to parsedTime or retryAt for clarity.

**[MINOR]** The shadow variable `t` on `} else if t, err := http.ParseTime(ra); err == nil {` shadows the outer `t` parameter name from the function signature — but this function has no `t` parameter, so there is no actual shadowing issue here. However, using `t` as a local variable for a `time.Time` is confusing since `t` conventionally means `*testing.T` in Go. Consider renaming to `parsedTime` or `retryAt` for clarity.
req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)
Review

[MINOR] After c.httpClient.Do(req) returns an error, the function returns immediately with a wrapped transport error. However, the body is not closed because resp is nil on transport error — this is correct, but deserves a comment for future readers since handleResponse uses defer for body close, it's not obvious why there's no defer here.

**[MINOR]** After `c.httpClient.Do(req)` returns an error, the function returns immediately with a wrapped transport error. However, the body is not closed because `resp` is nil on transport error — this is correct, but deserves a comment for future readers since `handleResponse` uses defer for body close, it's not obvious why there's no defer here.
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
if c.token != "" {
// Bearer is the OAuth2 standard and is accepted by GitHub for both
// classic PATs and fine-grained tokens. The alternative "token" scheme
// is GitHub-specific and offers no additional compatibility.
Review

[MINOR] The error body path also calls resp.Body.Close() explicitly rather than via defer. Same concern: if a future edit inserts an early return between reading the body and closing it, the body leaks. Structuring this as defer resp.Body.Close() right after Do() would be more robust. The current approach requires correctly handling both success and error paths manually.

**[MINOR]** The error body path also calls `resp.Body.Close()` explicitly rather than via defer. Same concern: if a future edit inserts an early return between reading the body and closing it, the body leaks. Structuring this as `defer resp.Body.Close()` right after `Do()` would be more robust. The current approach requires correctly handling both success and error paths manually.
req.Header.Set("Authorization", "Bearer "+c.token)
}
req.Header.Set("User-Agent", userAgent)
Review

[MINOR] The timer.Stop() call after <-timer.C is a no-op (timer already fired) and the comment acknowledges this. While harmless, it's slightly misleading. More importantly, timer.Stop() is not called on the ctx.Done() path before returning — it is called, which is correct. The no-op case is fine to keep for symmetry but the comment 'releases runtime resources promptly' is inaccurate since the timer has already fired and its resources are already released.

**[MINOR]** The `timer.Stop()` call after `<-timer.C` is a no-op (timer already fired) and the comment acknowledges this. While harmless, it's slightly misleading. More importantly, `timer.Stop()` is not called on the `ctx.Done()` path before returning — it is called, which is correct. The no-op case is fine to keep for symmetry but the comment 'releases runtime resources promptly' is inaccurate since the timer has already fired and its resources are already released.
if accept != "" {
Review

[MINOR] The handleResponse method is defined on *Client but uses no client state (c receiver is unused). This could be a package-level function. Minor since it doesn't affect correctness, but slightly misleading about the dependency on client state.

**[MINOR]** The `handleResponse` method is defined on `*Client` but uses no client state (`c` receiver is unused). This could be a package-level function. Minor since it doesn't affect correctness, but slightly misleading about the dependency on client state.
req.Header.Set("Accept", accept)
} else {
req.Header.Set("Accept", "application/vnd.github+json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
body, done, err := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
if done {
Outdated
Review

[MINOR] The int64 cast in if int64(len(body)) >= maxResponseBytes is unnecessary since maxResponseBytes is an untyped constant and len() returns int. Both sides of the comparison are int-typed. The cast is harmless but adds noise.

**[MINOR]** The `int64` cast in `if int64(len(body)) >= maxResponseBytes` is unnecessary since `maxResponseBytes` is an untyped constant and `len()` returns `int`. Both sides of the comparison are `int`-typed. The cast is harmless but adds noise.
return body, err
}
lastErr = err
Review

[NIT] In doRequest, after the handleResponse call, there's a check if resp.StatusCode == http.StatusTooManyRequests but resp could theoretically have been closed by handleResponse. The body is closed, but the resp.StatusCode field is still accessible on the struct. This is correct and safe in Go's net/http — just worth being aware of.

**[NIT]** In `doRequest`, after the `handleResponse` call, there's a check `if resp.StatusCode == http.StatusTooManyRequests` but `resp` could theoretically have been closed by `handleResponse`. The body is closed, but the `resp.StatusCode` field is still accessible on the struct. This is correct and safe in Go's net/http — just worth being aware of.
// Retry on 429 rate limit
if resp.StatusCode == http.StatusTooManyRequests && attempt < maxAttempts-1 {
// Check for Retry-After header and override backoff if present.
// Supports both integer seconds (common) and HTTP-date format (RFC 7231).
if ra := resp.Header.Get("Retry-After"); ra != "" {
if seconds, err := strconv.Atoi(ra); err == nil && seconds > 0 {
delay := time.Duration(seconds) * time.Second
if delay > maxRetryAfter {
delay = maxRetryAfter
}
if attempt < len(backoff) {
backoff[attempt] = delay
}
} else if retryAt, err := http.ParseTime(ra); err == nil {
delay := time.Until(retryAt)
if delay < 0 {
delay = 0
}
if delay > maxRetryAfter {
delay = maxRetryAfter
}
if attempt < len(backoff) {
backoff[attempt] = delay
}
}
}
continue
}
// Don't retry other errors
return nil, lastErr
}
return nil, lastErr
}
// handleResponse reads and closes the response body, returning the result.
// It uses defer to ensure the body is always closed regardless of code path.
// Returns (body, done, err) where done=true means the caller should return immediately.
func (c *Client) handleResponse(resp *http.Response, maxRespBytes int, maxErrBytes int) ([]byte, bool, error) {
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxRespBytes)+1))
if err != nil {
return nil, true, fmt.Errorf("read response body: %w", err)
}
if len(body) > maxRespBytes {
return nil, true, fmt.Errorf("response body exceeded %d bytes (truncated)", maxRespBytes)
}
return body, true, nil
}
errBody, readErr := io.ReadAll(io.LimitReader(resp.Body, int64(maxErrBytes)))
if readErr != nil && len(errBody) == 0 {
errBody = []byte(fmt.Sprintf("[error reading response body: %v]", readErr))
}
return nil, false, &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
}
// doGet is a convenience wrapper for GET requests with the default Accept header.
func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
return c.doRequest(ctx, http.MethodGet, reqURL, "")
}
+556
View File
@@ -0,0 +1,556 @@
package github
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestNewClient_DefaultBaseURL(t *testing.T) {
c := NewClient("test-token", "")
if c.baseURL != "https://api.github.com" {
t.Errorf("expected default base URL, got %q", c.baseURL)
}
}
func TestNewClient_CustomBaseURL(t *testing.T) {
c := NewClient("test-token", "https://github.concur.com/api/v3")
if c.baseURL != "https://github.concur.com/api/v3" {
t.Errorf("expected custom base URL, got %q", c.baseURL)
}
}
func TestNewClient_TrimsTrailingSlash(t *testing.T) {
c := NewClient("test-token", "https://github.concur.com/api/v3/")
if c.baseURL != "https://github.concur.com/api/v3" {
t.Errorf("expected trailing slash trimmed, got %q", c.baseURL)
}
}
func TestDoRequest_SetsAuthHeader(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(200)
w.Write([]byte("{}"))
}))
defer srv.Close()
c := NewClient("my-token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, _ = c.doGet(context.Background(), srv.URL+"/test")
if gotAuth != "Bearer my-token" {
t.Errorf("expected Bearer auth, got %q", gotAuth)
}
}
func TestDoRequest_SetsDefaultAcceptHeader(t *testing.T) {
var gotAccept string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAccept = r.Header.Get("Accept")
w.WriteHeader(200)
w.Write([]byte("{}"))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, _ = c.doGet(context.Background(), srv.URL+"/test")
if gotAccept != "application/vnd.github+json" {
t.Errorf("expected default Accept header, got %q", gotAccept)
}
}
func TestDoRequest_429Retry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{10 * time.Millisecond, 10 * time.Millisecond})
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)
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
}
func TestDoRequest_429ExhaustsRetries(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
_, err := c.doGet(context.Background(), srv.URL+"/test")
if err == nil {
t.Fatal("expected error after exhausting retries")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 429 {
t.Errorf("expected 429, got %d", apiErr.StatusCode)
}
if attempts != 3 {
t.Errorf("expected 3 attempts, got %d", attempts)
}
}
func TestDoRequest_404NoRetry(t *testing.T) {
attempts := 0
Review

[NIT] TestDoRequest_429ExhaustsRetries type-asserts err.(*APIError) directly rather than using errors.As. Since GetPullRequest and other methods wrap errors with fmt.Errorf("...: %w", err), but doGet/doRequest return the *APIError directly (not wrapped), the direct assertion works here. But it's inconsistent with the project's own IsNotFound/IsUnauthorized helpers which use errors.As. Test code should prefer errors.As for resilience.

**[NIT]** `TestDoRequest_429ExhaustsRetries` type-asserts `err.(*APIError)` directly rather than using `errors.As`. Since `GetPullRequest` and other methods wrap errors with `fmt.Errorf("...: %w", err)`, but `doGet`/`doRequest` return the `*APIError` directly (not wrapped), the direct assertion works here. But it's inconsistent with the project's own `IsNotFound`/`IsUnauthorized` helpers which use `errors.As`. Test code should prefer `errors.As` for resilience.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
w.WriteHeader(404)
w.Write([]byte(`{"message":"not found"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.doGet(context.Background(), srv.URL+"/test")
if err == nil {
t.Fatal("expected error for 404")
}
if attempts != 1 {
t.Errorf("expected 1 attempt (no retry on 404), got %d", attempts)
}
}
func TestDoRequest_401NoRetry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
w.WriteHeader(401)
w.Write([]byte(`{"message":"bad credentials"}`))
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()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.doGet(context.Background(), srv.URL+"/test")
if err == nil {
t.Fatal("expected error for 401")
}
if attempts != 1 {
t.Errorf("expected 1 attempt (no retry on 401), got %d", attempts)
}
}
Review

[NIT] TestDoRequest_429RetryAfterHeader waits 1 full second (Retry-After: 1) making it a slow test. The test comment acknowledges this, but consider using testing.Short() to skip it or documenting it as intentionally slow.

**[NIT]** TestDoRequest_429RetryAfterHeader waits 1 full second (Retry-After: 1) making it a slow test. The test comment acknowledges this, but consider using testing.Short() to skip it or documenting it as intentionally slow.
func TestIsNotFound(t *testing.T) {
err := &APIError{StatusCode: 404, Body: "not found"}
if !IsNotFound(err) {
t.Error("expected IsNotFound to return true for 404")
}
err2 := &APIError{StatusCode: 500, Body: "server error"}
if IsNotFound(err2) {
t.Error("expected IsNotFound to return false for 500")
}
}
func TestIsUnauthorized(t *testing.T) {
err := &APIError{StatusCode: 401, Body: "bad credentials"}
if !IsUnauthorized(err) {
t.Error("expected IsUnauthorized to return true for 401")
}
}
func TestAPIError_SanitizesNewlines(t *testing.T) {
err := &APIError{StatusCode: 500, Body: "line1\ninjected\rmore"}
msg := err.Error()
if strings.Contains(msg, "\n") || strings.Contains(msg, "\r") {
t.Errorf("expected newlines to be sanitized, got: %q", msg)
}
if !strings.Contains(msg, "line1 injected more") {
t.Errorf("expected sanitized body, got: %q", msg)
}
}
func TestDoRequest_429RetryAfterHeader(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow retry test in short mode")
}
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.Header().Set("Retry-After", "1")
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
// Use short backoff; Retry-After should override
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
start := time.Now()
body, err := c.doGet(context.Background(), srv.URL+"/test")
elapsed := time.Since(start)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != `{"ok":true}` {
Outdated
Review

[NIT] TestDoRequest_LimitsResponseBody allocates maxResponseBytes+1024 bytes (over 10 MiB) as a string for each test run. The comment acknowledges this but the fix isn't applied — consider using io.LimitedReader in the test handler or generating the body lazily to avoid the 10 MiB allocation in tests.

**[NIT]** `TestDoRequest_LimitsResponseBody` allocates `maxResponseBytes+1024` bytes (over 10 MiB) as a string for each test run. The comment acknowledges this but the fix isn't applied — consider using `io.LimitedReader` in the test handler or generating the body lazily to avoid the 10 MiB allocation in tests.
t.Errorf("unexpected body: %s", body)
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
// Retry-After: 1 means at least 1 second delay
if elapsed < 900*time.Millisecond {
t.Errorf("expected ~1s delay from Retry-After, got %v", elapsed)
}
}
func TestDoRequest_RetryAfterDoesNotMutateBackoff(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow retry test in short mode")
}
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.Header().Set("Retry-After", "1")
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
_, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the original retryBackoff slice was not mutated
if c.retryBackoff[0] != 1*time.Millisecond {
t.Errorf("retryBackoff[0] was mutated: got %v, want 1ms", c.retryBackoff[0])
}
if c.retryBackoff[1] != 1*time.Millisecond {
t.Errorf("retryBackoff[1] was mutated: got %v, want 1ms", c.retryBackoff[1])
}
}
func TestDoRequest_429RetryAfterHTTPDate(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow Retry-After HTTP-date test in short mode")
}
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
Outdated
Review

[NIT] TestDoRequest_LimitsResponseBody tests a constant value rather than actual behavior. The comment acknowledges this limitation. This is acceptable as a documentation-style test, but it adds no real safety guarantee — if maxResponseBytes is set correctly but the io.LimitReader call is removed, the test would still pass. Consider removing it or replacing with a test that actually sends a response exceeding the limit.

**[NIT]** `TestDoRequest_LimitsResponseBody` tests a constant value rather than actual behavior. The comment acknowledges this limitation. This is acceptable as a documentation-style test, but it adds no real safety guarantee — if `maxResponseBytes` is set correctly but the `io.LimitReader` call is removed, the test would still pass. Consider removing it or replacing with a test that actually sends a response exceeding the limit.
if attempts == 1 {
// Use HTTP-date format (RFC 7231) — a time 2 seconds in the future.
future := time.Now().Add(2 * time.Second).UTC()
w.Header().Set("Retry-After", future.Format(http.TimeFormat))
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}))
Outdated
Review

[NIT] TestDoRequest_LimitsResponseBody doesn't actually test the limit behavior — it only checks the constant value. The comment acknowledges this. This is a weak test; consider removing it or replacing it with an actual test using a small limit (e.g., setting maxResponseBytes to a small value via a test helper, or accepting that this particular boundary isn't testable without refactoring).

**[NIT]** `TestDoRequest_LimitsResponseBody` doesn't actually test the limit behavior — it only checks the constant value. The comment acknowledges this. This is a weak test; consider removing it or replacing it with an actual test using a small limit (e.g., setting `maxResponseBytes` to a small value via a test helper, or accepting that this particular boundary isn't testable without refactoring).
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
start := time.Now()
body, err := c.doGet(context.Background(), srv.URL+"/test")
elapsed := time.Since(start)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != `{"ok":true}` {
t.Errorf("unexpected body: %s", body)
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
// HTTP-date was ~2s in the future; by the time client processes it,
// time.Until gives ~1-2s. Verify it's meaningfully delayed (not instant).
if elapsed < 500*time.Millisecond {
t.Errorf("expected meaningful delay from HTTP-date Retry-After, got %v", elapsed)
}
}
func TestDoRequest_429RetryAfterHTTPDateInPast(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
// Use a time in the past — should result in zero/immediate retry.
past := time.Now().Add(-10 * time.Second).UTC()
w.Header().Set("Retry-After", past.Format(http.TimeFormat))
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{5 * time.Second, 5 * time.Second})
start := time.Now()
_, err := c.doGet(context.Background(), srv.URL+"/test")
elapsed := time.Since(start)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
// Past date should override the 5s backoff to ~0
if elapsed > 500*time.Millisecond {
t.Errorf("expected near-instant retry for past HTTP-date, got %v", elapsed)
}
}
func TestDoRequest_SetsUserAgentHeader(t *testing.T) {
var gotUA string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUA = r.Header.Get("User-Agent")
w.WriteHeader(200)
w.Write([]byte("{}"))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, _ = c.doGet(context.Background(), srv.URL+"/test")
if gotUA != "review-bot/1.0" {
t.Errorf("expected User-Agent 'review-bot/1.0', got %q", gotUA)
}
}
func TestDoRequest_LimitsResponseBody(t *testing.T) {
// Verify that oversized responses return an error rather than silently truncating.
bigBody := strings.Repeat("x", maxResponseBytes+1024)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(bigBody))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.doGet(context.Background(), srv.URL+"/test")
if err == nil {
t.Fatal("expected error for oversized response body")
}
if !strings.Contains(err.Error(), "exceeded") {
t.Errorf("expected truncation error, got: %v", err)
}
}
func TestDoRequest_AcceptsExactlyAtLimit(t *testing.T) {
// A response body exactly equal to maxResponseBytes should succeed (not error).
exactBody := strings.Repeat("x", maxResponseBytes)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(exactBody))
}))
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 for exactly-at-limit body: %v", err)
}
if len(body) != maxResponseBytes {
t.Errorf("expected body length %d, got %d", maxResponseBytes, len(body))
}
}
func TestDoRequest_SkipsAuthWhenTokenEmpty(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(200)
w.Write([]byte("{}"))
}))
defer srv.Close()
c := NewClient("", srv.URL, AllowInsecureHTTP()) // empty token
c.SetHTTPClient(srv.Client())
_, _ = c.doGet(context.Background(), srv.URL+"/test")
if gotAuth != "" {
t.Errorf("expected no Authorization header with empty token, got %q", gotAuth)
}
}
func TestNewClient_CheckRedirectStripsAuthOnCrossHost(t *testing.T) {
// Verify the CheckRedirect function is configured
c := NewClient("secret-token", "https://api.github.com")
if c.httpClient.CheckRedirect == nil {
t.Fatal("expected CheckRedirect to be set")
}
}
func TestDefaultCheckRedirect_RejectsHTTPSToHTTP(t *testing.T) {
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
req := &http.Request{
URL: &url.URL{Scheme: "http", Host: "api.github.com", Path: "/foo"},
Header: http.Header{"Authorization": []string{"Bearer token"}},
}
err := defaultCheckRedirect(req, []*http.Request{prev})
if err == nil {
t.Fatal("expected error on HTTPS→HTTP redirect")
}
if !strings.Contains(err.Error(), "refusing redirect from HTTPS to HTTP") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestDefaultCheckRedirect_StripsAuthOnCrossHost(t *testing.T) {
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
req := &http.Request{
URL: &url.URL{Scheme: "https", Host: "objects.githubusercontent.com", Path: "/bar"},
Header: http.Header{"Authorization": []string{"Bearer token"}},
}
err := defaultCheckRedirect(req, []*http.Request{prev})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if auth := req.Header.Get("Authorization"); auth != "" {
t.Errorf("expected Authorization header to be stripped, got %q", auth)
}
}
func TestDefaultCheckRedirect_PreservesAuthOnSameHost(t *testing.T) {
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
req := &http.Request{
URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/bar"},
Header: http.Header{"Authorization": []string{"Bearer token"}},
}
err := defaultCheckRedirect(req, []*http.Request{prev})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if auth := req.Header.Get("Authorization"); auth != "Bearer token" {
t.Errorf("expected Authorization to be preserved, got %q", auth)
}
}
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)
}
}
func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
c := NewClient("token", "https://api.github.com")
c.SetHTTPClient(nil)
if c.httpClient == nil {
t.Fatal("expected non-nil httpClient after SetHTTPClient(nil)")
}
if c.httpClient.Timeout != 30*time.Second {
t.Errorf("expected 30s timeout, got %v", c.httpClient.Timeout)
}
if c.httpClient.CheckRedirect == nil {
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
}
}
+13
View File
@@ -0,0 +1,13 @@
package github_test
import (
"gitea.weiker.me/rodin/review-bot/github"
"gitea.weiker.me/rodin/review-bot/vcs"
)
// Compile-time interface conformance assertions.
// These verify github.Client satisfies vcs.PRReader and vcs.FileReader.
var (
_ vcs.PRReader = (*github.Client)(nil)
_ vcs.FileReader = (*github.Client)(nil)
)
+135
View File
@@ -0,0 +1,135 @@
package github
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
"gitea.weiker.me/rodin/review-bot/vcs"
)
// GetFileContent fetches a file from a repo at the given ref.
// Delegates to GetFileContentAtRef with the provided ref.
Outdated
Review

[NIT] The GetFileContent signature accepts a ref parameter and delegates to GetFileContentAtRef, making GetFileContent essentially an alias. The doc comment says 'Fetches a file from the default branch' but it actually passes through whatever ref is given. If the interface requires GetFileContent to always use the default branch, the ref parameter shouldn't be accepted (or it should be ignored). If it accepts a ref, the doc comment is misleading.

**[NIT]** The `GetFileContent` signature accepts a `ref` parameter and delegates to `GetFileContentAtRef`, making `GetFileContent` essentially an alias. The doc comment says 'Fetches a file from the default branch' but it actually passes through whatever ref is given. If the interface requires `GetFileContent` to always use the default branch, the `ref` parameter shouldn't be accepted (or it should be ignored). If it accepts a ref, the doc comment is misleading.
Outdated
Review

[MINOR] Comment for GetFileContent is misleading: it states delegation with an empty ref, but the function accepts and forwards a ref parameter. Update the comment to reflect behavior (delegates to GetFileContentAtRef; default branch is used only when ref is empty).

**[MINOR]** Comment for GetFileContent is misleading: it states delegation with an empty ref, but the function accepts and forwards a ref parameter. Update the comment to reflect behavior (delegates to GetFileContentAtRef; default branch is used only when ref is empty).
func (c *Client) GetFileContent(ctx context.Context, owner, repo, path, ref string) (string, error) {
Review

[NIT] GetFileContent simply delegates to GetFileContentAtRef. The doc comment says 'Delegates to GetFileContentAtRef with the provided ref' which is accurate but redundant given that the implementation is a one-liner. The vcs.FileReader interface likely has a different signature than vcs.PRReader's method — this delegation is the right approach, but the doc comment could explain why this wrapper exists (to satisfy the FileReader interface which has a different signature from GetFileContentAtRef).

**[NIT]** `GetFileContent` simply delegates to `GetFileContentAtRef`. The doc comment says 'Delegates to GetFileContentAtRef with the provided ref' which is accurate but redundant given that the implementation is a one-liner. The `vcs.FileReader` interface likely has a different signature than `vcs.PRReader`'s method — this delegation is the right approach, but the doc comment could explain *why* this wrapper exists (to satisfy the FileReader interface which has a different signature from GetFileContentAtRef).
return c.GetFileContentAtRef(ctx, owner, repo, path, ref)
Review

[MINOR] GetFileContent's signature includes a ref parameter but the doc comment says 'Delegates to GetFileContentAtRef with an empty ref', which contradicts the actual implementation (it passes ref through). The doc comment is wrong and should say 'Delegates to GetFileContentAtRef with the provided ref'.

**[MINOR]** GetFileContent's signature includes a `ref` parameter but the doc comment says 'Delegates to GetFileContentAtRef with an empty ref', which contradicts the actual implementation (it passes `ref` through). The doc comment is wrong and should say 'Delegates to GetFileContentAtRef with the provided ref'.
Review

[MINOR] GetFileContent is defined in files.go but it's a pure delegation to GetFileContentAtRef which lives in pr.go. Both implement the FileReader interface, but splitting the delegation from the implementation across files is mildly confusing. Both could live in files.go or the delegation could be removed and GetFileContentAtRef used directly. Minor organization issue.

**[MINOR]** GetFileContent is defined in files.go but it's a pure delegation to GetFileContentAtRef which lives in pr.go. Both implement the FileReader interface, but splitting the delegation from the implementation across files is mildly confusing. Both could live in files.go or the delegation could be removed and GetFileContentAtRef used directly. Minor organization issue.
}
Review

[NIT] GetFileContent doc says 'Delegates to GetFileContentAtRef with the provided ref' — this is accurate but the function adds no value over calling GetFileContentAtRef directly. This is presumably required to satisfy a FileReader interface. If that's the case, a comment noting it satisfies the interface would help readers understand why this thin wrapper exists.

**[NIT]** `GetFileContent` doc says 'Delegates to GetFileContentAtRef with the provided ref' — this is accurate but the function adds no value over calling `GetFileContentAtRef` directly. This is presumably required to satisfy a `FileReader` interface. If that's the case, a comment noting it satisfies the interface would help readers understand why this thin wrapper exists.
// GetFileContentAtRef fetches a file at a specific ref from a repo.
Outdated
Review

[MINOR] ListContents assumes the contents API returns a JSON array. The GitHub Contents API returns an object for single-file paths. Consider handling both array and object responses (or return a clearer error) to avoid a JSON unmarshal error for file paths.

**[MINOR]** ListContents assumes the contents API returns a JSON array. The GitHub Contents API returns an object for single-file paths. Consider handling both array and object responses (or return a clearer error) to avoid a JSON unmarshal error for file paths.
// If ref is empty, the query parameter is omitted (uses default branch).
//
// Note: dot-segments ("." and "..") in the path are silently removed to
// prevent path traversal. This means a path like "foo/../bar" resolves
// to "foo/bar" rather than "bar".
func (c *Client) GetFileContentAtRef(ctx context.Context, owner, repo, path, ref string) (string, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path))
if ref != "" {
reqURL += "?ref=" + url.QueryEscape(ref)
}
body, err := c.doGet(ctx, reqURL)
if err != nil {
return "", fmt.Errorf("fetch file %s: %w", path, err)
}
var resp struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
Outdated
Review

[MINOR] escapePath drops ".." segments but does not remove the preceding segment, so paths like "a/./b/../c" normalize to "a/b/c" instead of "a/c". Clarify this behavior in the comment or consider implementing proper dot-segment normalization to avoid surprising results.

**[MINOR]** escapePath drops ".." segments but does not remove the preceding segment, so paths like "a/./b/../c" normalize to "a/b/c" instead of "a/c". Clarify this behavior in the comment or consider implementing proper dot-segment normalization to avoid surprising results.
}
if err := json.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("parse file content JSON: %w", err)
Outdated
Review

[NIT] escapePath removes dot-segments and empty segments, which can turn root ("" or ".") into an empty path, resulting in a trailing slash in the constructed URL (…/contents/). This is likely fine for GitHub, but consider documenting the behavior or handling root explicitly for clarity.

**[NIT]** escapePath removes dot-segments and empty segments, which can turn root ("" or ".") into an empty path, resulting in a trailing slash in the constructed URL (…/contents/). This is likely fine for GitHub, but consider documenting the behavior or handling root explicitly for clarity.
}
if resp.Encoding != "base64" {
return "", fmt.Errorf("unexpected encoding %q for file %s", resp.Encoding, path)
Outdated
Review

[MINOR] The fallback from array→object JSON parsing uses err from the outer json.Unmarshal in the error return (fmt.Errorf("parse contents JSON: %w", err)), but err here is the array-parse error ('cannot unmarshal object into Go value of type []entry'), which is misleading — the real parse failure is err2. Should return err2 instead: fmt.Errorf("parse contents JSON: %w", err2).

**[MINOR]** The fallback from array→object JSON parsing uses `err` from the outer `json.Unmarshal` in the error return (`fmt.Errorf("parse contents JSON: %w", err)`), but `err` here is the array-parse error ('cannot unmarshal object into Go value of type []entry'), which is misleading — the real parse failure is `err2`. Should return `err2` instead: `fmt.Errorf("parse contents JSON: %w", err2)`.
Outdated
Review

[MINOR] The fallback from array to object JSON parsing is an unusual approach. The error from the first json.Unmarshal (array attempt) is discarded silently. If the response is truly malformed JSON (not just an object vs array mismatch), only err2 from the single-entry unmarshal is surfaced. This is intentional per the comment but it means a corrupted array response that happens to partially parse as an object could go undetected. Consider checking if the body starts with [ vs { to choose the parsing path, which would be more explicit and preserve the original error on genuine parse failures.

**[MINOR]** The fallback from array to object JSON parsing is an unusual approach. The error from the first `json.Unmarshal` (array attempt) is discarded silently. If the response is truly malformed JSON (not just an object vs array mismatch), only `err2` from the single-entry unmarshal is surfaced. This is intentional per the comment but it means a corrupted array response that happens to partially parse as an object could go undetected. Consider checking if the body starts with `[` vs `{` to choose the parsing path, which would be more explicit and preserve the original error on genuine parse failures.
Outdated
Review

[NIT] The fallback from array to single-object JSON unmarshal silently swallows the original array parse error. If the body is valid JSON but neither an array nor an object matching the entry struct (e.g., a JSON number), the second unmarshal will succeed with zero values and return a single empty entry. Consider checking that single.Name != "" or similar before accepting the fallback.

**[NIT]** The fallback from array to single-object JSON unmarshal silently swallows the original array parse error. If the body is valid JSON but neither an array nor an object matching the `entry` struct (e.g., a JSON number), the second unmarshal will succeed with zero values and return a single empty entry. Consider checking that `single.Name != ""` or similar before accepting the fallback.
}
decoded, err := decodeBase64Content(resp.Content)
if err != nil {
return "", fmt.Errorf("decode base64 content for %s: %w", path, err)
}
return decoded, nil
security-review-bot marked this conversation as resolved Outdated
Outdated
Review

[MINOR] escapePath preserves dot-segments ("." and "..") in path components. Some servers normalize dot-segments, which could let an untrusted path escape the intended "contents" endpoint (e.g., "/contents/../../pulls"). While this stays on the same host, it could lead to unexpected behavior. Consider rejecting or encoding dot-segments to prevent path traversal in URL construction.

**[MINOR]** escapePath preserves dot-segments ("." and "..") in path components. Some servers normalize dot-segments, which could let an untrusted path escape the intended "contents" endpoint (e.g., "/contents/../../pulls"). While this stays on the same host, it could lead to unexpected behavior. Consider rejecting or encoding dot-segments to prevent path traversal in URL construction.
}
Outdated
Review

[MINOR] Silent empty-array handling: when the GitHub Contents API returns an empty JSON array [], json.Unmarshal into []entry succeeds with len(entries) == 0 and the function returns an empty slice without error. This is probably the correct behavior for an empty directory, but it's undocumented and could silently mask an unexpected server response. A comment clarifying this intent would be valuable.

**[MINOR]** Silent empty-array handling: when the GitHub Contents API returns an empty JSON array `[]`, `json.Unmarshal` into `[]entry` succeeds with `len(entries) == 0` and the function returns an empty slice without error. This is probably the correct behavior for an empty directory, but it's undocumented and could silently mask an unexpected server response. A comment clarifying this intent would be valuable.
// ListContents lists files and directories at a given path in a repo.
// 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
Outdated
Review

[MINOR] The ListContents fallback from array→object parse uses err2 for the object parse error but discards the original array parse error err. If both fail (e.g., the response is valid JSON but neither an array nor an object matching entry), only the object-unmarshal error is returned, which may be less informative. This is a minor diagnostics issue.

**[MINOR]** The `ListContents` fallback from array→object parse uses `err2` for the object parse error but discards the original array parse error `err`. If both fail (e.g., the response is valid JSON but neither an array nor an object matching `entry`), only the object-unmarshal error is returned, which may be less informative. This is a minor diagnostics issue.
// single-element slice.
Outdated
Review

[MINOR] escapePath silently removes .. and . segments rather than returning an error. For a security-sensitive operation (path traversal prevention), silently stripping these is arguably better than erroring, but the caller has no way to know the path was modified. The function is package-private so this is contained, but callers like GetFileContentAtRef will fetch a different path than requested without any indication. A comment explaining the deliberate behavior (not a bug) would improve maintainability.

**[MINOR]** `escapePath` silently removes `..` and `.` segments rather than returning an error. For a security-sensitive operation (path traversal prevention), silently stripping these is arguably better than erroring, but the caller has no way to know the path was modified. The function is package-private so this is contained, but callers like `GetFileContentAtRef` will fetch a different path than requested without any indication. A comment explaining the deliberate behavior (not a bug) would improve maintainability.
//
Outdated
Review

[MINOR] The ListContents function silently accepts {} (empty JSON object) as a valid response — the fallback from array parse failure to single-object parse succeeds for {}, and the single.Name == "" && single.Path == "" && single.Type == "" guard catches this case correctly. However, json.Unmarshal(body, &entries) where body is {} (an object, not array) will return an error, and the fallback will parse it as an empty entry — the zero-value check does catch this. This is correct, but the logic is subtle. A brief inline comment explaining that the zero-value check guards against {} or unexpected object shapes would help future readers.

**[MINOR]** The `ListContents` function silently accepts `{}` (empty JSON object) as a valid response — the fallback from array parse failure to single-object parse succeeds for `{}`, and the `single.Name == "" && single.Path == "" && single.Type == ""` guard catches this case correctly. However, `json.Unmarshal(body, &entries)` where body is `{}` (an object, not array) will return an error, and the fallback will parse it as an empty entry — the zero-value check does catch this. This is correct, but the logic is subtle. A brief inline comment explaining that the zero-value check guards against `{}` or unexpected object shapes would help future readers.
// Note: dot-segments ("." and "..") in the path are silently removed to
Outdated
Review

[MINOR] escapePath silently drops '.' and '..' segments, intentionally returning a potentially different path without error. While documented, this may hide caller mistakes. Consider returning an error on traversal attempts or preserving segments and letting the API respond.

**[MINOR]** escapePath silently drops '.' and '..' segments, intentionally returning a potentially different path without error. While documented, this may hide caller mistakes. Consider returning an error on traversal attempts or preserving segments and letting the API respond.
Outdated
Review

[NIT] The fallback for {} (empty object) guards against single.Name == "" && single.Path == "" && single.Type == "". A partial response with only type set (e.g. {"type":"file"}) would pass this guard and be returned as a valid entry with empty name/path. This edge case is unlikely from GitHub but the guard could be tightened to single.Name == "" alone since Name is the minimal required field.

**[NIT]** The fallback for `{}` (empty object) guards against `single.Name == "" && single.Path == "" && single.Type == ""`. A partial response with only `type` set (e.g. `{"type":"file"}`) would pass this guard and be returned as a valid entry with empty name/path. This edge case is unlikely from GitHub but the guard could be tightened to `single.Name == ""` alone since Name is the minimal required field.
// prevent path traversal. This means a path like "foo/../bar" resolves
// to "foo/bar" rather than "bar".
Outdated
Review

[MINOR] decodeBase64Content strips only '\n'. GitHub or proxies may use CRLF line breaks. Consider removing both '\r' and '\n' (or all whitespace) before decoding, or use base64.NewDecoder which tolerates newlines.

**[MINOR]** decodeBase64Content strips only '\n'. GitHub or proxies may use CRLF line breaks. Consider removing both '\r' and '\n' (or all whitespace) before decoding, or use base64.NewDecoder which tolerates newlines.
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
Outdated
Review

[NIT] escapePath silently removes dot segments ("." and ".."), which may surprise callers if their requested path is altered without error. The comment notes this intent; consider documenting this behavior in the public method docs (e.g., ListContents/GetFileContentAtRef) or returning an explicit error when dot-segments are provided.

**[NIT]** escapePath silently removes dot segments ("." and ".."), which may surprise callers if their requested path is altered without error. The comment notes this intent; consider documenting this behavior in the public method docs (e.g., ListContents/GetFileContentAtRef) or returning an explicit error when dot-segments are provided.
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path))
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.
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list contents %s: %w", path, err)
}
type entry struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
}
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.
// 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
Outdated
Review

[NIT] The escapePath function silently drops empty segments produced by consecutive slashes (e.g., a//b becomes a/b). The existing documentation mentions dot-segment removal but not double-slash collapsing. Minor doc gap.

**[NIT]** The `escapePath` function silently drops empty segments produced by consecutive slashes (e.g., `a//b` becomes `a/b`). The existing documentation mentions dot-segment removal but not double-slash collapsing. Minor doc gap.
// results in a zero-length slice returned without error.
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.
var entries []entry
if err := json.Unmarshal(body, &entries); err != nil {
var single entry
if err2 := json.Unmarshal(body, &single); err2 != nil {
return nil, fmt.Errorf("parse contents JSON: as array: %v; as object: %w", err, err2)
}
// Guard against empty objects ({}) or unexpected shapes that
// unmarshal successfully but carry no useful data.
if single.Name == "" && single.Path == "" && single.Type == "" {
return nil, fmt.Errorf("parse contents JSON: unexpected response format")
}
entries = []entry{single}
}
result := make([]vcs.ContentEntry, len(entries))
for i, e := range entries {
result[i] = vcs.ContentEntry{
Name: e.Name,
Review

[MINOR] The escapePath function silently removes dot-segments and empty segments rather than returning an error. The doc comment acknowledges this is intentional and explains the rationale. However the behavior (caller gets a different path than requested without error) could lead to subtle bugs where callers don't notice path traversal has been cleaned — for example escapePath("../secret") returns "secret" silently. Since this is documented behavior and the convention explicitly calls it out, this is a design trade-off rather than a bug, but it warrants attention if paths come from user input.

**[MINOR]** The `escapePath` function silently removes dot-segments and empty segments rather than returning an error. The doc comment acknowledges this is intentional and explains the rationale. However the behavior (caller gets a different path than requested without error) could lead to subtle bugs where callers don't notice path traversal has been cleaned — for example `escapePath("../secret")` returns `"secret"` silently. Since this is documented behavior and the convention explicitly calls it out, this is a design trade-off rather than a bug, but it warrants attention if paths come from user input.
Path: e.Path,
Type: e.Type,
}
}
return result, nil
}
// 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 "..") and empty segments (from consecutive slashes like
// "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.
func escapePath(p string) string {
parts := strings.Split(p, "/")
var clean []string
for _, part := range parts {
if part == "." || part == ".." || part == "" {
continue
}
clean = append(clean, url.PathEscape(part))
}
return strings.Join(clean, "/")
}
// decodeBase64Content decodes base64-encoded content from the GitHub contents API.
// GitHub returns base64 content with line breaks for formatting; we strip \r and \n before decoding.
func decodeBase64Content(encoded string) (string, error) {
// GitHub inserts newlines in base64 content
cleaned := strings.NewReplacer("\n", "", "\r", "").Replace(encoded)
decoded, err := base64.StdEncoding.DecodeString(cleaned)
if err != nil {
return "", err
}
return string(decoded), nil
}
+334
View File
@@ -0,0 +1,334 @@
package github
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestGetFileContent_DelegatesToGetFileContentAtRef(t *testing.T) {
var gotRef string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRef = r.URL.Query().Get("ref")
json.NewEncoder(w).Encode(map[string]string{
"content": "dGVzdA==", // "test" in base64
"encoding": "base64",
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
// Call with empty ref — should not include ref param
content, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "test" {
t.Errorf("expected 'test', got %q", content)
}
if gotRef != "" {
t.Errorf("expected empty ref, got %q", gotRef)
}
}
func TestGetFileContent_WithRef(t *testing.T) {
var gotRef string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRef = r.URL.Query().Get("ref")
json.NewEncoder(w).Encode(map[string]string{
"content": "dGVzdA==",
"encoding": "base64",
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "abc123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotRef != "abc123" {
t.Errorf("expected ref 'abc123', got %q", gotRef)
}
}
func TestGetFileContent_404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "missing.go", "")
if err == nil {
t.Fatal("expected error for 404")
}
}
func TestGetFileContent_401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
if err == nil {
t.Fatal("expected error for 401")
}
}
func TestGetFileContent_429Retry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
json.NewEncoder(w).Encode(map[string]string{
"content": "b2s=",
"encoding": "base64",
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond})
content, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "ok" {
t.Errorf("expected 'ok', got %q", content)
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
}
func TestGetFileContent_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`not json`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}
func TestListContents_HappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/contents/src" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
json.NewEncoder(w).Encode([]map[string]string{
{"name": "main.go", "path": "src/main.go", "type": "file"},
{"name": "lib", "path": "src/lib", "type": "dir"},
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
entries, err := c.ListContents(context.Background(), "owner", "repo", "src")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0].Name != "main.go" {
t.Errorf("expected name 'main.go', got %q", entries[0].Name)
}
if entries[0].Path != "src/main.go" {
t.Errorf("expected path 'src/main.go', got %q", entries[0].Path)
}
if entries[0].Type != "file" {
t.Errorf("expected type 'file', got %q", entries[0].Type)
}
if entries[1].Name != "lib" {
t.Errorf("expected name 'lib', got %q", entries[1].Name)
}
if entries[1].Type != "dir" {
t.Errorf("expected type 'dir', got %q", entries[1].Type)
}
}
func TestListContents_404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.ListContents(context.Background(), "owner", "repo", "missing")
if err == nil {
t.Fatal("expected error for 404")
}
}
func TestListContents_401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.ListContents(context.Background(), "owner", "repo", "src")
if err == nil {
t.Fatal("expected error for 401")
}
}
func TestListContents_429Retry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
json.NewEncoder(w).Encode([]map[string]string{
{"name": "file.go", "path": "file.go", "type": "file"},
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond})
entries, err := c.ListContents(context.Background(), "owner", "repo", ".")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
}
func TestListContents_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`not json`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.ListContents(context.Background(), "owner", "repo", "src")
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}
func TestDecodeBase64Content(t *testing.T) {
// Test with newlines (GitHub's format)
encoded := "cGFja2FnZSBt\nYWlu"
decoded, err := decodeBase64Content(encoded)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if decoded != "package main" {
t.Errorf("expected 'package main', got %q", decoded)
}
}
func TestDecodeBase64Content_Invalid(t *testing.T) {
_, err := decodeBase64Content("not!!!valid!!!base64")
if err == nil {
t.Fatal("expected error for invalid base64")
}
}
func TestEscapePath_RejectsDotSegments(t *testing.T) {
tests := []struct {
input string
want string
}{
{"src/main.go", "src/main.go"},
{"../etc/passwd", "etc/passwd"},
{"./src/../main.go", "src/main.go"},
{"a/b/c", "a/b/c"},
{"file with spaces.go", "file%20with%20spaces.go"},
{"a/./b/../c", "a/b/c"},
}
for _, tt := range tests {
got := escapePath(tt.input)
if got != tt.want {
t.Errorf("escapePath(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestDecodeBase64Content_CRLF(t *testing.T) {
// Base64 of "hello world" with CRLF line breaks inserted
encoded := "aGVs\r\nbG8g\r\nd29y\r\nbGQ="
decoded, err := decodeBase64Content(encoded)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if decoded != "hello world" {
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)
}
}
+212
View File
@@ -0,0 +1,212 @@
package github
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"gitea.weiker.me/rodin/review-bot/vcs"
)
// pullRequestResponse is the GitHub API response for a pull request.
type pullRequestResponse struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
Head struct {
SHA string `json:"sha"`
Ref string `json:"ref"`
} `json:"head"`
Base struct {
Ref string `json:"ref"`
} `json:"base"`
}
// changedFileResponse is the GitHub API response for a changed file in a PR.
type changedFileResponse struct {
Filename string `json:"filename"`
Status string `json:"status"`
Patch string `json:"patch"`
}
// commitStatusResponse is the GitHub combined status API response.
type commitStatusResponse struct {
Statuses []struct {
Context string `json:"context"`
State string `json:"state"`
Description string `json:"description"`
TargetURL string `json:"target_url"`
} `json:"statuses"`
}
// checkRunsResponse is the GitHub check runs API response.
type checkRunsResponse struct {
CheckRuns []struct {
Name string `json:"name"`
Conclusion *string `json:"conclusion"`
Status string `json:"status"`
HTMLURL string `json:"html_url"`
} `json:"check_runs"`
Review

[MINOR] The HTMLURL field name in checkRunsResponse deviates from the Acronym Capitalization pattern (style.md §2) which mandates all-caps acronyms: it should be HTMLURL for an unexported struct field following htmlURL convention, but since this is an unexported type used only for JSON unmarshaling, the real concern is cosmetic. However HTMLURL (two consecutive all-caps acronyms) is actually the correct form per Go conventions — this is not a real issue. Noting for completeness: the field name is correct.

**[MINOR]** The `HTMLURL` field name in `checkRunsResponse` deviates from the Acronym Capitalization pattern (style.md §2) which mandates all-caps acronyms: it should be `HTMLURL` for an unexported struct field following `htmlURL` convention, but since this is an unexported type used only for JSON unmarshaling, the real concern is cosmetic. However `HTMLURL` (two consecutive all-caps acronyms) is actually the correct form per Go conventions — this is not a real issue. Noting for completeness: the field name is correct.
}
// GetPullRequest fetches PR metadata.
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*vcs.PullRequest, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
Review

[MINOR] The commitStatusResponse.State field is parsed from the API response but never used — only the individual Statuses slice entries are iterated. This is a minor dead field. Either use State to short-circuit when overall state is known, or remove it from the struct to avoid confusion about its purpose.

**[MINOR]** The `commitStatusResponse.State` field is parsed from the API response but never used — only the individual `Statuses` slice entries are iterated. This is a minor dead field. Either use `State` to short-circuit when overall state is known, or remove it from the struct to avoid confusion about its purpose.
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("fetch PR: %w", err)
}
var resp pullRequestResponse
if err := json.Unmarshal(body, &resp); err != nil {
Review

[MINOR] The checkRunsResponse.TotalCount field is parsed but never used. The pagination termination relies on len(checkResp.CheckRuns) < 100 rather than TotalCount. This is fine for correctness, but the unused field adds noise. Either use it or remove it.

**[MINOR]** The `checkRunsResponse.TotalCount` field is parsed but never used. The pagination termination relies on `len(checkResp.CheckRuns) < 100` rather than `TotalCount`. This is fine for correctness, but the unused field adds noise. Either use it or remove it.
return nil, fmt.Errorf("parse PR JSON: %w", err)
}
return &vcs.PullRequest{
Number: resp.Number,
Title: resp.Title,
Body: resp.Body,
Head: vcs.HeadRef{SHA: resp.Head.SHA, Ref: resp.Head.Ref},
Base: vcs.BaseRef{Ref: resp.Base.Ref},
}, nil
}
// GetPullRequestDiff fetches the unified diff for a PR.
Review

[MINOR] The mapCheckRunStatus function signature takes a second _ string parameter that is explicitly ignored and documented as intentionally unused. Per the convention patterns, this is acceptable as documentation, but it would be cleaner to either remove the parameter entirely (if the function is only called internally) or keep it for forward-compatibility with a comment. Since it's unexported and only called in one place, removing the dead parameter would be cleaner.

**[MINOR]** The `mapCheckRunStatus` function signature takes a second `_ string` parameter that is explicitly ignored and documented as intentionally unused. Per the convention patterns, this is acceptable as documentation, but it would be cleaner to either remove the parameter entirely (if the function is only called internally) or keep it for forward-compatibility with a comment. Since it's unexported and only called in one place, removing the dead parameter would be cleaner.
// Uses Accept: application/vnd.github.diff to get raw diff text.
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
body, err := c.doRequest(ctx, http.MethodGet, reqURL, "application/vnd.github.diff")
if err != nil {
return "", fmt.Errorf("fetch diff: %w", err)
}
return string(body), nil
Review

[NIT] The maxPages = 100 constant limits to 10,000 files (100 pages × 100 per page). GitHub's API caps PRs at 3,000 changed files, so this bound is fine in practice. However, the constant is shared between GetPullRequestFiles and the check-runs pagination in GetCommitStatuses, which have different semantics. These could be separate constants with explanatory comments for clarity.

**[NIT]** The `maxPages = 100` constant limits to 10,000 files (100 pages × 100 per page). GitHub's API caps PRs at 3,000 changed files, so this bound is fine in practice. However, the constant is shared between `GetPullRequestFiles` and the check-runs pagination in `GetCommitStatuses`, which have different semantics. These could be separate constants with explanatory comments for clarity.
}
// maxPages is the upper bound on pagination loops to prevent unbounded iteration
// in case the server returns a full page indefinitely.
Outdated
Review

[MINOR] GetPullRequestFiles paginates until a page returns fewer than 100 items. If a misbehaving or malicious server keeps returning 100 items indefinitely, this could cause unbounded requests (potential DoS). Consider honoring Link headers, total counts, or imposing a reasonable max pages limit.

**[MINOR]** GetPullRequestFiles paginates until a page returns fewer than 100 items. If a misbehaving or malicious server keeps returning 100 items indefinitely, this could cause unbounded requests (potential DoS). Consider honoring Link headers, total counts, or imposing a reasonable max pages limit.
const maxPages = 100
Review

[MINOR] The maxPages = 100 constant caps pagination at 100 pages × 100 files = 10,000 files for PRs, and 100 pages × 100 check runs = 10,000 check runs. This constant is shared between two very different concerns (PR files and check runs). A PR with 10,000 files is pathological but possible in generated-code repos; silently truncating without returning an error or warning could cause incorrect reviews. Consider either documenting this limit explicitly or returning an error when the cap is hit.

**[MINOR]** The `maxPages = 100` constant caps pagination at 100 pages × 100 files = 10,000 files for PRs, and 100 pages × 100 check runs = 10,000 check runs. This constant is shared between two very different concerns (PR files and check runs). A PR with 10,000 files is pathological but possible in generated-code repos; silently truncating without returning an error or warning could cause incorrect reviews. Consider either documenting this limit explicitly or returning an error when the cap is hit.
// 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
for page := 1; page <= maxPages; page++ {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/files?per_page=100&page=%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("fetch PR files page %d: %w", page, err)
Review

[MINOR] The maxPages constant (100) is shared between GetPullRequestFiles and the check-runs pagination in GetCommitStatuses. A PR with exactly 10,000 files (100 pages × 100 per page) would silently return incomplete results with no error or warning. The GitHub API itself caps PRs at 3000 files, so in practice this is fine, but the silent truncation at the limit is a latent correctness issue. Consider logging a warning or returning an error when the page cap is hit.

**[MINOR]** The `maxPages` constant (100) is shared between `GetPullRequestFiles` and the check-runs pagination in `GetCommitStatuses`. A PR with exactly 10,000 files (100 pages × 100 per page) would silently return incomplete results with no error or warning. The GitHub API itself caps PRs at 3000 files, so in practice this is fine, but the silent truncation at the limit is a latent correctness issue. Consider logging a warning or returning an error when the page cap is hit.
}
var files []changedFileResponse
Review

[MINOR] GetPullRequestFiles pagination relies on len(files) < 100 to stop. This works but can be brittle. For maximum correctness, consider using the Link response header to determine if a next page exists.

**[MINOR]** GetPullRequestFiles pagination relies on len(files) < 100 to stop. This works but can be brittle. For maximum correctness, consider using the Link response header to determine if a next page exists.
if err := json.Unmarshal(body, &files); err != nil {
return nil, fmt.Errorf("parse PR files JSON: %w", err)
}
if len(files) == 0 {
break
}
Review

[MINOR] The mapCheckRunStatus function signature takes a second parameter _ string that is explicitly discarded. Since Go allows unused blank-identifier parameters, this is idiomatic, but the function could alternatively just take conclusion *string directly. The current form is acceptable but the doc comment explaining why the second param is ignored is good practice.

**[MINOR]** The `mapCheckRunStatus` function signature takes a second parameter `_ string` that is explicitly discarded. Since Go allows unused blank-identifier parameters, this is idiomatic, but the function could alternatively just take `conclusion *string` directly. The current form is acceptable but the doc comment explaining why the second param is ignored is good practice.
for _, f := range files {
allFiles = append(allFiles, vcs.ChangedFile{
Filename: f.Filename,
Status: f.Status,
Patch: f.Patch,
})
}
if len(files) < 100 {
break
}
}
return allFiles, nil
}
// GetCommitStatuses fetches both commit statuses and check runs for a SHA,
// merging them into a unified []vcs.CommitStatus slice.
// Returns nil (not an empty slice) when there are no statuses or check runs.
Outdated
Review

[NIT] The GetFileContentAtRef method is placed in pr.go but it's also the implementation delegate for GetFileContent in files.go. Conceptually it belongs in files.go alongside the other file-related methods. The split works because Go doesn't care which file a method is in, but for navigability (per the style.md pattern of organizing by concept) moving it to files.go would be cleaner.

**[NIT]** The `GetFileContentAtRef` method is placed in `pr.go` but it's also the implementation delegate for `GetFileContent` in `files.go`. Conceptually it belongs in `files.go` alongside the other file-related methods. The split works because Go doesn't care which file a method is in, but for navigability (per the `style.md` pattern of organizing by concept) moving it to `files.go` would be cleaner.
// If the commit statuses endpoint fails (e.g. 404 for an unknown SHA), the
// function returns immediately without attempting the check-runs endpoint.
// If the check-runs endpoint fails after statuses were fetched successfully,
// the function returns an error (not a partial result) so callers always get
Review

[NIT] The GetPullRequestFiles comment says 'Returns nil (not an empty slice) when the PR has no changed files' but if the first page returns an empty array, allFiles remains nil — this is correct. However the GitHub API actually returns an empty array for PRs with 0 changed files (valid edge case), so the nil vs empty distinction in the doc comment is accurate but subtle. No code change needed.

**[NIT]** The `GetPullRequestFiles` comment says 'Returns nil (not an empty slice) when the PR has no changed files' but if the first page returns an empty array, `allFiles` remains nil — this is correct. However the GitHub API actually returns an empty array for PRs with 0 changed files (valid edge case), so the nil vs empty distinction in the doc comment is accurate but subtle. No code change needed.
// either a complete view or a clear error signal.
func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]vcs.CommitStatus, error) {
var result []vcs.CommitStatus
// Fetch commit statuses
statusURL := fmt.Sprintf("%s/repos/%s/%s/commits/%s/status",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
statusBody, err := c.doGet(ctx, statusURL)
if err != nil {
Review

[MINOR] GetCommitStatuses check-runs pagination uses the same <100 sentinel to stop, which could loop unbounded if a server always returns 100 items. Consider using Link headers, total_count, or a maximum page cap as a safety limit.

**[MINOR]** GetCommitStatuses check-runs pagination uses the same <100 sentinel to stop, which could loop unbounded if a server always returns 100 items. Consider using Link headers, total_count, or a maximum page cap as a safety limit.
return nil, fmt.Errorf("fetch commit statuses: %w", err)
}
var statusResp commitStatusResponse
if err := json.Unmarshal(statusBody, &statusResp); err != nil {
Review

[MINOR] The GetPullRequestFiles pagination loop does not return an empty non-nil slice when there are no files (the first page returns 0 items), it returns nil. This is idiomatic Go for slices but may surprise callers who do len(files) == 0 vs a nil check. Not a bug, just worth documenting or ensuring callers handle nil consistently.

**[MINOR]** The `GetPullRequestFiles` pagination loop does not return an empty non-nil slice when there are no files (the first page returns 0 items), it returns `nil`. This is idiomatic Go for slices but may surprise callers who do `len(files) == 0` vs a nil check. Not a bug, just worth documenting or ensuring callers handle nil consistently.
return nil, fmt.Errorf("parse commit statuses JSON: %w", err)
}
for _, s := range statusResp.Statuses {
Review

[NIT] mapCheckRunStatus accepts a second parameter _ string that is intentionally unused. The function signature is slightly misleading for callers. Since this is unexported and only called from one place, simplifying to mapCheckRunStatus(conclusion *string) string would be cleaner. The current approach was likely chosen to match the struct fields but the unused parameter adds noise.

**[NIT]** `mapCheckRunStatus` accepts a second parameter `_ string` that is intentionally unused. The function signature is slightly misleading for callers. Since this is unexported and only called from one place, simplifying to `mapCheckRunStatus(conclusion *string) string` would be cleaner. The current approach was likely chosen to match the struct fields but the unused parameter adds noise.
Review

[MINOR] The GetPullRequestFiles function is documented to return nil (not empty slice) when there are no changed files, which is correct for nil-safe ranging. However, GetCommitStatuses similarly returns a nil result slice when there are no statuses (via var result []vcs.CommitStatus plus no appends), but this is not documented in the function comment. Minor documentation inconsistency.

**[MINOR]** The `GetPullRequestFiles` function is documented to return `nil` (not empty slice) when there are no changed files, which is correct for nil-safe ranging. However, `GetCommitStatuses` similarly returns a `nil` result slice when there are no statuses (via `var result []vcs.CommitStatus` plus no appends), but this is not documented in the function comment. Minor documentation inconsistency.
result = append(result, vcs.CommitStatus{
Context: s.Context,
Status: s.State,
Description: s.Description,
TargetURL: s.TargetURL,
})
}
// Fetch check runs (paginated)
Review

[NIT] The mapCheckRunStatus function signature accepts _ string (unused status field) and explains this in the comment. This is fine for now, but the stale and waiting conclusions are mapped to pendingwaiting is not a GitHub API conclusion value (it's an internal concept). This won't cause bugs but may be dead code.

**[NIT]** The `mapCheckRunStatus` function signature accepts `_ string` (unused status field) and explains this in the comment. This is fine for now, but the `stale` and `waiting` conclusions are mapped to `pending` — `waiting` is not a GitHub API conclusion value (it's an internal concept). This won't cause bugs but may be dead code.
for checkPage := 1; checkPage <= maxPages; checkPage++ {
checkURL := fmt.Sprintf("%s/repos/%s/%s/commits/%s/check-runs?per_page=100&page=%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha), checkPage)
checkBody, err := c.doGet(ctx, checkURL)
if err != nil {
return nil, fmt.Errorf("fetch check runs page %d: %w", checkPage, err)
}
var checkResp checkRunsResponse
if err := json.Unmarshal(checkBody, &checkResp); err != nil {
return nil, fmt.Errorf("parse check runs JSON: %w", err)
}
for _, cr := range checkResp.CheckRuns {
result = append(result, vcs.CommitStatus{
Context: cr.Name,
Review

[MINOR] In GetCommitStatuses, when the check-runs endpoint fails, the function returns nil, err but has already accumulated commit statuses in result. The doc comment says 'If the commit statuses endpoint fails... the function returns immediately without attempting the check-runs endpoint' but doesn't document what happens when the check-runs endpoint fails after statuses succeed. This is a partial-result scenario that callers cannot distinguish from a total failure. Consider either returning the partial result or documenting this behavior explicitly.

**[MINOR]** In `GetCommitStatuses`, when the check-runs endpoint fails, the function returns `nil, err` but has already accumulated commit statuses in `result`. The doc comment says 'If the commit statuses endpoint fails... the function returns immediately without attempting the check-runs endpoint' but doesn't document what happens when the check-runs endpoint fails after statuses succeed. This is a partial-result scenario that callers cannot distinguish from a total failure. Consider either returning the partial result or documenting this behavior explicitly.
Status: mapCheckRunStatus(cr.Conclusion),
Description: derefString(cr.Conclusion),
TargetURL: cr.HTMLURL,
})
Review

[NIT] The derefString helper is a small utility that could theoretically live in a shared internal utilities package if it's needed elsewhere. For now it's fine in pr.go but if the codebase grows, consider an internal/ package. Not a current issue.

**[NIT]** The `derefString` helper is a small utility that could theoretically live in a shared internal utilities package if it's needed elsewhere. For now it's fine in `pr.go` but if the codebase grows, consider an `internal/` package. Not a current issue.
}
if len(checkResp.CheckRuns) < 100 {
Review

[NIT] mapCheckRunStatus takes a second parameter (status) that is unused. Consider removing it or documenting/using it to avoid confusion for readers.

**[NIT]** mapCheckRunStatus takes a second parameter (status) that is unused. Consider removing it or documenting/using it to avoid confusion for readers.
break
}
}
return result, nil
}
// mapCheckRunStatus maps a check run conclusion to a vcs.CommitStatus status string.
// Conclusion alone determines the mapped state: nil conclusion means the run is
// still in progress (pending), regardless of the status field value.
Outdated
Review

[NIT] mapCheckRunStatus treats "cancelled", "skipped", and "neutral" as success. Ensure this policy matches project expectations and consider documenting the rationale, since some teams treat cancellations as non-success.

**[NIT]** mapCheckRunStatus treats "cancelled", "skipped", and "neutral" as success. Ensure this policy matches project expectations and consider documenting the rationale, since some teams treat cancellations as non-success.
func mapCheckRunStatus(conclusion *string) string {
if conclusion == nil {
// Still running or queued
Review

[NIT] The mapCheckRunStatus function accepts a second parameter _ string (the status field) but ignores it. The function signature signature documents intent to use status but doesn't. In practice, status (e.g., 'in_progress', 'queued') could provide additional fidelity when conclusion is nil. The ignored parameter adds dead weight to the function signature. Either use it or remove it.

**[NIT]** The `mapCheckRunStatus` function accepts a second parameter `_ string` (the status field) but ignores it. The function signature signature documents intent to use `status` but doesn't. In practice, `status` (e.g., 'in_progress', 'queued') could provide additional fidelity when `conclusion` is nil. The ignored parameter adds dead weight to the function signature. Either use it or remove it.
return "pending"
}
Review

[NIT] In GetCommitStatuses, if the commit statuses endpoint returns an error (e.g., 404), the function returns immediately without attempting the check-runs endpoint. Depending on the intended semantics, callers may expect partial results or a combined error. The current behavior (fail fast) is reasonable but undocumented.

**[NIT]** In `GetCommitStatuses`, if the commit statuses endpoint returns an error (e.g., 404), the function returns immediately without attempting the check-runs endpoint. Depending on the intended semantics, callers may expect partial results or a combined error. The current behavior (fail fast) is reasonable but undocumented.
switch *conclusion {
case "success":
return "success"
case "failure", "action_required", "timed_out":
Review

[MINOR] The mapCheckRunStatus mapping treats cancelled, skipped, and neutral as "success" (non-blocking). This is a policy decision that should be documented more prominently — cancelled in particular could reasonably be mapped to failure in some contexts. The comment // non-blocking is brief; a note explaining the design rationale (e.g. 'these do not indicate a blocking failure per GitHub's check suite semantics') would help future maintainers.

**[MINOR]** The `mapCheckRunStatus` mapping treats `cancelled`, `skipped`, and `neutral` as `"success"` (non-blocking). This is a policy decision that should be documented more prominently — `cancelled` in particular could reasonably be mapped to `failure` in some contexts. The comment `// non-blocking` is brief; a note explaining the design rationale (e.g. 'these do not indicate a blocking failure per GitHub's check suite semantics') would help future maintainers.
return "failure"
Outdated
Review

[NIT] The mapCheckRunStatus function signature takes an ignored second parameter _ string. The comment explains why, which is good. A minor alternative would be to not accept the parameter at all and have the caller omit it, but given this is a private helper called from one place with both values available, the current approach is defensible. The doc comment adequately explains the design choice.

**[NIT]** The `mapCheckRunStatus` function signature takes an ignored second parameter `_ string`. The comment explains why, which is good. A minor alternative would be to not accept the parameter at all and have the caller omit it, but given this is a private helper called from one place with both values available, the current approach is defensible. The doc comment adequately explains the design choice.
Outdated
Review

[MINOR] The mapCheckRunStatus function signature func mapCheckRunStatus(conclusion *string, _ string) string uses a blank identifier for the status parameter. While the comment explains why, a cleaner approach given the parameter is truly unused would be to just not include it and update the call site — or if the signature must stay for extensibility, at minimum name it (e.g., checkStatus string) so readers can understand what's being ignored. The blank _ with no name makes the call site mapCheckRunStatus(cr.Conclusion, cr.Status) harder to understand without reading the comment.

**[MINOR]** The `mapCheckRunStatus` function signature `func mapCheckRunStatus(conclusion *string, _ string) string` uses a blank identifier for the `status` parameter. While the comment explains why, a cleaner approach given the parameter is truly unused would be to just not include it and update the call site — or if the signature must stay for extensibility, at minimum name it (e.g., `checkStatus string`) so readers can understand what's being ignored. The blank `_` with no name makes the call site `mapCheckRunStatus(cr.Conclusion, cr.Status)` harder to understand without reading the comment.
case "cancelled", "skipped", "neutral":
Outdated
Review

[MINOR] mapCheckRunStatus ignores the second parameter (status string) entirely — it's named _. The function signature accepts the status for potential future use, but the unused parameter with a blank name could be confusing. If the status field (e.g. 'in_progress', 'queued') is never needed, the parameter should be removed from the signature; if it may be needed, a comment explaining why it's currently unused would help. The current behavior maps nil conclusion to 'pending' regardless of status, which may be correct, but 'stale' and 'waiting' conclusions (which GitHub also uses) would silently fall through to 'pending' rather than being explicit.

**[MINOR]** mapCheckRunStatus ignores the second parameter (status string) entirely — it's named `_`. The function signature accepts the status for potential future use, but the unused parameter with a blank name could be confusing. If the `status` field (e.g. 'in_progress', 'queued') is never needed, the parameter should be removed from the signature; if it may be needed, a comment explaining why it's currently unused would help. The current behavior maps nil conclusion to 'pending' regardless of status, which may be correct, but 'stale' and 'waiting' conclusions (which GitHub also uses) would silently fall through to 'pending' rather than being explicit.
return "success" // non-blocking: these do not indicate a blocking failure per GitHub check suite semantics
case "stale", "waiting":
return "pending"
default:
return "pending"
Outdated
Review

[MINOR] The mapCheckRunStatus function maps cancelled, skipped, and neutral to success (non-blocking). This is a meaningful policy decision that warrants a more explicit comment explaining why these are treated as non-failures from the review bot's perspective, rather than just // non-blocking. The current comment doesn't explain the reasoning to future maintainers.

**[MINOR]** The `mapCheckRunStatus` function maps `cancelled`, `skipped`, and `neutral` to `success` (non-blocking). This is a meaningful policy decision that warrants a more explicit comment explaining *why* these are treated as non-failures from the review bot's perspective, rather than just `// non-blocking`. The current comment doesn't explain the reasoning to future maintainers.
}
}
// derefString safely dereferences a string pointer, returning empty string if nil.
Review

[NIT] In GetCommitStatuses, the check run pagination loop uses checkPage as the variable name while the PR files loop uses page. Naming is internally consistent within each function, but the inconsistency between the two sibling pagination loops is a minor style nit.

**[NIT]** In `GetCommitStatuses`, the check run pagination loop uses `checkPage` as the variable name while the PR files loop uses `page`. Naming is internally consistent within each function, but the inconsistency between the two sibling pagination loops is a minor style nit.
func derefString(s *string) string {
Outdated
Review

[MINOR] mapCheckRunStatus has a dead code path: case "in_progress", "queued" will never be reached because conclusion == nil is checked first (returning "pending"), and when conclusion is non-nil it cannot be "in_progress" or "queued" (those are status values, not conclusion values in the GitHub API). This case is unreachable and misleading.

**[MINOR]** mapCheckRunStatus has a dead code path: `case "in_progress", "queued"` will never be reached because `conclusion == nil` is checked first (returning "pending"), and when conclusion is non-nil it cannot be "in_progress" or "queued" (those are status values, not conclusion values in the GitHub API). This case is unreachable and misleading.
if s == nil {
return ""
}
Outdated
Review

[MINOR] In mapCheckRunStatus, the cases "in_progress" and "queued" are in the switch *conclusion block, but conclusion is non-nil when this switch is reached. GitHub's API sets conclusion to null for in-progress/queued check runs, not to the strings "in_progress" or "queued" — those are status field values. So these cases are dead code and will never be reached. The nil-conclusion check at the top already handles the pending case correctly.

**[MINOR]** In `mapCheckRunStatus`, the cases `"in_progress"` and `"queued"` are in the `switch *conclusion` block, but `conclusion` is non-nil when this switch is reached. GitHub's API sets `conclusion` to null for in-progress/queued check runs, not to the strings `"in_progress"` or `"queued"` — those are `status` field values. So these cases are dead code and will never be reached. The nil-conclusion check at the top already handles the pending case correctly.
return *s
}
+637
View File
@@ -0,0 +1,637 @@
package github
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestGetPullRequest_HappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/pulls/42" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
json.NewEncoder(w).Encode(map[string]interface{}{
"number": 42,
"title": "Test PR",
"body": "Description",
"head": map[string]string{"sha": "abc123", "ref": "feature-branch"},
"base": map[string]string{"ref": "main"},
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
pr, err := c.GetPullRequest(context.Background(), "owner", "repo", 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pr.Number != 42 {
t.Errorf("expected number 42, got %d", pr.Number)
}
if pr.Title != "Test PR" {
t.Errorf("expected title 'Test PR', got %q", pr.Title)
}
if pr.Body != "Description" {
t.Errorf("expected body 'Description', got %q", pr.Body)
}
if pr.Head.SHA != "abc123" {
t.Errorf("expected head SHA 'abc123', got %q", pr.Head.SHA)
}
if pr.Head.Ref != "feature-branch" {
t.Errorf("expected head ref 'feature-branch', got %q", pr.Head.Ref)
}
if pr.Base.Ref != "main" {
t.Errorf("expected base ref 'main', got %q", pr.Base.Ref)
}
}
func TestGetPullRequest_404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequest(context.Background(), "owner", "repo", 999)
if err == nil {
t.Fatal("expected error for 404")
}
if !IsNotFound(err) {
t.Errorf("expected IsNotFound=true, got error: %v", err)
}
}
func TestGetPullRequest_401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequest(context.Background(), "owner", "repo", 1)
if err == nil {
t.Fatal("expected error for 401")
}
if !IsUnauthorized(err) {
t.Errorf("expected IsUnauthorized=true, got error: %v", err)
}
}
func TestGetPullRequest_429Retry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"number": 1,
"title": "PR",
"body": "",
"head": map[string]string{"sha": "abc", "ref": "br"},
"base": map[string]string{"ref": "main"},
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond})
pr, err := c.GetPullRequest(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pr.Number != 1 {
t.Errorf("expected number 1, got %d", pr.Number)
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
}
func TestGetPullRequest_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Review

[NIT] The table-driven TestGetCommitStatuses_CheckRunConclusions test creates a new httptest.Server for each subtest case rather than parameterizing the handler. This works but is slightly heavier than necessary. A single server whose handler reads from tt via closure capture (the subtests are sequential since t.Run without t.Parallel() runs sequentially) would be cleaner and follow the table-driven pattern more closely. Minor style concern.

**[NIT]** The table-driven `TestGetCommitStatuses_CheckRunConclusions` test creates a new `httptest.Server` for each subtest case rather than parameterizing the handler. This works but is slightly heavier than necessary. A single server whose handler reads from `tt` via closure capture (the subtests are sequential since `t.Run` without `t.Parallel()` runs sequentially) would be cleaner and follow the table-driven pattern more closely. Minor style concern.
w.WriteHeader(200)
w.Write([]byte(`{invalid json`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequest(context.Background(), "owner", "repo", 1)
if err == nil {
t.Fatal("expected error for malformed JSON")
}
if !strings.Contains(err.Error(), "parse PR JSON") {
t.Errorf("expected parse error, got: %v", err)
}
}
func TestGetPullRequestDiff_HappyPath(t *testing.T) {
expectedDiff := "diff --git a/file.go b/file.go\n--- a/file.go\n+++ b/file.go\n@@ -1,3 +1,4 @@\n+// new line\n"
var gotAccept string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAccept = r.Header.Get("Accept")
w.WriteHeader(200)
w.Write([]byte(expectedDiff))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
diff, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if diff != expectedDiff {
t.Errorf("unexpected diff: %q", diff)
}
if gotAccept != "application/vnd.github.diff" {
t.Errorf("expected diff Accept header, got %q", gotAccept)
}
}
func TestGetPullRequestDiff_404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 999)
if err == nil {
t.Fatal("expected error for 404")
}
}
func TestGetPullRequestDiff_401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 1)
if err == nil {
t.Fatal("expected error for 401")
}
}
func TestGetPullRequestFiles_HappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]map[string]interface{}{
{"filename": "main.go", "status": "modified", "patch": "@@ -1,3 +1,4 @@\n+line"},
{"filename": "test.go", "status": "added", "patch": "@@ -0,0 +1,5 @@\n+new file"},
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
files, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 2 {
t.Fatalf("expected 2 files, got %d", len(files))
}
if files[0].Filename != "main.go" {
t.Errorf("expected filename 'main.go', got %q", files[0].Filename)
}
if files[0].Status != "modified" {
t.Errorf("expected status 'modified', got %q", files[0].Status)
}
if files[0].Patch != "@@ -1,3 +1,4 @@\n+line" {
t.Errorf("unexpected patch: %q", files[0].Patch)
}
}
func TestGetPullRequestFiles_Pagination(t *testing.T) {
// Simulate > 100 files requiring pagination
page1Files := make([]map[string]string, 100)
for i := 0; i < 100; i++ {
page1Files[i] = map[string]string{
"filename": fmt.Sprintf("file%d.go", i),
"status": "modified",
"patch": fmt.Sprintf("patch%d", i),
}
}
page2Files := []map[string]string{
{"filename": "file100.go", "status": "added", "patch": "patch100"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := r.URL.Query().Get("page")
if page == "" || page == "1" {
json.NewEncoder(w).Encode(page1Files)
} else {
json.NewEncoder(w).Encode(page2Files)
}
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
files, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 101 {
t.Errorf("expected 101 files (paginated), got %d", len(files))
}
if files[100].Filename != "file100.go" {
t.Errorf("expected last file 'file100.go', got %q", files[100].Filename)
}
if files[100].Patch != "patch100" {
t.Errorf("expected last patch 'patch100', got %q", files[100].Patch)
}
}
func TestGetPullRequestFiles_BinaryFile_NoPatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Binary files have no patch field in GitHub response
json.NewEncoder(w).Encode([]map[string]interface{}{
{"filename": "image.png", "status": "added"},
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
files, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
if files[0].Patch != "" {
t.Errorf("expected empty patch for binary file, got %q", files[0].Patch)
}
}
func TestGetPullRequestFiles_404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 999)
if err == nil {
t.Fatal("expected error for 404")
}
}
func TestGetPullRequestFiles_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`not json`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}
func TestGetFileContentAtRef_HappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/contents/path/to/file.go" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("ref") != "abc123" {
t.Errorf("unexpected ref: %s", r.URL.Query().Get("ref"))
}
json.NewEncoder(w).Encode(map[string]string{
"content": "cGFja2FnZSBtYWlu", // "package main" in base64
"encoding": "base64",
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
content, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "path/to/file.go", "abc123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "package main" {
t.Errorf("expected 'package main', got %q", content)
}
}
func TestGetFileContentAtRef_EmptyRef(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("ref") != "" {
t.Errorf("expected no ref param, got %q", r.URL.Query().Get("ref"))
}
json.NewEncoder(w).Encode(map[string]string{
"content": "aGVsbG8=", // "hello" in base64
"encoding": "base64",
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
content, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "file.txt", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "hello" {
t.Errorf("expected 'hello', got %q", content)
}
}
func TestGetFileContentAtRef_404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "missing.go", "main")
if err == nil {
t.Fatal("expected error for 404")
}
}
func TestGetFileContentAtRef_401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "file.go", "main")
if err == nil {
t.Fatal("expected error for 401")
}
}
func TestGetFileContentAtRef_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`not valid json`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "file.go", "main")
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}
Review

[NIT] In TestGetCommitStatuses_CheckRunConclusions, each table entry creates a new httptest server inside a subtest. This is correct but could use t.Cleanup(srv.Close) instead of defer srv.Close() per the testing patterns (defer runs at function end, t.Cleanup runs at subtest end). In practice, defer works correctly here since the server is created inside the t.Run closure function body, but using t.Cleanup is more idiomatic per the documented patterns.

**[NIT]** In `TestGetCommitStatuses_CheckRunConclusions`, each table entry creates a new httptest server inside a subtest. This is correct but could use `t.Cleanup(srv.Close)` instead of `defer srv.Close()` per the testing patterns (defer runs at function end, t.Cleanup runs at subtest end). In practice, `defer` works correctly here since the server is created inside the `t.Run` closure function body, but using `t.Cleanup` is more idiomatic per the documented patterns.
func TestGetFileContentAtRef_429Retry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
json.NewEncoder(w).Encode(map[string]string{
"content": "b2s=", // "ok" in base64
"encoding": "base64",
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond})
content, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "file.go", "main")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "ok" {
t.Errorf("expected 'ok', got %q", content)
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
}
func TestGetCommitStatuses_HappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/status"):
json.NewEncoder(w).Encode(map[string]interface{}{
"state": "success",
"statuses": []map[string]string{
{
"context": "ci/build",
"state": "success",
"description": "Build passed",
"target_url": "https://ci.example.com/1",
},
},
})
case strings.Contains(r.URL.Path, "/check-runs"):
conclusion := "success"
json.NewEncoder(w).Encode(map[string]interface{}{
"total_count": 1,
"check_runs": []map[string]interface{}{
{
"name": "lint",
"conclusion": &conclusion,
"status": "completed",
"html_url": "https://github.com/check/1",
},
},
})
default:
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(404)
}
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
statuses, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "abc123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(statuses) != 2 {
t.Fatalf("expected 2 statuses, got %d", len(statuses))
}
// First should be from commit statuses
if statuses[0].Context != "ci/build" {
t.Errorf("expected context 'ci/build', got %q", statuses[0].Context)
}
if statuses[0].Status != "success" {
t.Errorf("expected status 'success', got %q", statuses[0].Status)
}
// Second should be from check runs
if statuses[1].Context != "lint" {
t.Errorf("expected context 'lint', got %q", statuses[1].Context)
}
if statuses[1].Status != "success" {
t.Errorf("expected status 'success', got %q", statuses[1].Status)
}
}
func TestGetCommitStatuses_CheckRunConclusions(t *testing.T) {
tests := []struct {
conclusion *string
status string
want string
}{
{stringPtr("success"), "completed", "success"},
{stringPtr("failure"), "completed", "failure"},
{stringPtr("action_required"), "completed", "failure"},
{stringPtr("timed_out"), "completed", "failure"},
{stringPtr("cancelled"), "completed", "success"},
{stringPtr("skipped"), "completed", "success"},
{stringPtr("neutral"), "completed", "success"},
{nil, "in_progress", "pending"},
{nil, "queued", "pending"},
}
for _, tt := range tests {
name := "nil"
if tt.conclusion != nil {
name = *tt.conclusion
}
t.Run(name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/status") {
json.NewEncoder(w).Encode(map[string]interface{}{
"state": "success",
"statuses": []interface{}{},
})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"total_count": 1,
"check_runs": []map[string]interface{}{
{
"name": "check",
"conclusion": tt.conclusion,
"status": tt.status,
"html_url": "https://github.com/check/1",
},
},
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
statuses, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "sha1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(statuses) != 1 {
t.Fatalf("expected 1 status, got %d", len(statuses))
}
if statuses[0].Status != tt.want {
t.Errorf("expected status %q, got %q", tt.want, statuses[0].Status)
}
})
}
}
func TestGetCommitStatuses_404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "badsha")
if err == nil {
t.Fatal("expected error for 404")
}
}
func TestGetCommitStatuses_401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "sha")
if err == nil {
t.Fatal("expected error for 401")
}
}
func TestGetCommitStatuses_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`not json`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
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")
if err == nil {
t.Fatal("expected error for malformed JSON")
Review

[NIT] strPtr is defined in pr_test.go (package github). The same helper (or a very similar one) might be useful in other test files. Consider moving it to a shared test helper file (e.g., helpers_test.go) within the package to avoid potential duplication if other test files need it.

**[NIT]** `strPtr` is defined in `pr_test.go` (package `github`). The same helper (or a very similar one) might be useful in other test files. Consider moving it to a shared test helper file (e.g., `helpers_test.go`) within the package to avoid potential duplication if other test files need it.
}
Review

[NIT] strPtr helper is defined in pr_test.go but files_test.go is in the same package and could theoretically need it. Both are in package github (white-box tests), so there's no duplication issue here. However, there is a duplicate strPtr in neither file — only pr_test.go has it. Fine as-is.

**[NIT]** strPtr helper is defined in pr_test.go but files_test.go is in the same package and could theoretically need it. Both are in `package github` (white-box tests), so there's no duplication issue here. However, there is a duplicate strPtr in neither file — only pr_test.go has it. Fine as-is.
Review

[NIT] strPtr is defined in pr_test.go but files_test.go and client_test.go are in the same package. If any other test file ever needs this helper, it will collide. It's already fine since both are in package github, but naming it something more descriptive (e.g., stringPtr) would match the codebase's convention of meaningful names over abbreviations per the style conventions.

**[NIT]** `strPtr` is defined in `pr_test.go` but `files_test.go` and `client_test.go` are in the same package. If any other test file ever needs this helper, it will collide. It's already fine since both are in `package github`, but naming it something more descriptive (e.g., `stringPtr`) would match the codebase's convention of meaningful names over abbreviations per the style conventions.
Review

[NIT] stringPtr helper is defined in pr_test.go. If future tests in files_test.go or client_test.go need similar helpers, there could be duplication. Consider moving to a shared test helper file, though for now it's fine since it's only used in pr_test.go.

**[NIT]** `stringPtr` helper is defined in `pr_test.go`. If future tests in `files_test.go` or `client_test.go` need similar helpers, there could be duplication. Consider moving to a shared test helper file, though for now it's fine since it's only used in `pr_test.go`.
}
Review

[NIT] strPtr helper is defined in pr_test.go but files_test.go is in the same package (package github). If files_test.go ever needs this helper, there would be a duplicate definition. Consider putting shared test helpers in a helpers_test.go file. Not a current problem since files_test.go doesn't use it, but worth considering for consistency.

**[NIT]** `strPtr` helper is defined in `pr_test.go` but `files_test.go` is in the same package (`package github`). If `files_test.go` ever needs this helper, there would be a duplicate definition. Consider putting shared test helpers in a `helpers_test.go` file. Not a current problem since `files_test.go` doesn't use it, but worth considering for consistency.
func stringPtr(s string) *string {
return &s
}
+1
View File
@@ -44,6 +44,7 @@ type PullRequest struct {
type ChangedFile struct {
Filename string `json:"filename"`
Status string `json:"status"`
Patch string `json:"patch"`
}
// ContentEntry represents a file or directory entry from the contents API.