Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9904f7d
gh pr create: CCR and multiselectwithsearch
BagToad Jan 30, 2026
04bf86a
Address PR review comments
BagToad Feb 4, 2026
78aaa87
Add toGitHubV4Strings helper to reduce code duplication
BagToad Feb 4, 2026
8456963
Merge branch 'trunk' into kw/pr-create-multi-select-with-search-ccr
BagToad Feb 6, 2026
6d9cac6
Merge branch 'trunk' into kw/pr-create-multi-select-with-search-ccr
BagToad Feb 6, 2026
c187b88
Merge branch 'trunk' into kw/pr-create-multi-select-with-search-ccr
BagToad Feb 10, 2026
620261f
Remove redundant comments
BagToad Feb 10, 2026
dd9ab71
Don't swallow error from FD
BagToad Feb 10, 2026
7373de3
Remove redundant comment
BagToad Feb 10, 2026
cf08f4d
Apply suggestion from @BagToad
BagToad Feb 10, 2026
db167d3
Preserve org/slug format for team reviewer slugs
BagToad Feb 11, 2026
1209b24
Partition bot reviewers separately for RequestReviewsByLogin
BagToad Feb 11, 2026
ad64d10
Wire bot reviewer logins through CreatePullRequest
BagToad Feb 11, 2026
3866164
Update test assertions to expect org/slug team format
BagToad Feb 11, 2026
e361335
Skip reviewer metadata fetch when using search-based selection
BagToad Feb 11, 2026
a8655bc
Include bot logins in login-based reviewer mutation guard
BagToad Feb 11, 2026
1cb7763
Normalize /slug team shorthand to org/slug and fix docs
BagToad Feb 11, 2026
1d73095
Use org/slug format in test fixtures and remove /slug normalization
BagToad Feb 11, 2026
72a6e9f
Move PR review queries from queries_pr.go to queries_pr_review.go
BagToad Feb 13, 2026
ceb9044
Clarify ReviewerCandidate relationship to AssignableActor
BagToad Feb 13, 2026
6341588
Add TODO requestReviewsByLoginCleanup in CreatePullRequest
BagToad Mar 5, 2026
8f62e81
Label Copilot detection in SuggestedReviewerActorsForRepo as a hack
BagToad Mar 5, 2026
dd7e44e
Check state.ActorReviewers in MetadataSurvey reviewer search gate
BagToad Mar 5, 2026
37776cf
Add TODO requestReviewsByLoginCleanup on static reviewer MultiSelect
BagToad Mar 5, 2026
07138b6
Remove /slug team reviewer shorthand normalization
BagToad Mar 5, 2026
49f1bd8
Add TODO requestReviewsByLoginCleanup on GHES ID-based reviewer path
BagToad Mar 5, 2026
08c7a4c
Replace @copilot with Copilot reviewer login in gh pr create
BagToad Mar 5, 2026
90bfa62
Exclude current user from suggested reviewers in gh pr create
BagToad Mar 5, 2026
1bba50b
Fix duplicate reviewers in gh pr edit by passing logins as defaults
BagToad Mar 5, 2026
24fb765
Exclude PR author from reviewer candidates in SuggestedReviewerActors
BagToad Mar 5, 2026
7382b86
Fetch org teams via repository.owner inline fragment
BagToad Mar 6, 2026
93c4340
Merge pull request #12627 from cli/kw/pr-create-multi-select-with-sea…
BagToad Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
501 changes: 34 additions & 467 deletions api/queries_pr.go

Large diffs are not rendered by default.

646 changes: 619 additions & 27 deletions api/queries_pr_review.go

Large diffs are not rendered by default.

258 changes: 231 additions & 27 deletions api/queries_pr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,18 +160,16 @@ func mockReviewerResponse(suggestions, collabs, teams, totalCollabs, totalTeams

return fmt.Sprintf(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [%s]}},
"node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [%s]}},
"repository": {
"owner": {"__typename": "Organization", "teams": {"nodes": [%s]}, "teamsTotalCount": {"totalCount": %d}},
"collaborators": {"nodes": [%s]},
"collaboratorsTotalCount": {"totalCount": %d}
},
"organization": {
"teams": {"nodes": [%s]},
"teamsTotalCount": {"totalCount": %d}
}
}
}`, strings.Join(suggestionNodes, ","), strings.Join(collabNodes, ","), totalCollabs,
strings.Join(teamNodes, ","), totalTeams)
}`, strings.Join(suggestionNodes, ","),
strings.Join(teamNodes, ","), totalTeams,
strings.Join(collabNodes, ","), totalCollabs)
}

func TestSuggestedReviewerActors(t *testing.T) {
Expand Down Expand Up @@ -235,18 +233,15 @@ func TestSuggestedReviewerActors(t *testing.T) {
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [
"node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [
{"isAuthor": true, "reviewer": {"__typename": "User", "login": "author", "name": "Author"}},
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}},
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s2", "name": "S2"}}
]}},
"repository": {
"owner": {"__typename": "Organization", "teams": {"nodes": [{"slug": "team1"}]}, "teamsTotalCount": {"totalCount": 3}},
"collaborators": {"nodes": [{"login": "c1", "name": "C1"}]},
"collaboratorsTotalCount": {"totalCount": 5}
},
"organization": {
"teams": {"nodes": [{"slug": "team1"}]},
"teamsTotalCount": {"totalCount": 3}
}
}
}`))
Expand All @@ -255,6 +250,30 @@ func TestSuggestedReviewerActors(t *testing.T) {
expectedLogins: []string{"s1", "s2", "c1", "OWNER/team1"},
expectedMore: 8,
},
{
name: "author excluded from collaborators",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"author": {"login": "theauthor"}, "suggestedReviewerActors": {"nodes": [
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}}
]}},
"repository": {
"collaborators": {"nodes": [
{"login": "theauthor", "name": "The Author"},
{"login": "c1", "name": "C1"}
]},
"collaboratorsTotalCount": {"totalCount": 5}
}
}
}`))
},
expectedCount: 2,
expectedLogins: []string{"s1", "c1"},
expectedMore: 5,
},
{
name: "deduplication across sources",
httpStubs: func(reg *httpmock.Registry) {
Expand All @@ -263,19 +282,16 @@ func TestSuggestedReviewerActors(t *testing.T) {
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [
"node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "shareduser", "name": "Shared"}}
]}},
"repository": {
"owner": {"__typename": "Organization", "teams": {"nodes": [{"slug": "team1"}]}, "teamsTotalCount": {"totalCount": 5}},
"collaborators": {"nodes": [
{"login": "shareduser", "name": "Shared"},
{"login": "c1", "name": "C1"}
]},
"collaboratorsTotalCount": {"totalCount": 10}
},
"organization": {
"teams": {"nodes": [{"slug": "team1"}]},
"teamsTotalCount": {"totalCount": 5}
}
}
}`))
Expand All @@ -291,16 +307,15 @@ func TestSuggestedReviewerActors(t *testing.T) {
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [
"node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}}
]}},
"repository": {
"owner": {"__typename": "User"},
"collaborators": {"nodes": [{"login": "c1", "name": "C1"}]},
"collaboratorsTotalCount": {"totalCount": 3}
},
"organization": null
},
"errors": [{"message": "Could not resolve to an Organization with the login of 'OWNER'."}]
}
}
}`))
},
expectedCount: 2,
Expand All @@ -314,17 +329,14 @@ func TestSuggestedReviewerActors(t *testing.T) {
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [
"node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [
{"isAuthor": false, "reviewer": {"__typename": "Bot", "login": "copilot-pull-request-reviewer"}},
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}}
]}},
"repository": {
"owner": {"__typename": "Organization", "teams": {"nodes": []}, "teamsTotalCount": {"totalCount": 0}},
"collaborators": {"nodes": []},
"collaboratorsTotalCount": {"totalCount": 5}
},
"organization": {
"teams": {"nodes": []},
"teamsTotalCount": {"totalCount": 0}
}
}
}`))
Expand Down Expand Up @@ -362,3 +374,195 @@ func TestSuggestedReviewerActors(t *testing.T) {
})
}
}

// mockReviewerResponseForRepo generates a GraphQL response for SuggestedReviewerActorsForRepo tests.
// It creates collaborators (c1, c2...) and teams (team1, team2...).
func mockReviewerResponseForRepo(collabs, teams, totalCollabs, totalTeams int) string {
return mockReviewerResponseForRepoWithCopilot(collabs, teams, totalCollabs, totalTeams, false)
}

// mockReviewerResponseForRepoWithCopilot generates a GraphQL response for SuggestedReviewerActorsForRepo tests.
// If copilotAvailable is true, includes Copilot in the first open PR's suggested reviewers.
func mockReviewerResponseForRepoWithCopilot(collabs, teams, totalCollabs, totalTeams int, copilotAvailable bool) string {
var collabNodes, teamNodes []string

for i := 1; i <= collabs; i++ {
collabNodes = append(collabNodes,
fmt.Sprintf(`{"login": "c%d", "name": "C%d"}`, i, i))
}
for i := 1; i <= teams; i++ {
teamNodes = append(teamNodes,
fmt.Sprintf(`{"slug": "team%d"}`, i))
}

pullRequestsJSON := `"pullRequests": {"nodes": []}`
if copilotAvailable {
pullRequestsJSON = `"pullRequests": {"nodes": [{"suggestedReviewerActors": {"nodes": [{"reviewer": {"__typename": "Bot", "login": "copilot-pull-request-reviewer"}}]}}]}`
}

return fmt.Sprintf(`{
"data": {
"viewer": {"login": "testuser"},
"repository": {
%s,
"owner": {"__typename": "Organization", "teams": {"nodes": [%s]}, "teamsTotalCount": {"totalCount": %d}},
"collaborators": {"nodes": [%s]},
"collaboratorsTotalCount": {"totalCount": %d}
}
}
}`, pullRequestsJSON,
strings.Join(teamNodes, ","), totalTeams,
strings.Join(collabNodes, ","), totalCollabs)
}

func TestSuggestedReviewerActorsForRepo(t *testing.T) {
tests := []struct {
name string
httpStubs func(*httpmock.Registry)
expectedCount int
expectedLogins []string
expectedMore int
expectError bool
}{
{
name: "both sources plentiful - 5 each from cascading quota",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(mockReviewerResponseForRepo(6, 6, 20, 10)))
},
expectedCount: 10,
expectedLogins: []string{"c1", "c2", "c3", "c4", "c5", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5"},
expectedMore: 30,
},
{
name: "few collaborators - teams fill gap",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(mockReviewerResponseForRepo(2, 10, 2, 15)))
},
expectedCount: 10,
expectedLogins: []string{"c1", "c2", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5", "OWNER/team6", "OWNER/team7", "OWNER/team8"},
expectedMore: 17,
},
{
name: "no collaborators - teams only",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(mockReviewerResponseForRepo(0, 10, 0, 20)))
},
expectedCount: 10,
expectedLogins: []string{"OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5", "OWNER/team6", "OWNER/team7", "OWNER/team8", "OWNER/team9", "OWNER/team10"},
expectedMore: 20,
},
{
name: "personal repo - no organization teams",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(`{
"data": {
"repository": {
"pullRequests": {"nodes": []},
"owner": {"__typename": "User"},
"collaborators": {"nodes": [{"login": "c1", "name": "C1"}]},
"collaboratorsTotalCount": {"totalCount": 3}
}
}
}`))
},
expectedCount: 1,
expectedLogins: []string{"c1"},
expectedMore: 3,
},
{
name: "empty repo",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(mockReviewerResponseForRepo(0, 0, 0, 0)))
},
expectedCount: 0,
expectedLogins: []string{},
expectedMore: 0,
},
{
name: "copilot available - prepended to candidates",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(mockReviewerResponseForRepoWithCopilot(3, 2, 5, 5, true)))
},
expectedCount: 6,
expectedLogins: []string{"copilot-pull-request-reviewer", "c1", "c2", "c3", "OWNER/team1", "OWNER/team2"},
expectedMore: 10,
},
{
name: "copilot not available - not included",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(mockReviewerResponseForRepoWithCopilot(3, 2, 5, 5, false)))
},
expectedCount: 5,
expectedLogins: []string{"c1", "c2", "c3", "OWNER/team1", "OWNER/team2"},
expectedMore: 10,
},
{
name: "viewer excluded from collaborators",
httpStubs: func(reg *httpmock.Registry) {
// c1 matches the viewer login "testuser" won't be in this fixture,
// but we can craft a response where the viewer login matches a collaborator.
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(`{
"data": {
"viewer": {"login": "c2"},
"repository": {
"pullRequests": {"nodes": []},
"owner": {"__typename": "Organization", "teams": {"nodes": []}, "teamsTotalCount": {"totalCount": 0}},
"collaborators": {"nodes": [
{"login": "c1", "name": "C1"},
{"login": "c2", "name": "C2"},
{"login": "c3", "name": "C3"}
]},
"collaboratorsTotalCount": {"totalCount": 3}
}
}
}`))
},
expectedCount: 2,
expectedLogins: []string{"c1", "c3"},
expectedMore: 3,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(reg)
}

client := newTestClient(reg)
repo, _ := ghrepo.FromFullName("OWNER/REPO")

candidates, moreResults, err := SuggestedReviewerActorsForRepo(client, repo, "")
if tt.expectError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expectedCount, len(candidates), "candidate count mismatch")
assert.Equal(t, tt.expectedMore, moreResults, "moreResults mismatch")

logins := make([]string, len(candidates))
for i, c := range candidates {
logins[i] = c.Login()
}
assert.Equal(t, tt.expectedLogins, logins)
})
}
}
2 changes: 1 addition & 1 deletion pkg/cmd/issue/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ func createRun(opts *CreateOptions) (err error) {
Repo: baseRepo,
State: &tb,
}
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support)
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support, nil)
if err != nil {
return
}
Expand Down
Loading
Loading