gh pr create: CCR and multiselectwithsearch

This commit is contained in:
Kynan Ware 2026-01-30 15:50:56 -07:00
parent a9a0486c70
commit 9904f7d1b9
9 changed files with 731 additions and 251 deletions

View file

@ -615,29 +615,41 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
}
}
// reviewers are requested in yet another additional mutation
reviewParams := make(map[string]interface{})
if ids, ok := params["userReviewerIds"]; ok && !isBlank(ids) {
reviewParams["userIds"] = ids
}
if ids, ok := params["teamReviewerIds"]; ok && !isBlank(ids) {
reviewParams["teamIds"] = ids
}
// Request reviewers using either login-based (github.com) or ID-based (GHES) mutation
userLogins, hasUserLogins := params["userReviewerLogins"].([]string)
teamSlugs, hasTeamSlugs := params["teamReviewerSlugs"].([]string)
//TODO: How much work to extract this into own method and use for create and edit?
if len(reviewParams) > 0 {
reviewQuery := `
if hasUserLogins || hasTeamSlugs {
// Use login-based mutation (RequestReviewsByLogin) for github.com
err := RequestReviewsByLogin(client, repo, pr.ID, userLogins, nil, teamSlugs, true)
if err != nil {
return pr, err
}
} else {
// Use ID-based mutation (requestReviews) for GHES compatibility
reviewParams := make(map[string]interface{})
if ids, ok := params["userReviewerIds"]; ok && !isBlank(ids) {
reviewParams["userIds"] = ids
}
if ids, ok := params["teamReviewerIds"]; ok && !isBlank(ids) {
reviewParams["teamIds"] = ids
}
//TODO: How much work to extract this into own method and use for create and edit?
if len(reviewParams) > 0 {
reviewQuery := `
mutation PullRequestCreateRequestReviews($input: RequestReviewsInput!) {
requestReviews(input: $input) { clientMutationId }
}`
reviewParams["pullRequestId"] = pr.ID
reviewParams["union"] = true
variables := map[string]interface{}{
"input": reviewParams,
}
err := client.GraphQL(repo.RepoHost(), reviewQuery, variables, &result)
if err != nil {
return pr, err
reviewParams["pullRequestId"] = pr.ID
reviewParams["union"] = true
variables := map[string]interface{}{
"input": reviewParams,
}
err := client.GraphQL(repo.RepoHost(), reviewQuery, variables, &result)
if err != nil {
return pr, err
}
}
}
@ -1109,6 +1121,126 @@ func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string,
return candidates, moreResults, nil
}
// SuggestedReviewerActorsForRepo fetches potential reviewers for a repository.
// Unlike SuggestedReviewerActors, this doesn't require an existing PR - used for gh pr create.
// It combines results from two sources using a cascading quota system:
// - repository collaborators (base quota: 5)
// - organization teams (base quota: 5 + unfilled from collaborators)
//
// This ensures we show up to 10 total candidates, with each source filling any
// unfilled quota from the previous source. Results are deduplicated.
// Returns the candidates, a MoreResults count, and an error.
func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query string) ([]ReviewerCandidate, int, error) {
type responseData struct {
Repository struct {
// Check for Copilot availability by looking at any open PR's suggested reviewers
PullRequests struct {
Nodes []struct {
SuggestedActors struct {
Nodes []struct {
Reviewer struct {
TypeName string `graphql:"__typename"`
Bot struct {
Login string
} `graphql:"... on Bot"`
}
}
} `graphql:"suggestedReviewerActors(first: 10)"`
}
} `graphql:"pullRequests(first: 1, states: [OPEN])"`
Collaborators struct {
Nodes []struct {
Login string
Name string
}
} `graphql:"collaborators(first: 10, query: $query)"`
CollaboratorsTotalCount struct {
TotalCount int
} `graphql:"collaboratorsTotalCount: collaborators(first: 0)"`
} `graphql:"repository(owner: $owner, name: $name)"`
Organization struct {
Teams struct {
Nodes []struct {
Slug string
}
} `graphql:"teams(first: 10, query: $query)"`
TeamsTotalCount struct {
TotalCount int
} `graphql:"teamsTotalCount: teams(first: 0)"`
} `graphql:"organization(login: $owner)"`
}
variables := map[string]interface{}{
"query": githubv4.String(query),
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
}
var result responseData
err := client.Query(repo.RepoHost(), "SuggestedReviewerActorsForRepo", &result, variables)
// Handle the case where the owner is not an organization - the query still returns
// partial data (repository), so we can continue processing.
if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) {
return nil, 0, err
}
// Build candidates using cascading quota logic
seen := make(map[string]bool)
var candidates []ReviewerCandidate
const baseQuota = 5
// Check for Copilot availability from open PR's suggested reviewers
for _, pr := range result.Repository.PullRequests.Nodes {
for _, actor := range pr.SuggestedActors.Nodes {
if actor.Reviewer.TypeName == "Bot" && actor.Reviewer.Bot.Login == CopilotReviewerLogin {
candidates = append(candidates, NewReviewerBot(CopilotReviewerLogin))
seen[CopilotReviewerLogin] = true
break
}
}
}
// Collaborators
collaboratorsAdded := 0
for _, c := range result.Repository.Collaborators.Nodes {
if collaboratorsAdded >= baseQuota {
break
}
if c.Login == "" {
continue
}
if !seen[c.Login] {
seen[c.Login] = true
candidates = append(candidates, NewReviewerUser(c.Login, c.Name))
collaboratorsAdded++
}
}
// Teams: quota = base + unfilled from collaborators
teamsQuota := baseQuota + (baseQuota - collaboratorsAdded)
teamsAdded := 0
ownerName := repo.RepoOwner()
for _, t := range result.Organization.Teams.Nodes {
if teamsAdded >= teamsQuota {
break
}
if t.Slug == "" {
continue
}
teamLogin := fmt.Sprintf("%s/%s", ownerName, t.Slug)
if !seen[teamLogin] {
seen[teamLogin] = true
candidates = append(candidates, NewReviewerTeam(ownerName, t.Slug))
teamsAdded++
}
}
// MoreResults uses unfiltered total counts (teams will be 0 for personal repos)
moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Organization.TeamsTotalCount.TotalCount
return candidates, moreResults, nil
}
func UpdatePullRequestBranch(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestBranchInput) error {
var mutation struct {
UpdatePullRequestBranch struct {

View file

@ -362,3 +362,170 @@ 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": {
"repository": {
%s,
"collaborators": {"nodes": [%s]},
"collaboratorsTotalCount": {"totalCount": %d}
},
"organization": {
"teams": {"nodes": [%s]},
"teamsTotalCount": {"totalCount": %d}
}
}
}`, pullRequestsJSON, strings.Join(collabNodes, ","), totalCollabs,
strings.Join(teamNodes, ","), totalTeams)
}
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": []},
"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: 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,
},
}
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)
})
}
}