feat(cmd): wire --provider and --base-url flags into CLI (Phase 5) #106
@@ -91,6 +91,10 @@ func main() {
|
||||
// NOTE: If a user passes both --vcs-url and --gitea-url, the last one on
|
||||
// the command line takes effect (standard flag package behavior). This is
|
||||
// acceptable since --gitea-url is deprecated and both serve the same purpose.
|
||||
//
|
||||
// ORDERING: This must remain AFTER vcsURL's flag.String declaration and BEFORE
|
||||
// flag.Parse(). The *vcsURL dereference captures the env-var-resolved default
|
||||
// at registration time; moving flag.Parse() above this line would break it.
|
||||
|
|
||||
flag.StringVar(vcsURL, "gitea-url", *vcsURL, "Deprecated: use --vcs-url instead")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
@@ -387,11 +387,15 @@ func (c *Client) doRequestWithBody(ctx context.Context, method, reqURL string, r
|
||||
|
||||
|
gpt-review-bot
commented
[NIT] Minor formatting: there is an extra blank line between the closing brace of doRequestWithBody and the start of doJSONRequest that could be removed for consistency. **[NIT]** Minor formatting: there is an extra blank line between the closing brace of doRequestWithBody and the start of doJSONRequest that could be removed for consistency.
sonnet-review-bot
commented
[NIT] There is a spurious blank line before the closing brace of **[NIT]** There is a spurious blank line before the closing brace of `doRequestWithBody` (after `return c.doRequestCore(...)`). Minor formatting issue that gofmt would not flag since it's inside a function body, but it's visually inconsistent.
|
||||
}
|
||||
|
sonnet-review-bot
commented
[NIT] doJSONRequest is added but not yet called by any public methods in the diff (the github package has no PostReview/DismissReview/etc. visible in the diff). This is fine for an incremental PR, but it means the new helper is untested at the integration level. The unit tests for doJSONRequest itself are present and cover the retry path. **[NIT]** doJSONRequest is added but not yet called by any public methods in the diff (the github package has no PostReview/DismissReview/etc. visible in the diff). This is fine for an incremental PR, but it means the new helper is untested at the integration level. The unit tests for doJSONRequest itself are present and cover the retry path.
|
||||
// doJSONRequest performs an HTTP request with a JSON body and returns the response body.
|
||||
// It handles HTTPS validation, authentication, and response reading.
|
||||
// It handles HTTPS validation, authentication, response reading, and retries on HTTP 429
|
||||
// rate limit responses (matching the retry behavior of doRequest).
|
||||
|
sonnet-review-bot
commented
[MINOR] **[MINOR]** `doJSONRequest` does not retry on HTTP 429 like `doRequest` does. Since `PostReview` and `DismissReview` are write operations that can be rate-limited by GitHub, missing retry logic here could cause failures in high-traffic repos. Consider either adding retry logic or documenting that write operations are not retried.
|
||||
// This is a general-purpose helper used by any method that needs to send JSON payloads
|
||||
// (e.g. PostReview, DismissReview).
|
||||
|
sonnet-review-bot
commented
[MAJOR] doJSONRequest duplicates ~100 lines of retry logic that already exists in doRequest. Both functions implement identical 429-retry-with-backoff loops, HTTPS validation, and Retry-After header parsing. This violates DRY and doubles the maintenance burden — a future change to retry semantics must be applied in two places. The preferred approach would be to make doRequest accept an optional body (io.Reader) or to extract the shared retry scaffolding into a single private helper that both GET and write operations call. **[MAJOR]** doJSONRequest duplicates ~100 lines of retry logic that already exists in doRequest. Both functions implement identical 429-retry-with-backoff loops, HTTPS validation, and Retry-After header parsing. This violates DRY and doubles the maintenance burden — a future change to retry semantics must be applied in two places. The preferred approach would be to make doRequest accept an optional body (io.Reader) or to extract the shared retry scaffolding into a single private helper that both GET and write operations call.
|
||||
func (c *Client) doJSONRequest(ctx context.Context, method, reqURL string, payload any) ([]byte, error) {
|
||||
const maxErrorBodyBytes = 4 * 1024
|
||||
const (
|
||||
|
sonnet-review-bot
commented
[MINOR] **[MINOR]** `doJSONRequest` is added to `client.go` but is only used internally by `review.go` methods. Placing transport-layer helpers (`doRequestCore`, `doRequestWithBody`) together with a higher-level JSON serialisation helper makes the file slightly inconsistent. This is a minor organisation concern — a comment grouping the helpers or moving `doJSONRequest` to `review.go` would improve cohesion, but the current placement is not wrong.
|
||||
maxErrorBodyBytes = 4 * 1024
|
||||
maxRetryAfter = 120 * time.Second
|
||||
)
|
||||
|
||||
jsonBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -408,34 +412,89 @@ func (c *Client) doJSONRequest(ctx context.Context, method, reqURL string, paylo
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
defaultBackoff := []time.Duration{1 * time.Second, 2 * time.Second}
|
||||
var backoff []time.Duration
|
||||
if c.retryBackoff != nil && len(c.retryBackoff) == maxRetryAttempts-1 {
|
||||
backoff = make([]time.Duration, len(c.retryBackoff))
|
||||
copy(backoff, c.retryBackoff)
|
||||
} else {
|
||||
backoff = make([]time.Duration, len(defaultBackoff))
|
||||
copy(backoff, defaultBackoff)
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetryAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
var delay time.Duration
|
||||
if attempt-1 < len(backoff) {
|
||||
delay = backoff[attempt-1]
|
||||
}
|
||||
if delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-timer.C:
|
||||
timer.Stop()
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxResponseBytes)+1))
|
||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body: %w", err)
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
if len(body) > maxResponseBytes {
|
||||
return nil, fmt.Errorf("response body exceeded %d bytes", maxResponseBytes)
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
return body, nil
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
|
||||
respStatus := resp.StatusCode
|
||||
retryAfterHeader := resp.Header.Get("Retry-After")
|
||||
|
||||
body, done, handleErr := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
|
||||
if done {
|
||||
return body, handleErr
|
||||
}
|
||||
lastErr = handleErr
|
||||
|
||||
// Retry on 429 rate limit
|
||||
if respStatus == http.StatusTooManyRequests && attempt < maxRetryAttempts-1 {
|
||||
if ra := retryAfterHeader; 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
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, int64(maxErrorBodyBytes)))
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -592,3 +593,55 @@ func TestSetRetryBackoff_RejectsInvalidLength(t *testing.T) {
|
||||
t.Fatalf("unexpected error for valid backoff: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoJSONRequest_429Retry(t *testing.T) {
|
||||
attempts := 0
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
w.WriteHeader(429)
|
||||
w.Write([]byte(`{"message":"rate limit exceeded"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{"id":1}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewClient("token", ts.URL, AllowInsecureHTTP())
|
||||
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
|
||||
|
||||
|
sonnet-review-bot
commented
[MINOR] **[MINOR]** `c.SetRetryBackoff(...)` return value is not checked in `TestDoJSONRequest_429Retry`. The call on line 613 ignores the error. All other tests in the file use `if err := c.SetRetryBackoff(...); err != nil { t.Fatalf(...) }`. Should be consistent.
|
||||
body, err := c.doJSONRequest(context.Background(), http.MethodPost, ts.URL+"/test", map[string]string{"key": "val"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Errorf("expected 3 attempts, got %d", attempts)
|
||||
}
|
||||
if string(body) != `{"id":1}` {
|
||||
t.Errorf("unexpected body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoJSONRequest_429ExhaustsRetries(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(429)
|
||||
w.Write([]byte(`{"message":"rate limit"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := NewClient("token", ts.URL, AllowInsecureHTTP())
|
||||
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
|
||||
|
||||
_, err := c.doJSONRequest(context.Background(), http.MethodPost, ts.URL+"/test", map[string]string{"key": "val"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error after exhausting retries")
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.StatusCode != 429 {
|
||||
t.Errorf("expected 429, got %d", apiErr.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
[NIT] The VCS provider validation switch comes after
setupLoggerbut beforevalidateReviewerName. The ordering is sensible but provider validation could reasonably be done before logging setup since it's a hard prerequisite. Current ordering is fine — just noting the implicit ordering contract.[NIT] The
--base-urlflag comment says 'defaults to https://api.github.com' but the actual default applied by the flag is empty string, with the defaulting logic deferred to the client-init switch. The flag help text is technically correct but could confuse users inspecting--helpoutput, since the flag's zero value appears as empty. Consider either setting the default directly in the flag registration (conditional on provider) or clarifying the help text.[NIT] The
envOrDefaultBoolfunction is defined and tested but never used in main.go after this refactor. It was presumably used before and left as dead code. Consider removing it to keep the file clean.[NIT] The --gitea-url alias comment block (lines 97-106) is thorough documentation of a non-obvious flag.StringVar trick. Consider extracting the alias registration into a one-liner with a brief inline comment; the current 10-line comment block dominates the flag registration section visually.