gh pr create: CCR and multiselectwithsearch
This commit is contained in:
parent
a9a0486c70
commit
9904f7d1b9
9 changed files with 731 additions and 251 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue