Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
be1e210
prshared: named prompt interface parameters
BagToad Nov 21, 2025
0beb74b
MultiSelectWithSearch initial implementation
BagToad Nov 22, 2025
d04317c
Add dynamic assignee search to PR edit flow
BagToad Nov 24, 2025
7e7f8c6
Pass editable to assigneeSearchFunc and update metadata
BagToad Nov 24, 2025
38578f7
Add comment describing logger
BagToad Dec 12, 2025
d46f42a
Refactor MultiSelectWithSearch to use result struct
BagToad Dec 12, 2025
07dfdf9
Update edit tests
BagToad Jan 8, 2026
ad8c770
Only support assignee searchfunc on GitHub.com
BagToad Jan 12, 2026
c0df490
Clarify TODO comment for reviewer search function
BagToad Jan 12, 2026
f6a09a3
Apply suggestions from code review
BagToad Jan 22, 2026
30cfbd9
Apply suggestions from code review
BagToad Jan 22, 2026
38f9d78
Fix linter and mock prompter signature
BagToad Jan 26, 2026
346bd8c
Simplify suggested assignable actors
BagToad Jan 26, 2026
dc105ce
Simplify variables map in SuggestedAssignableActors
BagToad Jan 26, 2026
e3a3a01
Add comments to assigneeSearchFunc for clarity
BagToad Jan 26, 2026
48bea46
Remove unused Viewer struct from SuggestedAssignableActors
BagToad Jan 26, 2026
fb031b2
Add test for legacy assignee flow on GHES
BagToad Jan 26, 2026
af124cd
Add test for MultiSelectWithSearch error propagation
BagToad Jan 26, 2026
28e0766
Return total assignee count in SuggestedAssignableActors
BagToad Jan 26, 2026
a8053d6
Remove redundant comment in editRun test
BagToad Jan 26, 2026
968a912
Remove outdated TODO comments in survey.go
BagToad Jan 26, 2026
a33d809
Apply suggestions from code review
BagToad Jan 26, 2026
23e80a9
Remove outdated comment in SuggestedAssignableActors
BagToad Jan 26, 2026
6adf803
Merge pull request #12526 from cli/github-cli-1070-multi-select-with-…
BagToad Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions api/queries_pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,98 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in
return client.REST(repo.RepoHost(), "DELETE", path, buf, nil)
}

// SuggestedAssignableActors fetches up to 10 suggested actors for a specific assignable
// (Issue or PullRequest) node ID. `assignableID` is the GraphQL node ID for the Issue/PR.
// Returns the actors, the total count of available assignees in the repo, and an error.
func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignableID string, query string) ([]AssignableActor, int, error) {
type responseData struct {
Repository struct {
AssignableUsers struct {
TotalCount int
}
} `graphql:"repository(owner: $owner, name: $name)"`
Node struct {
Issue struct {
SuggestedActors struct {
Nodes []struct {
TypeName string `graphql:"__typename"`
User struct {
ID string
Login string
Name string
} `graphql:"... on User"`
Bot struct {
ID string
Login string
} `graphql:"... on Bot"`
}
} `graphql:"suggestedActors(first: 10, query: $query)"`
} `graphql:"... on Issue"`
PullRequest struct {
SuggestedActors struct {
Nodes []struct {
TypeName string `graphql:"__typename"`
User struct {
ID string
Login string
Name string
} `graphql:"... on User"`
Bot struct {
ID string
Login string
} `graphql:"... on Bot"`
}
} `graphql:"suggestedActors(first: 10, query: $query)"`
} `graphql:"... on PullRequest"`
} `graphql:"node(id: $id)"`
}

variables := map[string]interface{}{
"id": githubv4.ID(assignableID),
"query": githubv4.String(query),
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
}

var result responseData
if err := client.Query(repo.RepoHost(), "SuggestedAssignableActors", &result, variables); err != nil {
return nil, 0, err
}

availableAssigneesCount := result.Repository.AssignableUsers.TotalCount

var nodes []struct {
TypeName string `graphql:"__typename"`
User struct {
ID string
Login string
Name string
} `graphql:"... on User"`
Bot struct {
ID string
Login string
} `graphql:"... on Bot"`
}

if result.Node.PullRequest.SuggestedActors.Nodes != nil {
nodes = result.Node.PullRequest.SuggestedActors.Nodes
} else if result.Node.Issue.SuggestedActors.Nodes != nil {
nodes = result.Node.Issue.SuggestedActors.Nodes
}

actors := make([]AssignableActor, 0, len(nodes))

for _, n := range nodes {
if n.TypeName == "User" && n.User.Login != "" {
actors = append(actors, AssignableUser{id: n.User.ID, login: n.User.Login, name: n.User.Name})
} else if n.TypeName == "Bot" && n.Bot.Login != "" {
actors = append(actors, AssignableBot{id: n.Bot.ID, login: n.Bot.Login})
}
}

return actors, availableAssigneesCount, nil
}

func UpdatePullRequestBranch(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestBranchInput) error {
var mutation struct {
UpdatePullRequestBranch struct {
Expand Down
214 changes: 214 additions & 0 deletions internal/prompter/accessible_prompter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,217 @@ func TestAccessiblePrompter(t *testing.T) {
assert.Equal(t, []int{1}, multiSelectValues)
})

t.Run("MultiSelectWithSearch - basic flow", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
persistentOptions := []string{"persistent-option-1"}
searchFunc := func(input string) prompter.MultiSelectSearchResult {
var searchResultKeys []string
var searchResultLabels []string

// Initial search with no input
if input == "" {
moreResults := 2
searchResultKeys = []string{"initial-result-1", "initial-result-2"}
searchResultLabels = []string{"Initial Result Label 1", "Initial Result Label 2"}
return prompter.MultiSelectSearchResult{
Keys: searchResultKeys,
Labels: searchResultLabels,
MoreResults: moreResults,
Err: nil,
}
}

// Subsequent search with input
moreResults := 0
searchResultKeys = []string{"search-result-1", "search-result-2"}
searchResultLabels = []string{"Search Result Label 1", "Search Result Label 2"}
return prompter.MultiSelectSearchResult{
Keys: searchResultKeys,
Labels: searchResultLabels,
MoreResults: moreResults,
Err: nil,
}
}

go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select an option \r\n")
require.NoError(t, err)

// Select the search option, which will always be the first option
_, err = console.SendLine("1")
require.NoError(t, err)

// Submit search
_, err = console.SendLine("0")
require.NoError(t, err)

// Wait for the search prompt to appear
_, err = console.ExpectString("Search for an option")
require.NoError(t, err)

// Enter some search text to trigger the search
_, err = console.SendLine("search text")
require.NoError(t, err)

// Wait for the multiselect prompt to re-appear after search
_, err = console.ExpectString("Select an option \r\n")
require.NoError(t, err)

// Select the first search result
_, err = console.SendLine("2")
require.NoError(t, err)

// This confirms selections
_, err = console.SendLine("0")
require.NoError(t, err)
}()
multiSelectValues, err := p.MultiSelectWithSearch("Select an option", "Search for an option", []string{}, persistentOptions, searchFunc)
require.NoError(t, err)
assert.Equal(t, []string{"search-result-1"}, multiSelectValues)
})

t.Run("MultiSelectWithSearch - defaults are pre-selected", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
initialSearchResultKeys := []string{"initial-result-1"}
initialSearchResultLabels := []string{"Initial Result Label 1"}
defaultOptions := initialSearchResultKeys
searchFunc := func(input string) prompter.MultiSelectSearchResult {
// Initial search with no input
if input == "" {
moreResults := 2
return prompter.MultiSelectSearchResult{
Keys: initialSearchResultKeys,
Labels: initialSearchResultLabels,
MoreResults: moreResults,
Err: nil,
}
}

// No search selected, so this should fail the test.
t.FailNow()
return prompter.MultiSelectSearchResult{
Keys: nil,
Labels: nil,
MoreResults: 0,
Err: nil,
}
}

go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select an option (default: Initial Result Label 1) \r\n")
require.NoError(t, err)

// This confirms default selections
_, err = console.SendLine("0")
require.NoError(t, err)
}()
multiSelectValues, err := p.MultiSelectWithSearch("Select an option", "Search for an option", defaultOptions, initialSearchResultKeys, searchFunc)
require.NoError(t, err)
assert.Equal(t, defaultOptions, multiSelectValues)
})

t.Run("MultiSelectWithSearch - selected options persist between searches", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
initialSearchResultKeys := []string{"initial-result-1"}
initialSearchResultLabels := []string{"Initial Result Label 1"}
moreResultKeys := []string{"more-result-1"}
moreResultLabels := []string{"More Result Label 1"}

searchFunc := func(input string) prompter.MultiSelectSearchResult {
// Initial search with no input
if input == "" {
moreResults := 2
return prompter.MultiSelectSearchResult{
Keys: initialSearchResultKeys,
Labels: initialSearchResultLabels,
MoreResults: moreResults,
Err: nil,
}
}

// Subsequent search with input "more"
if input == "more" {
return prompter.MultiSelectSearchResult{
Keys: moreResultKeys,
Labels: moreResultLabels,
MoreResults: 0,
Err: nil,
}
}

// No other searches expected
t.FailNow()
return prompter.MultiSelectSearchResult{
Keys: nil,
Labels: nil,
MoreResults: 0,
Err: nil,
}
}

go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select an option \r\n")
require.NoError(t, err)

// Select one of our initial search results
_, err = console.SendLine("2")
require.NoError(t, err)

// Select to search
_, err = console.SendLine("1")
require.NoError(t, err)

// Submit the search selection
_, err = console.SendLine("0")
require.NoError(t, err)

// Wait for the search prompt to appear
_, err = console.ExpectString("Search for an option")
require.NoError(t, err)

// Enter some search text to trigger the search
_, err = console.SendLine("more")
require.NoError(t, err)

// Wait for the multiselect prompt to re-appear after search
_, err = console.ExpectString("Select up to")
require.NoError(t, err)

// Select the new option from the new search results
_, err = console.SendLine("3")
require.NoError(t, err)

// Submit selections
_, err = console.SendLine("0")
require.NoError(t, err)
}()
multiSelectValues, err := p.MultiSelectWithSearch("Select an option", "Search for an option", []string{}, []string{}, searchFunc)
require.NoError(t, err)
expectedValues := append(initialSearchResultKeys, moreResultKeys...)
assert.Equal(t, expectedValues, multiSelectValues)
})

t.Run("MultiSelectWithSearch - search error propagates", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)

searchFunc := func(input string) prompter.MultiSelectSearchResult {
return prompter.MultiSelectSearchResult{
Err: fmt.Errorf("search error"),
}
}

_, err := p.MultiSelectWithSearch("Select", "Search", []string{}, []string{}, searchFunc)
require.Error(t, err)
require.Contains(t, err.Error(), "search error")
})

t.Run("Input", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
Expand Down Expand Up @@ -642,6 +853,9 @@ func newTestVirtualTerminal(t *testing.T) *expect.Console {
failOnExpectError(t),
failOnSendError(t),
expect.WithDefaultTimeout(time.Second),
// Use this logger to debug expect based tests by printing the
// characters being read to stdout.
// expect.WithLogger(log.New(os.Stdout, "", 0)),
}

console, err := expect.NewConsole(consoleOpts...)
Expand Down
Loading
Loading