diff --git a/api/queries_pr.go b/api/queries_pr.go index de98c661f57..c806d6bb75b 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -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 { diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 770ff0e7691..2c26a16a0aa 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -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...) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index c2233fd9266..2bf49eb5877 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -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) } diff --git a/internal/prompter/prompter_mock.go b/internal/prompter/prompter_mock.go index b15f8bf96a7..fd6492df815 100644 --- a/internal/prompter/prompter_mock.go +++ b/internal/prompter/prompter_mock.go @@ -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 { diff --git a/internal/prompter/test.go b/internal/prompter/test.go index dfa124fcad6..599fd389358 100644 --- a/internal/prompter/test.go +++ b/internal/prompter/test.go @@ -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}) } diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 4261efda767..2fcb85c569a 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -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 } } } } } diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 11cb74b1ffd..d9082496aff 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.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" 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 { diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index cd26c1a0a3c..56b868a36b8 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -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,11 +887,60 @@ 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 } + } } } } + `)) + mockPullRequestUpdate(reg) + }, + 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", @@ -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 } } } } } diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 5cf55ad37e9..7e6e772198c 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -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, diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 4b66bb0fa89..e350671b947 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -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) { diff --git a/pkg/cmd/preview/prompter/prompter.go b/pkg/cmd/preview/prompter/prompter.go index 5b44a5cbf7e..93dbbe61160 100644 --- a/pkg/cmd/preview/prompter/prompter.go +++ b/pkg/cmd/preview/prompter/prompter.go @@ -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")