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:
Kynan Ware 2026-03-23 18:45:09 -06:00
parent 33783748f3
commit 11f177a8c3
7 changed files with 151 additions and 62 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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(`

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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 {

View file

@ -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")