Merge pull request #12526 from cli/github-cli-1070-multi-select-with-search-ccr

`gh pr edit`: new interactive prompt for assignee selection, performance and accessibility improvements
This commit is contained in:
Kynan Ware 2026-01-27 09:16:26 -07:00 committed by GitHub
commit 6adf803127
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 820 additions and 68 deletions

View file

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

View file

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

View file

@ -21,6 +21,15 @@ type Prompter interface {
Select(prompt string, defaultValue string, options []string) (int, error)
// MultiSelect prompts the user to select one or more options from a list of options.
MultiSelect(prompt string, defaults []string, options []string) ([]int, error)
// MultiSelectWithSearch is MultiSelect with an added search option to the list,
// prompting the user for text input to filter the options via the searchFunc.
// Items selected in the search are persisted in the list after subsequent searches.
// Items passed in persistentOptions are always shown in the list, even when not selected.
// Unlike MultiSelect, MultiselectWithSearch returns the selected option strings,
// not their indices, since the list of options is dynamic.
// The searchFunc has the signature: func(query string) MultiSelectSearchResult.
// In the returned MultiSelectSearchResult, Keys are the values eventually returned by MultiSelectWithSearch and Labels are what is shown to the user in the prompt.
MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error)
// Input prompts the user to enter a string value.
Input(prompt string, defaultValue string) (string, error)
// Password prompts the user to enter a password.
@ -320,6 +329,10 @@ func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAl
return text, nil
}
func (p *accessiblePrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
}
type surveyPrompter struct {
prompter *ghPrompter.Prompter
stdin ghPrompter.FileReader
@ -336,6 +349,160 @@ func (p *surveyPrompter) MultiSelect(prompt string, defaultValues, options []str
return p.prompter.MultiSelect(prompt, defaultValues, options)
}
func (p *surveyPrompter) MultiSelectWithSearch(prompt string, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
}
type MultiSelectSearchResult struct {
Keys []string
Labels []string
MoreResults int
Err error
}
func multiSelectWithSearch(p Prompter, prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
selectedOptions := defaultValues
// The optionKeyLabels map is used to uniquely identify optionKeyLabels
// and provide optional display labels.
optionKeyLabels := make(map[string]string)
for _, k := range selectedOptions {
optionKeyLabels[k] = k
}
searchResult := searchFunc("")
if searchResult.Err != nil {
return nil, fmt.Errorf("failed to search: %w", searchResult.Err)
}
searchResultKeys := searchResult.Keys
searchResultLabels := searchResult.Labels
moreResults := searchResult.MoreResults
for i, k := range searchResultKeys {
optionKeyLabels[k] = searchResultLabels[i]
}
for {
// Build dynamic option list -> search sentinel, selections, search results, persistent options.
optionKeys := make([]string, 0, 1+len(selectedOptions)+len(searchResultKeys)+len(persistentValues))
optionLabels := make([]string, 0, len(optionKeys))
// 1. Search sentinel.
optionKeys = append(optionKeys, "")
if moreResults > 0 {
optionLabels = append(optionLabels, fmt.Sprintf("Search (%d more)", moreResults))
} else {
optionLabels = append(optionLabels, "Search")
}
// 2. Selections
for _, k := range selectedOptions {
l := optionKeyLabels[k]
if l == "" {
l = k
}
optionKeys = append(optionKeys, k)
optionLabels = append(optionLabels, l)
}
// 3. Search results
for _, k := range searchResultKeys {
// It's already selected or persistent, if we add here we'll have duplicates.
if slices.Contains(selectedOptions, k) || slices.Contains(persistentValues, k) {
continue
}
l := optionKeyLabels[k]
if l == "" {
l = k
}
optionKeys = append(optionKeys, k)
optionLabels = append(optionLabels, l)
}
// 4. Persistent options
for _, k := range persistentValues {
if slices.Contains(selectedOptions, k) {
continue
}
l := optionKeyLabels[k]
if l == "" {
l = k
}
optionKeys = append(optionKeys, k)
optionLabels = append(optionLabels, l)
}
selectedOptionLabels := make([]string, len(selectedOptions))
for i, k := range selectedOptions {
l := optionKeyLabels[k]
if l == "" {
l = k
}
selectedOptionLabels[i] = l
}
selectedIdxs, err := p.MultiSelect(prompt, selectedOptionLabels, optionLabels)
if err != nil {
return nil, err
}
pickedSearch := false
var newSelectedOptions []string
for _, idx := range selectedIdxs {
if idx == 0 { // Search sentinel selected
pickedSearch = true
continue
}
if idx < 0 || idx >= len(optionKeys) {
continue
}
key := optionKeys[idx]
if key == "" {
continue
}
newSelectedOptions = append(newSelectedOptions, key)
}
selectedOptions = newSelectedOptions
for _, k := range selectedOptions {
if _, ok := optionKeyLabels[k]; !ok {
optionKeyLabels[k] = k
}
}
if pickedSearch {
query, err := p.Input(searchPrompt, "")
if err != nil {
return nil, err
}
searchResult := searchFunc(query)
if searchResult.Err != nil {
return nil, searchResult.Err
}
searchResultKeys = searchResult.Keys
searchResultLabels = searchResult.Labels
moreResults = searchResult.MoreResults
for i, k := range searchResultKeys {
optionKeyLabels[k] = searchResultLabels[i]
}
continue
}
return selectedOptions, nil
}
}
func (p *surveyPrompter) Input(prompt, defaultValue string) (string, error) {
return p.prompter.Input(prompt, defaultValue)
}

View file

@ -38,6 +38,9 @@ var _ Prompter = &PrompterMock{}
// MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) {
// panic("mock out the MultiSelect method")
// },
// MultiSelectWithSearchFunc: func(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
// panic("mock out the MultiSelectWithSearch method")
// },
// PasswordFunc: func(prompt string) (string, error) {
// panic("mock out the Password method")
// },
@ -72,6 +75,9 @@ type PrompterMock struct {
// MultiSelectFunc mocks the MultiSelect method.
MultiSelectFunc func(prompt string, defaults []string, options []string) ([]int, error)
// MultiSelectWithSearchFunc mocks the MultiSelectWithSearch method.
MultiSelectWithSearchFunc func(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error)
// PasswordFunc mocks the Password method.
PasswordFunc func(prompt string) (string, error)
@ -123,6 +129,19 @@ type PrompterMock struct {
// Options is the options argument value.
Options []string
}
// MultiSelectWithSearch holds details about calls to the MultiSelectWithSearch method.
MultiSelectWithSearch []struct {
// Prompt is the prompt argument value.
Prompt string
// SearchPrompt is the searchPrompt argument value.
SearchPrompt string
// Defaults is the defaults argument value.
Defaults []string
// PersistentOptions is the persistentOptions argument value.
PersistentOptions []string
// SearchFunc is the searchFunc argument value.
SearchFunc func(string) MultiSelectSearchResult
}
// Password holds details about calls to the Password method.
Password []struct {
// Prompt is the prompt argument value.
@ -138,15 +157,16 @@ type PrompterMock struct {
Options []string
}
}
lockAuthToken sync.RWMutex
lockConfirm sync.RWMutex
lockConfirmDeletion sync.RWMutex
lockInput sync.RWMutex
lockInputHostname sync.RWMutex
lockMarkdownEditor sync.RWMutex
lockMultiSelect sync.RWMutex
lockPassword sync.RWMutex
lockSelect sync.RWMutex
lockAuthToken sync.RWMutex
lockConfirm sync.RWMutex
lockConfirmDeletion sync.RWMutex
lockInput sync.RWMutex
lockInputHostname sync.RWMutex
lockMarkdownEditor sync.RWMutex
lockMultiSelect sync.RWMutex
lockMultiSelectWithSearch sync.RWMutex
lockPassword sync.RWMutex
lockSelect sync.RWMutex
}
// AuthToken calls AuthTokenFunc.
@ -387,6 +407,54 @@ func (mock *PrompterMock) MultiSelectCalls() []struct {
return calls
}
// MultiSelectWithSearch calls MultiSelectWithSearchFunc.
func (mock *PrompterMock) MultiSelectWithSearch(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
if mock.MultiSelectWithSearchFunc == nil {
panic("PrompterMock.MultiSelectWithSearchFunc: method is nil but Prompter.MultiSelectWithSearch was just called")
}
callInfo := struct {
Prompt string
SearchPrompt string
Defaults []string
PersistentOptions []string
SearchFunc func(string) MultiSelectSearchResult
}{
Prompt: prompt,
SearchPrompt: searchPrompt,
Defaults: defaults,
PersistentOptions: persistentOptions,
SearchFunc: searchFunc,
}
mock.lockMultiSelectWithSearch.Lock()
mock.calls.MultiSelectWithSearch = append(mock.calls.MultiSelectWithSearch, callInfo)
mock.lockMultiSelectWithSearch.Unlock()
return mock.MultiSelectWithSearchFunc(prompt, searchPrompt, defaults, persistentOptions, searchFunc)
}
// MultiSelectWithSearchCalls gets all the calls that were made to MultiSelectWithSearch.
// Check the length with:
//
// len(mockedPrompter.MultiSelectWithSearchCalls())
func (mock *PrompterMock) MultiSelectWithSearchCalls() []struct {
Prompt string
SearchPrompt string
Defaults []string
PersistentOptions []string
SearchFunc func(string) MultiSelectSearchResult
} {
var calls []struct {
Prompt string
SearchPrompt string
Defaults []string
PersistentOptions []string
SearchFunc func(string) MultiSelectSearchResult
}
mock.lockMultiSelectWithSearch.RLock()
calls = mock.calls.MultiSelectWithSearch
mock.lockMultiSelectWithSearch.RUnlock()
return calls
}
// Password calls PasswordFunc.
func (mock *PrompterMock) Password(prompt string) (string, error) {
if mock.PasswordFunc == nil {

View file

@ -25,10 +25,11 @@ func NewMockPrompter(t *testing.T) *MockPrompter {
type MockPrompter struct {
t *testing.T
ghPrompter.PrompterMock
authTokenStubs []authTokenStub
confirmDeletionStubs []confirmDeletionStub
inputHostnameStubs []inputHostnameStub
markdownEditorStubs []markdownEditorStub
authTokenStubs []authTokenStub
confirmDeletionStubs []confirmDeletionStub
inputHostnameStubs []inputHostnameStub
markdownEditorStubs []markdownEditorStub
multiSelectWithSearchStubs []multiSelectWithSearchStub
}
type authTokenStub struct {
@ -49,6 +50,10 @@ type markdownEditorStub struct {
fn func(string, string, bool) (string, error)
}
type multiSelectWithSearchStub struct {
fn func(string, string, []string, []string, func(string) MultiSelectSearchResult) ([]string, error)
}
func (m *MockPrompter) AuthToken() (string, error) {
var s authTokenStub
if len(m.authTokenStubs) == 0 {
@ -92,6 +97,16 @@ func (m *MockPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed
return s.fn(prompt, defaultValue, blankAllowed)
}
func (m *MockPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
var s multiSelectWithSearchStub
if len(m.multiSelectWithSearchStubs) == 0 {
return nil, NoSuchPromptErr(prompt)
}
s = m.multiSelectWithSearchStubs[0]
m.multiSelectWithSearchStubs = m.multiSelectWithSearchStubs[1:len(m.multiSelectWithSearchStubs)]
return s.fn(prompt, searchPrompt, defaults, persistentOptions, searchFunc)
}
func (m *MockPrompter) RegisterAuthToken(stub func() (string, error)) {
m.authTokenStubs = append(m.authTokenStubs, authTokenStub{fn: stub})
}

View file

@ -845,7 +845,7 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) {
{ "data": { "repository": { "suggestedActors": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID", "__typename": "Bot" },
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
{ "login": "monalisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
],
"pageInfo": { "hasNextPage": false }
} } } }

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"
shared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
@ -292,6 +293,12 @@ func editRun(opts *EditOptions) error {
apiClient := api.NewClientFromHTTP(httpClient)
// Wire up search functions for assignees and reviewers.
// TODO KW: Wire up reviewer search func if/when it exists.
if issueFeatures.ActorIsAssignable {
editable.AssigneeSearchFunc = assigneeSearchFunc(apiClient, repo, &editable, pr.ID)
}
opts.IO.StartProgressIndicator()
err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable, opts.Detector.ProjectsV1())
opts.IO.StopProgressIndicator()
@ -331,6 +338,57 @@ func editRun(opts *EditOptions) error {
return nil
}
// assigneeSearchFunc is intended to be an arg for MultiSelectWithSearch
// to return potential assignee actors.
// It also contains an important enclosure to update the editable's
// assignable actors metadata for later ID resolution - this is required
// while we continue to use IDs for mutating assignees with the GQL API.
func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable *shared.Editable, assignableID string) func(string) prompter.MultiSelectSearchResult {
searchFunc := func(input string) prompter.MultiSelectSearchResult {
actors, availableAssigneesCount, err := api.SuggestedAssignableActors(
apiClient,
repo,
assignableID,
input)
if err != nil {
return prompter.MultiSelectSearchResult{
Keys: nil,
Labels: nil,
MoreResults: 0,
Err: err,
}
}
logins := make([]string, 0, len(actors))
displayNames := make([]string, 0, len(actors))
for _, a := range actors {
if a.Login() != "" {
logins = append(logins, a.Login())
} else {
continue
}
if a.DisplayName() != "" {
displayNames = append(displayNames, a.DisplayName())
} else {
displayNames = append(displayNames, a.Login())
}
// Update the assignable actors metadata in the editable struct
// so that updating the PR later can resolve the actor ID.
editable.Metadata.AssignableActors = append(editable.Metadata.AssignableActors, a)
}
return prompter.MultiSelectSearchResult{
Keys: logins,
Labels: displayNames,
MoreResults: availableAssigneesCount,
Err: nil,
}
}
return searchFunc
}
func updatePullRequest(httpClient *http.Client, repo ghrepo.Interface, id string, number int, editable shared.Editable) error {
var wg errgroup.Group
wg.Go(func() error {

View file

@ -714,7 +714,13 @@ func Test_editRun(t *testing.T) {
editFields: func(e *shared.Editable, _ string) error {
e.Title.Value = "new title"
e.Body.Value = "new body"
e.Assignees.Value = []string{"monalisa", "hubot"}
// When ActorAssignees is enabled, the interactive flow returns display names (or logins for non-users)
e.Assignees.Value = []string{"monalisa (Mona Display Name)", "hubot"}
// Populate metadata to simulate what searchFunc would do during prompting
e.Metadata.AssignableActors = []api.AssignableActor{
api.NewAssignableBot("HUBOTID", "hubot"),
api.NewAssignableUser("MONAID", "monalisa", "Mona Display Name"),
}
e.Labels.Value = []string{"feature", "TODO", "bug"}
e.Labels.Add = []string{"feature", "TODO", "bug"}
e.Labels.Remove = []string{"docs"}
@ -728,7 +734,8 @@ func Test_editRun(t *testing.T) {
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
// interactive but reviewers not chosen; need everything except reviewers/teams
mockRepoMetadata(reg, mockRepoMetadataOptions{assignees: true, labels: true, projects: true, milestones: true})
// assignees: false because searchFunc handles dynamic fetching (metadata populated in test mock)
mockRepoMetadata(reg, mockRepoMetadataOptions{assignees: false, labels: true, projects: true, milestones: true})
mockPullRequestUpdate(reg)
mockPullRequestUpdateActorAssignees(reg)
mockPullRequestUpdateLabels(reg)
@ -822,8 +829,13 @@ func Test_editRun(t *testing.T) {
require.Equal(t, []string{"hubot"}, e.Assignees.Default)
require.Equal(t, []string{"hubot"}, e.Assignees.DefaultLogins)
// Adding MonaLisa as PR assignee, should preserve hubot.
e.Assignees.Value = []string{"hubot", "MonaLisa (Mona Display Name)"}
// Adding monalisa as PR assignee, should preserve hubot.
e.Assignees.Value = []string{"hubot", "monalisa (Mona Display Name)"}
// Populate metadata to simulate what searchFunc would do during prompting
e.Metadata.AssignableActors = []api.AssignableActor{
api.NewAssignableBot("HUBOTID", "hubot"),
api.NewAssignableUser("MONAID", "monalisa", "Mona Display Name"),
}
return nil
},
},
@ -831,17 +843,6 @@ func Test_editRun(t *testing.T) {
EditorRetriever: testEditorRetriever{},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "suggestedActors": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID", "__typename": "Bot" },
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
mockPullRequestUpdate(reg)
reg.Register(
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
@ -886,7 +887,7 @@ func Test_editRun(t *testing.T) {
{ "data": { "repository": { "assignableUsers": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID" },
{ "login": "MonaLisa", "id": "MONAID" }
{ "login": "monalisa", "id": "MONAID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
@ -895,6 +896,55 @@ func Test_editRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/pull/123\n",
},
{
name: "interactive GHES uses legacy assignee flow without search",
input: &EditOptions{
Detector: &fd.DisabledDetectorMock{},
SelectorArg: "123",
Finder: shared.NewMockFinder("123", &api.PullRequest{
URL: "https://github.com/OWNER/REPO/pull/123",
Assignees: api.Assignees{
Nodes: []api.GitHubUser{{Login: "octocat", ID: "OCTOID"}},
TotalCount: 1,
},
}, ghrepo.New("OWNER", "REPO")),
Interactive: true,
Surveyor: testSurveyor{
fieldsToEdit: func(e *shared.Editable) error {
e.Assignees.Edited = true
return nil
},
editFields: func(e *shared.Editable, _ string) error {
require.False(t, e.Assignees.ActorAssignees)
require.Nil(t, e.AssigneeSearchFunc)
require.Contains(t, e.Assignees.Options, "monalisa")
require.Contains(t, e.Assignees.Options, "hubot")
e.Assignees.Value = []string{"monalisa", "hubot"}
return nil
},
},
Fetcher: testFetcher{},
EditorRetriever: testEditorRetriever{},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Exclude(t, httpmock.GraphQL(`query RepositoryAssignableActors\b`))
reg.Register(
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "assignableUsers": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID" },
{ "login": "monalisa", "id": "MONAID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Exclude(t, httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`))
mockPullRequestUpdate(reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123\n",
},
{
name: "non-interactive projects v1 unsupported doesn't fetch v1 metadata",
input: &EditOptions{
@ -1001,7 +1051,7 @@ func mockRepoMetadata(reg *httpmock.Registry, opt mockRepoMetadataOptions) {
{ "data": { "repository": { "suggestedActors": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID", "__typename": "Bot" },
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
{ "login": "monalisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
],
"pageInfo": { "hasNextPage": false }
} } } }

View file

@ -6,19 +6,22 @@ import (
"github.com/cli/cli/v2/api"
"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/pkg/set"
)
type Editable struct {
Title EditableString
Body EditableString
Base EditableString
Reviewers EditableSlice
Assignees EditableAssignees
Labels EditableSlice
Projects EditableProjects
Milestone EditableString
Metadata api.RepoMetadataResult
Title EditableString
Body EditableString
Base EditableString
Reviewers EditableSlice
ReviewerSearchFunc func(string) ([]string, []string, error)
Assignees EditableAssignees
AssigneeSearchFunc func(string) prompter.MultiSelectSearchResult
Labels EditableSlice
Projects EditableProjects
Milestone EditableString
Metadata api.RepoMetadataResult
}
type EditableString struct {
@ -277,6 +280,7 @@ type EditPrompter interface {
Input(string, string) (string, error)
MarkdownEditor(string, string, bool) (string, error)
MultiSelect(string, []string, []string) ([]int, error)
MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error)
Confirm(string, bool) (bool, error)
}
@ -302,10 +306,24 @@ func EditFieldsSurvey(p EditPrompter, editable *Editable, editorCommand string)
}
}
if editable.Assignees.Edited {
editable.Assignees.Value, err = multiSelectSurvey(
p, "Assignees", editable.Assignees.Default, editable.Assignees.Options)
if err != nil {
return err
if editable.AssigneeSearchFunc != nil {
editable.Assignees.Options = []string{}
editable.Assignees.Value, err = p.MultiSelectWithSearch(
"Assignees",
"Search assignees",
editable.Assignees.DefaultLogins,
// No persistent options required here as teams cannot be assignees.
[]string{},
editable.AssigneeSearchFunc)
if err != nil {
return err
}
} else {
editable.Assignees.Value, err = multiSelectSurvey(
p, "Assignees", editable.Assignees.Default, editable.Assignees.Options)
if err != nil {
return err
}
}
}
if editable.Labels.Edited {
@ -408,10 +426,27 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable,
teamReviewers = true
}
}
fetchAssignees := false
if editable.Assignees.Edited {
// Similar as above, this is likely an interactive flow if no Add/Remove slices are set.
// The addition here is that we also check for an assignee search func.
// If we have a search func, we don't need to fetch assignees since we
// assume that will be done dynamically in the prompting flow.
if len(editable.Assignees.Add) == 0 && len(editable.Assignees.Remove) == 0 && editable.AssigneeSearchFunc == nil {
fetchAssignees = true
}
// However, if we have Add/Remove operations (non-interactive flow),
// we do need to fetch the assignees.
if len(editable.Assignees.Add) > 0 || len(editable.Assignees.Remove) > 0 {
fetchAssignees = true
}
}
input := api.RepoMetadataInput{
Reviewers: editable.Reviewers.Edited,
TeamReviewers: teamReviewers,
Assignees: editable.Assignees.Edited,
Assignees: fetchAssignees,
ActorAssignees: editable.Assignees.ActorAssignees,
Labels: editable.Labels.Edited,
ProjectsV1: editable.Projects.Edited && projectV1Support == gh.ProjectsV1Supported,

View file

@ -9,6 +9,7 @@ import (
"github.com/cli/cli/v2/api"
"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/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/surveyext"
@ -35,11 +36,12 @@ const (
)
type Prompt interface {
Input(string, string) (string, error)
Select(string, string, []string) (int, error)
MarkdownEditor(string, string, bool) (string, error)
Confirm(string, bool) (bool, error)
MultiSelect(string, []string, []string) ([]int, error)
Input(prompt string, defaultValue string) (string, error)
Select(prompt string, defaultValue string, options []string) (int, error)
MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error)
Confirm(prompt string, defaultValue bool) (bool, error)
MultiSelect(prompt string, defaults []string, options []string) ([]int, error)
MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error)
}
func ConfirmIssueSubmission(p Prompt, allowPreview bool, allowMetadata bool) (Action, error) {

View file

@ -2,6 +2,7 @@ package prompter
import (
"fmt"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/gh"
@ -25,32 +26,35 @@ func NewCmdPrompter(f *cmdutil.Factory, runF func(*prompterOptions) error) *cobr
}
const (
selectPrompt = "select"
multiSelectPrompt = "multi-select"
inputPrompt = "input"
passwordPrompt = "password"
confirmPrompt = "confirm"
authTokenPrompt = "auth-token"
confirmDeletionPrompt = "confirm-deletion"
inputHostnamePrompt = "input-hostname"
markdownEditorPrompt = "markdown-editor"
selectPrompt = "select"
multiSelectPrompt = "multi-select"
multiSelectWithSearchPrompt = "multi-select-with-search"
inputPrompt = "input"
passwordPrompt = "password"
confirmPrompt = "confirm"
authTokenPrompt = "auth-token"
confirmDeletionPrompt = "confirm-deletion"
inputHostnamePrompt = "input-hostname"
markdownEditorPrompt = "markdown-editor"
)
prompterTypeFuncMap := map[string]func(prompter.Prompter, *iostreams.IOStreams) error{
selectPrompt: runSelect,
multiSelectPrompt: runMultiSelect,
inputPrompt: runInput,
passwordPrompt: runPassword,
confirmPrompt: runConfirm,
authTokenPrompt: runAuthToken,
confirmDeletionPrompt: runConfirmDeletion,
inputHostnamePrompt: runInputHostname,
markdownEditorPrompt: runMarkdownEditor,
selectPrompt: runSelect,
multiSelectPrompt: runMultiSelect,
multiSelectWithSearchPrompt: runMultiSelectWithSearch,
inputPrompt: runInput,
passwordPrompt: runPassword,
confirmPrompt: runConfirm,
authTokenPrompt: runAuthToken,
confirmDeletionPrompt: runConfirmDeletion,
inputHostnamePrompt: runInputHostname,
markdownEditorPrompt: runMarkdownEditor,
}
allPromptsOrder := []string{
selectPrompt,
multiSelectPrompt,
multiSelectWithSearchPrompt,
inputPrompt,
passwordPrompt,
confirmPrompt,
@ -70,6 +74,7 @@ func NewCmdPrompter(f *cmdutil.Factory, runF func(*prompterOptions) error) *cobr
Available prompt types:
- select
- multi-select
- multi-select-with-search
- input
- password
- confirm
@ -149,6 +154,52 @@ func runMultiSelect(p prompter.Prompter, io *iostreams.IOStreams) error {
return nil
}
func runMultiSelectWithSearch(p prompter.Prompter, io *iostreams.IOStreams) error {
fmt.Fprintln(io.Out, "Demonstrating Multi Select With Search")
persistentOptions := []string{"persistent-option-1"}
searchFunc := func(input string) prompter.MultiSelectSearchResult {
var searchResultKeys []string
var searchResultLabels []string
if input == "" {
moreResults := 2 // Indicate that there are more results available
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,
}
}
// In a real implementation, this function would perform a search based on the input.
// Here, we return a static set of options for demonstration purposes.
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,
}
}
selections, err := p.MultiSelectWithSearch("Select an option", "Search for an option", []string{}, persistentOptions, searchFunc)
if err != nil {
return err
}
if len(selections) == 0 {
fmt.Fprintln(io.Out, "No options selected.")
return nil
}
fmt.Fprintf(io.Out, "Selected options: %s\n", strings.Join(selections, ", "))
return nil
}
func runInput(p prompter.Prompter, io *iostreams.IOStreams) error {
fmt.Fprintln(io.Out, "Demonstrating Text Input")
text, err := p.Input("Favorite meal?", "Breakfast")