feat(pr create, issue create): search-based assignee selection in MetadataSurvey
Wire up MultiSelectWithSearch for assignees in MetadataSurvey, replacing the static MultiSelect that required bulk fetching all assignable actors. This applies to both gh pr create and gh issue create interactive flows when selecting assignees via the 'Add metadata' prompt. Changes: - Add assigneeSearchFunc parameter to MetadataSurvey - Skip assignee bulk fetch when search func is available - New SearchRepoAssignableActors API function for repo-level search (create flows have no issue/PR node ID yet) - New RepoAssigneeSearchFunc in shared editable.go - Refactor actorsToSearchResult helper shared by both search functions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
33783748f3
commit
11f177a8c3
7 changed files with 151 additions and 62 deletions
|
|
@ -1298,6 +1298,69 @@ func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]AssignableAc
|
|||
return actors, nil
|
||||
}
|
||||
|
||||
// SearchRepoAssignableActors searches assignable actors for a repository with an optional
|
||||
// query string. Unlike RepoAssignableActors which fetches all actors with pagination, this
|
||||
// returns up to 10 results matching the query, suitable for search-based selection.
|
||||
func SearchRepoAssignableActors(client *Client, repo ghrepo.Interface, query string) ([]AssignableActor, int, error) {
|
||||
type responseData struct {
|
||||
Repository struct {
|
||||
AssignableUsers struct {
|
||||
TotalCount int
|
||||
}
|
||||
SuggestedActors struct {
|
||||
Nodes []struct {
|
||||
User struct {
|
||||
ID string
|
||||
Login string
|
||||
Name string
|
||||
TypeName string `graphql:"__typename"`
|
||||
} `graphql:"... on User"`
|
||||
Bot struct {
|
||||
ID string
|
||||
Login string
|
||||
TypeName string `graphql:"__typename"`
|
||||
} `graphql:"... on Bot"`
|
||||
}
|
||||
} `graphql:"suggestedActors(first: 10, query: $query, capabilities: CAN_BE_ASSIGNED)"`
|
||||
} `graphql:"repository(owner: $owner, name: $name)"`
|
||||
}
|
||||
|
||||
var q *githubv4.String
|
||||
if query != "" {
|
||||
v := githubv4.String(query)
|
||||
q = &v
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"name": githubv4.String(repo.RepoName()),
|
||||
"query": q,
|
||||
}
|
||||
|
||||
var result responseData
|
||||
if err := client.Query(repo.RepoHost(), "SearchRepoAssignableActors", &result, variables); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var actors []AssignableActor
|
||||
for _, node := range result.Repository.SuggestedActors.Nodes {
|
||||
if node.User.TypeName == "User" {
|
||||
actors = append(actors, AssignableUser{
|
||||
id: node.User.ID,
|
||||
login: node.User.Login,
|
||||
name: node.User.Name,
|
||||
})
|
||||
} else if node.Bot.TypeName == "Bot" {
|
||||
actors = append(actors, AssignableBot{
|
||||
id: node.Bot.ID,
|
||||
login: node.Bot.Login,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return actors, result.Repository.AssignableUsers.TotalCount, nil
|
||||
}
|
||||
|
||||
type RepoLabel struct {
|
||||
ID string
|
||||
Name string
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
fd "github.com/cli/cli/v2/internal/featuredetection"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
|
|
@ -313,7 +314,11 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
Repo: baseRepo,
|
||||
State: &tb,
|
||||
}
|
||||
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support, nil)
|
||||
var assigneeSearchFunc func(string) prompter.MultiSelectSearchResult
|
||||
if issueFeatures.ActorIsAssignable {
|
||||
assigneeSearchFunc = prShared.RepoAssigneeSearchFunc(apiClient, baseRepo)
|
||||
}
|
||||
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support, nil, assigneeSearchFunc)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -495,12 +495,18 @@ func Test_createRun(t *testing.T) {
|
|||
switch message {
|
||||
case "What would you like to add?":
|
||||
return prompter.IndexesFor(options, "Assignees")
|
||||
case "Assignees":
|
||||
return prompter.IndexesFor(options, "Copilot (AI)", "MonaLisa (Mona Display Name)")
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected multi-select prompt: %s", message)
|
||||
}
|
||||
}
|
||||
pm.MultiSelectWithSearchFunc = func(message, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) {
|
||||
switch message {
|
||||
case "Assignees":
|
||||
return []string{"copilot-swe-agent", "MonaLisa"}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected multi-select-with-search prompt: %s", message)
|
||||
}
|
||||
}
|
||||
pm.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
|
||||
switch message {
|
||||
case "What's next?":
|
||||
|
|
@ -524,17 +530,6 @@ func Test_createRun(t *testing.T) {
|
|||
"viewerPermission": "WRITE"
|
||||
} } }
|
||||
`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "suggestedActors": {
|
||||
"nodes": [
|
||||
{ "login": "copilot-swe-agent", "id": "COPILOTID", "name": "Copilot (AI)", "__typename": "Bot" },
|
||||
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`mutation IssueCreate\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
|
|
|
|||
|
|
@ -406,6 +406,7 @@ func createRun(opts *CreateOptions) error {
|
|||
return err
|
||||
}
|
||||
var reviewerSearchFunc func(string) prompter.MultiSelectSearchResult
|
||||
var assigneeSearchFunc func(string) prompter.MultiSelectSearchResult
|
||||
if issueFeatures.ActorIsAssignable {
|
||||
reviewerSearchFunc = func(query string) prompter.MultiSelectSearchResult {
|
||||
candidates, moreResults, err := api.SuggestedReviewerActorsForRepo(client, ctx.PRRefs.BaseRepo(), query)
|
||||
|
|
@ -420,6 +421,7 @@ func createRun(opts *CreateOptions) error {
|
|||
}
|
||||
return prompter.MultiSelectSearchResult{Keys: keys, Labels: labels, MoreResults: moreResults}
|
||||
}
|
||||
assigneeSearchFunc = shared.RepoAssigneeSearchFunc(client, ctx.PRRefs.BaseRepo())
|
||||
}
|
||||
|
||||
state, err := NewIssueState(*ctx, *opts)
|
||||
|
|
@ -598,7 +600,7 @@ func createRun(opts *CreateOptions) error {
|
|||
Repo: ctx.PRRefs.BaseRepo(),
|
||||
State: state,
|
||||
}
|
||||
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, projectsV1Support, reviewerSearchFunc)
|
||||
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, projectsV1Support, reviewerSearchFunc, assigneeSearchFunc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -616,30 +616,45 @@ func milestoneSurvey(p EditPrompter, title string, opts []string) (result string
|
|||
// dynamically fetches assignable actors for the given assignable (Issue/PR) node ID.
|
||||
func AssigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, assignableID string) func(string) prompter.MultiSelectSearchResult {
|
||||
return func(input string) prompter.MultiSelectSearchResult {
|
||||
actors, availableAssigneesCount, err := api.SuggestedAssignableActors(
|
||||
apiClient, repo, assignableID, input)
|
||||
actors, count, err := api.SuggestedAssignableActors(apiClient, repo, assignableID, input)
|
||||
if err != nil {
|
||||
return prompter.MultiSelectSearchResult{Err: err}
|
||||
}
|
||||
|
||||
logins := make([]string, 0, len(actors))
|
||||
displayNames := make([]string, 0, len(actors))
|
||||
|
||||
for _, a := range actors {
|
||||
if a.Login() == "" {
|
||||
continue
|
||||
}
|
||||
logins = append(logins, a.Login())
|
||||
if a.DisplayName() != "" {
|
||||
displayNames = append(displayNames, a.DisplayName())
|
||||
} else {
|
||||
displayNames = append(displayNames, a.Login())
|
||||
}
|
||||
}
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: logins,
|
||||
Labels: displayNames,
|
||||
MoreResults: availableAssigneesCount,
|
||||
}
|
||||
return actorsToSearchResult(actors, count)
|
||||
}
|
||||
}
|
||||
|
||||
// RepoAssigneeSearchFunc returns a search function for MultiSelectWithSearch that
|
||||
// dynamically fetches assignable actors at the repository level. Used during create
|
||||
// flows where no issue/PR node ID exists yet.
|
||||
func RepoAssigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface) func(string) prompter.MultiSelectSearchResult {
|
||||
return func(input string) prompter.MultiSelectSearchResult {
|
||||
actors, count, err := api.SearchRepoAssignableActors(apiClient, repo, input)
|
||||
if err != nil {
|
||||
return prompter.MultiSelectSearchResult{Err: err}
|
||||
}
|
||||
return actorsToSearchResult(actors, count)
|
||||
}
|
||||
}
|
||||
|
||||
func actorsToSearchResult(actors []api.AssignableActor, totalCount int) prompter.MultiSelectSearchResult {
|
||||
logins := make([]string, 0, len(actors))
|
||||
displayNames := make([]string, 0, len(actors))
|
||||
|
||||
for _, a := range actors {
|
||||
if a.Login() == "" {
|
||||
continue
|
||||
}
|
||||
logins = append(logins, a.Login())
|
||||
if a.DisplayName() != "" {
|
||||
displayNames = append(displayNames, a.DisplayName())
|
||||
} else {
|
||||
displayNames = append(displayNames, a.Login())
|
||||
}
|
||||
}
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: logins,
|
||||
Labels: displayNames,
|
||||
MoreResults: totalCount,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ type RepoMetadataFetcher interface {
|
|||
RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error)
|
||||
}
|
||||
|
||||
func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState, projectsV1Support gh.ProjectsV1Support, reviewerSearchFunc func(string) prompter.MultiSelectSearchResult) error {
|
||||
func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState, projectsV1Support gh.ProjectsV1Support, reviewerSearchFunc func(string) prompter.MultiSelectSearchResult, assigneeSearchFunc func(string) prompter.MultiSelectSearchResult) error {
|
||||
isChosen := func(m string) bool {
|
||||
for _, c := range state.Metadata {
|
||||
if m == c {
|
||||
|
|
@ -184,11 +184,12 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
|
|||
// When search-based reviewer selection is available, skip the expensive assignable-users
|
||||
// and teams fetch since reviewers are found dynamically via the search function.
|
||||
useReviewerSearch := state.ActorReviewers && reviewerSearchFunc != nil
|
||||
useAssigneeSearch := state.ActorAssignees && assigneeSearchFunc != nil
|
||||
metadataInput := api.RepoMetadataInput{
|
||||
Reviewers: isChosen("Reviewers") && !useReviewerSearch,
|
||||
TeamReviewers: isChosen("Reviewers") && !useReviewerSearch,
|
||||
Assignees: isChosen("Assignees"),
|
||||
ActorAssignees: isChosen("Assignees") && state.ActorAssignees,
|
||||
Assignees: isChosen("Assignees") && !useAssigneeSearch,
|
||||
ActorAssignees: isChosen("Assignees") && !useAssigneeSearch && state.ActorAssignees,
|
||||
Labels: isChosen("Labels"),
|
||||
ProjectsV1: isChosen("Projects") && projectsV1Support == gh.ProjectsV1Supported,
|
||||
ProjectsV2: isChosen("Projects"),
|
||||
|
|
@ -212,24 +213,25 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
|
|||
}
|
||||
|
||||
// Populate the list of selectable assignees and their default selections.
|
||||
// This logic maps the default assignees from `state` to the corresponding actors or users
|
||||
// so that the correct display names are preselected in the prompt.
|
||||
// When search-based selection is available, skip building the static list.
|
||||
var assignees []string
|
||||
var assigneesDefault []string
|
||||
if state.ActorAssignees {
|
||||
for _, u := range metadataResult.AssignableActors {
|
||||
assignees = append(assignees, u.DisplayName())
|
||||
if !useAssigneeSearch {
|
||||
if state.ActorAssignees {
|
||||
for _, u := range metadataResult.AssignableActors {
|
||||
assignees = append(assignees, u.DisplayName())
|
||||
|
||||
if slices.Contains(state.Assignees, u.Login()) {
|
||||
assigneesDefault = append(assigneesDefault, u.DisplayName())
|
||||
if slices.Contains(state.Assignees, u.Login()) {
|
||||
assigneesDefault = append(assigneesDefault, u.DisplayName())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, u := range metadataResult.AssignableUsers {
|
||||
assignees = append(assignees, u.DisplayName())
|
||||
} else {
|
||||
for _, u := range metadataResult.AssignableUsers {
|
||||
assignees = append(assignees, u.DisplayName())
|
||||
|
||||
if slices.Contains(state.Assignees, u.Login()) {
|
||||
assigneesDefault = append(assigneesDefault, u.DisplayName())
|
||||
if slices.Contains(state.Assignees, u.Login()) {
|
||||
assigneesDefault = append(assigneesDefault, u.DisplayName())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -286,16 +288,23 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
|
|||
}
|
||||
}
|
||||
if isChosen("Assignees") {
|
||||
if len(assignees) > 0 {
|
||||
if useAssigneeSearch {
|
||||
selectedAssignees, err := p.MultiSelectWithSearch(
|
||||
"Assignees",
|
||||
"Search assignees",
|
||||
state.Assignees,
|
||||
[]string{},
|
||||
assigneeSearchFunc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values.Assignees = selectedAssignees
|
||||
} else if len(assignees) > 0 {
|
||||
selected, err := p.MultiSelect("Assignees", assigneesDefault, assignees)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, i := range selected {
|
||||
// Previously, this logic relied upon `assignees` being in `<login>` or `<login> (<name>)` form,
|
||||
// however the inclusion of actors breaks this convention.
|
||||
// Instead, we map the selected indexes to the source that populated `assignees` rather than
|
||||
// relying on parsing the information out.
|
||||
if state.ActorAssignees {
|
||||
values.Assignees = append(values.Assignees, metadataResult.AssignableActors[i].Login())
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ func TestMetadataSurvey_selectAll(t *testing.T) {
|
|||
Assignees: []string{"hubot"},
|
||||
Type: PRMetadata,
|
||||
}
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported, nil)
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported, nil, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "", stdout.String())
|
||||
|
|
@ -117,7 +117,7 @@ func TestMetadataSurvey_keepExisting(t *testing.T) {
|
|||
Assignees: []string{"hubot"},
|
||||
}
|
||||
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported, nil)
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported, nil, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "", stdout.String())
|
||||
|
|
@ -146,7 +146,7 @@ func TestMetadataSurveyProjectV1Deprecation(t *testing.T) {
|
|||
return []int{0}, nil
|
||||
})
|
||||
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Supported, nil)
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Supported, nil, nil)
|
||||
require.ErrorContains(t, err, "expected test error")
|
||||
|
||||
require.True(t, fetcher.projectsV1Requested, "expected projectsV1 to be requested")
|
||||
|
|
@ -167,7 +167,7 @@ func TestMetadataSurveyProjectV1Deprecation(t *testing.T) {
|
|||
return []int{0}, nil
|
||||
})
|
||||
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Unsupported, nil)
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Unsupported, nil, nil)
|
||||
require.ErrorContains(t, err, "expected test error")
|
||||
|
||||
require.False(t, fetcher.projectsV1Requested, "expected projectsV1 not to be requested")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue