323 lines
9.0 KiB
Go
323 lines
9.0 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestConvertPatPR(t *testing.T) {
|
|
raw := &patPR{
|
|
Number: 10,
|
|
Title: "Feature Y",
|
|
HTMLURL: "https://github.com/org/repo/pull/10",
|
|
State: "open",
|
|
Draft: false,
|
|
Additions: 200,
|
|
Deletions: 30,
|
|
CreatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
|
|
UpdatedAt: time.Date(2025, 3, 2, 0, 0, 0, 0, time.UTC),
|
|
RequestedReviewers: []struct {
|
|
Login string `json:"login"`
|
|
}{
|
|
{Login: "alice-reviewer"},
|
|
},
|
|
RequestedTeams: []struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
}{
|
|
{Slug: "platform-team"},
|
|
},
|
|
User: struct {
|
|
Login string `json:"login"`
|
|
}{Login: "bob"},
|
|
Head: struct {
|
|
Ref string `json:"ref"`
|
|
SHA string `json:"sha"`
|
|
}{Ref: "feature-y", SHA: "deadbeef1234"},
|
|
Base: struct {
|
|
Ref string `json:"ref"`
|
|
}{Ref: "main"},
|
|
}
|
|
|
|
pr := convertPatPR(raw, "org", "repo")
|
|
|
|
if pr.Number != 10 {
|
|
t.Errorf("number = %d, want 10", pr.Number)
|
|
}
|
|
if pr.State != "open" {
|
|
t.Errorf("state = %q, want open", pr.State)
|
|
}
|
|
if pr.AuthorLogin != "bob" {
|
|
t.Errorf("author = %q, want bob", pr.AuthorLogin)
|
|
}
|
|
if pr.HeadBranch != "feature-y" {
|
|
t.Errorf("head = %q, want feature-y", pr.HeadBranch)
|
|
}
|
|
if pr.HeadSHA != "deadbeef1234" {
|
|
t.Errorf("head_sha = %q, want deadbeef1234", pr.HeadSHA)
|
|
}
|
|
if pr.Mergeable {
|
|
t.Error("expected mergeable = false when nil")
|
|
}
|
|
if len(pr.RequestedReviewers) != 2 {
|
|
t.Fatalf("requested reviewers = %d, want 2", len(pr.RequestedReviewers))
|
|
}
|
|
if pr.RequestedReviewers[0] != (RequestedReviewer{Login: "alice-reviewer", Type: reviewerTypeUser}) {
|
|
t.Errorf("unexpected first requested reviewer: %#v", pr.RequestedReviewers[0])
|
|
}
|
|
if pr.RequestedReviewers[1] != (RequestedReviewer{Login: "platform-team", Type: reviewerTypeTeam}) {
|
|
t.Errorf("unexpected second requested reviewer: %#v", pr.RequestedReviewers[1])
|
|
}
|
|
if pr.MergedAt != nil {
|
|
t.Error("expected nil MergedAt")
|
|
}
|
|
}
|
|
|
|
func TestConvertPatPR_Merged(t *testing.T) {
|
|
mergedAt := "2025-03-05T10:00:00Z"
|
|
raw := &patPR{
|
|
Number: 5,
|
|
State: "closed",
|
|
MergedAt: &mergedAt,
|
|
User: struct {
|
|
Login string `json:"login"`
|
|
}{Login: "alice"},
|
|
Head: struct {
|
|
Ref string `json:"ref"`
|
|
SHA string `json:"sha"`
|
|
}{Ref: "fix"},
|
|
Base: struct {
|
|
Ref string `json:"ref"`
|
|
}{Ref: "main"},
|
|
}
|
|
|
|
pr := convertPatPR(raw, "org", "repo")
|
|
|
|
if pr.State != prStateMerged {
|
|
t.Errorf("state = %q, want merged", pr.State)
|
|
}
|
|
if pr.MergedAt == nil {
|
|
t.Fatal("expected non-nil MergedAt")
|
|
}
|
|
}
|
|
|
|
func TestConvertPatPR_Mergeable(t *testing.T) {
|
|
mergeable := true
|
|
raw := &patPR{
|
|
Number: 1,
|
|
State: "open",
|
|
Mergeable: &mergeable,
|
|
User: struct {
|
|
Login string `json:"login"`
|
|
}{Login: "alice"},
|
|
Head: struct {
|
|
Ref string `json:"ref"`
|
|
SHA string `json:"sha"`
|
|
}{Ref: "b"},
|
|
Base: struct {
|
|
Ref string `json:"ref"`
|
|
}{Ref: "main"},
|
|
}
|
|
|
|
pr := convertPatPR(raw, "o", "r")
|
|
if !pr.Mergeable {
|
|
t.Error("expected mergeable = true")
|
|
}
|
|
}
|
|
|
|
func TestConvertPatPR_MergeableState(t *testing.T) {
|
|
raw := &patPR{
|
|
Number: 2,
|
|
State: "open",
|
|
MergeableState: "CLEAN", // GitHub REST uses lowercase but be defensive
|
|
User: struct {
|
|
Login string `json:"login"`
|
|
}{Login: "alice"},
|
|
Head: struct {
|
|
Ref string `json:"ref"`
|
|
SHA string `json:"sha"`
|
|
}{Ref: "b"},
|
|
Base: struct {
|
|
Ref string `json:"ref"`
|
|
}{Ref: "main"},
|
|
}
|
|
|
|
pr := convertPatPR(raw, "o", "r")
|
|
if pr.MergeableState != "clean" {
|
|
t.Errorf("expected normalized mergeable_state=clean, got %q", pr.MergeableState)
|
|
}
|
|
}
|
|
|
|
func TestConvertPatRequestedReviewers(t *testing.T) {
|
|
raw := &patPR{
|
|
RequestedReviewers: []struct {
|
|
Login string `json:"login"`
|
|
}{
|
|
{Login: "alice"},
|
|
{},
|
|
},
|
|
RequestedTeams: []struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
}{
|
|
{Slug: "my-team"},
|
|
{Name: "fallback-team"},
|
|
{},
|
|
},
|
|
}
|
|
|
|
got := convertPatRequestedReviewers(raw)
|
|
if len(got) != 3 {
|
|
t.Fatalf("requested reviewers = %d, want 3", len(got))
|
|
}
|
|
if got[0] != (RequestedReviewer{Login: "alice", Type: reviewerTypeUser}) {
|
|
t.Errorf("unexpected first reviewer: %#v", got[0])
|
|
}
|
|
if got[1] != (RequestedReviewer{Login: "my-team", Type: reviewerTypeTeam}) {
|
|
t.Errorf("unexpected second reviewer: %#v", got[1])
|
|
}
|
|
if got[2] != (RequestedReviewer{Login: "fallback-team", Type: reviewerTypeTeam}) {
|
|
t.Errorf("unexpected third reviewer: %#v", got[2])
|
|
}
|
|
}
|
|
|
|
func TestPATClient_RecordsRateHeadersOnSuccess(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-RateLimit-Limit", "5000")
|
|
w.Header().Set("X-RateLimit-Remaining", "4998")
|
|
w.Header().Set("X-RateLimit-Reset", "2000000000")
|
|
w.Header().Set("X-RateLimit-Resource", "core")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"login":"octocat"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
tracker := NewRateTracker(nil, nil)
|
|
c.WithRateTracker(tracker)
|
|
|
|
var out struct {
|
|
Login string `json:"login"`
|
|
}
|
|
if err := c.get(context.Background(), "/user", &out); err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
snap, ok := tracker.Snapshot(ResourceCore)
|
|
if !ok {
|
|
t.Fatalf("expected core snapshot")
|
|
}
|
|
if snap.Remaining != 4998 || snap.Limit != 5000 {
|
|
t.Fatalf("snap = %+v", snap)
|
|
}
|
|
}
|
|
|
|
// Regression: when a 429 carries valid X-RateLimit-Reset headers, the reset
|
|
// time from the headers must win — not the synthetic +1h fallback in
|
|
// markRateExhausted. Previously, recordRateHeaders called Record(snap)
|
|
// followed by markRateExhausted(time.Time{}), and the second call clobbered
|
|
// the real reset with a 1-hour pause that could over-throttle the poller.
|
|
func TestPATClient_RateLimit429_PreservesRealReset(t *testing.T) {
|
|
realReset := time.Now().Add(7 * time.Minute).UTC().Truncate(time.Second)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("X-RateLimit-Limit", "5000")
|
|
w.Header().Set("X-RateLimit-Remaining", "0")
|
|
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(realReset.Unix(), 10))
|
|
w.Header().Set("X-RateLimit-Resource", "core")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"message":"API rate limit exceeded"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
tracker := NewRateTracker(nil, nil)
|
|
c.WithRateTracker(tracker)
|
|
|
|
var out struct{}
|
|
if err := c.get(context.Background(), "/repos/o/r/pulls/1", &out); err == nil {
|
|
t.Fatalf("expected error from 429")
|
|
}
|
|
snap, ok := tracker.Snapshot(ResourceCore)
|
|
if !ok {
|
|
t.Fatalf("expected core snapshot")
|
|
}
|
|
if !snap.ResetAt.Equal(realReset) {
|
|
t.Errorf("expected reset_at preserved from headers (%s), got %s (off by %s)",
|
|
realReset, snap.ResetAt, snap.ResetAt.Sub(realReset))
|
|
}
|
|
if !tracker.IsExhausted(ResourceCore) {
|
|
t.Errorf("expected core to be exhausted")
|
|
}
|
|
}
|
|
|
|
// When a 429 has no rate-limit headers, the synthetic 1h fallback still
|
|
// applies so the poller pauses instead of hammering the secondary limit.
|
|
func TestPATClient_RateLimit429_NoHeaders_UsesSyntheticReset(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"message":"abuse detection"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
tracker := NewRateTracker(nil, nil)
|
|
c.WithRateTracker(tracker)
|
|
|
|
var out struct{}
|
|
if err := c.get(context.Background(), "/repos/o/r/pulls/1", &out); err == nil {
|
|
t.Fatalf("expected error from 429")
|
|
}
|
|
if !tracker.IsExhausted(ResourceCore) {
|
|
t.Fatalf("expected core exhausted via synthetic fallback")
|
|
}
|
|
}
|
|
|
|
func TestPATClient_MarksExhaustedFromRateLimitBody(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// No headers — secondary limits sometimes omit them entirely.
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"message":"API rate limit exceeded for user."}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
tracker := NewRateTracker(nil, nil)
|
|
c.WithRateTracker(tracker)
|
|
|
|
var out struct{}
|
|
if err := c.get(context.Background(), "/repos/o/r/pulls/1", &out); err == nil {
|
|
t.Fatalf("expected error from 403")
|
|
}
|
|
if !tracker.IsExhausted(ResourceCore) {
|
|
t.Fatalf("expected core exhausted from body parse")
|
|
}
|
|
}
|
|
|
|
// newPATClientPointingAt builds a PATClient whose underlying HTTP client
|
|
// reroutes any github API URL to the given test server.
|
|
func newPATClientPointingAt(t *testing.T, baseURL string) *PATClient {
|
|
t.Helper()
|
|
c := NewPATClient("test-token")
|
|
c.httpClient = &http.Client{
|
|
Transport: &rewriteTransport{base: baseURL},
|
|
Timeout: 2 * time.Second,
|
|
}
|
|
return c
|
|
}
|
|
|
|
type rewriteTransport struct{ base string }
|
|
|
|
func (rt *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
rewritten := strings.Replace(req.URL.String(), githubAPIBase, rt.base, 1)
|
|
req2, err := http.NewRequestWithContext(req.Context(), req.Method, rewritten, req.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req2.Header = req.Header.Clone()
|
|
return http.DefaultTransport.RoundTrip(req2)
|
|
}
|