From 5e2b2779d1d57d23c35c7ba2e94026a47e164ee2 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 9 Mar 2026 11:30:02 +0000 Subject: [PATCH 1/7] refactor(api): add IssueRepoInfo for minimal issue repo queries Add a new IssueRepoInfo function that fetches only the fields needed for issue creation (id, name, owner, hasIssuesEnabled, viewerPermission), avoiding defaultBranchRef and other fields that require Contents:Read. Also add StubIssueRepoInfoResponse helper to httpmock for testing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_repo.go | 38 ++++++++++++++++++++++++++++++++++++++ pkg/httpmock/legacy.go | 14 ++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/api/queries_repo.go b/api/queries_repo.go index d358255d8..bbf60a602 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -314,6 +314,44 @@ func FetchRepository(client *Client, repo ghrepo.Interface, fields []string) (*R return InitRepoHostname(result.Repository, repo.RepoHost()), nil } +// IssueRepoInfo fetches only the repository fields needed for issue creation, +// avoiding fields like defaultBranchRef that require additional token permissions. +func IssueRepoInfo(client *Client, repo ghrepo.Interface) (*Repository, error) { + query := ` + query IssueRepositoryInfo($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id + name + owner { login } + hasIssuesEnabled + viewerPermission + } + }` + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "name": repo.RepoName(), + } + + var result struct { + Repository *Repository + } + if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil { + return nil, err + } + if result.Repository == nil { + return nil, GraphQLError{ + GraphQLError: &ghAPI.GraphQLError{ + Errors: []ghAPI.GraphQLErrorItem{{ + Type: "NOT_FOUND", + Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()), + }}, + }, + } + } + + return InitRepoHostname(result.Repository, repo.RepoHost()), nil +} + func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { query := ` fragment repo on Repository { diff --git a/pkg/httpmock/legacy.go b/pkg/httpmock/legacy.go index ad92d0572..1484734f3 100644 --- a/pkg/httpmock/legacy.go +++ b/pkg/httpmock/legacy.go @@ -22,6 +22,20 @@ func (r *Registry) StubRepoInfoResponse(owner, repo, branch string) { `, repo, owner, branch))) } +func (r *Registry) StubIssueRepoInfoResponse(owner, repo string) { + r.Register( + GraphQL(`query IssueRepositoryInfo\b`), + StringResponse(fmt.Sprintf(` + { "data": { "repository": { + "id": "REPOID", + "name": "%s", + "owner": {"login": "%s"}, + "hasIssuesEnabled": true, + "viewerPermission": "WRITE" + } } } + `, repo, owner))) +} + func (r *Registry) StubRepoResponse(owner, repo string) { r.StubRepoResponseWithPermission(owner, repo, "WRITE") } From 11e5be7842ec46f3fa806a97646fa0e1fec826f3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 9 Mar 2026 11:30:46 +0000 Subject: [PATCH 2/7] test(api): add tests for GitHubRepo and IssueRepoInfo Add success, not-found, and edge case tests for both GitHubRepo and IssueRepoInfo, covering field population, parent repo handling, viewer permission checks, and issues-disabled scenarios. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_repo_test.go | 182 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 174 insertions(+), 8 deletions(-) diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index ae00a98b2..4fe7074f1 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -26,15 +26,181 @@ func TestGitHubRepo_notFound(t *testing.T) { client := newTestClient(httpReg) repo, err := GitHubRepo(client, ghrepo.New("OWNER", "REPO")) - if err == nil { - t.Fatal("GitHubRepo did not return an error") - } - if wants := "GraphQL: Could not resolve to a Repository with the name 'OWNER/REPO'."; err.Error() != wants { - t.Errorf("GitHubRepo error: want %q, got %q", wants, err.Error()) - } - if repo != nil { - t.Errorf("GitHubRepo: expected nil repo, got %v", repo) + require.EqualError(t, err, "GraphQL: Could not resolve to a Repository with the name 'OWNER/REPO'.") + assert.Nil(t, repo) +} + +func TestGitHubRepo_success(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "name": "REPO", + "owner": {"login": "OWNER"}, + "hasIssuesEnabled": true, + "description": "a cool repo", + "hasWikiEnabled": true, + "viewerPermission": "ADMIN", + "defaultBranchRef": {"name": "main"}, + "parent": null, + "mergeCommitAllowed": true, + "rebaseMergeAllowed": true, + "squashMergeAllowed": false + } } }`)) + + client := newTestClient(httpReg) + repo, err := GitHubRepo(client, ghrepo.New("OWNER", "REPO")) + require.NoError(t, err) + assert.Equal(t, &Repository{ + ID: "REPOID", + Name: "REPO", + Owner: RepositoryOwner{Login: "OWNER"}, + HasIssuesEnabled: true, + Description: "a cool repo", + HasWikiEnabled: true, + ViewerPermission: "ADMIN", + DefaultBranchRef: BranchRef{Name: "main"}, + MergeCommitAllowed: true, + RebaseMergeAllowed: true, + hostname: "github.com", + }, repo) + assert.True(t, repo.ViewerCanPush()) + assert.True(t, repo.ViewerCanTriage()) +} + +func TestGitHubRepo_withParent(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "name": "REPO", + "owner": {"login": "OWNER"}, + "hasIssuesEnabled": true, + "description": "", + "hasWikiEnabled": false, + "viewerPermission": "READ", + "defaultBranchRef": {"name": "main"}, + "parent": { + "id": "PARENTID", + "name": "PARENT-REPO", + "owner": {"login": "PARENT-OWNER"}, + "hasIssuesEnabled": true, + "description": "parent repo", + "hasWikiEnabled": true, + "viewerPermission": "READ", + "defaultBranchRef": {"name": "develop"} + }, + "mergeCommitAllowed": false, + "rebaseMergeAllowed": false, + "squashMergeAllowed": true + } } }`)) + + client := newTestClient(httpReg) + repo, err := GitHubRepo(client, ghrepo.New("OWNER", "REPO")) + require.NoError(t, err) + wantParent := &Repository{ + ID: "PARENTID", + Name: "PARENT-REPO", + Owner: RepositoryOwner{Login: "PARENT-OWNER"}, + HasIssuesEnabled: true, + Description: "parent repo", + HasWikiEnabled: true, + ViewerPermission: "READ", + DefaultBranchRef: BranchRef{Name: "develop"}, + hostname: "github.com", } + assert.Equal(t, &Repository{ + ID: "REPOID", + Name: "REPO", + Owner: RepositoryOwner{Login: "OWNER"}, + HasIssuesEnabled: true, + ViewerPermission: "READ", + DefaultBranchRef: BranchRef{Name: "main"}, + Parent: wantParent, + SquashMergeAllowed: true, + hostname: "github.com", + }, repo) + assert.False(t, repo.ViewerCanPush()) + assert.False(t, repo.ViewerCanTriage()) +} + +func TestIssueRepoInfo_notFound(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(`{ "data": { "repository": null } }`)) + + client := newTestClient(httpReg) + repo, err := IssueRepoInfo(client, ghrepo.New("OWNER", "REPO")) + require.EqualError(t, err, "GraphQL: Could not resolve to a Repository with the name 'OWNER/REPO'.") + assert.Nil(t, repo) +} + +func TestIssueRepoInfo_success(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "name": "REPO", + "owner": {"login": "OWNER"}, + "hasIssuesEnabled": true, + "viewerPermission": "WRITE" + } } }`)) + + client := newTestClient(httpReg) + repo, err := IssueRepoInfo(client, ghrepo.New("OWNER", "REPO")) + require.NoError(t, err) + assert.Equal(t, &Repository{ + ID: "REPOID", + Name: "REPO", + Owner: RepositoryOwner{Login: "OWNER"}, + HasIssuesEnabled: true, + ViewerPermission: "WRITE", + hostname: "github.com", + }, repo) + assert.True(t, repo.ViewerCanTriage()) +} + +func TestIssueRepoInfo_issuesDisabled(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "name": "REPO", + "owner": {"login": "OWNER"}, + "hasIssuesEnabled": false, + "viewerPermission": "READ" + } } }`)) + + client := newTestClient(httpReg) + repo, err := IssueRepoInfo(client, ghrepo.New("OWNER", "REPO")) + require.NoError(t, err) + assert.Equal(t, &Repository{ + ID: "REPOID", + Name: "REPO", + Owner: RepositoryOwner{Login: "OWNER"}, + ViewerPermission: "READ", + hostname: "github.com", + }, repo) + assert.False(t, repo.ViewerCanTriage()) } func Test_RepoMetadata(t *testing.T) { From 1d95b633e39d3f86238eac55229e1b520c0edffa Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 9 Mar 2026 11:33:12 +0000 Subject: [PATCH 3/7] fix(issue create): use IssueRepoInfo to avoid requiring Contents:Read permission Switch issue create from GitHubRepo to IssueRepoInfo so that gh issue create works with fine-grained PATs that only have Issues:Write and Metadata:Read permissions. Fixes cli/cli#12798 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/issue/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index da3648c31..2544698cb 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -239,7 +239,7 @@ func createRun(opts *CreateOptions) (err error) { fmt.Fprintf(opts.IO.ErrOut, "\nCreating issue in %s\n\n", ghrepo.FullName(baseRepo)) } - repo, err := api.GitHubRepo(apiClient, baseRepo) + repo, err := api.IssueRepoInfo(apiClient, baseRepo) if err != nil { return } From 64416e1ea18f2734db85db264b183dc2373406df Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 9 Mar 2026 11:35:13 +0000 Subject: [PATCH 4/7] test(issue create): update stubs for IssueRepositoryInfo query Update test mocks to match the renamed GraphQL query used by IssueRepoInfo, and switch to StubIssueRepoInfoResponse helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/issue/create/create_test.go | 30 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 80c8f76d3..7c98f6177 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -411,7 +411,7 @@ func Test_createRun(t *testing.T) { name: "editor", httpStubs: func(r *httpmock.Registry) { r.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "REPOID", @@ -440,7 +440,7 @@ func Test_createRun(t *testing.T) { name: "editor and template", httpStubs: func(r *httpmock.Registry) { r.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "REPOID", @@ -516,7 +516,7 @@ func Test_createRun(t *testing.T) { }, httpStubs: func(r *httpmock.Registry) { r.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "REPOID", @@ -589,7 +589,7 @@ func Test_createRun(t *testing.T) { }, httpStubs: func(r *httpmock.Registry) { r.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "REPOID", @@ -721,7 +721,7 @@ func TestIssueCreate(t *testing.T) { defer http.Verify(t) http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "REPOID", @@ -754,7 +754,7 @@ func TestIssueCreate_recover(t *testing.T) { defer http.Verify(t) http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "REPOID", @@ -838,7 +838,7 @@ func TestIssueCreate_nonLegacyTemplate(t *testing.T) { defer http.Verify(t) http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "REPOID", @@ -901,7 +901,7 @@ func TestIssueCreate_continueInBrowser(t *testing.T) { defer http.Verify(t) http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "REPOID", @@ -947,7 +947,7 @@ func TestIssueCreate_metadata(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubRepoInfoResponse("OWNER", "REPO", "main") + http.StubIssueRepoInfoResponse("OWNER", "REPO") http.Register( httpmock.GraphQL(`query RepositoryAssignableActors\b`), httpmock.StringResponse(` @@ -1057,7 +1057,7 @@ func TestIssueCreate_disabledIssues(t *testing.T) { defer http.Verify(t) http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "REPOID", @@ -1084,7 +1084,7 @@ func TestIssueCreate_AtMeAssignee(t *testing.T) { `), ) http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "REPOID", @@ -1127,7 +1127,7 @@ func TestIssueCreate_AtCopilotAssignee(t *testing.T) { defer http.Verify(t) http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "REPOID", @@ -1168,7 +1168,7 @@ func TestIssueCreate_projectsV2(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubRepoInfoResponse("OWNER", "REPO", "main") + http.StubIssueRepoInfoResponse("OWNER", "REPO") http.Register( httpmock.GraphQL(`query RepositoryProjectList\b`), httpmock.StringResponse(` @@ -1261,7 +1261,7 @@ func TestProjectsV1Deprecation(t *testing.T) { ios, _, _, _ := iostreams.Test() reg := &httpmock.Registry{} - reg.StubRepoInfoResponse("OWNER", "REPO", "main") + reg.StubIssueRepoInfoResponse("OWNER", "REPO") reg.Register( // ( is required to avoid matching projectsV2 httpmock.GraphQL(`projects\(`), @@ -1298,7 +1298,7 @@ func TestProjectsV1Deprecation(t *testing.T) { ios, _, _, _ := iostreams.Test() reg := &httpmock.Registry{} - reg.StubRepoInfoResponse("OWNER", "REPO", "main") + reg.StubIssueRepoInfoResponse("OWNER", "REPO") // ( is required to avoid matching projectsV2 reg.Exclude(t, httpmock.GraphQL(`projects\(`)) From aad0239683616b63951bd028b769eeefd2a437df Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 9 Mar 2026 11:49:22 +0000 Subject: [PATCH 5/7] fix(issue transfer): use IssueRepoInfo to fetch minimal fields for issues Only the destination repo ID is needed for issue transfer. Switch from GitHubRepo to IssueRepoInfo to use minimal fields appropriate for issues. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/issue/transfer/transfer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/issue/transfer/transfer.go b/pkg/cmd/issue/transfer/transfer.go index a6dfb9b23..8ac1ff3fe 100644 --- a/pkg/cmd/issue/transfer/transfer.go +++ b/pkg/cmd/issue/transfer/transfer.go @@ -105,7 +105,7 @@ func issueTransfer(httpClient *http.Client, issueID string, destRepo ghrepo.Inte destinationRepoID = r.ID } else { apiClient := api.NewClientFromHTTP(httpClient) - r, err := api.GitHubRepo(apiClient, destRepo) + r, err := api.IssueRepoInfo(apiClient, destRepo) if err != nil { return "", err } From 9f5dfa80c89861be3c1183ed6dc1d1d699323250 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 9 Mar 2026 11:49:44 +0000 Subject: [PATCH 6/7] test(issue transfer): update stub for IssueRepositoryInfo query Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/issue/transfer/transfer_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/issue/transfer/transfer_test.go b/pkg/cmd/issue/transfer/transfer_test.go index 2b12db944..12faad1b6 100644 --- a/pkg/cmd/issue/transfer/transfer_test.go +++ b/pkg/cmd/issue/transfer/transfer_test.go @@ -169,7 +169,7 @@ func Test_transferRunSuccessfulIssueTransfer(t *testing.T) { } } }`)) http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "dest-id", From 27fb2da1c84d612066d0ce75c039fc5f59f19265 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 9 Mar 2026 12:16:02 +0000 Subject: [PATCH 7/7] fix: improve docs around IssueRepoInfo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/queries_repo.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index bbf60a602..5bcf326e6 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -314,8 +314,9 @@ func FetchRepository(client *Client, repo ghrepo.Interface, fields []string) (*R return InitRepoHostname(result.Repository, repo.RepoHost()), nil } -// IssueRepoInfo fetches only the repository fields needed for issue creation, -// avoiding fields like defaultBranchRef that require additional token permissions. +// IssueRepoInfo fetches only the repository fields needed for issue operations such as +// issue creation and transfer, avoiding fields like defaultBranchRef that require additional +// token permissions. func IssueRepoInfo(client *Client, repo ghrepo.Interface) (*Repository, error) { query := ` query IssueRepositoryInfo($owner: String!, $name: String!) { @@ -338,6 +339,8 @@ func IssueRepoInfo(client *Client, repo ghrepo.Interface) (*Repository, error) { if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil { return nil, err } + // The GraphQL API should have returned an error in case of a missing repository, but this isn't + // guaranteed to happen when an authentication token with insufficient permissions is being used. if result.Repository == nil { return nil, GraphQLError{ GraphQLError: &ghAPI.GraphQLError{