diff --git a/api/queries_repo.go b/api/queries_repo.go index d358255d8..e5806b91e 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -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 diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index da3648c31..2a3dda638 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -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 } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index e02929a45..57de4c9b5 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -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(` diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 76a30e24b..d8230dc3c 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -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 } diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 566c9939f..1e13146c2 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -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, } } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index cc66bbe5c..71cfcd063 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -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 `` or ` ()` 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 { diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 23ba96cef..384e54895 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -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")