package github import ( "context" "errors" "net/http" "strings" "testing" "time" ) func TestGHClient_GetIssue(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "issue view", Stdout: `{"number":5,"title":"Broken login","url":"https://github.com/acme/widget/issues/5", "state":"CLOSED","body":"steps","createdAt":"2026-03-01T08:00:00Z", "updatedAt":"2026-03-02T08:00:00Z","closedAt":"2026-03-03T08:00:00Z", "author":{"login":"carol"},"labels":[{"name":"bug"},{"name":"p1"}], "assignees":[{"login":"dave"}]}`, }) issue, err := NewGHClient().GetIssue(context.Background(), "acme", "widget", 5) if err != nil { t.Fatalf("GetIssue: %v", err) } assertGHArgv(t, calls(t), 0, []string{ "issue", "view", "5", "--repo", "acme/widget", "--json", "number,title,url,state,body,author,labels,assignees,createdAt,updatedAt,closedAt", }) if issue.Number != 5 || issue.Title != "Broken login" || issue.Body != "steps" { t.Errorf("scalars = %#v", issue) } if issue.URL != "https://github.com/acme/widget/issues/5" || issue.HTMLURL != issue.URL { t.Errorf("URL/HTMLURL = (%q, %q)", issue.URL, issue.HTMLURL) } if issue.State != "closed" { t.Errorf("state = %q, want lowercased closed", issue.State) } if issue.AuthorLogin != "carol" || 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,p1" || strings.Join(issue.Assignees, ",") != "dave" { t.Errorf("labels/assignees = (%v, %v)", issue.Labels, issue.Assignees) } if !issue.CreatedAt.Equal(time.Date(2026, 3, 1, 8, 0, 0, 0, time.UTC)) { t.Errorf("created_at = %v", issue.CreatedAt) } if !issue.UpdatedAt.Equal(time.Date(2026, 3, 2, 8, 0, 0, 0, time.UTC)) { t.Errorf("updated_at = %v", issue.UpdatedAt) } wantClosed := time.Date(2026, 3, 3, 8, 0, 0, 0, time.UTC) if issue.ClosedAt == nil || !issue.ClosedAt.Equal(wantClosed) { t.Errorf("closed_at = %v, want %v", issue.ClosedAt, wantClosed) } } // The gh CLI emits "" (not null) for an open issue's closedAt. func TestGHClient_GetIssue_EmptyClosedAtDecodesToNil(t *testing.T) { newFakeGH(t, ghResponse{ Prefix: "issue view", Stdout: `{"number":6,"state":"OPEN","closedAt":"","author":{"login":"carol"}}`, }) issue, err := NewGHClient().GetIssue(context.Background(), "acme", "widget", 6) if err != nil { t.Fatalf("GetIssue: %v", err) } if issue.ClosedAt != nil { t.Errorf("closed_at = %v, want nil", issue.ClosedAt) } } func TestGHClient_GetIssue_NotFoundBecomesTypedError(t *testing.T) { newFakeGH(t, ghResponse{ Prefix: "issue view", Stderr: "gh: HTTP 404: Not Found", Exit: 1, }) issue, err := NewGHClient().GetIssue(context.Background(), "acme", "widget", 404) if issue != nil { t.Errorf("issue = %#v, want nil", issue) } 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/issues/404" { t.Errorf("endpoint = %q", apiErr.Endpoint) } } func TestGHClient_GetIssue_OtherFailureIsWrapped(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "issue view", Stderr: "HTTP 500: server error", Exit: 1}) _, err := NewGHClient().GetIssue(context.Background(), "acme", "widget", 6) if err == nil || !strings.Contains(err.Error(), "get issue #6") { t.Fatalf("err = %v, want a wrapped get-issue error", err) } var apiErr *GitHubAPIError if errors.As(err, &apiErr) { t.Errorf("a 500 must not be promoted to *GitHubAPIError, got %#v", apiErr) } } func TestGHClient_GetRepository_NotFoundBecomesTypedError(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api /repos/acme/widget", Stderr: "gh: 404 Not Found", Exit: 1}) repository, err := NewGHClient().GetRepository(context.Background(), "acme", "widget") if repository != nil { t.Errorf("repository = %#v, want nil", repository) } 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" { t.Errorf("endpoint = %q", apiErr.Endpoint) } } func TestGHClient_ListRepositoryForksUsesForkNetwork(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "api repos/acme/widget/forks?per_page=100", Stdout: `[[{"id":200,"full_name":"alice/widget-renamed","name":"widget-renamed","owner":{"login":"alice"},"fork":true,"parent":{"id":100,"full_name":"acme/widget"},"permissions":{"push":true}}]]`, }) forks, err := NewGHClient().ListRepositoryForks(context.Background(), "acme", "widget") if err != nil { t.Fatalf("ListRepositoryForks: %v", err) } assertGHArgv(t, calls(t), 0, []string{ "api", "repos/acme/widget/forks?per_page=100", "--paginate", "--slurp", }) if len(forks) != 1 || forks[0].FullName != "alice/widget-renamed" || forks[0].ParentID != 100 { t.Fatalf("forks = %#v", forks) } } func TestGHClient_GetPR_NotFoundBecomesTypedError(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "pr view", Stderr: "gh: HTTP 404: Not Found", Exit: 1}) pr, err := NewGHClient().GetPR(context.Background(), "acme", "widget", 7) if pr != nil { t.Errorf("pr = %#v, want nil", pr) } 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/pulls/7" { t.Errorf("endpoint = %q", apiErr.Endpoint) } } // TestGHClient_GetPR_RequestsOutcomeFields covers AC-09: the --json field // list gains changedFiles, mergedBy, autoMergeRequest, and never requests // closedBy (absent from the gh CLI's PR field set). The decoded PR carries // the three new values. func TestGHClient_GetPR_RequestsOutcomeFields(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "pr view", Stdout: `{"number":2554,"title":"fix","url":"https://github.com/kdlbs/kandev/pull/2554", "state":"MERGED","headRefName":"h","headRefOid":"abc","baseRefName":"main", "isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN", "additions":10,"deletions":2,"changedFiles":8, "author":{"login":"alice"},"mergedBy":{"login":"carlosflorencio"}, "autoMergeRequest":{"enabledAt":"2026-08-12T00:00:00Z"}, "createdAt":"2026-08-01T00:00:00Z","updatedAt":"2026-08-02T00:00:00Z", "mergedAt":"2026-08-12T00:00:00Z","closedAt":"2026-08-12T00:00:00Z"}`, }) pr, err := NewGHClient().GetPR(context.Background(), "kdlbs", "kandev", 2554) if err != nil { t.Fatalf("GetPR: %v", err) } argv := calls(t)[0] jsonArgIdx := -1 for i, a := range argv { if a == "--json" { jsonArgIdx = i + 1 break } } if jsonArgIdx < 0 || jsonArgIdx >= len(argv) { t.Fatalf("no --json argument found in %v", argv) } fieldList := argv[jsonArgIdx] for _, want := range []string{"changedFiles", "mergedBy", "autoMergeRequest"} { if !strings.Contains(fieldList, want) { t.Errorf("--json field list missing %q: %s", want, fieldList) } } if strings.Contains(fieldList, "closedBy") { t.Errorf("--json field list must not request closedBy (not in gh CLI's PR field set): %s", fieldList) } if pr.ChangedFiles != 8 { t.Errorf("ChangedFiles = %d, want 8", pr.ChangedFiles) } if pr.MergedByLogin != "carlosflorencio" { t.Errorf("MergedByLogin = %q, want carlosflorencio", pr.MergedByLogin) } if !pr.AutoMergeEnabled { t.Error("AutoMergeEnabled = false, want true (non-null autoMergeRequest)") } } // TestGHClient_GetPR_NoMergerOrAutoMergeDecodesToZeroValues covers the // "never observed / not armed" side: a null mergedBy and a null // autoMergeRequest must decode to an empty login and AutoMergeEnabled=false, // never a placeholder value. func TestGHClient_GetPR_NoMergerOrAutoMergeDecodesToZeroValues(t *testing.T) { newFakeGH(t, ghResponse{ Prefix: "pr view", Stdout: `{"number":2476,"title":"wip","url":"https://github.com/kdlbs/kandev/pull/2476", "state":"CLOSED","isDraft":true,"changedFiles":114, "author":{"login":"alice"},"mergedBy":null,"autoMergeRequest":null, "createdAt":"2026-08-01T00:00:00Z","updatedAt":"2026-08-02T00:00:00Z","closedAt":"2026-08-05T00:00:00Z"}`, }) pr, err := NewGHClient().GetPR(context.Background(), "kdlbs", "kandev", 2476) if err != nil { t.Fatalf("GetPR: %v", err) } if pr.MergedByLogin != "" { t.Errorf("MergedByLogin = %q, want empty", pr.MergedByLogin) } if pr.AutoMergeEnabled { t.Error("AutoMergeEnabled = true, want false (null autoMergeRequest)") } if pr.ChangedFiles != 114 { t.Errorf("ChangedFiles = %d, want 114", pr.ChangedFiles) } } func TestGHClient_GetPR_UnparseableOutput(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "pr view", Stdout: "not json"}) if _, err := NewGHClient().GetPR(context.Background(), "acme", "widget", 7); err == nil || !strings.Contains(err.Error(), "parse PR response") { t.Fatalf("err = %v, want a parse failure", err) } } func TestGHClient_FindPRByBranch(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "pr list", Stdout: `[{"number":12,"title":"fork PR","url":"https://github.com/acme/widget/pull/12", "state":"OPEN","headRefName":"feature","headRefOid":"abc123","baseRefName":"main", "author":{"login":"alice"},"isDraft":false,"mergeable":"MERGEABLE", "mergeStateStatus":"CLEAN","additions":3,"deletions":1, "createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-02T00:00:00Z"}]`, }) pr, err := NewGHClient().FindPRByBranch(context.Background(), "acme", "widget", "feature") if err != nil { t.Fatalf("FindPRByBranch: %v", err) } assertGHArgv(t, calls(t), 0, []string{ "pr", "list", "--repo", "acme/widget", "--head", "feature", "--state", "open", "--json", "number,title,url,state,headRefName,headRefOid,baseRefName,author,isDraft," + "mergeable,mergeStateStatus,additions,deletions,createdAt,updatedAt,headRepository,headRepositoryOwner", "--limit", "1", }) if pr == nil { t.Fatal("pr = nil, want the single listed PR") } if pr.Number != 12 || pr.HeadBranch != "feature" || pr.HeadSHA != "abc123" { t.Errorf("pr = %#v", pr) } if pr.BaseBranch != "main" || pr.AuthorLogin != "alice" || pr.State != "open" { t.Errorf("pr = %#v", pr) } if !pr.Mergeable || pr.MergeableState != "clean" { t.Errorf("mergeable = (%v, %q)", pr.Mergeable, pr.MergeableState) } if pr.RepoOwner != "acme" || pr.RepoName != "widget" { t.Errorf("repo = (%q, %q)", pr.RepoOwner, pr.RepoName) } } func TestGHClient_FindPRByHead_UsesExactSourceRepository(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "pr list", Stdout: `[{ "number":12,"title":"fork PR","url":"https://github.com/kdlbs/kandev/pull/12", "state":"OPEN","headRefName":"feature","headRefOid":"abc123","baseRefName":"main", "author":{"login":"alice"},"isDraft":false,"mergeable":"MERGEABLE", "mergeStateStatus":"CLEAN","headRepository":{"id":"200","name":"kandev","nameWithOwner":"alice/kandev"}, "headRepositoryOwner":{"login":"alice"} }]`, }) pr, err := NewGHClient().FindPRByHead(context.Background(), "kdlbs", "kandev", "alice", "kandev", "feature") if err != nil { t.Fatalf("FindPRByHead: %v", err) } assertGHArgv(t, calls(t), 0, []string{ "pr", "list", "--repo", "kdlbs/kandev", "--head", "feature", "--state", "open", "--json", "number,title,url,state,headRefName,headRefOid,baseRefName,author,isDraft," + "mergeable,mergeStateStatus,additions,deletions,createdAt,updatedAt,headRepository,headRepositoryOwner", "--limit", "100", }) if pr == nil || pr.HeadRepoOwner != "alice" || pr.HeadRepoName != "kandev" { t.Fatalf("PR = %#v, want source alice/kandev", pr) } } func TestGHClient_FindPRByHead_ScansAllMatchingBranches(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "pr list", Stdout: `[ {"number":11,"title":"other fork","url":"https://github.com/kdlbs/kandev/pull/11", "state":"OPEN","headRefName":"feature","headRefOid":"other","baseRefName":"main", "headRepository":{"name":"other-kandev","nameWithOwner":"alice/other-kandev"}, "headRepositoryOwner":{"login":"alice"}}, {"number":12,"title":"requested fork","url":"https://github.com/kdlbs/kandev/pull/12", "state":"OPEN","headRefName":"feature","headRefOid":"requested","baseRefName":"main", "headRepository":{"name":"kandev","nameWithOwner":"alice/kandev"}, "headRepositoryOwner":{"login":"alice"}} ]`, }) pr, err := NewGHClient().FindPRByHead(context.Background(), "kdlbs", "kandev", "alice", "kandev", "feature") if err != nil { t.Fatalf("FindPRByHead: %v", err) } assertGHArgv(t, calls(t), 0, []string{ "pr", "list", "--repo", "kdlbs/kandev", "--head", "feature", "--state", "open", "--json", "number,title,url,state,headRefName,headRefOid,baseRefName,author,isDraft," + "mergeable,mergeStateStatus,additions,deletions,createdAt,updatedAt,headRepository,headRepositoryOwner", "--limit", "100", }) if pr == nil || pr.Number != 12 || pr.HeadRepoOwner != "alice" { t.Fatalf("PR = %#v, want the requested fork PR", pr) } } func TestGHClient_FindPRByBranch_NoMatchReturnsNilWithoutError(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "pr list", Stdout: `[]`}) pr, err := NewGHClient().FindPRByBranch(context.Background(), "acme", "widget", "feature") if err != nil { t.Fatalf("FindPRByBranch: %v", err) } if pr != nil { t.Errorf("pr = %#v, want nil for an empty list", pr) } } func TestGHClient_FindPRByBranch_Errors(t *testing.T) { t.Run("command failure names the branch", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "pr list", Stderr: "boom", Exit: 1}) _, err := NewGHClient().FindPRByBranch(context.Background(), "acme", "widget", "feature") if err == nil || !strings.Contains(err.Error(), `find PR by branch "feature"`) { t.Fatalf("err = %v, want the branch named in the wrap", err) } }) t.Run("unparseable output", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "pr list", Stdout: "{"}) _, err := NewGHClient().FindPRByBranch(context.Background(), "acme", "widget", "feature") if err == nil || !strings.Contains(err.Error(), "parse PR list") { t.Fatalf("err = %v, want a parse failure", err) } }) } func TestGHClient_ListAuthoredPRs(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "pr list", Stdout: `[{"number":1,"title":"first","state":"OPEN","headRefName":"a","baseRefName":"main", "author":{"login":"me"}}, {"number":2,"title":"second","state":"OPEN","headRefName":"b","baseRefName":"main", "author":{"login":"me"}}]`, }) prs, err := NewGHClient().ListAuthoredPRs(context.Background(), "acme", "widget") if err != nil { t.Fatalf("ListAuthoredPRs: %v", err) } argv := calls(t)[0] // The @me author filter is what scopes this to the signed-in account; // without it the call silently lists the whole repo. if !containsPair(argv, "--author", "@me") { t.Errorf("argv = %q, want --author @me", argv) } if !containsPair(argv, "--state", "open") || !containsPair(argv, "--repo", "acme/widget") { t.Errorf("argv = %q, want --state open and --repo acme/widget", argv) } if len(prs) != 2 || prs[0].Number != 1 || prs[1].Number != 2 { t.Fatalf("prs = %#v", prs) } if prs[0].RepoOwner != "acme" || prs[0].RepoName != "widget" { t.Errorf("repo = (%q, %q)", prs[0].RepoOwner, prs[0].RepoName) } } func TestGHClient_ListAuthoredPRs_Errors(t *testing.T) { t.Run("command failure", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "pr list", Stderr: "boom", Exit: 1}) prs, err := NewGHClient().ListAuthoredPRs(context.Background(), "acme", "widget") if prs != nil { t.Errorf("prs = %#v, want nil", prs) } if err == nil || !strings.Contains(err.Error(), "list authored PRs") { t.Fatalf("err = %v, want a wrapped list-authored error", err) } }) t.Run("unparseable output", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "pr list", Stdout: "["}) _, err := NewGHClient().ListAuthoredPRs(context.Background(), "acme", "widget") if err == nil || !strings.Contains(err.Error(), "parse PR list") { t.Fatalf("err = %v, want a parse failure", err) } }) } func TestGHClient_ListReviewRequestedPRs(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "api search/issues", Stdout: `[{"id":501,"node_id":"PR_a","number":3,"title":"Review me", "html_url":"https://github.com/acme/web/pull/3","state":"closed","draft":true, "created_at":"2026-04-01T00:00:00Z","updated_at":"2026-04-02T00:00:00Z", "repository_url":"https://api.github.com/repos/acme/web","user":{"login":"erin"}, "pull_request":{"merged_at":"2026-04-03T00:00:00Z"}}]`, }) prs, err := NewGHClient().ListReviewRequestedPRs(context.Background(), ReviewScopeUser, "repo:acme/web", "") if err != nil { t.Fatalf("ListReviewRequestedPRs: %v", err) } assertGHArgv(t, calls(t), 0, []string{ "api", "search/issues", "-X", "GET", "-f", "q=type:pr state:open user-review-requested:@me -is:draft repo:acme/web", "-f", "per_page=50", "--jq", ".items", }) if len(prs) != 1 { t.Fatalf("prs = %#v, want one", prs) } pr := prs[0] if pr.ID != 501 || pr.NodeID != "PR_a" || pr.Number != 3 || pr.Title != "Review me" { t.Errorf("identity = %#v", pr) } if pr.State != prStateMerged { t.Errorf("state = %q, want merged — merged_at promotes a closed search hit", pr.State) } if pr.RepoOwner != "acme" || pr.RepoName != "web" || pr.AuthorLogin != "erin" || !pr.Draft { t.Errorf("pr = %#v", pr) } } func TestGHClient_ListReviewRequestedPRs_CustomQueryIsUsedVerbatim(t *testing.T) { calls := newFakeGH(t, ghResponse{Prefix: "api search/issues", Stdout: `[]`}) if _, err := NewGHClient().ListReviewRequestedPRs( context.Background(), ReviewScopeUser, "ignored", "is:pr author:me", ); err != nil { t.Fatalf("ListReviewRequestedPRs: %v", err) } if !containsPair(calls(t)[0], "-f", "q=is:pr author:me") { t.Errorf("argv = %q, want the custom query passed through untouched", calls(t)[0]) } } func TestGHClient_ListReviewRequestedPRs_Errors(t *testing.T) { t.Run("command failure", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api search/issues", Stderr: "boom", Exit: 1}) _, err := NewGHClient().ListReviewRequestedPRs(context.Background(), "", "", "") if err == nil || !strings.Contains(err.Error(), "list review-requested PRs") { t.Fatalf("err = %v, want a wrapped error", err) } }) t.Run("unparseable output", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api search/issues", Stdout: "nope"}) _, err := NewGHClient().ListReviewRequestedPRs(context.Background(), "", "", "") if err == nil || !strings.Contains(err.Error(), "parse search results") { t.Fatalf("err = %v, want a parse failure", err) } }) } func TestGHClient_SearchPRsPaged(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "api search/issues", Stdout: `{"total_count":42,"items":[{"id":1,"number":1,"title":"one","state":"open", "repository_url":"https://api.github.com/repos/o/r","pull_request":{}}]}`, }) page, err := NewGHClient().SearchPRsPaged(context.Background(), "is:open", "", 2, 25) if err != nil { t.Fatalf("SearchPRsPaged: %v", err) } assertGHArgv(t, calls(t), 0, []string{ "api", "search/issues", "-X", "GET", "-f", "q=type:pr is:open", "-f", "per_page=25", "-f", "page=2", }) if page.TotalCount != 42 || page.Page != 2 || page.PerPage != 25 { t.Errorf("page meta = (%d, %d, %d), want (42, 2, 25)", page.TotalCount, page.Page, page.PerPage) } if len(page.PRs) != 1 || page.PRs[0].Number != 1 { t.Errorf("prs = %#v", page.PRs) } } func TestGHClient_SearchPRs_UsesTheDefaultFirstPage(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "api search/issues", Stdout: `{"total_count":0,"items":[]}`, }) prs, err := NewGHClient().SearchPRs(context.Background(), "", "") if err != nil { t.Fatalf("SearchPRs: %v", err) } if len(prs) != 0 { t.Errorf("prs = %#v, want empty", prs) } argv := calls(t)[0] if !containsPair(argv, "-f", "per_page=50") || !containsPair(argv, "-f", "page=1") { t.Errorf("argv = %q, want the default page 1 of 50", argv) } } func TestGHClient_SearchPRsPaged_Errors(t *testing.T) { t.Run("command failure", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api search/issues", Stderr: "boom", Exit: 1}) _, err := NewGHClient().SearchPRsPaged(context.Background(), "", "", 1, 10) if err == nil || !strings.Contains(err.Error(), "search PRs") { t.Fatalf("err = %v, want a wrapped search error", err) } }) t.Run("unparseable envelope", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api search/issues", Stdout: "nope"}) _, err := NewGHClient().SearchPRsPaged(context.Background(), "", "", 1, 10) if err == nil || !strings.Contains(err.Error(), "parse PR search response") { t.Fatalf("err = %v, want an envelope parse failure", err) } }) t.Run("unparseable items", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api search/issues", Stdout: `{"total_count":1,"items":{}}`}) _, err := NewGHClient().SearchPRsPaged(context.Background(), "", "", 1, 10) if err == nil || !strings.Contains(err.Error(), "parse search results") { t.Fatalf("err = %v, want an items parse failure", err) } }) } func TestGHClient_ListIssuesPaged_DropsPullRequests(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "api search/issues", Stdout: `{"total_count":3,"items":[ {"id":10,"node_id":"I_a","number":10,"title":"a real issue","body":"details", "html_url":"https://github.com/o/r/issues/10","state":"open", "created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z", "repository_url":"https://api.github.com/repos/o/r","user":{"login":"frank"}, "labels":[{"name":"bug"}],"assignees":[{"login":"grace"}]}, {"id":11,"number":11,"title":"a PR","state":"open", "repository_url":"https://api.github.com/repos/o/r","pull_request":{"url":"u"}}]}`, }) page, err := NewGHClient().ListIssuesPaged(context.Background(), "label:bug", "", 1, 20) if err != nil { t.Fatalf("ListIssuesPaged: %v", err) } if !containsPair(calls(t)[0], "-f", "q=type:issue label:bug") { t.Errorf("argv = %q, want the injected type:issue qualifier", calls(t)[0]) } if len(page.Issues) != 1 { t.Fatalf("issues = %#v, want the pull_request item dropped", page.Issues) } got := page.Issues[0] if got.ID != 10 || got.NodeID != "I_a" || got.Number != 10 || got.Title != "a real issue" { t.Errorf("issue = %#v", got) } if got.Body != "details" || got.RepoOwner != "o" || got.RepoName != "r" { t.Errorf("issue = %#v", got) } if strings.Join(got.Labels, ",") != "bug" || strings.Join(got.Assignees, ",") != "grace" { t.Errorf("labels/assignees = (%v, %v)", got.Labels, got.Assignees) } if page.TotalCount != 3 || page.Page != 1 || page.PerPage != 20 { t.Errorf("page meta = (%d, %d, %d)", page.TotalCount, page.Page, page.PerPage) } } func TestGHClient_ListIssues_UsesTheDefaultFirstPage(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "api search/issues", Stdout: `{"total_count":0,"items":[]}`, }) issues, err := NewGHClient().ListIssues(context.Background(), "", "") if err != nil { t.Fatalf("ListIssues: %v", err) } if len(issues) != 0 { t.Errorf("issues = %#v, want empty", issues) } argv := calls(t)[0] if !containsPair(argv, "-f", "per_page=50") || !containsPair(argv, "-f", "page=1") { t.Errorf("argv = %q, want the default page 1 of 50", argv) } } func TestGHClient_ListIssuesPaged_Errors(t *testing.T) { t.Run("command failure", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api search/issues", Stderr: "boom", Exit: 1}) _, err := NewGHClient().ListIssuesPaged(context.Background(), "", "", 1, 10) if err == nil || !strings.Contains(err.Error(), "list issues") { t.Fatalf("err = %v, want a wrapped list-issues error", err) } }) t.Run("unparseable output", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api search/issues", Stdout: "nope"}) _, err := NewGHClient().ListIssuesPaged(context.Background(), "", "", 1, 10) if err == nil || !strings.Contains(err.Error(), "parse issue search response") { t.Fatalf("err = %v, want a parse failure", err) } }) } func TestGHClient_GetIssueState(t *testing.T) { calls := newFakeGH(t, ghResponse{Prefix: "api repos/", Stdout: "closed\n"}) state, err := NewGHClient().GetIssueState(context.Background(), "acme", "widget", 7) if err != nil { t.Fatalf("GetIssueState: %v", err) } assertGHArgv(t, calls(t), 0, []string{ "api", "repos/acme/widget/issues/7", "--jq", ".state", }) if state != "closed" { t.Errorf("state = %q, want the trimmed closed", state) } } func TestGHClient_GetIssueState_Error(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api repos/", Stderr: "boom", Exit: 1}) state, err := NewGHClient().GetIssueState(context.Background(), "acme", "widget", 7) if state != "" { t.Errorf("state = %q, want empty", state) } if err == nil || !strings.Contains(err.Error(), "get issue state") { t.Fatalf("err = %v, want a wrapped error", err) } } func TestGHClient_HasRepositoryAccess(t *testing.T) { t.Run("readable repo", func(t *testing.T) { calls := newFakeGH(t, ghResponse{Prefix: "api /repos/", Stdout: "acme/widget\n"}) ok, err := NewGHClient().HasRepositoryAccess(context.Background(), "acme", "widget") if err != nil { t.Fatalf("HasRepositoryAccess: %v", err) } if !ok { t.Error("access = false, want true") } assertGHArgv(t, calls(t), 0, []string{"api", "/repos/acme/widget", "--jq", ".full_name"}) }) t.Run("blank output means no access", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api /repos/", Stdout: "\n"}) ok, err := NewGHClient().HasRepositoryAccess(context.Background(), "acme", "widget") if err != nil { t.Fatalf("HasRepositoryAccess: %v", err) } if ok { t.Error("access = true, want false for empty output") } }) t.Run("command failure propagates", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api /repos/", Stderr: "HTTP 404", Exit: 1}) ok, err := NewGHClient().HasRepositoryAccess(context.Background(), "acme", "widget") if ok { t.Error("access = true, want false on error") } if err == nil { t.Fatal("expected the gh failure to propagate") } }) } func TestGHClient_ListUserOrgs(t *testing.T) { calls := newFakeGH(t, ghResponse{ Prefix: "api user/orgs", Stdout: `[{"login":"acme","avatar_url":"https://avatars/acme"},{"login":"other"}]`, }) orgs, err := NewGHClient().ListUserOrgs(context.Background()) if err != nil { t.Fatalf("ListUserOrgs: %v", err) } assertGHArgv(t, calls(t), 0, []string{"api", "user/orgs", "--paginate"}) if len(orgs) != 2 { t.Fatalf("orgs = %#v, want 2", orgs) } if orgs[0] != (GitHubOrg{Login: "acme", AvatarURL: "https://avatars/acme"}) { t.Errorf("orgs[0] = %#v", orgs[0]) } if orgs[1] != (GitHubOrg{Login: "other"}) { t.Errorf("orgs[1] = %#v", orgs[1]) } } func TestGHClient_ListUserOrgs_Errors(t *testing.T) { t.Run("command failure", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api user/orgs", Stderr: "boom", Exit: 1}) orgs, err := NewGHClient().ListUserOrgs(context.Background()) if orgs != nil { t.Errorf("orgs = %#v, want nil", orgs) } if err == nil || !strings.Contains(err.Error(), "list user orgs") { t.Fatalf("err = %v, want a wrapped error", err) } }) t.Run("unparseable output", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api user/orgs", Stdout: "nope"}) _, err := NewGHClient().ListUserOrgs(context.Background()) if err == nil || !strings.Contains(err.Error(), "parse orgs") { t.Fatalf("err = %v, want a parse failure", err) } }) } // containsPair reports whether argv holds `flag` immediately followed by value. func containsPair(argv []string, flag, value string) bool { for i := 0; i < len(argv)-1; i++ { if argv[i] == flag && argv[i+1] == value { return true } } return false } func TestGHClient_SearchPRs_PropagatesTheUnderlyingFailure(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api search/issues", Stderr: "boom", Exit: 1}) prs, err := NewGHClient().SearchPRs(context.Background(), "", "") if prs != nil { t.Errorf("prs = %#v, want nil", prs) } if err == nil || !strings.Contains(err.Error(), "search PRs") { t.Fatalf("err = %v, want the paged error surfaced unchanged", err) } } func TestGHClient_ListIssues_PropagatesTheUnderlyingFailure(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api search/issues", Stderr: "boom", Exit: 1}) issues, err := NewGHClient().ListIssues(context.Background(), "", "") if issues != nil { t.Errorf("issues = %#v, want nil", issues) } if err == nil || !strings.Contains(err.Error(), "list issues") { t.Fatalf("err = %v, want the paged error surfaced unchanged", err) } } func TestGHClient_GetPRCommitDetail_Errors(t *testing.T) { const sha = "2222222222222222222222222222222222222222" t.Run("command failure", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api repos/", Stderr: "boom", Exit: 1}) _, err := NewGHClient().GetPRCommitDetail(context.Background(), "acme", "widget", sha) if err == nil || !strings.Contains(err.Error(), "get PR commit detail") { t.Fatalf("err = %v, want a wrapped error", err) } }) t.Run("unparseable output", func(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api repos/", Stdout: "nope"}) _, err := NewGHClient().GetPRCommitDetail(context.Background(), "acme", "widget", sha) if err == nil || !strings.Contains(err.Error(), "parse PR commit detail") { t.Fatalf("err = %v, want a parse failure", err) } }) } func TestGHClient_RequestReviewers_FailureNamesThePR(t *testing.T) { newFakeGH(t, ghResponse{Prefix: "api repos/", Stderr: "HTTP 422", Exit: 1}) err := NewGHClient().RequestReviewers(context.Background(), "acme", "widget", 42, []string{"octocat"}) if err == nil || !strings.Contains(err.Error(), "request reviewers on PR #42") { t.Fatalf("err = %v, want the PR named in the wrap", err) } } // gh pr view exposes the head repository under several shapes. nameWithOwner // backfills a missing owner/name, and httpsUrl is the fallback when cloneUrl // is absent — without them a fork PR loses the remote it must be pushed to. func TestConvertGHPR_HeadRepositoryFallbacks(t *testing.T) { t.Run("nameWithOwner backfills a missing owner and name", func(t *testing.T) { raw := &ghPR{Number: 1, State: "OPEN"} raw.HeadRepository.NameWithOwner = "contributor/widget-fork" raw.HeadRepository.HTTPSURL = "https://github.com/contributor/widget-fork.git" pr := convertGHPR(raw, "acme", "widget") if pr.HeadRepoOwner != "contributor" || pr.HeadRepoName != "widget-fork" { t.Errorf("head repo = (%q, %q), want it split out of nameWithOwner", pr.HeadRepoOwner, pr.HeadRepoName) } if pr.HeadRepoCloneURL != "https://github.com/contributor/widget-fork.git" { t.Errorf("clone url = %q, want the httpsUrl fallback", pr.HeadRepoCloneURL) } }) t.Run("explicit fields win over nameWithOwner", func(t *testing.T) { raw := &ghPR{Number: 1, State: "OPEN"} raw.HeadRepositoryOwner.Login = "real-owner" raw.HeadRepository.Name = "real-name" raw.HeadRepository.NameWithOwner = "stale/stale-name" raw.HeadRepository.CloneURL = "https://github.com/real-owner/real-name.git" raw.HeadRepository.HTTPSURL = "https://ignored" pr := convertGHPR(raw, "acme", "widget") if pr.HeadRepoOwner != "real-owner" || pr.HeadRepoName != "real-name" { t.Errorf("head repo = (%q, %q)", pr.HeadRepoOwner, pr.HeadRepoName) } if pr.HeadRepoCloneURL != "https://github.com/real-owner/real-name.git" { t.Errorf("clone url = %q, want cloneUrl to win", pr.HeadRepoCloneURL) } }) // gh pr view never returns baseRepository, so the --repo arguments are the // only trusted target identity and the base default branch stays unknown. t.Run("the base repo comes from the caller, not the payload", func(t *testing.T) { pr := convertGHPR(&ghPR{Number: 1, State: "OPEN", BaseRefName: "release/2.0"}, "acme", "widget") if pr.BaseRepoOwner != "acme" || pr.BaseRepoName != "widget" { t.Errorf("base repo = (%q, %q), want the caller's acme/widget", pr.BaseRepoOwner, pr.BaseRepoName) } if pr.BaseDefaultBranch != "" { t.Errorf("base default branch = %q, want empty — the base ref is not the default branch", pr.BaseDefaultBranch) } }) } func TestConvertGHRequestedReviewers_SkipsAnEmptyTeam(t *testing.T) { got := convertGHRequestedReviewers([]ghRequestedReviewer{ {TypeName: "Team"}, {TypeName: "User", Login: "alice"}, }) if len(got) != 1 { t.Fatalf("reviewers = %#v, want the nameless team dropped", got) } if got[0] != (RequestedReviewer{Login: "alice", Type: reviewerTypeUser}) { t.Errorf("reviewers[0] = %#v", got[0]) } }