1119 lines
38 KiB
Go
1119 lines
38 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"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: boolPtr(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"`
|
|
SHA string `json:"sha"`
|
|
}{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 !pr.IsDraftObserved {
|
|
t.Error("expected IsDraftObserved = true when draft is present in the response")
|
|
}
|
|
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 TestExecuteGraphQLPreservesHTTPProviderRetryDeadline(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
configure func(http.Header, time.Time)
|
|
minWait time.Duration
|
|
}{
|
|
{
|
|
name: "429 retry-after",
|
|
status: http.StatusTooManyRequests,
|
|
body: `{"message":"rate limit exceeded"}`,
|
|
minWait: 119 * time.Second,
|
|
configure: func(headers http.Header, _ time.Time) {
|
|
headers.Set("Retry-After", "120")
|
|
},
|
|
},
|
|
{
|
|
name: "403 reset",
|
|
status: http.StatusForbidden,
|
|
body: `{"message":"API rate limit exceeded"}`,
|
|
minWait: 119 * time.Second,
|
|
configure: func(headers http.Header, now time.Time) {
|
|
headers.Set("X-RateLimit-Limit", "5000")
|
|
headers.Set("X-RateLimit-Remaining", "0")
|
|
headers.Set("X-RateLimit-Reset", strconv.FormatInt(now.Add(120*time.Second).Unix(), 10))
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
tt.configure(w.Header(), now)
|
|
w.WriteHeader(tt.status)
|
|
_, _ = w.Write([]byte(tt.body))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
client := newPATClientPointingAt(t, srv.URL)
|
|
var out map[string]any
|
|
err := client.ExecuteGraphQL(context.Background(), "query Test { rateLimit { resetAt } }", nil, &out)
|
|
if err == nil {
|
|
t.Fatal("ExecuteGraphQL succeeded, want provider error")
|
|
}
|
|
var apiErr *GitHubAPIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("error = %T %v, want GitHubAPIError", err, err)
|
|
}
|
|
if apiErr.RetryAt == nil || apiErr.RetryAt.Before(now.Add(tt.minWait)) {
|
|
t.Fatalf("retry deadline = %v, want at least %v", apiErr.RetryAt, now.Add(tt.minWait))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestConvertPatPR_MissingDraftAndChangedFilesLeavesUnobserved covers
|
|
// AC-12a on the REST decode path: a response that omits draft and
|
|
// changed_files must decode to unobserved (Observed=false), not a
|
|
// fabricated false/0 masquerading as a real observation.
|
|
func TestConvertPatPR_MissingDraftAndChangedFilesLeavesUnobserved(t *testing.T) {
|
|
raw := &patPR{Number: 11, State: "open"}
|
|
pr := convertPatPR(raw, "org", "repo")
|
|
if pr.IsDraftObserved {
|
|
t.Error("IsDraftObserved = true, want false when draft is absent from the response")
|
|
}
|
|
if pr.ChangedFilesObserved {
|
|
t.Error("ChangedFilesObserved = true, want false when changed_files is absent from the response")
|
|
}
|
|
}
|
|
|
|
// TestConvertPatPR_ExplicitFalseAndZeroAreObserved covers AC-12a's other
|
|
// half: a response that genuinely reports draft=false and changed_files=0
|
|
// must be distinguishable from one that omits them — both mark
|
|
// Observed=true.
|
|
func TestConvertPatPR_ExplicitFalseAndZeroAreObserved(t *testing.T) {
|
|
raw := &patPR{Number: 11, State: "open", Draft: boolPtr(false), ChangedFiles: intPtr(0)}
|
|
pr := convertPatPR(raw, "org", "repo")
|
|
if !pr.IsDraftObserved {
|
|
t.Error("IsDraftObserved = false, want true for an explicit draft=false")
|
|
}
|
|
if pr.Draft {
|
|
t.Error("Draft = true, want false")
|
|
}
|
|
if !pr.ChangedFilesObserved {
|
|
t.Error("ChangedFilesObserved = false, want true for an explicit changed_files=0")
|
|
}
|
|
if pr.ChangedFiles != 0 {
|
|
t.Errorf("ChangedFiles = %d, want 0", pr.ChangedFiles)
|
|
}
|
|
}
|
|
|
|
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"`
|
|
SHA string `json:"sha"`
|
|
}{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 TestPATClientSearchPreservesImmutableIdentity(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/search/issues" {
|
|
t.Errorf("path = %q, want /search/issues", r.URL.Path)
|
|
http.Error(w, "unexpected path", http.StatusNotFound)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"total_count":1,"items":[{"id":101,"node_id":"PR_kwDOA","number":7,"title":"Fix auth","html_url":"https://github.com/acme/web/pull/7","state":"open","repository_url":"https://api.github.com/repos/acme/web","pull_request":{}}]}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
client := newPATClientPointingAt(t, srv.URL)
|
|
page, err := client.SearchPRsPaged(context.Background(), "", "auth", 1, 5)
|
|
if err != nil {
|
|
t.Fatalf("search PRs: %v", err)
|
|
}
|
|
if len(page.PRs) != 1 || page.PRs[0].ID != 101 || page.PRs[0].NodeID != "PR_kwDOA" {
|
|
t.Fatalf("PRs = %#v", page.PRs)
|
|
}
|
|
}
|
|
|
|
func TestPATClient_RequestReviewers_UsesGitHubReviewRequestEndpoint(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("method = %s, want POST", r.Method)
|
|
}
|
|
if r.URL.Path != "/repos/acme/widget/pulls/42/requested_reviewers" {
|
|
t.Errorf("path = %q", r.URL.Path)
|
|
}
|
|
var body struct {
|
|
Reviewers []string `json:"reviewers"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode body: %v", err)
|
|
}
|
|
if got := strings.Join(body.Reviewers, ","); got != "octocat,hubot" {
|
|
t.Errorf("reviewers = %q", got)
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
if err := newPATClientPointingAt(t, srv.URL).RequestReviewers(context.Background(), "acme", "widget", 42, []string{"octocat", "hubot"}); err != nil {
|
|
t.Fatalf("RequestReviewers: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPATClient_ListCheckRunsPaginatesCheckRuns(t *testing.T) {
|
|
var requestedPages []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/repos/acme/widget/commits/sha/status" {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"statuses":[]}`))
|
|
return
|
|
}
|
|
if r.URL.Path != "/repos/acme/widget/commits/sha/check-runs" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
http.Error(w, "unexpected path", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
requestedPages = append(requestedPages, r.URL.Query().Get("page"))
|
|
if r.URL.Query().Get("per_page") != "100" {
|
|
t.Errorf("per_page = %q, want 100", r.URL.Query().Get("per_page"))
|
|
}
|
|
if r.URL.Query().Get("page") == "2" {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"check_runs":[{"name":"late failure","status":"completed","conclusion":"failure","html_url":"https://checks/fail"}]}`))
|
|
return
|
|
}
|
|
w.Header().Set("Link", `<https://api.github.com/repos/acme/widget/commits/sha/check-runs?per_page=100&page=2>; rel="next"`)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"check_runs":[{"name":"new success","status":"completed","conclusion":"success","html_url":"https://checks/ok"}]}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
checks, err := c.ListCheckRuns(context.Background(), "acme", "widget", "sha")
|
|
if err != nil {
|
|
t.Fatalf("ListCheckRuns: %v", err)
|
|
}
|
|
if len(requestedPages) != 2 {
|
|
t.Fatalf("requested pages = %v, want first page and page 2", requestedPages)
|
|
}
|
|
if got := computeOverallCheckStatus(checks); got != "failure" {
|
|
t.Fatalf("overall check status = %q, want failure; checks=%#v", got, checks)
|
|
}
|
|
}
|
|
|
|
func TestPATClient_ListWorkflowRunsPaginatesAndListsAttemptJobs(t *testing.T) {
|
|
c, requests := newLinkPaginatedPATServer(t, "/repos/acme/widget/actions/runs", []string{
|
|
`{"workflow_runs":[{"id":7,"run_attempt":1,"workflow_id":9,"name":"Run tests","event":"pull_request","status":"completed","conclusion":"action_required","head_sha":"sha","head_branch":"feature","head_repository":{"full_name":"contributor/widget-fork","name":"widget-fork","owner":{"login":"contributor"}},"html_url":"https://github.com/acme/widget/actions/runs/7","created_at":"2026-09-01T10:00:00Z","updated_at":"2026-09-01T10:00:00Z","pull_requests":[]}]}`,
|
|
`{"workflow_runs":[{"id":8,"run_attempt":1,"workflow_id":10,"name":"Lint","event":"pull_request","status":"completed","conclusion":"success","head_sha":"sha","head_branch":"feature","html_url":"https://github.com/acme/widget/actions/runs/8","created_at":"2026-09-01T10:00:00Z","updated_at":"2026-09-01T11:00:00Z","pull_requests":[]}]}`,
|
|
})
|
|
|
|
runs, err := c.ListWorkflowRuns(context.Background(), "acme", "widget", "sha")
|
|
if err != nil {
|
|
t.Fatalf("ListWorkflowRuns: %v", err)
|
|
}
|
|
if len(runs) != 2 || runs[0].HeadRepoOwner != "contributor" || runs[0].HeadRepoName != "widget-fork" {
|
|
t.Fatalf("runs = %#v", runs)
|
|
}
|
|
if len(*requests) != 2 {
|
|
t.Fatalf("workflow run requests = %d, want 2", len(*requests))
|
|
}
|
|
if got := parseQueryValues(t, (*requests)[0].Query).Get("head_sha"); got != "sha" {
|
|
t.Fatalf("head_sha = %q, want sha", got)
|
|
}
|
|
if got := parseQueryValues(t, (*requests)[0].Query).Get("per_page"); got != "100" {
|
|
t.Fatalf("per_page = %q, want 100", got)
|
|
}
|
|
|
|
jobClient, jobRequests := newRecordingPATServer(t, map[string]string{
|
|
"/repos/acme/widget/actions/runs/7/attempts/1/jobs": `{"jobs":[{"id":70,"name":"approval gate","status":"completed","conclusion":null}]}`,
|
|
})
|
|
jobs, err := jobClient.ListWorkflowRunJobs(context.Background(), "acme", "widget", 7, 1)
|
|
if err != nil {
|
|
t.Fatalf("ListWorkflowRunJobs: %v", err)
|
|
}
|
|
if len(jobs) != 1 || jobs[0].Name != "approval gate" || jobs[0].Conclusion != "" {
|
|
t.Fatalf("jobs = %#v", jobs)
|
|
}
|
|
if len(*jobRequests) != 1 || parseQueryValues(t, (*jobRequests)[0].Query).Get("per_page") != "100" {
|
|
t.Fatalf("job requests = %#v", *jobRequests)
|
|
}
|
|
}
|
|
|
|
func TestPATClient_PRCommitDetailUsesExactSHAAndMergesPages(t *testing.T) {
|
|
var requested []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requested = append(requested, r.URL.RequestURI())
|
|
if r.URL.Path != "/repos/acme/widget/commits/2222222222222222222222222222222222222222" {
|
|
t.Errorf("path = %q", r.URL.Path)
|
|
}
|
|
if r.URL.Query().Get("per_page") != "100" {
|
|
t.Errorf("per_page = %q, want 100", r.URL.Query().Get("per_page"))
|
|
}
|
|
if r.URL.Query().Get("page") == "2" {
|
|
_, _ = w.Write([]byte(`{"sha":"2222222222222222222222222222222222222222","commit":{"message":"ignored","author":{"name":"Other","date":"2026-08-05T11:00:00Z"}},"stats":{"additions":99,"deletions":99},"files":[{"filename":"two.txt","status":"removed","additions":0,"deletions":2,"patch":"@@ -1 +0,0 @@\n-gone"}]}`))
|
|
return
|
|
}
|
|
w.Header().Set("Link", `<`+githubAPIBase+`/repos/acme/widget/commits/2222222222222222222222222222222222222222?per_page=100&page=2>; rel="next"`)
|
|
_, _ = w.Write([]byte(`{"sha":"2222222222222222222222222222222222222222","commit":{"message":"remote detail","author":{"name":"Octo Cat","date":"2026-08-04T11:00:00Z"}},"author":{"login":"octocat"},"stats":{"additions":7,"deletions":3},"files":[{"filename":"one.txt","status":"modified","additions":4,"deletions":1,"patch":"@@ -1 +1 @@\n-old\n+new"}]}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
detail, err := newPATClientPointingAt(t, srv.URL).GetPRCommitDetail(
|
|
context.Background(), "acme", "widget", "2222222222222222222222222222222222222222",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("GetPRCommitDetail: %v", err)
|
|
}
|
|
if detail.AuthorLogin != "octocat" || detail.AuthorName != "Octo Cat" || detail.FilesChanged != 2 {
|
|
t.Fatalf("detail = %#v", detail)
|
|
}
|
|
if detail.Additions != 7 || detail.Deletions != 3 || len(detail.Files) != 2 {
|
|
t.Fatalf("detail stats/files = %#v", detail)
|
|
}
|
|
if len(requested) != 2 {
|
|
t.Fatalf("requests = %#v, want two pages", requested)
|
|
}
|
|
}
|
|
|
|
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"`
|
|
SHA string `json:"sha"`
|
|
}{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"`
|
|
SHA string `json:"sha"`
|
|
}{Ref: "main"},
|
|
}
|
|
|
|
pr := convertPatPR(raw, "o", "r")
|
|
if pr.MergeableState != "clean" {
|
|
t.Errorf("expected normalized mergeable_state=clean, got %q", pr.MergeableState)
|
|
}
|
|
}
|
|
|
|
func TestPATClient_ListCheckRuns_PaginatesCheckRuns(t *testing.T) {
|
|
var checkRunQueries []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/repos/acme/widget/commits/sha/check-runs":
|
|
checkRunQueries = append(checkRunQueries, r.URL.RawQuery)
|
|
switch r.URL.Query().Get("page") {
|
|
case "", "1":
|
|
w.Header().Set("Link", `<`+githubAPIBase+`/repos/acme/widget/commits/sha/check-runs?per_page=100&page=2>; rel="next"`)
|
|
_, _ = w.Write([]byte(`{"check_runs":[{"name":"unit","status":"completed","conclusion":"success","html_url":"https://ci/unit"}]}`))
|
|
case "2":
|
|
_, _ = w.Write([]byte(`{"check_runs":[{"name":"lint","status":"completed","conclusion":"failure","html_url":"https://ci/lint"}]}`))
|
|
default:
|
|
t.Errorf("unexpected check-runs page %q", r.URL.Query().Get("page"))
|
|
http.Error(w, "unexpected page", http.StatusInternalServerError)
|
|
}
|
|
case "/repos/acme/widget/commits/sha/status":
|
|
_, _ = w.Write([]byte(`{"statuses":[]}`))
|
|
default:
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
http.Error(w, "unexpected path", http.StatusInternalServerError)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
checks, err := c.ListCheckRuns(context.Background(), "acme", "widget", "sha")
|
|
if err != nil {
|
|
t.Fatalf("ListCheckRuns: %v", err)
|
|
}
|
|
if len(checks) != 2 {
|
|
t.Fatalf("checks = %d, want 2: %+v", len(checks), checks)
|
|
}
|
|
if checks[1].Name != "lint" || checks[1].Conclusion != "failure" {
|
|
t.Fatalf("expected failed lint check from page 2, got %+v", checks[1])
|
|
}
|
|
if len(checkRunQueries) != 2 {
|
|
t.Fatalf("check-runs requests = %d, want 2 (%v)", len(checkRunQueries), checkRunQueries)
|
|
}
|
|
if !strings.Contains(checkRunQueries[0], "per_page=100") {
|
|
t.Fatalf("first check-runs request should request per_page=100, got %q", checkRunQueries[0])
|
|
}
|
|
}
|
|
|
|
func TestPATClient_FindPRByBranch_UsesGraphQLHeadRefName(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/graphql" {
|
|
t.Errorf("unexpected path %q, want /graphql", r.URL.Path)
|
|
http.Error(w, "unexpected path", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("method = %s, want POST", r.Method)
|
|
}
|
|
var body struct {
|
|
Query string `json:"query"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode request body: %v", err)
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !strings.Contains(body.Query, `pullRequests(first: 2, states: OPEN, headRefName: "feature")`) {
|
|
t.Errorf("query should look up branch by headRefName, got: %s", body.Query)
|
|
}
|
|
if strings.Contains(body.Query, `head=acme:feature`) || strings.Contains(body.Query, `ref(qualifiedName:`) {
|
|
t.Errorf("query should not require a base-repo branch ref, got: %s", body.Query)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{
|
|
"data": {
|
|
"b0": {
|
|
"pullRequests": {
|
|
"nodes": [{
|
|
"number": 12,
|
|
"state": "OPEN", "title": "fork PR", "url": "https://x/12",
|
|
"isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN",
|
|
"headRefName": "feature", "baseRefName": "main", "headRefOid": "abc123",
|
|
"author": {"login":"alice"},
|
|
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z",
|
|
"reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0},
|
|
"commits": {"nodes": []}
|
|
}]
|
|
}
|
|
}
|
|
}
|
|
}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
pr, err := c.FindPRByBranch(context.Background(), "acme", "widget", "feature")
|
|
if err != nil {
|
|
t.Fatalf("FindPRByBranch: %v", err)
|
|
}
|
|
if pr == nil || pr.Number != 12 || pr.HeadBranch != "feature" {
|
|
t.Fatalf("unexpected PR: %#v", pr)
|
|
}
|
|
}
|
|
|
|
func TestPATClient_FindPRByHead_UsesExactSourceRepository(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/repos/kdlbs/kandev/pulls" {
|
|
t.Errorf("path = %q, want /repos/kdlbs/kandev/pulls", r.URL.Path)
|
|
}
|
|
if got := r.URL.Query().Get("head"); got != "alice:feature" {
|
|
t.Errorf("head = %q, want alice:feature", got)
|
|
}
|
|
if got := r.URL.Query().Get("per_page"); got != "100" {
|
|
t.Errorf("per_page = %q, want 100", got)
|
|
}
|
|
_, _ = w.Write([]byte(`[{"number":12,"title":"fork PR","html_url":"https://x/12","state":"open",
|
|
"head":{"ref":"feature","sha":"abc123","repo":{"id":200,"name":"kandev","full_name":"alice/kandev","owner":{"login":"alice"}}},
|
|
"base":{"ref":"main","repo":{"id":100,"name":"kandev","full_name":"kdlbs/kandev","owner":{"login":"kdlbs"}}},
|
|
"user":{"login":"alice"}}]`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
pr, err := newPATClientPointingAt(t, srv.URL).FindPRByHead(
|
|
context.Background(), "kdlbs", "kandev", "alice", "kandev", "feature",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("FindPRByHead: %v", err)
|
|
}
|
|
if pr == nil || pr.Number != 12 || pr.HeadRepoOwner != "alice" || pr.HeadRepoName != "kandev" {
|
|
t.Fatalf("PR = %#v, want source alice/kandev", pr)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestPATClient_FetchBranchProtection(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
wantHasRule bool
|
|
wantRequired int
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "200 with required reviews",
|
|
status: http.StatusOK,
|
|
body: `{"required_pull_request_reviews":{"required_approving_review_count":2}}`,
|
|
wantHasRule: true,
|
|
wantRequired: 2,
|
|
},
|
|
{
|
|
name: "200 with rule but no required reviews block",
|
|
status: http.StatusOK,
|
|
body: `{"required_pull_request_reviews":null}`,
|
|
wantHasRule: true,
|
|
},
|
|
{
|
|
name: "404 maps to no rule",
|
|
status: http.StatusNotFound,
|
|
body: `{"message":"Branch not protected"}`,
|
|
wantHasRule: false,
|
|
},
|
|
{
|
|
name: "403 (no admin scope) maps to no rule",
|
|
status: http.StatusForbidden,
|
|
body: `{"message":"Resource not accessible by integration"}`,
|
|
wantHasRule: false,
|
|
},
|
|
{
|
|
name: "500 propagates as error",
|
|
status: http.StatusInternalServerError,
|
|
body: `{"message":"server error"}`,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
wantPath := "/repos/o/r/branches/main/protection"
|
|
if r.URL.Path != wantPath {
|
|
t.Fatalf("path = %q, want %q", r.URL.Path, wantPath)
|
|
}
|
|
w.WriteHeader(tc.status)
|
|
_, _ = w.Write([]byte(tc.body))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
bp, err := c.FetchBranchProtection(context.Background(), "o", "r", "main")
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Fatal("want error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if bp.HasRule != tc.wantHasRule {
|
|
t.Fatalf("HasRule = %v, want %v", bp.HasRule, tc.wantHasRule)
|
|
}
|
|
if bp.RequiredApprovingReviewCount != tc.wantRequired {
|
|
t.Fatalf("RequiredApprovingReviewCount = %d, want %d",
|
|
bp.RequiredApprovingReviewCount, tc.wantRequired)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListUserRepos_Success(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/user":
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"login":"octocat"}`))
|
|
case "/search/repositories":
|
|
q := r.URL.Query().Get("q")
|
|
if !strings.Contains(q, "user:octocat") {
|
|
t.Errorf("q = %q, want to contain user:octocat", q)
|
|
}
|
|
if !strings.Contains(q, "demo") {
|
|
t.Errorf("q = %q, want to contain demo", q)
|
|
}
|
|
if got := r.URL.Query().Get("per_page"); got != "50" {
|
|
t.Errorf("per_page = %q, want 50", got)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"items":[
|
|
{"full_name":"octocat/demo","owner":{"login":"octocat"},"name":"demo","private":false,"default_branch":"main","description":"Public demo"},
|
|
{"full_name":"octocat/demo-private","owner":{"login":"octocat"},"name":"demo-private","private":true,"default_branch":"trunk","description":null}
|
|
]}`))
|
|
default:
|
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
repos, err := c.ListUserRepos(context.Background(), "demo", 50)
|
|
if err != nil {
|
|
t.Fatalf("ListUserRepos: %v", err)
|
|
}
|
|
if len(repos) != 2 {
|
|
t.Fatalf("repos = %d, want 2", len(repos))
|
|
}
|
|
if repos[0].FullName != "octocat/demo" || repos[0].Owner != "octocat" || repos[0].Name != "demo" || repos[0].Private {
|
|
t.Errorf("unexpected first repo: %#v", repos[0])
|
|
}
|
|
if repos[0].DefaultBranch != "main" {
|
|
t.Errorf("first repo default_branch = %q, want main", repos[0].DefaultBranch)
|
|
}
|
|
if repos[0].Description != "Public demo" {
|
|
t.Errorf("first repo description = %q, want Public demo", repos[0].Description)
|
|
}
|
|
if !repos[1].Private {
|
|
t.Errorf("expected second repo private")
|
|
}
|
|
if repos[1].DefaultBranch != "trunk" {
|
|
t.Errorf("second repo default_branch = %q, want trunk", repos[1].DefaultBranch)
|
|
}
|
|
// JSON `null` description must decode to an empty string (omitempty drops it on serialize).
|
|
if repos[1].Description != "" {
|
|
t.Errorf("second repo description = %q, want empty string", repos[1].Description)
|
|
}
|
|
}
|
|
|
|
func TestListUserRepos_EmptyQuery(t *testing.T) {
|
|
var gotQuery string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/user":
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"login":"alice"}`))
|
|
case "/search/repositories":
|
|
gotQuery = r.URL.Query().Get("q")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"items":[]}`))
|
|
default:
|
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
repos, err := c.ListUserRepos(context.Background(), "", 0)
|
|
if err != nil {
|
|
t.Fatalf("ListUserRepos: %v", err)
|
|
}
|
|
if len(repos) != 0 {
|
|
t.Fatalf("repos = %d, want 0", len(repos))
|
|
}
|
|
if gotQuery != "user:alice" {
|
|
t.Errorf("q = %q, want exactly %q", gotQuery, "user:alice")
|
|
}
|
|
}
|
|
|
|
func TestListUserRepos_LimitClamping(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
inLimit int
|
|
wantPerPage string
|
|
}{
|
|
{"zero defaults to 20", 0, "20"},
|
|
{"negative defaults to 20", -5, "20"},
|
|
{"in range passes through", 42, "42"},
|
|
{"exceeds cap clamps to 100", 500, "100"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var gotPerPage string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/user":
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"login":"alice"}`))
|
|
case "/search/repositories":
|
|
gotPerPage = r.URL.Query().Get("per_page")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"items":[]}`))
|
|
default:
|
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
if _, err := c.ListUserRepos(context.Background(), "", tc.inLimit); err != nil {
|
|
t.Fatalf("ListUserRepos: %v", err)
|
|
}
|
|
if gotPerPage != tc.wantPerPage {
|
|
t.Errorf("per_page = %q, want %q", gotPerPage, tc.wantPerPage)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListUserRepos_APIError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/user":
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"login":"alice"}`))
|
|
case "/search/repositories":
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"message":"boom"}`))
|
|
default:
|
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
repos, err := c.ListUserRepos(context.Background(), "", 0)
|
|
if err == nil {
|
|
t.Fatal("expected error from 500, got nil")
|
|
}
|
|
if repos != nil {
|
|
t.Errorf("expected nil repos on error, got %v", repos)
|
|
}
|
|
}
|
|
|
|
func TestListUserRepos_AuthError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/user" {
|
|
t.Fatalf("unexpected path %q", r.URL.Path)
|
|
}
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"message":"bad creds"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
repos, err := c.ListUserRepos(context.Background(), "", 0)
|
|
if err == nil {
|
|
t.Fatal("expected error when /user returns 401")
|
|
}
|
|
if repos != nil {
|
|
t.Errorf("expected nil repos on auth error, got %v", repos)
|
|
}
|
|
}
|
|
|
|
func TestClampRepoSearchLimit(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in int
|
|
want int
|
|
}{
|
|
{"zero", 0, 20},
|
|
{"negative", -1, 20},
|
|
{"small", 10, 10},
|
|
{"exact cap", 100, 100},
|
|
{"over cap", 1000, 100},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := clampRepoSearchLimit(tc.in); got != tc.want {
|
|
t.Errorf("clampRepoSearchLimit(%d) = %d, want %d", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
func TestListAccessibleRepos_Success(t *testing.T) {
|
|
var gotAffiliation, gotSort, gotPerPage string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/user/repos" {
|
|
// t.Fatalf must not run in a handler goroutine (it's a data race);
|
|
// use Errorf+return and let the main goroutine fail the assertion.
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
http.Error(w, "unexpected path", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
gotAffiliation = r.URL.Query().Get("affiliation")
|
|
gotSort = r.URL.Query().Get("sort")
|
|
gotPerPage = r.URL.Query().Get("per_page")
|
|
w.WriteHeader(http.StatusOK)
|
|
// Flat array (NOT a search wrapper with .items).
|
|
_, _ = w.Write([]byte(`[
|
|
{"full_name":"kdlbs/kandev","owner":{"login":"kdlbs"},"name":"kandev","private":false,"default_branch":"main","description":"the app","pushed_at":"2025-05-01T10:00:00Z"},
|
|
{"full_name":"alice/secret","owner":{"login":"alice"},"name":"secret","private":true,"default_branch":"trunk","description":null}
|
|
]`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
repos, err := c.ListAccessibleRepos(context.Background(), "", 50)
|
|
if err != nil {
|
|
t.Fatalf("ListAccessibleRepos: %v", err)
|
|
}
|
|
if gotAffiliation != "owner,collaborator,organization_member" {
|
|
t.Errorf("affiliation = %q, want owner,collaborator,organization_member", gotAffiliation)
|
|
}
|
|
if gotSort != "pushed" {
|
|
t.Errorf("sort = %q, want pushed", gotSort)
|
|
}
|
|
if gotPerPage != "50" {
|
|
t.Errorf("per_page = %q, want 50", gotPerPage)
|
|
}
|
|
if len(repos) != 2 {
|
|
t.Fatalf("repos = %d, want 2", len(repos))
|
|
}
|
|
if repos[0].FullName != "kdlbs/kandev" || repos[0].Owner != "kdlbs" || repos[0].DefaultBranch != "main" {
|
|
t.Errorf("unexpected first repo: %#v", repos[0])
|
|
}
|
|
if repos[0].Description != "the app" {
|
|
t.Errorf("first repo description = %q, want 'the app'", repos[0].Description)
|
|
}
|
|
if repos[0].PushedAt == nil {
|
|
t.Errorf("first repo PushedAt nil, want non-nil")
|
|
}
|
|
if !repos[1].Private || repos[1].DefaultBranch != "trunk" {
|
|
t.Errorf("unexpected second repo: %#v", repos[1])
|
|
}
|
|
// JSON null description must decode to empty string.
|
|
if repos[1].Description != "" {
|
|
t.Errorf("second repo description = %q, want empty", repos[1].Description)
|
|
}
|
|
}
|
|
|
|
func TestListAccessibleRepos_QueryFilterAndClamp(t *testing.T) {
|
|
var gotPerPage string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/user/repos" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
http.Error(w, "unexpected path", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
gotPerPage = r.URL.Query().Get("per_page")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`[
|
|
{"full_name":"acme/widget","owner":{"login":"acme"},"name":"widget"},
|
|
{"full_name":"acme/gadget","owner":{"login":"acme"},"name":"gadget"},
|
|
{"full_name":"u/other","owner":{"login":"u"},"name":"other"}
|
|
]`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
// limit above cap must clamp per_page to 100; query filters client-side.
|
|
repos, err := c.ListAccessibleRepos(context.Background(), "WIDGET", 5000)
|
|
if err != nil {
|
|
t.Fatalf("ListAccessibleRepos: %v", err)
|
|
}
|
|
if gotPerPage != "100" {
|
|
t.Errorf("per_page = %q, want 100 (clamped)", gotPerPage)
|
|
}
|
|
if len(repos) != 1 || repos[0].FullName != "acme/widget" {
|
|
t.Fatalf("got %v, want [acme/widget] (case-insensitive substring on full_name)", repos)
|
|
}
|
|
}
|
|
|
|
func TestListAccessibleRepos_InstallationUsesInstallationEndpoint(t *testing.T) {
|
|
var gotPerPage string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/installation/repositories" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
http.Error(w, "unexpected path", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
gotPerPage = r.URL.Query().Get("per_page")
|
|
_, _ = w.Write([]byte(`{"repositories":[
|
|
{"full_name":"acme/widget","owner":{"login":"acme"},"name":"widget","private":true,"default_branch":"main"}
|
|
]}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := NewTokenClient("installation-token", TokenPrincipal{Kind: TokenCredentialInstallation})
|
|
c.httpClient = &http.Client{Transport: &rewriteTransport{base: srv.URL}}
|
|
repos, err := c.ListAccessibleRepos(context.Background(), "widget", 50)
|
|
if err != nil {
|
|
t.Fatalf("ListAccessibleRepos: %v", err)
|
|
}
|
|
if gotPerPage != "50" || len(repos) != 1 || repos[0].FullName != "acme/widget" {
|
|
t.Fatalf("per_page=%q repos=%+v", gotPerPage, repos)
|
|
}
|
|
}
|
|
|
|
func TestListAccessibleRepos_AuthError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/user/repos" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
}
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"message":"bad creds"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := newPATClientPointingAt(t, srv.URL)
|
|
repos, err := c.ListAccessibleRepos(context.Background(), "", 0)
|
|
if err == nil {
|
|
t.Fatal("expected error on 401")
|
|
}
|
|
if repos != nil {
|
|
t.Errorf("expected nil repos on error, got %v", repos)
|
|
}
|
|
var apiErr *GitHubAPIError
|
|
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("err = %v, want *GitHubAPIError with 401", err)
|
|
}
|
|
}
|