Files
kandev/apps/backend/internal/github/gh_client_runtime_test.go
2026-09-23 13:31:12 +01:00

692 lines
26 KiB
Go

package github
import (
"context"
"encoding/base64"
"errors"
"net/http"
"strconv"
"strings"
"testing"
"time"
)
func TestGHClient_IsAuthenticated(t *testing.T) {
t.Run("gh auth status succeeds", func(t *testing.T) {
calls := newFakeGH(t, ghResponse{Prefix: "auth status", Stdout: "Logged in to github.com\n"})
ok, err := NewGHClient().IsAuthenticated(context.Background())
if err != nil {
t.Fatalf("IsAuthenticated: %v", err)
}
if !ok {
t.Error("authenticated = false, want true")
}
assertGHArgv(t, calls(t), 0, []string{"auth", "status", "--hostname", "github.com"})
})
// Any non-zero exit is "not authenticated" — the message text is
// locale-dependent, so it is deliberately not parsed.
t.Run("a non-zero exit reports false without an error", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "auth status", Stderr: "You are not logged in", Exit: 1})
ok, err := NewGHClient().IsAuthenticated(context.Background())
if err != nil {
t.Fatalf("a plain auth failure must not surface an error, got %v", err)
}
if ok {
t.Error("authenticated = true, want false")
}
})
// Cancellation must stay distinguishable from "not authenticated" so
// callers don't treat a shutdown as a lost login.
t.Run("cancellation propagates", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "auth status", Stdout: "ok"})
ctx, cancel := context.WithCancel(context.Background())
cancel()
ok, err := NewGHClient().IsAuthenticated(ctx)
if ok {
t.Error("authenticated = true, want false")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want it to unwrap to context.Canceled", err)
}
})
}
func TestGHClient_RunAuthDiagnostics(t *testing.T) {
t.Run("success captures stdout when stderr is empty", func(t *testing.T) {
calls := newFakeGH(t, ghResponse{Prefix: "auth status", Stdout: "Logged in as octocat"})
got := NewGHClient().RunAuthDiagnostics(context.Background())
assertGHArgv(t, calls(t), 0, []string{"auth", "status", "--hostname", "github.com"})
if got.Command != "gh auth status --hostname github.com" {
t.Errorf("command = %q", got.Command)
}
if got.Output != "Logged in as octocat" {
t.Errorf("output = %q, want the stdout text", got.Output)
}
if got.ExitCode != 0 {
t.Errorf("exit code = %d, want 0", got.ExitCode)
}
})
// gh writes its auth report to stderr, so stderr wins when both are set.
t.Run("stderr is preferred over stdout", func(t *testing.T) {
newFakeGH(t, ghResponse{
Prefix: "auth status", Stdout: "ignored", Stderr: "You are not logged into any hosts", Exit: 1,
})
got := NewGHClient().RunAuthDiagnostics(context.Background())
if got.Output != "You are not logged into any hosts" {
t.Errorf("output = %q, want the stderr text", got.Output)
}
if got.ExitCode != 1 {
t.Errorf("exit code = %d, want 1", got.ExitCode)
}
})
// A silent non-zero exit would otherwise render as a blank diagnostics
// panel, so the underlying error is surfaced as the output.
t.Run("a silent failure surfaces the underlying error", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "auth status", Exit: 3})
got := NewGHClient().RunAuthDiagnostics(context.Background())
if got.ExitCode != 3 {
t.Errorf("exit code = %d, want 3", got.ExitCode)
}
if !strings.Contains(got.Output, "gh auth status did not run") {
t.Errorf("output = %q, want the did-not-run explanation", got.Output)
}
})
t.Run("a cancelled context reports the context error too", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "auth status", Stdout: "ok"})
ctx, cancel := context.WithCancel(context.Background())
cancel()
got := NewGHClient().RunAuthDiagnostics(ctx)
if got.ExitCode != -1 {
t.Errorf("exit code = %d, want -1 for a non-exec failure", got.ExitCode)
}
if !strings.Contains(got.Output, "context:") {
t.Errorf("output = %q, want the context cause included", got.Output)
}
})
}
func TestGHClient_FetchRateLimit(t *testing.T) {
t.Run("no tracker is a no-op that never execs gh", func(t *testing.T) {
calls := newFakeGH(t, ghResponse{Prefix: "api rate_limit", Stdout: `{}`})
if err := NewGHClient().FetchRateLimit(context.Background()); err != nil {
t.Fatalf("FetchRateLimit: %v", err)
}
if len(calls(t)) != 0 {
t.Errorf("gh calls = %d, want 0 without a tracker", len(calls(t)))
}
})
t.Run("seeds every reported bucket", func(t *testing.T) {
reset := time.Now().Add(30 * time.Minute).UTC().Truncate(time.Second)
calls := newFakeGH(t, ghResponse{
Prefix: "api rate_limit",
Stdout: `{"resources":{
"core":{"limit":5000,"remaining":4990,"reset":` + strconv.FormatInt(reset.Unix(), 10) + `},
"graphql":{"limit":5000,"remaining":4000,"reset":` + strconv.FormatInt(reset.Unix(), 10) + `},
"search":{"limit":30,"remaining":29,"reset":` + strconv.FormatInt(reset.Unix(), 10) + `}}}`,
})
tracker := NewRateTracker(nil, nil)
if err := NewGHClient().WithRateTracker(tracker).FetchRateLimit(context.Background()); err != nil {
t.Fatalf("FetchRateLimit: %v", err)
}
assertGHArgv(t, calls(t), 0, []string{"api", "rate_limit"})
for _, want := range []struct {
resource Resource
limit int
remaining int
}{
{ResourceCore, 5000, 4990},
{ResourceGraphQL, 5000, 4000},
{ResourceSearch, 30, 29},
} {
snap, ok := tracker.Snapshot(want.resource)
if !ok {
t.Errorf("%s: no snapshot recorded", want.resource)
continue
}
if snap.Limit != want.limit || snap.Remaining != want.remaining {
t.Errorf("%s = %d/%d, want %d/%d",
want.resource, snap.Remaining, snap.Limit, want.remaining, want.limit)
}
if !snap.ResetAt.Equal(reset) {
t.Errorf("%s reset = %v, want %v", want.resource, snap.ResetAt, reset)
}
}
})
// An all-zero bucket means gh reported nothing for that resource; recording
// it would falsely present the bucket as exhausted.
t.Run("all-zero buckets are skipped", func(t *testing.T) {
newFakeGH(t, ghResponse{
Prefix: "api rate_limit",
Stdout: `{"resources":{"core":{"limit":5000,"remaining":10,"reset":1893456000},
"graphql":{"limit":0,"remaining":0,"reset":0},
"search":{"limit":0,"remaining":0,"reset":0}}}`,
})
tracker := NewRateTracker(nil, nil)
if err := NewGHClient().WithRateTracker(tracker).FetchRateLimit(context.Background()); err != nil {
t.Fatalf("FetchRateLimit: %v", err)
}
if _, ok := tracker.Snapshot(ResourceCore); !ok {
t.Error("core snapshot missing")
}
if _, ok := tracker.Snapshot(ResourceGraphQL); ok {
t.Error("graphql snapshot recorded, want the all-zero bucket skipped")
}
if _, ok := tracker.Snapshot(ResourceSearch); ok {
t.Error("search snapshot recorded, want the all-zero bucket skipped")
}
})
t.Run("command failure", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api rate_limit", Stderr: "boom", Exit: 1})
err := NewGHClient().WithRateTracker(NewRateTracker(nil, nil)).FetchRateLimit(context.Background())
if err == nil || !strings.Contains(err.Error(), "fetch rate limit") {
t.Fatalf("err = %v, want a wrapped fetch error", err)
}
})
t.Run("unparseable output", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api rate_limit", Stdout: "nope"})
err := NewGHClient().WithRateTracker(NewRateTracker(nil, nil)).FetchRateLimit(context.Background())
if err == nil || !strings.Contains(err.Error(), "parse rate_limit response") {
t.Fatalf("err = %v, want a parse failure", err)
}
})
}
func TestGHClient_ListCheckRuns_Errors(t *testing.T) {
t.Run("check-runs command failure", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api --paginate", Stderr: "boom", Exit: 1})
_, err := NewGHClient().ListCheckRuns(context.Background(), "acme", "widget", "sha")
if err == nil || !strings.Contains(err.Error(), "list check runs") {
t.Fatalf("err = %v, want a wrapped check-runs error", err)
}
})
t.Run("undecodable check-run stream", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api --paginate", Stdout: `{"name":"unit"`})
_, err := NewGHClient().ListCheckRuns(context.Background(), "acme", "widget", "sha")
if err == nil || !strings.Contains(err.Error(), "parse check runs") {
t.Fatalf("err = %v, want a check-run decode failure", err)
}
})
t.Run("status command failure", func(t *testing.T) {
newFakeGH(t,
ghResponse{Prefix: "api --paginate", Stdout: ""},
ghResponse{Prefix: "api repos/", Stderr: "boom", Exit: 1},
)
_, err := NewGHClient().ListCheckRuns(context.Background(), "acme", "widget", "sha")
if err == nil || !strings.Contains(err.Error(), "list status contexts") {
t.Fatalf("err = %v, want a wrapped status error", err)
}
})
t.Run("unparseable status contexts", func(t *testing.T) {
newFakeGH(t,
ghResponse{Prefix: "api --paginate", Stdout: ""},
ghResponse{Prefix: "api repos/", Stdout: "nope"},
)
_, err := NewGHClient().ListCheckRuns(context.Background(), "acme", "widget", "sha")
if err == nil || !strings.Contains(err.Error(), "parse status contexts") {
t.Fatalf("err = %v, want a status parse failure", err)
}
})
}
// A commit status context and a check run sharing a name collapse into one
// entry, with the check run winning.
func TestGHClient_ListCheckRuns_MergesStatusContexts(t *testing.T) {
newFakeGH(t,
ghResponse{
Prefix: "api --paginate",
Stdout: `{"name":"unit","status":"completed","conclusion":"success","html_url":"https://ci/unit"}`,
},
ghResponse{
Prefix: "api repos/",
Stdout: `[{"context":"unit","state":"failure","target_url":"https://legacy/unit"},
{"context":"legacy-only","state":"success","target_url":"https://legacy/other",
"description":"all good"}]`,
},
)
checks, err := NewGHClient().ListCheckRuns(context.Background(), "acme", "widget", "sha")
if err != nil {
t.Fatalf("ListCheckRuns: %v", err)
}
if len(checks) != 2 {
t.Fatalf("checks = %#v, want the duplicate name merged into 2 entries", checks)
}
byName := map[string]CheckRun{}
for _, c := range checks {
byName[c.Name] = c
}
unit, ok := byName["unit"]
if !ok {
t.Fatal("unit check missing")
}
if unit.Source != checkSourceCheckRun || unit.Conclusion != "success" {
t.Errorf("unit = %#v, want the check-run entry to win over the status context", unit)
}
legacy, ok := byName["legacy-only"]
if !ok {
t.Fatal("legacy-only check missing")
}
if legacy.Source != checkSourceStatusContext || legacy.Conclusion != checkStatusSuccess {
t.Errorf("legacy-only = %#v", legacy)
}
if legacy.Output != "all good" {
t.Errorf("legacy-only output = %q, want the status description", legacy.Output)
}
}
func TestGHClient_GetPRStatus(t *testing.T) {
newFakeGH(t, ghPRStatusResponses()...)
status, err := NewGHClient().GetPRStatus(context.Background(), "acme", "widget", 42)
if err != nil {
t.Fatalf("GetPRStatus: %v", err)
}
if status.PR == nil || status.PR.Number != 42 {
t.Fatalf("PR = %#v", status.PR)
}
if status.ReviewState != computedReviewStateApproved || status.ReviewCount != 1 {
t.Errorf("review = (%q, %d), want (approved, 1)", status.ReviewState, status.ReviewCount)
}
if status.ChecksState != "failure" {
t.Errorf("checks state = %q, want failure", status.ChecksState)
}
if status.ChecksTotal != 2 || status.ChecksPassing != 1 {
t.Errorf("checks = %d/%d, want 1/2", status.ChecksPassing, status.ChecksTotal)
}
if !status.ChecksPopulated || !status.ReviewCountsPopulated {
t.Error("the gh REST path counted checks and reviews; both flags must be set")
}
if status.UnresolvedReviewThreadsPopulated {
t.Error("UnresolvedReviewThreadsPopulated must stay false — no threads were fetched")
}
}
func TestGHClient_GetPR_NormalizesCLIRateLimitStderr(t *testing.T) {
for _, stderr := range []string{
"GraphQL: API rate limit already exceeded for user",
"gh: HTTP 429: Too Many Requests",
} {
t.Run(stderr, func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "pr view", Stderr: stderr, Exit: 1})
_, err := NewGHClient().GetPR(context.Background(), "acme", "widget", 42)
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusTooManyRequests {
t.Fatalf("err = %v, want a typed 429 rate-limit error", err)
}
})
}
}
func TestGHClient_GetPRFeedback(t *testing.T) {
responses := append(ghPRStatusResponses(),
ghResponse{
Prefix: "api repos/acme/widget/pulls/42/comments",
Stdout: `[{"id":20,"body":"inline","created_at":"2026-01-05T12:00:00Z",
"user":{"login":"alice","type":"User"}}]`,
},
ghResponse{Prefix: "api repos/acme/widget/issues/42/comments", Stdout: `[]`},
)
newFakeGH(t, responses...)
feedback, err := NewGHClient().GetPRFeedback(context.Background(), "acme", "widget", 42)
if err != nil {
t.Fatalf("GetPRFeedback: %v", err)
}
if feedback.PR == nil || feedback.PR.Number != 42 {
t.Fatalf("PR = %#v", feedback.PR)
}
if len(feedback.Reviews) != 1 || len(feedback.Checks) != 2 {
t.Errorf("reviews/checks = (%d, %d), want (1, 2)", len(feedback.Reviews), len(feedback.Checks))
}
if len(feedback.Comments) != 1 || feedback.Comments[0].ID != 20 {
t.Errorf("comments = %#v", feedback.Comments)
}
if !feedback.HasIssues {
t.Error("HasIssues = false, want true — one check concluded failure")
}
}
// ghPRStatusResponses is the dispatch set the PR status rollup needs: the PR
// itself, its reviews, its paginated check runs, and its commit statuses.
// The comment endpoints are matched before the bare `api repos/` arm, so
// callers appending them must place them ahead of nothing — the ordering here
// already puts the specific pulls/issues comment paths first when appended.
func ghPRStatusResponses() []ghResponse {
return []ghResponse{
{
Prefix: "pr view",
Stdout: `{"number":42,"state":"OPEN","mergeStateStatus":"BLOCKED",
"headRefName":"feature","headRefOid":"headsha","baseRefName":"main",
"author":{"login":"bob"}}`,
},
{
Prefix: "api --paginate",
Stdout: `{"name":"unit","status":"completed","conclusion":"success","html_url":"https://ci/unit"}
{"name":"lint","status":"completed","conclusion":"failure","html_url":"https://ci/lint"}`,
},
{
Prefix: "api repos/acme/widget/pulls/42/reviews",
Stdout: `[{"id":1,"state":"APPROVED","submitted_at":"2026-01-05T10:00:00Z",
"user":{"login":"alice"}}]`,
},
{Prefix: "api repos/acme/widget/commits", Stdout: `[]`},
}
}
func TestGHClient_ListRepoDirectory(t *testing.T) {
calls := newFakeGH(t, ghResponse{
Prefix: "api repos/",
Stdout: `[{"name":"spec.md","path":"docs/spec.md","type":"file","sha":"aaa","size":128},
{"name":"images","path":"docs/images","type":"dir","sha":"bbb"}]`,
})
entries, err := NewGHClient().ListRepoDirectory(context.Background(), "acme", "widget", "docs", "main")
if err != nil {
t.Fatalf("ListRepoDirectory: %v", err)
}
assertGHArgv(t, calls(t), 0, []string{
"api", "repos/acme/widget/contents/docs", "-X", "GET", "-f", "ref=main",
})
want := []RepoContentEntry{
{Name: "spec.md", Path: "docs/spec.md", Type: RepoContentTypeFile, SHA: "aaa", Size: 128},
{Name: "images", Path: "docs/images", Type: RepoContentTypeDir, SHA: "bbb"},
}
if len(entries) != len(want) {
t.Fatalf("entries = %#v, want %#v", entries, want)
}
for i := range want {
if entries[i] != want[i] {
t.Errorf("entries[%d] = %#v, want %#v", i, entries[i], want[i])
}
}
}
func TestGHClient_ListRepoDirectory_RootOmitsPathAndRef(t *testing.T) {
calls := newFakeGH(t, ghResponse{Prefix: "api repos/", Stdout: `[]`})
if _, err := NewGHClient().ListRepoDirectory(context.Background(), "acme", "widget", "", ""); err != nil {
t.Fatalf("ListRepoDirectory: %v", err)
}
assertGHArgv(t, calls(t), 0, []string{"api", "repos/acme/widget/contents", "-X", "GET"})
}
func TestGHClient_ListRepoDirectory_Errors(t *testing.T) {
t.Run("404 becomes a typed error", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api repos/", Stderr: "gh: HTTP 404: Not Found", Exit: 1})
entries, err := NewGHClient().ListRepoDirectory(context.Background(), "acme", "widget", "nope", "")
if entries != nil {
t.Errorf("entries = %#v, want nil", entries)
}
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotFound {
t.Fatalf("err = %v, want a *GitHubAPIError with 404", err)
}
if apiErr.Endpoint != "repos/acme/widget/contents/nope" {
t.Errorf("endpoint = %q", apiErr.Endpoint)
}
})
// 422 is not one of the statuses config sync's fetch classification needs
// promoted (404/401/403/429/5xx), so it stays the residue case: any
// status this client does not recognize falls through to a bare wrap
// rather than being guessed at.
t.Run("other failures stay untyped", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api repos/", Stderr: "HTTP 422", Exit: 1})
_, err := NewGHClient().ListRepoDirectory(context.Background(), "acme", "widget", "docs", "")
if err == nil || !strings.Contains(err.Error(), "list repo directory") {
t.Fatalf("err = %v, want a wrapped error", err)
}
var apiErr *GitHubAPIError
if errors.As(err, &apiErr) {
t.Fatalf("err = %v, want it to stay untyped", err)
}
})
for _, tt := range []struct {
name string
stderr string
want int
}{
{"401 becomes a typed error", "gh: HTTP 401: Bad credentials", http.StatusUnauthorized},
{"403 becomes a typed error", "gh: HTTP 403: Forbidden", http.StatusForbidden},
{"429 becomes a typed error", "gh: HTTP 429: Too Many Requests", http.StatusTooManyRequests},
{"5xx becomes a typed error", "gh: HTTP 503: Service Unavailable", http.StatusServiceUnavailable},
} {
t.Run(tt.name, func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api repos/", Stderr: tt.stderr, Exit: 1})
entries, err := NewGHClient().ListRepoDirectory(context.Background(), "acme", "widget", "docs", "")
if entries != nil {
t.Errorf("entries = %#v, want nil", entries)
}
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != tt.want {
t.Fatalf("err = %v, want a *GitHubAPIError with %d", err, tt.want)
}
})
}
t.Run("unparseable output", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api repos/", Stdout: "nope"})
_, err := NewGHClient().ListRepoDirectory(context.Background(), "acme", "widget", "docs", "")
if err == nil || !strings.Contains(err.Error(), "list repo directory: decode") {
t.Fatalf("err = %v, want a decode failure", err)
}
})
}
func TestGHClient_GetRepoFileContent(t *testing.T) {
content := "package main\n"
encoded := base64.StdEncoding.EncodeToString([]byte(content))
// GitHub wraps base64 at 60 chars; `\n` here is the JSON escape, so the
// decoded field really carries an embedded newline that must be stripped.
wrapped := encoded[:6] + `\n` + encoded[6:]
calls := newFakeGH(t, ghResponse{
Prefix: "api repos/",
Stdout: `{"type":"file","encoding":"base64","content":"` + wrapped + `"}`,
})
got, err := NewGHClient().GetRepoFileContent(context.Background(), "acme", "widget", "cmd/main.go", "abc")
if err != nil {
t.Fatalf("GetRepoFileContent: %v", err)
}
assertGHArgv(t, calls(t), 0, []string{
"api", "repos/acme/widget/contents/cmd/main.go", "-X", "GET", "-f", "ref=abc",
})
if string(got) != content {
t.Errorf("content = %q, want %q", got, content)
}
}
func TestGHClient_GetRepoFileContent_Errors(t *testing.T) {
t.Run("404 becomes a typed error", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api repos/", Stderr: "gh: HTTP 404: Not Found", Exit: 1})
_, err := NewGHClient().GetRepoFileContent(context.Background(), "acme", "widget", "gone.txt", "")
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotFound {
t.Fatalf("err = %v, want a *GitHubAPIError with 404", err)
}
if apiErr.Endpoint != "repos/acme/widget/contents/gone.txt" {
t.Errorf("endpoint = %q", apiErr.Endpoint)
}
})
// 422 stays the residue case for the same reason as
// TestGHClient_ListRepoDirectory_Errors: it is not one of the statuses
// config sync's fetch classification promotes.
t.Run("other failures stay untyped", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api repos/", Stderr: "HTTP 422", Exit: 1})
_, err := NewGHClient().GetRepoFileContent(context.Background(), "acme", "widget", "x", "")
if err == nil || !strings.Contains(err.Error(), "get repo file content") {
t.Fatalf("err = %v, want a wrapped error", err)
}
var apiErr *GitHubAPIError
if errors.As(err, &apiErr) {
t.Fatalf("err = %v, want it to stay untyped", err)
}
})
for _, tt := range []struct {
name string
stderr string
want int
}{
{"401 becomes a typed error", "gh: HTTP 401: Bad credentials", http.StatusUnauthorized},
{"403 becomes a typed error", "gh: HTTP 403: Forbidden", http.StatusForbidden},
{"429 becomes a typed error", "gh: HTTP 429: Too Many Requests", http.StatusTooManyRequests},
{"5xx becomes a typed error", "gh: HTTP 503: Service Unavailable", http.StatusServiceUnavailable},
} {
t.Run(tt.name, func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api repos/", Stderr: tt.stderr, Exit: 1})
_, err := NewGHClient().GetRepoFileContent(context.Background(), "acme", "widget", "x", "")
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != tt.want {
t.Fatalf("err = %v, want a *GitHubAPIError with %d", err, tt.want)
}
})
}
t.Run("unparseable output", func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api repos/", Stdout: "nope"})
_, err := NewGHClient().GetRepoFileContent(context.Background(), "acme", "widget", "x", "")
if err == nil || !strings.Contains(err.Error(), "get repo file content: decode") {
t.Fatalf("err = %v, want a decode failure", err)
}
})
t.Run("unsupported encoding", func(t *testing.T) {
newFakeGH(t, ghResponse{
Prefix: "api repos/", Stdout: `{"type":"file","encoding":"none","content":""}`,
})
_, err := NewGHClient().GetRepoFileContent(context.Background(), "acme", "widget", "x", "")
if err == nil || !strings.Contains(err.Error(), `unsupported encoding "none"`) {
t.Fatalf("err = %v, want the encoding named", err)
}
})
t.Run("undecodable base64", func(t *testing.T) {
newFakeGH(t, ghResponse{
Prefix: "api repos/", Stdout: `{"type":"file","encoding":"base64","content":"!!!"}`,
})
_, err := NewGHClient().GetRepoFileContent(context.Background(), "acme", "widget", "x", "")
if err == nil || !strings.Contains(err.Error(), "decode base64") {
t.Fatalf("err = %v, want a base64 failure", err)
}
})
}
// The exec budget starts only after the throttle slot is acquired, and is
// capped at ghCLITimeout — a caller deadline further out must not extend it.
func TestResolveGHExecTimeout(t *testing.T) {
t.Run("no deadline takes the full budget", func(t *testing.T) {
if got := resolveGHExecTimeout(context.Background()); got != ghCLITimeout {
t.Errorf("timeout = %v, want %v", got, ghCLITimeout)
}
})
t.Run("a nearer deadline wins", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
got := resolveGHExecTimeout(ctx)
if got > 2*time.Second || got <= 0 {
t.Errorf("timeout = %v, want just under 2s", got)
}
})
t.Run("a further deadline is capped", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
defer cancel()
if got := resolveGHExecTimeout(ctx); got != ghCLITimeout {
t.Errorf("timeout = %v, want it capped at %v", got, ghCLITimeout)
}
})
}
func TestFirstArg(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"nil", nil, "<no-args>"},
{"empty", []string{}, "<no-args>"},
{"single", []string{"api"}, "api"},
{"multiple", []string{"pr", "view", "1"}, "pr"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := firstArg(tc.args); got != tc.want {
t.Errorf("firstArg(%v) = %q, want %q", tc.args, got, tc.want)
}
})
}
}
func TestConvertGHIssue(t *testing.T) {
raw := &ghIssue{
Number: 11,
Title: "Crash on start",
URL: "https://github.com/acme/widget/issues/11",
State: "CLOSED",
Body: "stack trace",
CreatedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC),
ClosedAt: "2026-02-03T00:00:00Z",
}
raw.Author.Login = "erin"
raw.Labels = []struct {
Name string `json:"name"`
}{{Name: "bug"}, {Name: "regression"}}
raw.Assignees = []struct {
Login string `json:"login"`
}{{Login: "frank"}}
issue := convertGHIssue(raw, "acme", "widget")
if issue.Number != 11 || issue.Title != "Crash on start" || issue.Body != "stack trace" {
t.Errorf("scalars = %#v", issue)
}
if issue.URL != raw.URL || issue.HTMLURL != raw.URL {
t.Errorf("URL/HTMLURL = (%q, %q), want both %q", issue.URL, issue.HTMLURL, raw.URL)
}
if issue.State != "closed" {
t.Errorf("state = %q, want lowercased", issue.State)
}
if issue.AuthorLogin != "erin" || issue.RepoOwner != "acme" || issue.RepoName != "widget" {
t.Errorf("author/repo = (%q, %q, %q)", issue.AuthorLogin, issue.RepoOwner, issue.RepoName)
}
if strings.Join(issue.Labels, ",") != "bug,regression" {
t.Errorf("labels = %v", issue.Labels)
}
if strings.Join(issue.Assignees, ",") != "frank" {
t.Errorf("assignees = %v", issue.Assignees)
}
if !issue.CreatedAt.Equal(raw.CreatedAt) || !issue.UpdatedAt.Equal(raw.UpdatedAt) {
t.Errorf("timestamps = (%v, %v)", issue.CreatedAt, issue.UpdatedAt)
}
wantClosed := time.Date(2026, 2, 3, 0, 0, 0, 0, time.UTC)
if issue.ClosedAt == nil || !issue.ClosedAt.Equal(wantClosed) {
t.Errorf("closed_at = %v, want %v", issue.ClosedAt, wantClosed)
}
}
func TestGHClient_ParsePRList(t *testing.T) {
t.Run("stamps the repo identity onto every row", func(t *testing.T) {
prs, err := (&GHClient{}).parsePRList(
`[{"number":1,"state":"OPEN","author":{"login":"a"}},
{"number":2,"state":"CLOSED","mergedAt":"2026-01-01T00:00:00Z","author":{"login":"b"}}]`,
"acme", "widget",
)
if err != nil {
t.Fatalf("parsePRList: %v", err)
}
if len(prs) != 2 {
t.Fatalf("prs = %#v, want 2", prs)
}
for i, pr := range prs {
if pr.RepoOwner != "acme" || pr.RepoName != "widget" {
t.Errorf("prs[%d] repo = (%q, %q)", i, pr.RepoOwner, pr.RepoName)
}
}
if prs[0].State != "open" {
t.Errorf("prs[0] state = %q, want open", prs[0].State)
}
if prs[1].State != prStateMerged {
t.Errorf("prs[1] state = %q, want merged", prs[1].State)
}
})
t.Run("unparseable input", func(t *testing.T) {
if _, err := (&GHClient{}).parsePRList("nope", "o", "r"); err == nil ||
!strings.Contains(err.Error(), "parse PR list") {
t.Fatalf("err = %v, want a parse failure", err)
}
})
}