1151 lines · 39.5 KB · Go
RawDownload
package github import (	"context"	"encoding/json"	"errors"	"fmt"	"net/http"	"net/http/httptest"	"strconv"	"strings"	"sync"	"sync/atomic"	"testing"	"time") func writeRateLimits(t *testing.T, w http.ResponseWriter, resetAt time.Time, coreRemaining, graphqlRemaining int) {	t.Helper()	if err := json.NewEncoder(w).Encode(map[string]any{"resources": map[string]any{		"core":    map[string]any{"limit": 5000, "remaining": coreRemaining, "reset": resetAt.Unix()},		"graphql": map[string]any{"limit": 5000, "remaining": graphqlRemaining, "reset": resetAt.Unix()},	}}); err != nil {		t.Fatalf("encode rate limits: %v", err)	}} func TestListRepositoryIssuesPaginatesAndLimits(t *testing.T) {	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		if got := r.Header.Get("Authorization"); got != "Bearer token" {			t.Fatalf("auth header mismatch: present=%t length=%d", got != "", len(got))		}		switch r.URL.Query().Get("page") {		case "":			w.Header().Set("Link", `<`+serverURL(r)+`?page=2>; rel="next", <`+serverURL(r)+`?page=2>; rel="last"`)			_ = json.NewEncoder(w).Encode([]map[string]any{{"number": 1}, {"number": 2}})		case "2":			_ = json.NewEncoder(w).Encode([]map[string]any{{"number": 3}})		default:			t.Fatalf("unexpected page: %s", r.URL.RawQuery)		}	}))	defer server.Close() 	var messages []string	reporter := Reporter(func(message string) { messages = append(messages, message) })	client := New(Options{Token: "token", BaseURL: server.URL, PageDelay: -1})	rows, err := client.ListRepositoryIssues(context.Background(), "openclaw", "gitcrawl", ListIssuesOptions{Limit: 3}, reporter)	if err != nil {		t.Fatalf("list issues: %v", err)	}	if len(rows) != 3 {		t.Fatalf("rows: got %d want 3", len(rows))	}	if intValue(rows[2]["number"]) != 3 {		t.Fatalf("last number: %#v", rows[2]["number"])	}	joined := strings.Join(messages, "\n")	if !strings.Contains(joined, "page 1/2 fetched") || !strings.Contains(joined, "page 2/2 fetched") {		t.Fatalf("expected page X/Y log lines, got:\n%s", joined)	}} func TestListRepositoryIssuesUsesExpectedTotalWhenNoLastLink(t *testing.T) {	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		switch r.URL.Query().Get("page") {		case "":			// Cursor-style pagination: only "next", no "last".			w.Header().Set("Link", `<`+serverURL(r)+`?page=2>; rel="next"`)			rows := make([]map[string]any, 100)			for i := range rows {				rows[i] = map[string]any{"number": i + 1}			}			_ = json.NewEncoder(w).Encode(rows)		case "2":			rows := make([]map[string]any, 50)			for i := range rows {				rows[i] = map[string]any{"number": 100 + i + 1}			}			_ = json.NewEncoder(w).Encode(rows)		default:			t.Fatalf("unexpected page: %s", r.URL.RawQuery)		}	}))	defer server.Close() 	var messages []string	reporter := Reporter(func(message string) { messages = append(messages, message) })	client := New(Options{BaseURL: server.URL, PageDelay: -1})	rows, err := client.ListRepositoryIssues(context.Background(), "openclaw", "gitcrawl", ListIssuesOptions{ExpectedTotal: 150}, reporter)	if err != nil {		t.Fatalf("list issues: %v", err)	}	if len(rows) != 150 {		t.Fatalf("rows: got %d want 150", len(rows))	}	joined := strings.Join(messages, "\n")	if !strings.Contains(joined, "page 1/2 fetched") || !strings.Contains(joined, "page 2/2 fetched") {		t.Fatalf("expected page X/Y log lines from hint, got:\n%s", joined)	}} func TestPaginateRaisesTotalWhenActualExceedsHint(t *testing.T) {	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		switch r.URL.Query().Get("page") {		case "":			w.Header().Set("Link", `<`+serverURL(r)+`?page=2>; rel="next"`)			_ = json.NewEncoder(w).Encode([]map[string]any{{"number": 1}})		case "2":			_ = json.NewEncoder(w).Encode([]map[string]any{{"number": 2}})		default:			t.Fatalf("unexpected page: %s", r.URL.RawQuery)		}	}))	defer server.Close() 	var messages []string	reporter := Reporter(func(message string) { messages = append(messages, message) })	client := New(Options{BaseURL: server.URL, PageDelay: -1})	// Hint underestimates (1 page) but the API actually returns 2.	if _, err := client.ListRepositoryIssues(context.Background(), "o", "r", ListIssuesOptions{ExpectedTotal: 1}, reporter); err != nil {		t.Fatalf("list: %v", err)	}	joined := strings.Join(messages, "\n")	if !strings.Contains(joined, "page 2/2 fetched") {		t.Fatalf("expected total to be raised to actual page count, got:\n%s", joined)	}} func TestRequestErrorIncludesStatus(t *testing.T) {	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		http.Error(w, "nope", http.StatusUnauthorized)	}))	defer server.Close() 	client := New(Options{BaseURL: server.URL, PageDelay: -1})	_, err := client.GetRepo(context.Background(), "openclaw", "gitcrawl", nil)	if err == nil {		t.Fatal("expected error")	}	requestErr, ok := err.(*RequestError)	if !ok {		t.Fatalf("error type: %T", err)	}	if requestErr.Status != http.StatusUnauthorized {		t.Fatalf("status: got %d want %d", requestErr.Status, http.StatusUnauthorized)	}	if !strings.Contains((&RequestError{Method: "GET", URL: "https://example.test", Status: 500}).Error(), "status 500") {		t.Fatal("request error without body missing status")	}	if !strings.Contains((&RequestError{Method: "POST", URL: "https://example.test", Status: 400, Body: "bad"}).Error(), "bad") {		t.Fatal("request error with body missing body")	}} func TestClientSingleResourceAndCollectionEndpoints(t *testing.T) {	requests := map[string]int{}	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		requests[r.URL.Path]++		if r.Header.Get("Accept") != "application/vnd.github+json" {			t.Fatalf("accept header = %q", r.Header.Get("Accept"))		}		switch r.URL.Path {		case "/repos/openclaw/gitcrawl":			_ = json.NewEncoder(w).Encode(map[string]any{"id": 1})		case "/repos/openclaw/gitcrawl/issues/7":			_ = json.NewEncoder(w).Encode(map[string]any{"number": 7})		case "/repos/openclaw/gitcrawl/pulls/8":			_ = json.NewEncoder(w).Encode(map[string]any{"number": 8})		case "/repos/openclaw/gitcrawl/issues/7/comments",			"/repos/openclaw/gitcrawl/pulls/8/reviews",			"/repos/openclaw/gitcrawl/pulls/8/comments",			"/repos/openclaw/gitcrawl/pulls/8/files",			"/repos/openclaw/gitcrawl/pulls/8/commits":			_ = json.NewEncoder(w).Encode([]map[string]any{{"id": 1}})		case "/repos/openclaw/gitcrawl/commits/abc/check-runs":			_ = json.NewEncoder(w).Encode(map[string]any{"check_runs": []map[string]any{{"name": "test"}}})		case "/repos/openclaw/gitcrawl/actions/runs":			_ = json.NewEncoder(w).Encode(map[string]any{"workflow_runs": []map[string]any{{"id": 99}}})		case "/repos/openclaw/gitcrawl/actions/runs/99":			_ = json.NewEncoder(w).Encode(map[string]any{"id": 99})		default:			t.Fatalf("unexpected path: %s", r.URL.String())		}	}))	defer server.Close() 	client := New(Options{BaseURL: server.URL, PageDelay: -1})	ctx := context.Background()	if row, err := client.GetRepo(ctx, "openclaw", "gitcrawl", nil); err != nil || intValue(row["id"]) != 1 {		t.Fatalf("get repo = %#v %v", row, err)	}	if row, err := client.GetIssue(ctx, "openclaw", "gitcrawl", 7, nil); err != nil || intValue(row["number"]) != 7 {		t.Fatalf("get issue = %#v %v", row, err)	}	if row, err := client.GetPull(ctx, "openclaw", "gitcrawl", 8, nil); err != nil || intValue(row["number"]) != 8 {		t.Fatalf("get pull = %#v %v", row, err)	}	for name, fn := range map[string]func() ([]map[string]any, error){		"comments": func() ([]map[string]any, error) { return client.ListIssueComments(ctx, "openclaw", "gitcrawl", 7, nil) },		"reviews":  func() ([]map[string]any, error) { return client.ListPullReviews(ctx, "openclaw", "gitcrawl", 8, nil) },		"review-comments": func() ([]map[string]any, error) {			return client.ListPullReviewComments(ctx, "openclaw", "gitcrawl", 8, nil)		},		"files":   func() ([]map[string]any, error) { return client.ListPullFiles(ctx, "openclaw", "gitcrawl", 8, nil) },		"commits": func() ([]map[string]any, error) { return client.ListPullCommits(ctx, "openclaw", "gitcrawl", 8, nil) },		"checks": func() ([]map[string]any, error) {			return client.ListCommitCheckRuns(ctx, "openclaw", "gitcrawl", "abc", nil)		},		"runs": func() ([]map[string]any, error) {			return client.ListWorkflowRuns(ctx, "openclaw", "gitcrawl", ListWorkflowRunsOptions{HeadSHA: "abc"}, nil)		},	} {		rows, err := fn()		if err != nil || len(rows) != 1 {			t.Fatalf("%s rows = %+v err=%v", name, rows, err)		}	}	if run, err := client.GetWorkflowRun(ctx, "openclaw", "gitcrawl", "99", nil); err != nil ||		intValue(run["id"]) != 99 {		t.Fatalf("get workflow run = %+v err=%v", run, err)	}	if len(requests) != 11 {		t.Fatalf("requests = %+v", requests)	}} func TestCheckRunsAndWorkflowRunsPaginate(t *testing.T) {	requests := map[string]int{}	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		requests[r.URL.Path]++		page := r.URL.Query().Get("page")		switch r.URL.Path {		case "/repos/openclaw/gitcrawl/commits/abc/check-runs":			switch page {			case "":				w.Header().Set("Link", `<`+serverURL(r)+`?page=2>; rel="next"`)				_ = json.NewEncoder(w).Encode(map[string]any{"check_runs": []map[string]any{{"name": "test-1"}, {"name": "test-2"}}})			case "2":				_ = json.NewEncoder(w).Encode(map[string]any{"check_runs": []map[string]any{{"name": "test-3"}}})			default:				t.Fatalf("unexpected check-runs page: %s", r.URL.RawQuery)			}		case "/repos/openclaw/gitcrawl/actions/runs":			switch page {			case "":				w.Header().Set("Link", `<`+serverURL(r)+`?page=2>; rel="next"`)				_ = json.NewEncoder(w).Encode(map[string]any{"workflow_runs": []map[string]any{{"id": 1}, {"id": 2}}})			case "2":				_ = json.NewEncoder(w).Encode(map[string]any{"workflow_runs": []map[string]any{{"id": 3}, {"id": 4}}})			default:				t.Fatalf("unexpected workflow-runs page: %s", r.URL.RawQuery)			}		default:			t.Fatalf("unexpected path: %s", r.URL.String())		}	}))	defer server.Close() 	client := New(Options{BaseURL: server.URL, PageDelay: -1})	ctx := context.Background()	checks, err := client.ListCommitCheckRuns(ctx, "openclaw", "gitcrawl", "abc", nil)	if err != nil {		t.Fatalf("list checks: %v", err)	}	if len(checks) != 3 || checks[2]["name"] != "test-3" {		t.Fatalf("checks = %#v", checks)	}	runs, err := client.ListWorkflowRuns(ctx, "openclaw", "gitcrawl", ListWorkflowRunsOptions{HeadSHA: "abc", Limit: 3}, nil)	if err != nil {		t.Fatalf("list runs: %v", err)	}	if len(runs) != 3 || intValue(runs[2]["id"]) != 3 {		t.Fatalf("runs = %#v", runs)	}	if requests["/repos/openclaw/gitcrawl/commits/abc/check-runs"] != 2 || requests["/repos/openclaw/gitcrawl/actions/runs"] != 2 {		t.Fatalf("requests = %+v", requests)	}} func TestListPullReviewThreadsDecodesGraphQLEnvelope(t *testing.T) {	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		if r.URL.Path != "/graphql" {			t.Fatalf("unexpected path: %s", r.URL.String())		}		if r.Method != http.MethodPost {			t.Fatalf("method = %s", r.Method)		}		if r.Header.Get("Content-Type") != "application/json" {			t.Fatalf("content-type = %q", r.Header.Get("Content-Type"))		}		_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"repository": map[string]any{"pullRequest": map[string]any{			"reviewThreads": map[string]any{				"nodes": []map[string]any{{					"id":         "PRRT_1",					"isResolved": false,					"comments":   map[string]any{"nodes": []map[string]any{{"id": "PRRC_1"}}},				}},				"pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""},			},		}}}})	}))	defer server.Close() 	client := New(Options{BaseURL: server.URL, PageDelay: -1})	rows, err := client.ListPullReviewThreads(context.Background(), "openclaw", "gitcrawl", 8, nil)	if err != nil {		t.Fatalf("list review threads: %v", err)	}	if len(rows) != 1 || rows[0]["id"] != "PRRT_1" {		t.Fatalf("rows = %#v", rows)	}} func TestListPullReviewThreadsUsesEnterpriseGraphQLEndpoint(t *testing.T) {	var graphQLPath string	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		graphQLPath = r.URL.Path		if r.URL.Path != "/api/graphql" {			http.Error(w, "wrong graphql endpoint", http.StatusNotFound)			return		}		_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"repository": map[string]any{"pullRequest": map[string]any{			"reviewThreads": map[string]any{				"nodes":    []map[string]any{},				"pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""},			},		}}}})	}))	defer server.Close() 	client := New(Options{BaseURL: server.URL + "/api/v3", PageDelay: -1})	if _, err := client.ListPullReviewThreads(context.Background(), "openclaw", "gitcrawl", 8, nil); err != nil {		t.Fatalf("list review threads: %v", err)	}	if graphQLPath != "/api/graphql" {		t.Fatalf("graphql path = %q, want /api/graphql", graphQLPath)	}} func TestListPullReviewThreadsRetriesWithBody(t *testing.T) {	var calls int32	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		var body graphqlEnvelope		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {			t.Fatalf("decode graphql request %d: %v", atomic.LoadInt32(&calls)+1, err)		}		if body.Query == "" || body.Variables["owner"] != "openclaw" || body.Variables["repo"] != "gitcrawl" {			t.Fatalf("graphql request body = %+v", body)		}		if atomic.AddInt32(&calls, 1) == 1 {			w.Header().Set("Retry-After", "1")			http.Error(w, "slow down", http.StatusTooManyRequests)			return		}		_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"repository": map[string]any{"pullRequest": map[string]any{			"reviewThreads": map[string]any{				"nodes":    []map[string]any{},				"pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""},			},		}}}})	}))	defer server.Close() 	client := New(Options{BaseURL: server.URL, PageDelay: -1})	if _, err := client.ListPullReviewThreads(context.Background(), "openclaw", "gitcrawl", 8, nil); err != nil {		t.Fatalf("list review threads: %v", err)	}	if got := atomic.LoadInt32(&calls); got != 2 {		t.Fatalf("calls = %d, want 2", got)	}} func TestListPullReviewThreadsPaginatesReviewThreadComments(t *testing.T) {	var calls int	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		calls++		if r.URL.Path != "/graphql" {			t.Fatalf("unexpected path: %s", r.URL.String())		}		var body graphqlEnvelope		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {			t.Fatalf("decode request: %v", err)		}		switch calls {		case 1:			if body.Variables["threadID"] != nil {				t.Fatalf("first request should fetch review threads, variables=%+v", body.Variables)			}			_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"repository": map[string]any{"pullRequest": map[string]any{				"reviewThreads": map[string]any{					"nodes": []map[string]any{{						"id":         "PRRT_1",						"isResolved": false,						"comments": map[string]any{							"nodes":    []map[string]any{{"id": "PRRC_1"}},							"pageInfo": map[string]any{"hasNextPage": true, "endCursor": "comment-cursor-1"},						},					}},					"pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""},				},			}}}})		case 2:			if body.Variables["threadID"] != "PRRT_1" || body.Variables["cursor"] != "comment-cursor-1" {				t.Fatalf("comment page variables = %+v", body.Variables)			}			_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{				"node": map[string]any{					"comments": map[string]any{						"nodes":    []map[string]any{{"id": "PRRC_2"}},						"pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""},					},				},			}})		default:			t.Fatalf("unexpected graphql call %d", calls)		}	}))	defer server.Close() 	client := New(Options{BaseURL: server.URL, PageDelay: -1})	rows, err := client.ListPullReviewThreads(context.Background(), "openclaw", "gitcrawl", 8, nil)	if err != nil {		t.Fatalf("list review threads: %v", err)	}	if calls != 2 {		t.Fatalf("calls = %d", calls)	}	if len(rows) != 1 || rows[0]["id"] != "PRRT_1" {		t.Fatalf("rows = %#v", rows)	}	comments, ok := rows[0]["comments"].(map[string]any)	if !ok {		t.Fatalf("comments = %#v", rows[0]["comments"])	}	nodes, ok := comments["nodes"].([]map[string]any)	if !ok || len(nodes) != 2 || nodes[1]["id"] != "PRRC_2" {		t.Fatalf("comment nodes = %#v", comments["nodes"])	}} func TestNextPageAndReporterBranches(t *testing.T) {	header := `<https://api.github.test/repos/o/r/issues?page=2&state=open>; rel="next", <https://api.github.test/repos/o/r/issues?page=9>; rel="last"`	if got := nextPage(header, "https://api.github.test"); got != "/repos/o/r/issues?page=2&state=open" {		t.Fatalf("next page = %q", got)	}	gheHeader := `<https://ghe.example/api/v3/repos/o/r/issues?page=2>; rel="next"`	if got := nextPage(gheHeader, "https://ghe.example/api/v3"); got != "/repos/o/r/issues?page=2" {		t.Fatalf("ghe next page = %q", got)	}	if got := nextPage(`<bad-url>; rel="last"`, "https://api.github.test"); got != "" {		t.Fatalf("bad next page = %q", got)	}	if got := lastPage(header); got != 9 {		t.Fatalf("last page = %d", got)	}	if got := lastPage(`<https://api.github.test/x?page=3>; rel="next"`); got != 0 {		t.Fatalf("last page without rel=last = %d", got)	}	if got := lastPage(`<%zz>; rel="last"`); got != 0 {		t.Fatalf("last page bad url = %d", got)	}	var messages []string	Reporter(func(message string) { messages = append(messages, message) }).Printf("hello %d", 1)	if len(messages) != 1 || messages[0] != "hello 1" {		t.Fatalf("messages = %+v", messages)	}	Reporter(nil).Printf("ignored")} func TestClientErrorAndHelperBranches(t *testing.T) {	client := New(Options{})	if client.baseURL != "https://api.github.com" || client.userAgent != "gitcrawl" {		t.Fatalf("defaults = %+v", client)	}	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		switch r.URL.Path {		case "/repos/openclaw/gitcrawl":			_, _ = w.Write([]byte("{"))		case "/repos/openclaw/gitcrawl/issues":			_ = json.NewEncoder(w).Encode([]map[string]any{{"number": 1}, {"number": 2}, {"number": 3}})		default:			t.Fatalf("unexpected path: %s", r.URL.String())		}	}))	defer server.Close()	client = New(Options{BaseURL: server.URL, PageDelay: -1})	if _, err := client.GetRepo(context.Background(), "openclaw", "gitcrawl", nil); err == nil {		t.Fatal("bad json should fail")	}	rows, err := client.ListRepositoryIssues(context.Background(), "openclaw", "gitcrawl", ListIssuesOptions{State: "closed", Since: "2026-04-30T00:00:00Z", Limit: 2}, nil)	if err != nil {		t.Fatalf("limited issues: %v", err)	}	if len(rows) != 2 {		t.Fatalf("limited rows = %+v", rows)	}	if got := pathEscape("owner/name"); got != "owner%2Fname" {		t.Fatalf("escaped path = %q", got)	}

Lines 1–500 of 1151.