e324f034b5
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 40s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m55s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 3m14s
Implement the GitHub API client with PRReader and FileReader interface conformance for both github.com and GitHub Enterprise. New files: - github/client.go: Client struct, NewClient with configurable base URL, HTTP helpers with 429 retry and Retry-After support - github/pr.go: GetPullRequest, GetPullRequestDiff (per-request Accept header), GetPullRequestFiles (paginated, populates Patch field), GetFileContentAtRef (base64 decode), GetCommitStatuses (merges commit statuses + check runs with conclusion mapping) - github/files.go: GetFileContent (delegates to GetFileContentAtRef), ListContents, escapePath, decodeBase64Content helpers Type changes: - vcs/types.go: Add Patch field to ChangedFile struct Tests cover: happy path, 404, 401, 429+retry, malformed response, pagination, binary files, check run conclusion mapping, base64 decoding. Compile-time checks: var _ vcs.PRReader = (*Client)(nil) var _ vcs.FileReader = (*Client)(nil) Exit criteria met: - go test ./github/... passes (all methods) - NewClient with empty baseURL uses https://api.github.com - NewClient with GHE URL targets correctly - GetFileContent delegates to GetFileContentAtRef with empty ref - GetPullRequestFiles paginates and populates Patch field - GetCommitStatuses merges both commit statuses and check-runs
187 lines
4.8 KiB
Go
187 lines
4.8 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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)
|
|
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)
|
|
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.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)
|
|
c.SetHTTPClient(srv.Client())
|
|
c.RetryBackoff = []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)
|
|
c.SetHTTPClient(srv.Client())
|
|
c.RetryBackoff = []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
|
|
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)
|
|
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"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("token", srv.URL)
|
|
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)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|