feat(github): implement GitHub API client foundation #101
Reference in New Issue
Block a user
Delete Branch "issue-80-a-client"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #98. Part of #80.
Implement the base
Clientstruct,NewClientwith configurable base URL/GHE support, HTTP helpers with 429 retry and Retry-After handling. Also addsPatchfield tovcs.ChangedFile.Files:
vcs/types.go,github/client.go,github/client_test.goPart 1 of 3 — see #99 (PRReader) and #100 (FileReader) for follow-up work.
Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
0e7e12a9)Sonnet Review
Summary
Well-implemented GitHub API client foundation with thorough security considerations (auth header stripping on cross-host redirects, HTTPS enforcement, log injection prevention) and comprehensive test coverage. The code is idiomatic Go and follows the documented patterns correctly. A few minor issues worth noting but none blocking.
Findings
github/client.gohandleResponsereturnsdone=false(the 429 case that will be retried),respis already closed byhandleResponse's defer. The subsequentresp.StatusCode == http.StatusTooManyRequestscheck on line 258 reads from a closed response, but this is safe becauserespis a struct value (not a stream) andStatusCodewas already populated. However, the design wherehandleResponseowns the response lifecycle (via defer) but the caller still needs to inspectresp.StatusCodeafterward is a subtle coupling. The caller readsresp.StatusCodeon line 258 afterhandleResponsehas already closed the body — this works but is fragile. Consider returning the status code fromhandleResponseor restructuring to make the ownership clearer.github/client.goattempt < maxAttempts-1is evaluated afterhandleResponsereturnsdone=false. This means on the final attempt (attempt==2), a 429 response falls through toreturn nil, lastErrcorrectly. However, thecontinueon a 429 means the loop incrementsattemptand hits the backoff sleep at the top — butbackoffhas only 2 entries for 3 attempts. The indexingbackoff[attempt-1]for attempt=1 gives backoff[0] and for attempt=2 gives backoff[1], which is correct. But the guardattempt < maxAttempts-1(i.e., attempt < 2) means we only continue for attempts 0 and 1, never attempt 2. On attempt 2 a 429 hitsreturn nil, lastErr. This is correct behavior but the interaction between the backoff slice length and maxAttempts is implicit and could silently break if maxAttempts is changed without updating the default backoff.github/client_test.goTestDoRequest_RetryAfterDoesNotMutateBackoffskips in short mode witht.Skip("skipping slow retry test in short mode")but the test sets aRetry-After: 1header which causes a ~1 second delay. This is intentional for the actual retry test, but the 'does not mutate' property could be verified without the real delay by usingRetryAfter: 0or a past date. Minor since the skip guard is appropriate.github/client.gotimer.Stop()after<-timer.Cwith a comment 'no-op after fire; kept for symmetry'. The standard Go idiom from the docs recommends this for correctness in case of concurrent resets, but for a non-reset timer this is indeed a no-op. The comment explains the intent — this is fine as written.github/client_test.goTestDoRequest_RetryAfterDoesNotMutateBackoffaccessesc.retryBackoffdirectly (a private field) from within thegithubpackage test — this works because the test is inpackage github(white-box test). This is intentional and correct given the test file is in the same package.Recommendation
APPROVE — Approve. The implementation is high quality — security-conscious redirect handling, clean separation of concerns, idiomatic error types with
errors.As, proper copy of the backoff slice to prevent mutation, and thorough test coverage including edge cases (past HTTP-dates, exact-at-limit body sizes, empty tokens). The minor issues identified are design observations rather than bugs: theresplifetime coupling betweenhandleResponseand the caller's status check is the most noteworthy, but it works correctly as written. The conventions aroundtesting.Short()for slow tests are followed. CI passed. Ready to merge.Review by sonnet
Evaluated against
0e7e12a9@@ -0,0 +227,4 @@case <-timer.C:timer.Stop() // no-op after fire; kept for symmetry with the ctx.Done casecase <-ctx.Done():timer.Stop()[NIT] The timer pattern uses
timer.Stop()after<-timer.Cwith a comment 'no-op after fire; kept for symmetry'. The standard Go idiom from the docs recommends this for correctness in case of concurrent resets, but for a non-reset timer this is indeed a no-op. The comment explains the intent — this is fine as written.@@ -0,0 +248,4 @@req.Header.Set("Accept", accept)} else {req.Header.Set("Accept", "application/vnd.github+json")}[MINOR] After
handleResponsereturnsdone=false(the 429 case that will be retried),respis already closed byhandleResponse's defer. The subsequentresp.StatusCode == http.StatusTooManyRequestscheck on line 258 reads from a closed response, but this is safe becauserespis a struct value (not a stream) andStatusCodewas already populated. However, the design wherehandleResponseowns the response lifecycle (via defer) but the caller still needs to inspectresp.StatusCodeafterward is a subtle coupling. The caller readsresp.StatusCodeon line 258 afterhandleResponsehas already closed the body — this works but is fragile. Consider returning the status code fromhandleResponseor restructuring to make the ownership clearer.@@ -0,0 +255,4 @@return nil, fmt.Errorf("do request: %w", err)}body, done, err := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)[MINOR] The retry condition
attempt < maxAttempts-1is evaluated afterhandleResponsereturnsdone=false. This means on the final attempt (attempt==2), a 429 response falls through toreturn nil, lastErrcorrectly. However, thecontinueon a 429 means the loop incrementsattemptand hits the backoff sleep at the top — butbackoffhas only 2 entries for 3 attempts. The indexingbackoff[attempt-1]for attempt=1 gives backoff[0] and for attempt=2 gives backoff[1], which is correct. But the guardattempt < maxAttempts-1(i.e., attempt < 2) means we only continue for attempts 0 and 1, never attempt 2. On attempt 2 a 429 hitsreturn nil, lastErr. This is correct behavior but the interaction between the backoff slice length and maxAttempts is implicit and could silently break if maxAttempts is changed without updating the default backoff.@@ -0,0 +176,4 @@err2 := &APIError{StatusCode: 500, Body: "server error"}if IsNotFound(err2) {t.Error("expected IsNotFound to return false for 500")}[NIT] The test
TestDoRequest_RetryAfterDoesNotMutateBackoffskips in short mode witht.Skip("skipping slow retry test in short mode")but the test sets aRetry-After: 1header which causes a ~1 second delay. This is intentional for the actual retry test, but the 'does not mutate' property could be verified without the real delay by usingRetryAfter: 0or a past date. Minor since the skip guard is appropriate.@@ -0,0 +192,4 @@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") {[NIT]
TestDoRequest_RetryAfterDoesNotMutateBackoffaccessesc.retryBackoffdirectly (a private field) from within thegithubpackage test — this works because the test is inpackage github(white-box test). This is intentional and correct given the test file is in the same package.Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
0e7e12a9)Security Review
Summary
The new GitHub API client is implemented with sound security practices: HTTPS enforcement for credentials, safe redirect handling to prevent token leakage, bounded response reading, and cautious error reporting. CI has passed and no exploitable vulnerabilities were identified.
Recommendation
APPROVE — Proceed with merge. The client’s protections against HTTPS-to-HTTP downgrades, cross-host Authorization leakage, and oversized responses are appropriate. The retry behavior respects Retry-After with sane caps, and error messages are sanitized and bounded. No additional security issues were found in this change.
Review by security
Evaluated against
0e7e12a9Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
0e7e12a9)Gpt Review
Summary
Well-designed GitHub client foundation with safe redirect handling, sensible retry logic (including Retry-After support), and thorough tests. Changes to VCS types are minimal and appropriate.
Recommendation
APPROVE — The implementation follows idiomatic Go patterns: clear package docs, concrete return type from constructor, small helper methods, and robust error handling with structured APIError and errors.As. Redirect policy prevents credential leakage, and the 429 retry logic respects Retry-After with caps and preserves the original backoff slice. Tests are comprehensive and cover headers, retries, limits, and security checks. CI passed. Proceed to merge.
Review by gpt
Evaluated against
0e7e12a9Addressed the two MINOR findings. See commit
3d1260d.@@ -0,0 +200,4 @@} else {backoff = []time.Duration{1 * time.Second, 2 * time.Second}}if len(backoff) != maxAttempts-1 {Fixed. Added a panic guard ensuring
len(backoff) == maxAttempts-1. If someone changesmaxAttemptswithout updating the default backoff, this fires immediately in tests rather than silently producing incorrect retry behavior.@@ -0,0 +261,4 @@return nil, fmt.Errorf("do request: %w", err)}// Capture response metadata before handleResponse takes body ownership.Fixed. Now capturing
resp.StatusCodeandRetry-Afterheader into local variables before callinghandleResponse, making the ownership transfer explicit. The caller no longer touchesrespafter handing it off.Re NITs:
Retry-After: 0, but the skip guard handles the slow path cleanly. Leaving as-is.Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
3d1260d3)Sonnet Review
Summary
Well-structured GitHub API client foundation with thorough security measures (HTTPS enforcement, auth-stripping on cross-host redirects, log injection sanitization), comprehensive retry logic with Retry-After support, and excellent test coverage. The code follows established Go patterns throughout.
Findings
github/client.gogithub/client.godelay == 0(e.g., a past HTTP-date Retry-After), the timer block is skipped entirely due to theif delay > 0guard, which is correct. However, ifattempt-1 >= len(backoff)(impossible given the length check, but defensive readers will note it), delay stays 0 and is also skipped. The logic is correct but theattempt-1 < len(backoff)guard (line 245) is now dead code because the panic above guaranteeslen(backoff) == maxAttempts-1 == 2andattemptranges 1..2, soattempt-1is always 0 or 1, always in bounds. The dead branch is harmless but slightly confusing.github/client.goSetHTTPClientandSetRetryBackoffmethods are documented as 'test setup only' and 'must be called before any goroutines issue requests'. Consider either unexported test helpers via an export_test.go bridge (following the stdlib export_test.go pattern documented in the testing patterns) to avoid exposing these mutation methods on the public API surface, or at minimum adding a//nolintor similar signal. As-is, exported mutation methods on a type documented as 'safe for concurrent use' is a mild API design tension.github/client_test.goerr.(*APIError)rather thanerrors.As. While this works since doRequest returns *APIError unwrapped, usingerrors.Aswould be more idiomatic per the error-handling patterns and would remain correct if error wrapping is added later.github/client.godelay == 0, the timer is never created buttimer.Stop()is only called after the select fires. This is fine. However, in thecase <-timer.Cbranch,timer.Stop()is called after the channel has already fired — the comment acknowledges it's a no-op. This is correct per Go docs but the symmetry comment is slightly misleading;timer.Stop()after drain is genuinely a no-op and serves no purpose beyond visual symmetry. Not a bug.Recommendation
APPROVE — Approve with minor notes. The implementation is high quality: security-conscious (HTTPS enforcement, credential stripping, log injection sanitization), well-tested (27 tests covering happy paths, retry exhaustion, header behavior, Retry-After formats, body size limits, redirect policy), and follows Go patterns throughout. The one convention deviation worth flagging is the
panicindoRequestfor backoff length mismatch — the repo convention says 'Return errors; never panic' — but this is a programming-error guard that will only fire when a developer misconfigures the backoff array, and is effectively dead in correctly-configured code. The test-setup methods exposed on the public API (SetHTTPClient,SetRetryBackoff) are a mild design tension with the 'safe for concurrent use' guarantee, but are clearly documented. These are not blocking issues given CI passes.Review by sonnet
Evaluated against
3d1260d3@@ -0,0 +113,4 @@func defaultCheckRedirect(req *http.Request, via []*http.Request) error {if len(via) >= 10 {return fmt.Errorf("stopped after 10 redirects")}[MINOR] The
SetHTTPClientandSetRetryBackoffmethods are documented as 'test setup only' and 'must be called before any goroutines issue requests'. Consider either unexported test helpers via an export_test.go bridge (following the stdlib export_test.go pattern documented in the testing patterns) to avoid exposing these mutation methods on the public API surface, or at minimum adding a//nolintor similar signal. As-is, exported mutation methods on a type documented as 'safe for concurrent use' is a mild API design tension.@@ -0,0 +194,4 @@// Length must be maxAttempts-1 (one entry per retry gap). Panic early on misconfiguration// so a maxAttempts change without a matching backoff update is caught in tests, not production.var backoff []time.Durationif c.retryBackoff != nil {[MINOR] The panic on backoff length mismatch is a deliberate 'catch misconfiguration in tests' guard, but panicking in a library function violates the repo convention ('Return errors; never panic'). The comment justifies it as a programming error caught early, but it means any test that calls SetRetryBackoff with the wrong length (e.g., during future refactoring that changes maxAttempts) will panic rather than getting a useful error. Consider returning an error from doRequest or validating at SetRetryBackoff time instead.
@@ -0,0 +245,4 @@}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[MINOR] When
delay == 0(e.g., a past HTTP-date Retry-After), the timer block is skipped entirely due to theif delay > 0guard, which is correct. However, ifattempt-1 >= len(backoff)(impossible given the length check, but defensive readers will note it), delay stays 0 and is also skipped. The logic is correct but theattempt-1 < len(backoff)guard (line 245) is now dead code because the panic above guaranteeslen(backoff) == maxAttempts-1 == 2andattemptranges 1..2, soattempt-1is always 0 or 1, always in bounds. The dead branch is harmless but slightly confusing.@@ -0,0 +295,4 @@if attempt < len(backoff) {backoff[attempt] = delay}}[NIT] Minor timer resource leak: when
delay == 0, the timer is never created buttimer.Stop()is only called after the select fires. This is fine. However, in thecase <-timer.Cbranch,timer.Stop()is called after the channel has already fired — the comment acknowledges it's a no-op. This is correct per Go docs but the symmetry comment is slightly misleading;timer.Stop()after drain is genuinely a no-op and serves no purpose beyond visual symmetry. Not a bug.@@ -0,0 +164,4 @@t.Fatal("expected error for 401")}if attempts != 1 {t.Errorf("expected 1 attempt (no retry on 401), got %d", attempts)[NIT] TestDoRequest_429ExhaustsRetries uses a direct type assertion
err.(*APIError)rather thanerrors.As. While this works since doRequest returns *APIError unwrapped, usingerrors.Aswould be more idiomatic per the error-handling patterns and would remain correct if error wrapping is added later.Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
3d1260d3)Gpt Review
Summary
Solid foundation for a GitHub API client with comprehensive tests and sensible defaults. However, the client panics on a backoff misconfiguration, which violates the repository’s error-handling policy and Go best practices.
Findings
github/client.gogithub/client.gogithub/client.gogithub/client.gogithub/client.goRecommendation
REQUEST_CHANGES — Replace the panic in doRequest with a non-panicking approach consistent with the repository’s error-handling policy. Options include: (1) validate and enforce the expected backoff length in SetRetryBackoff by returning an error to the caller; (2) tolerate mismatched lengths by using provided values and treating missing entries as zero-delay; or (3) default to an internal safe backoff when invalid. This ensures the library never panics in production. Additionally, consider minor cleanups: remove the unnecessary timer.Stop call post-fire, and tweak the oversized-body error message to avoid implying a truncated body was returned. If no further options are foreseen, consider whether functional options are necessary; otherwise, keeping them is fine if more options are planned.
Review by gpt
Evaluated against
3d1260d3@@ -0,0 +72,4 @@return target, true}return nil, false}[MINOR] Functional options are used for a single AllowInsecureHTTP option. While acceptable if you plan to add more options soon, the documented configuration patterns suggest avoiding functional options for <3 options unless growth is expected.
@@ -0,0 +149,4 @@baseURL: strings.TrimRight(baseURL, "/"),allowInsecureHTTP: cfg.allowInsecureHTTP,token: token,httpClient: &http.Client{[MINOR] The strict coupling between maxAttempts and backoff length could be relaxed. Consider validating the slice in SetRetryBackoff (returning an error) or tolerating mismatched lengths by using available entries and treating missing entries as zero delays, instead of enforcing equality at request time.
@@ -0,0 +163,4 @@// Passing nil restores the default client (30s timeout + auth-stripping// CheckRedirect policy matching NewClient).//// Callers providing a non-nil client are responsible for configuring a safe[MAJOR] doRequest panics if the computed backoff length does not equal maxAttempts-1. Repository conventions require returning errors and never panicking; this should be handled gracefully (e.g., defaulting, validation at setter time, or returning an error).
@@ -0,0 +191,4 @@const maxRetryAfter = 120 * time.Second// backoff holds per-attempt delays: backoff[i] is the delay before attempt i+1.// Length must be maxAttempts-1 (one entry per retry gap). Panic early on misconfiguration[NIT] Calling timer.Stop after the timer has already fired is unnecessary. It’s harmless but can be removed for clarity.
@@ -0,0 +274,4 @@// Retry on 429 rate limitif respStatus == 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).[MINOR] The error message references "(truncated)" for oversized success responses, but the function returns an error without returning a truncated body. Consider rewording to avoid implying truncated data was returned.
Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
3d1260d3)Security Review
Summary
Solid foundational GitHub client with secure defaults: enforces HTTPS for tokens by default, strips Authorization on cross-host redirects, caps response sizes, and honors Retry-After with bounds. Tests cover critical behaviors. One minor availability concern noted.
Findings
github/client.goRecommendation
APPROVE — The implementation follows secure patterns: it avoids sending credentials over plaintext by default, strips Authorization on cross-host redirects to prevent credential leakage, caps response body sizes to prevent resource exhaustion, and bounds Retry-After to mitigate long delays. CI passed and tests cover key security behaviors. Consider replacing the panic on backoff length mismatch with a safe default or an explicit error to prevent potential availability issues if misused. Otherwise, the changes are sound from a security perspective and can be merged.
Review by security
Evaluated against
3d1260d3@@ -0,0 +201,4 @@backoff = []time.Duration{1 * time.Second, 2 * time.Second}}if len(backoff) != maxAttempts-1 {panic(fmt.Sprintf("github: backoff length %d does not match maxAttempts-1 (%d)", len(backoff), maxAttempts-1))[MINOR] doRequest panics if the computed backoff length doesn't match maxAttempts-1. While intended to catch misconfiguration in tests, panics can crash the process and create a denial-of-service if this method is misused in production. Prefer returning an error or falling back to a safe default to avoid process termination.
Re gpt-review-bot findings (commit
61819ac):MAJOR #1 (panic on backoff misconfiguration): Fixed — removed the panic from
doRequest. Validation now happens inSetRetryBackoffwhich returns an error on invalid length.doRequestdefensively falls back to the safe default backoff{1s, 2s}if somehow misconfigured.MINOR #2 (backoff/maxAttempts coupling): Fixed —
SetRetryBackoffvalidates length againstmaxRetryAttempts-1at configuration time, making the coupling explicit and catching mismatches early with a clear error message.MINOR #4 (truncated error message): Fixed — reworded from "exceeded N bytes (truncated)" to "exceeded N bytes" since no truncated body is returned.
MINOR #5 (functional options): Keeping as-is. This is idiomatic Go for constructor options and allows future growth without breaking the API. The pattern costs nothing at runtime and one option is a valid starting point.
Added
TestSetRetryBackoff_RejectsInvalidLengthto verify the validation. All tests pass.Sonnet Review
Summary
This is a well-implemented GitHub API client foundation. The code is idiomatic Go, follows the established patterns, has thorough test coverage with good edge case handling, and CI passes. A few minor observations worth noting but none blocking.
Findings
github/client.godefaultBackofflocal variable is declared but serves only as a template for the else branch. The variable name and the copy-pattern are fine, but it could be simplified slightly: theif c.retryBackoff != nil && len(c.retryBackoff) == maxRetryAttempts-1guard duplicates the invariant already enforced bySetRetryBackoff. A comment noting that the length check here is a defensive guard against direct struct construction (bypassing the setter) would clarify the intent.github/client.goc.httpClient.Do(req)are returned immediately without settinglastErr, meaning if a transport error occurs on attempt > 0 after successful 429 retries, the function correctly returns the error — but if the intent is to return the last meaningful error in all cases, the transport error path bypasseslastErr. This is fine as-is since transport errors are not retried per the documented contract, but a brief inline comment noting 'transport errors are non-retryable, return immediately' would improve clarity.github/client.godefaultCheckRedirectfunction useslen(via) >= 10to cap redirects, matching net/http's own default of 10. However, the stdlib's defaultCheckRedirectreturnsErrUseLastResponse-based behavior; this returns a plainfmt.Errorf. This is functionally equivalent (both cause the redirect to fail) but the error message won't be recognized as a redirect limit error by callers usingerrors.Is. Since this is internal behavior and unlikely to be checked by callers, it's a minor style point only.github/client_test.goTestSetHTTPClient_NilRestoresDefaultandTestSetRetryBackoff_RejectsInvalidLength. Minor formatting —gofmtwould normalize this to a single blank line.github/client.goallowInsecureHTTPfield is stored on bothclientConfigandClient. While this is a deliberate copy (the option is read once at construction and stored for use indoRequest), adding a brief comment on theClient.allowInsecureHTTPfield noting it is set fromClientOptionat construction time would make the data flow clearer for future maintainers.Recommendation
APPROVE — Approve. The implementation is solid: correct error handling with
%wwrapping anderrors.As, proper use ofio.LimitReaderfor body size enforcement, defensive copy of the backoff slice to prevent mutation, security-conscious redirect policy, and comprehensive test coverage including edge cases (past HTTP-date, exact-at-limit body, no-token HTTP, backoff mutation guard). All findings are nits. The code follows the repository's conventions and the established Go patterns throughout.Review by sonnet
Evaluated against
61819ac3@@ -0,0 +90,4 @@// This should only be used for trusted internal deployments or testing.func AllowInsecureHTTP() ClientOption {return func(c *clientConfig) {c.allowInsecureHTTP = true[NIT] The
allowInsecureHTTPfield is stored on bothclientConfigandClient. While this is a deliberate copy (the option is read once at construction and stored for use indoRequest), adding a brief comment on theClient.allowInsecureHTTPfield noting it is set fromClientOptionat construction time would make the data flow clearer for future maintainers.@@ -0,0 +117,4 @@func defaultCheckRedirect(req *http.Request, via []*http.Request) error {if len(via) >= 10 {return fmt.Errorf("stopped after 10 redirects")}[NIT] The
defaultCheckRedirectfunction useslen(via) >= 10to cap redirects, matching net/http's own default of 10. However, the stdlib's defaultCheckRedirectreturnsErrUseLastResponse-based behavior; this returns a plainfmt.Errorf. This is functionally equivalent (both cause the redirect to fail) but the error message won't be recognized as a redirect limit error by callers usingerrors.Is. Since this is internal behavior and unlikely to be checked by callers, it's a minor style point only.@@ -0,0 +197,4 @@// Transport errors (network failures, context cancellation) are not retried.func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {const maxRetryAfter = 120 * time.Second[NIT] The
defaultBackofflocal variable is declared but serves only as a template for the else branch. The variable name and the copy-pattern are fine, but it could be simplified slightly: theif c.retryBackoff != nil && len(c.retryBackoff) == maxRetryAttempts-1guard duplicates the invariant already enforced bySetRetryBackoff. A comment noting that the length check here is a defensive guard against direct struct construction (bypassing the setter) would clarify the intent.@@ -0,0 +268,4 @@return nil, fmt.Errorf("do request: %w", err)}// Capture response metadata before handleResponse takes body ownership.[NIT] Transport errors from
c.httpClient.Do(req)are returned immediately without settinglastErr, meaning if a transport error occurs on attempt > 0 after successful 429 retries, the function correctly returns the error — but if the intent is to return the last meaningful error in all cases, the transport error path bypasseslastErr. This is fine as-is since transport errors are not retried per the documented contract, but a brief inline comment noting 'transport errors are non-retryable, return immediately' would improve clarity.@@ -0,0 +486,4 @@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"}},[NIT] Double blank line between
TestSetHTTPClient_NilRestoresDefaultandTestSetRetryBackoff_RejectsInvalidLength. Minor formatting —gofmtwould normalize this to a single blank line.Gpt Review
Summary
Solid, idiomatic foundation for a GitHub API client with clear error handling, redirect safety, and robust 429 retry logic. Tests are comprehensive and adhere to repository conventions; CI passed.
Recommendation
APPROVE — The implementation follows Go patterns well: a clear NewClient constructor, safe redirect policy, structured APIError, context-aware requests, and bounded body reads. Retry-After handling for both seconds and HTTP-date formats is correct and non-mutating to the configured backoff, and the early refusal to send credentials over HTTP is a good safety guard (with an override for trusted environments). The addition of Patch to vcs.ChangedFile is straightforward and backwards-compatible for JSON decoding. No changes requested.
Review by gpt
Evaluated against
61819ac3Security Review
Summary
Solid, security-conscious GitHub API client foundation with HTTPS-by-default, redirect hardening, response size limits, and rate-limit retry behavior. No exploitable vulnerabilities identified in this change set.
Findings
github/client.gogithub/client.goRecommendation
APPROVE — Approve the PR. The client defaults to secure behavior (HTTPS-only with tokens, redirect policy that strips Authorization on cross-host and blocks HTTPS→HTTP, response size limits, and bounded Retry-After handling). For defense in depth, consider adding a check to ensure request hosts match the configured baseURL to avoid accidental credential leakage if a future internal call passes an arbitrary URL, and consider redacting query strings from URL-containing error messages.
Review by security
Evaluated against
61819ac3@@ -0,0 +217,4 @@const maxErrorBodyBytes = 4 * 1024// Reject non-HTTPS URLs early since the URL is immutable across retries.if c.token != "" && !c.allowInsecureHTTP {[MINOR] doRequest validates HTTPS when a token is present but does not ensure the request host matches the configured baseURL. If future internal callers accidentally pass an arbitrary URL, the Authorization header could be sent to an unintended host. Consider enforcing that reqURL's host equals the client's baseURL host (or only accept relative paths) to prevent credential leakage by misuse.
@@ -0,0 +223,4 @@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)[NIT] The error message for refusing non-HTTPS includes the full URL. If this error is logged upstream, URL query strings could leak sensitive parameters. Consider eliding or redacting query components in error messages.