From be1e21095ca01a5684eebf2c133d0434b2f62f51 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 21 Nov 2025 08:37:43 -0700 Subject: [PATCH 01/23] prshared: named prompt interface parameters Updated the Prompt interface in survey.go to include parameter names for all methods, improving code readability and clarity. --- pkg/cmd/pr/shared/survey.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 4b66bb0fa89..56885487e22 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -35,11 +35,11 @@ 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) } func ConfirmIssueSubmission(p Prompt, allowPreview bool, allowMetadata bool) (Action, error) { From 0beb74bf729349c09fb3e8144226639bb0472fe6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Sat, 22 Nov 2025 15:01:55 -0700 Subject: [PATCH 02/23] MultiSelectWithSearch initial implementation Initial implementation of MultiSelectWithSearch: - Implement by survey and accessible prompters. They use the same internal func under the hood. - Implement in `gh preview prompter` for initial testing and demonstration - Implement interface changes across the codebase and mocks to satisfy compiler. - Implement tests for new MultiSelectWithSearch prompter --- internal/prompter/accessible_prompter_test.go | 162 ++++++++++++++++++ internal/prompter/prompter.go | 154 +++++++++++++++++ internal/prompter/prompter_mock.go | 86 +++++++++- internal/prompter/test.go | 25 ++- pkg/cmd/pr/shared/survey.go | 5 + pkg/cmd/preview/prompter/prompter.go | 77 +++++++-- 6 files changed, 478 insertions(+), 31 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 770ff0e7691..03baa34b743 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -224,6 +224,167 @@ 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) ([]string, []string, int, error) { + 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 searchResultKeys, searchResultLabels, moreResults, 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 searchResultKeys, searchResultLabels, moreResults, 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) ([]string, []string, int, error) { + // Initial search with no input + if input == "" { + moreResults := 2 + return initialSearchResultKeys, initialSearchResultLabels, moreResults, nil + } + + // No search selected, so this should fail the test. + t.FailNow() + return nil, nil, 0, 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) ([]string, []string, int, error) { + // Initial search with no input + if input == "" { + moreResults := 2 + return initialSearchResultKeys, initialSearchResultLabels, moreResults, nil + } + + // Subsequent search with input "more" + if input == "more" { + return moreResultKeys, moreResultLabels, 0, nil + } + + // No other searches expected + t.FailNow() + return nil, nil, 0, 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("Input", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) @@ -642,6 +803,7 @@ func newTestVirtualTerminal(t *testing.T) *expect.Console { failOnExpectError(t), failOnSendError(t), expect.WithDefaultTimeout(time.Second), + // 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..dddb49035e3 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 args and return values are: func(query) (map[keys]labels, moreResultsCount, searchError) + // Where the selected keys are eventually returned by MultiSelectWithSearch and the labels are what is shown to the user in the prompt. + MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]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) ([]string, []string, int, error)) ([]string, error) { + return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc) +} + type surveyPrompter struct { prompter *ghPrompter.Prompter stdin ghPrompter.FileReader @@ -336,6 +349,147 @@ 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) ([]string, []string, int, error)) ([]string, error) { + return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc) +} + +func multiSelectWithSearch(p Prompter, prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) ([]string, []string, int, error)) ([]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 + } + + searchResultKeys, searchResultLabels, moreResults, err := searchFunc("") + if err != nil { + return nil, fmt.Errorf("failed to search: %w", err) + } + + 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 + } + + searchResultKeys, searchResultLabels, moreResults, err = searchFunc(query) + if err != nil { + return nil, err + } + + 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..4543004a07c 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) ([]string, []string, int, error)) ([]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) ([]string, []string, int, error)) ([]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) ([]string, []string, int, error) + } // 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) ([]string, []string, int, error)) ([]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) ([]string, []string, int, error) + }{ + 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) ([]string, []string, int, error) +} { + var calls []struct { + Prompt string + SearchPrompt string + Defaults []string + PersistentOptions []string + SearchFunc func(string) ([]string, []string, int, error) + } + 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..adaa0db6dad 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,12 @@ type markdownEditorStub struct { fn func(string, string, bool) (string, error) } +type multiSelectWithSearchStub struct { + prompt string + searchPrompt string + fn func(string, string, []string, []string) ([]string, error) +} + func (m *MockPrompter) AuthToken() (string, error) { var s authTokenStub if len(m.authTokenStubs) == 0 { @@ -92,6 +99,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) ([]string, []string, int, error)) ([]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) +} + func (m *MockPrompter) RegisterAuthToken(stub func() (string, error)) { m.authTokenStubs = append(m.authTokenStubs, authTokenStub{fn: stub}) } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 56885487e22..5197d6acede 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -40,6 +40,7 @@ type Prompt interface { 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) ([]string, []string, int, error)) ([]string, error) } func ConfirmIssueSubmission(p Prompt, allowPreview bool, allowMetadata bool) (Action, error) { @@ -207,6 +208,7 @@ 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. + // TODO: KW21 This will need to go away since we're going to dynamically load assignees via search. var assignees []string var assigneesDefault []string if state.ActorAssignees { @@ -264,6 +266,9 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface fmt.Fprintln(io.ErrOut, "warning: no available reviewers") } } + // TODO: KW21 This will need to change to use MultiSelectWithSearch once it's implemented. + // MultiSelectWithSearch will return the selected strings directly instead of indices, + // so the logic here will need to be updated accordingly. if isChosen("Assignees") { if len(assignees) > 0 { selected, err := p.MultiSelect("Assignees", assigneesDefault, assignees) diff --git a/pkg/cmd/preview/prompter/prompter.go b/pkg/cmd/preview/prompter/prompter.go index 5b44a5cbf7e..35ff901d48c 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,42 @@ 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) ([]string, []string, int, error) { + 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 searchResultKeys, searchResultLabels, moreResults, 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 searchResultKeys, searchResultLabels, moreResults, 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") From d04317c2737792eb68f16b2a313117f9bebf0ff9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 24 Nov 2025 10:43:27 -0700 Subject: [PATCH 03/23] Add dynamic assignee search to PR edit flow Introduces SuggestedAssignableActors API query and wires up a dynamic assignee search function in the PR edit command. Updates Editable and EditPrompter interfaces to support search-based multi-select for assignees, improving the user experience when assigning users to pull requests. --- api/queries_pr.go | 108 ++++++++++++++++++++++++++++++++++ pkg/cmd/pr/edit/edit.go | 36 ++++++++++++ pkg/cmd/pr/shared/editable.go | 56 +++++++++++++----- 3 files changed, 186 insertions(+), 14 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index de98c661f57..d994d3aae06 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -701,6 +701,114 @@ 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. +// If query is empty, the query variable is passed as null to omit filtering. +func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignableID string, query string) ([]AssignableActor, error) { + type responseData struct { + Viewer struct { + ID string + Login string + Name string + } `graphql:"viewer"` + Node struct { + Issue struct { + 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)"` + } `graphql:"... on Issue"` + PullRequest struct { + 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)"` + } `graphql:"... on PullRequest"` + } `graphql:"node(id: $id)"` + } + + variables := map[string]interface{}{ + "id": githubv4.ID(assignableID), + } + if query != "" { + variables["query"] = githubv4.String(query) + } else { + variables["query"] = (*githubv4.String)(nil) + } + + var result responseData + if err := client.Query(repo.RepoHost(), "SuggestedAssignableActors", &result, variables); err != nil { + return nil, err + } + + var 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"` + } + + 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)+1) // +1 in case we add viewer + viewer := result.Viewer + viewerLogin := viewer.Login + viewerIncluded := false + + for _, n := range nodes { + if n.User.TypeName == "User" && n.User.Login != "" { + actors = append(actors, AssignableUser{id: n.User.ID, login: n.User.Login, name: n.User.Name}) + if query == "" && viewerLogin != "" && n.User.Login == viewerLogin { + viewerIncluded = true + } + } else if n.Bot.TypeName == "Bot" && n.Bot.Login != "" { + actors = append(actors, AssignableBot{id: n.Bot.ID, login: n.Bot.Login}) + if query == "" && viewerLogin != "" && n.Bot.Login == viewerLogin { + viewerIncluded = true + } + } + } + + // When query is blank, append viewer if not already present. + if query == "" && viewerLogin != "" && !viewerIncluded { + actors = append(actors, AssignableUser{id: viewer.ID, login: viewer.Login, name: viewer.Name}) + } + return actors, nil +} + func UpdatePullRequestBranch(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestBranchInput) error { var mutation struct { UpdatePullRequestBranch struct { diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 11cb74b1ffd..f62ca096b8a 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -292,6 +292,10 @@ func editRun(opts *EditOptions) error { apiClient := api.NewClientFromHTTP(httpClient) + // Wire up search functions for assignees and reviewers. + // TODO: Wire up reviewer search func. + editable.AssigneeSearchFunc = assigneeSearchFunc(apiClient, repo, pr.ID) + opts.IO.StartProgressIndicator() err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable, opts.Detector.ProjectsV1()) opts.IO.StopProgressIndicator() @@ -331,6 +335,38 @@ func editRun(opts *EditOptions) error { return nil } +func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, assignableID string) func(string) ([]string, []string, int, error) { + searchFunc := func(input string) ([]string, []string, int, error) { + actors, err := api.SuggestedAssignableActors( + apiClient, + repo, + assignableID, + input) + if err != nil { + return nil, nil, 0, err + } + + var logins []string + var displayNames []string + + 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()) + } + } + return logins, displayNames, 0, 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/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 5cf55ad37e9..c69e5a0b5ac 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -10,15 +10,17 @@ import ( ) 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) ([]string, []string, int, error) + Labels EditableSlice + Projects EditableProjects + Milestone EditableString + Metadata api.RepoMetadataResult } type EditableString struct { @@ -277,6 +279,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) ([]string, []string, int, error)) ([]string, error) Confirm(string, bool) (bool, error) } @@ -302,10 +305,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 +425,21 @@ 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 because + // if that is set, the prompter will handle dynamic fetching of assignees. + if len(editable.Assignees.Add) == 0 && len(editable.Assignees.Remove) == 0 && editable.AssigneeSearchFunc == nil { + 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, From 7e7f8c6f6e15c280250f292bb19d732eb8075d93 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 24 Nov 2025 11:19:34 -0700 Subject: [PATCH 04/23] Pass editable to assigneeSearchFunc and update metadata The assigneeSearchFunc now receives the editable struct to update its Metadata.AssignableActors field with suggested assignable actors. This change ensures that the editable struct has the necessary actor metadata for later PR updates. --- pkg/cmd/pr/edit/edit.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index f62ca096b8a..b327500fab4 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -294,7 +294,7 @@ func editRun(opts *EditOptions) error { // Wire up search functions for assignees and reviewers. // TODO: Wire up reviewer search func. - editable.AssigneeSearchFunc = assigneeSearchFunc(apiClient, repo, pr.ID) + editable.AssigneeSearchFunc = assigneeSearchFunc(apiClient, repo, &editable, pr.ID) opts.IO.StartProgressIndicator() err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable, opts.Detector.ProjectsV1()) @@ -335,7 +335,7 @@ func editRun(opts *EditOptions) error { return nil } -func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, assignableID string) func(string) ([]string, []string, int, error) { +func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable *shared.Editable, assignableID string) func(string) ([]string, []string, int, error) { searchFunc := func(input string) ([]string, []string, int, error) { actors, err := api.SuggestedAssignableActors( apiClient, @@ -361,6 +361,10 @@ func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, assignable } 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 logins, displayNames, 0, nil } From 38578f7991220aae7829384708edfc9ca4b98201 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:48:58 -0700 Subject: [PATCH 05/23] Add comment describing logger Added a comment explaining how to enable logging in expect-based tests by using expect.WithLogger. This helps developers debug by printing characters read to stdout. --- internal/prompter/accessible_prompter_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 03baa34b743..f205d042d88 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -803,6 +803,8 @@ 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)), } From d46f42a752e7348b57db708b88e6ea6d78bf08d9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:07:47 -0700 Subject: [PATCH 06/23] Refactor MultiSelectWithSearch to use result struct Refactored the MultiSelectWithSearch function and related interfaces to use a MultiSelectSearchResult struct instead of multiple return values. This change improves clarity and extensibility of the search function signature, and updates all usages, mocks, and tests accordingly. --- internal/prompter/accessible_prompter_test.go | 81 +++++++++++++------ internal/prompter/prompter.go | 33 +++++--- internal/prompter/prompter_mock.go | 14 ++-- internal/prompter/test.go | 2 +- pkg/cmd/pr/edit/edit.go | 19 ++++- pkg/cmd/pr/shared/editable.go | 5 +- pkg/cmd/pr/shared/survey.go | 3 +- pkg/cmd/preview/prompter/prompter.go | 16 +++- 8 files changed, 122 insertions(+), 51 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index f205d042d88..20ce5d844ce 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -228,26 +228,36 @@ func TestAccessiblePrompter(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) persistentOptions := []string{"persistent-option-1"} - searchFunc := func(input string) ([]string, []string, int, error) { - 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 searchResultKeys, searchResultLabels, moreResults, nil + 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 searchResultKeys, searchResultLabels, moreResults, 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() { + go func() { // Wait for prompt to appear _, err := console.ExpectString("Select an option \r\n") require.NoError(t, err) @@ -291,16 +301,26 @@ func TestAccessiblePrompter(t *testing.T) { initialSearchResultKeys := []string{"initial-result-1"} initialSearchResultLabels := []string{"Initial Result Label 1"} defaultOptions := initialSearchResultKeys - searchFunc := func(input string) ([]string, []string, int, error) { + searchFunc := func(input string) prompter.MultiSelectSearchResult { // Initial search with no input if input == "" { moreResults := 2 - return initialSearchResultKeys, initialSearchResultLabels, moreResults, nil + return prompter.MultiSelectSearchResult{ + Keys: initialSearchResultKeys, + Labels: initialSearchResultLabels, + MoreResults: moreResults, + Err: nil, + } } // No search selected, so this should fail the test. t.FailNow() - return nil, nil, 0, nil + return prompter.MultiSelectSearchResult{ + Keys: nil, + Labels: nil, + MoreResults: 0, + Err: nil, + } } go func() { @@ -325,21 +345,36 @@ func TestAccessiblePrompter(t *testing.T) { moreResultKeys := []string{"more-result-1"} moreResultLabels := []string{"More Result Label 1"} - searchFunc := func(input string) ([]string, []string, int, error) { + searchFunc := func(input string) prompter.MultiSelectSearchResult { // Initial search with no input if input == "" { moreResults := 2 - return initialSearchResultKeys, initialSearchResultLabels, moreResults, nil + return prompter.MultiSelectSearchResult{ + Keys: initialSearchResultKeys, + Labels: initialSearchResultLabels, + MoreResults: moreResults, + Err: nil, + } } // Subsequent search with input "more" if input == "more" { - return moreResultKeys, moreResultLabels, 0, nil + return prompter.MultiSelectSearchResult{ + Keys: moreResultKeys, + Labels: moreResultLabels, + MoreResults: 0, + Err: nil, + } } // No other searches expected t.FailNow() - return nil, nil, 0, nil + return prompter.MultiSelectSearchResult{ + Keys: nil, + Labels: nil, + MoreResults: 0, + Err: nil, + } } go func() { diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index dddb49035e3..5fef325d577 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -29,7 +29,7 @@ type Prompter interface { // not their indices, since the list of options is dynamic. // The searchFunc args and return values are: func(query) (map[keys]labels, moreResultsCount, searchError) // Where the selected keys are eventually returned by MultiSelectWithSearch and the labels are what is shown to the user in the prompt. - MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) + 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. @@ -329,7 +329,7 @@ func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAl return text, nil } -func (p *accessiblePrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) { +func (p *accessiblePrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc) } @@ -349,11 +349,18 @@ 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) ([]string, []string, int, error)) ([]string, error) { +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) } -func multiSelectWithSearch(p Prompter, prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) { +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 @@ -363,10 +370,13 @@ func multiSelectWithSearch(p Prompter, prompt, searchPrompt string, defaultValue optionKeyLabels[k] = k } - searchResultKeys, searchResultLabels, moreResults, err := searchFunc("") - if err != nil { - return nil, fmt.Errorf("failed to search: %w", err) + 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] @@ -474,10 +484,13 @@ func multiSelectWithSearch(p Prompter, prompt, searchPrompt string, defaultValue return nil, err } - searchResultKeys, searchResultLabels, moreResults, err = searchFunc(query) - 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] diff --git a/internal/prompter/prompter_mock.go b/internal/prompter/prompter_mock.go index 4543004a07c..fd6492df815 100644 --- a/internal/prompter/prompter_mock.go +++ b/internal/prompter/prompter_mock.go @@ -38,7 +38,7 @@ 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) ([]string, []string, int, error)) ([]string, error) { +// 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) { @@ -76,7 +76,7 @@ type PrompterMock struct { 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) ([]string, []string, int, error)) ([]string, error) + 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) @@ -140,7 +140,7 @@ type PrompterMock struct { // PersistentOptions is the persistentOptions argument value. PersistentOptions []string // SearchFunc is the searchFunc argument value. - SearchFunc func(string) ([]string, []string, int, error) + SearchFunc func(string) MultiSelectSearchResult } // Password holds details about calls to the Password method. Password []struct { @@ -408,7 +408,7 @@ func (mock *PrompterMock) MultiSelectCalls() []struct { } // MultiSelectWithSearch calls MultiSelectWithSearchFunc. -func (mock *PrompterMock) MultiSelectWithSearch(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) { +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") } @@ -417,7 +417,7 @@ func (mock *PrompterMock) MultiSelectWithSearch(prompt string, searchPrompt stri SearchPrompt string Defaults []string PersistentOptions []string - SearchFunc func(string) ([]string, []string, int, error) + SearchFunc func(string) MultiSelectSearchResult }{ Prompt: prompt, SearchPrompt: searchPrompt, @@ -440,14 +440,14 @@ func (mock *PrompterMock) MultiSelectWithSearchCalls() []struct { SearchPrompt string Defaults []string PersistentOptions []string - SearchFunc func(string) ([]string, []string, int, error) + SearchFunc func(string) MultiSelectSearchResult } { var calls []struct { Prompt string SearchPrompt string Defaults []string PersistentOptions []string - SearchFunc func(string) ([]string, []string, int, error) + SearchFunc func(string) MultiSelectSearchResult } mock.lockMultiSelectWithSearch.RLock() calls = mock.calls.MultiSelectWithSearch diff --git a/internal/prompter/test.go b/internal/prompter/test.go index adaa0db6dad..8d3f64700b4 100644 --- a/internal/prompter/test.go +++ b/internal/prompter/test.go @@ -99,7 +99,7 @@ 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) ([]string, []string, int, error)) ([]string, error) { +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) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index b327500fab4..ff683af9824 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" @@ -335,15 +336,20 @@ func editRun(opts *EditOptions) error { return nil } -func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable *shared.Editable, assignableID string) func(string) ([]string, []string, int, error) { - searchFunc := func(input string) ([]string, []string, int, error) { +func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable *shared.Editable, assignableID string) func(string) prompter.MultiSelectSearchResult { + searchFunc := func(input string) prompter.MultiSelectSearchResult { actors, err := api.SuggestedAssignableActors( apiClient, repo, assignableID, input) if err != nil { - return nil, nil, 0, err + return prompter.MultiSelectSearchResult{ + Keys: nil, + Labels: nil, + MoreResults: 0, + Err: err, + } } var logins []string @@ -366,7 +372,12 @@ func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable * // so that updating the PR later can resolve the actor ID. editable.Metadata.AssignableActors = append(editable.Metadata.AssignableActors, a) } - return logins, displayNames, 0, nil + return prompter.MultiSelectSearchResult{ + Keys: logins, + Labels: displayNames, + MoreResults: 0, + Err: nil, + } } return searchFunc } diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index c69e5a0b5ac..7cfe49d17b5 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -6,6 +6,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/set" ) @@ -16,7 +17,7 @@ type Editable struct { Reviewers EditableSlice ReviewerSearchFunc func(string) ([]string, []string, error) Assignees EditableAssignees - AssigneeSearchFunc func(string) ([]string, []string, int, error) + AssigneeSearchFunc func(string) prompter.MultiSelectSearchResult Labels EditableSlice Projects EditableProjects Milestone EditableString @@ -279,7 +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) ([]string, []string, int, error)) ([]string, error) + MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) Confirm(string, bool) (bool, error) } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 5197d6acede..9ba600ae4ba 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" @@ -40,7 +41,7 @@ type Prompt interface { 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) ([]string, []string, int, error)) ([]string, 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 35ff901d48c..93dbbe61160 100644 --- a/pkg/cmd/preview/prompter/prompter.go +++ b/pkg/cmd/preview/prompter/prompter.go @@ -157,7 +157,7 @@ func runMultiSelect(p prompter.Prompter, io *iostreams.IOStreams) error { 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) ([]string, []string, int, error) { + searchFunc := func(input string) prompter.MultiSelectSearchResult { var searchResultKeys []string var searchResultLabels []string @@ -165,7 +165,12 @@ func runMultiSelectWithSearch(p prompter.Prompter, io *iostreams.IOStreams) erro 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 searchResultKeys, searchResultLabels, moreResults, nil + 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. @@ -173,7 +178,12 @@ func runMultiSelectWithSearch(p prompter.Prompter, io *iostreams.IOStreams) erro moreResults := 0 searchResultKeys = []string{"search-result-1", "search-result-2"} searchResultLabels = []string{"Search Result Label 1", "Search Result Label 2"} - return searchResultKeys, searchResultLabels, moreResults, nil + 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) From 07dfdf97aeb357606c1daed37736ffefd9beb585 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:26:21 -0700 Subject: [PATCH 07/23] Update edit tests Updated test mocks and logic to consistently use lowercase 'monalisa' for login names and display names for user assignees. Improved handling of dynamic assignee fetching in interactive flows by relying on searchFunc and metadata population, and clarified logic in FetchOptions to fetch assignees only when necessary. These changes ensure more accurate simulation of interactive assignment and better test coverage for actor assignee features. --- internal/prompter/accessible_prompter_test.go | 44 +++++++++---------- pkg/cmd/issue/edit/edit_test.go | 2 +- pkg/cmd/pr/edit/edit_test.go | 37 +++++++++------- pkg/cmd/pr/shared/editable.go | 10 ++++- 4 files changed, 51 insertions(+), 42 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 20ce5d844ce..c944ef6cd6d 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -228,15 +228,27 @@ func TestAccessiblePrompter(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"} + 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, @@ -245,19 +257,7 @@ func TestAccessiblePrompter(t *testing.T) { } } - // 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() { + go func() { // Wait for prompt to appear _, err := console.ExpectString("Select an option \r\n") require.NoError(t, err) 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_test.go b/pkg/cmd/pr/edit/edit_test.go index cd26c1a0a3c..2bd2497f4d4 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,8 @@ 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 } - } } } } - `)) + // No RepositoryAssignableActors query needed - searchFunc handles dynamic fetching + // (metadata populated in test mock) mockPullRequestUpdate(reg) reg.Register( httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`), @@ -886,7 +889,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 } } } } } @@ -1001,7 +1004,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 7cfe49d17b5..7e6e772198c 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -430,11 +430,17 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable, 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 because - // if that is set, the prompter will handle dynamic fetching of assignees. + // 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{ From ad8c770013ffddd355d0a4e80705dd92883c396e Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:47:39 -0700 Subject: [PATCH 08/23] Only support assignee searchfunc on GitHub.com --- pkg/cmd/pr/edit/edit.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index ff683af9824..6710224e36d 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -295,7 +295,9 @@ func editRun(opts *EditOptions) error { // Wire up search functions for assignees and reviewers. // TODO: Wire up reviewer search func. - editable.AssigneeSearchFunc = assigneeSearchFunc(apiClient, repo, &editable, pr.ID) + if issueFeatures.ActorIsAssignable { + editable.AssigneeSearchFunc = assigneeSearchFunc(apiClient, repo, &editable, pr.ID) + } opts.IO.StartProgressIndicator() err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable, opts.Detector.ProjectsV1()) From c0df49043fe0d3a4c03743723ffd4f7b12b54ffe Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Sun, 11 Jan 2026 20:02:52 -0700 Subject: [PATCH 09/23] Clarify TODO comment for reviewer search function Updated the TODO comment to specify wiring up the reviewer search function if or when it exists, providing clearer intent for future development. --- pkg/cmd/pr/edit/edit.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 6710224e36d..0ced8b58cb5 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -294,7 +294,7 @@ func editRun(opts *EditOptions) error { apiClient := api.NewClientFromHTTP(httpClient) // Wire up search functions for assignees and reviewers. - // TODO: Wire up reviewer search func. + // TODO KW: Wire up reviewer search func if/when it exists. if issueFeatures.ActorIsAssignable { editable.AssigneeSearchFunc = assigneeSearchFunc(apiClient, repo, &editable, pr.ID) } From f6a09a3e5cf45872835f3c24543bd2f3b658b705 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:23:51 -0700 Subject: [PATCH 10/23] Apply suggestions from code review Co-authored-by: Babak K. Shandiz --- api/queries_pr.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index d994d3aae06..962999f09c7 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -715,16 +715,15 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable Issue struct { SuggestedActors struct { Nodes []struct { + TypeName string `graphql:"__typename"` 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)"` @@ -732,16 +731,15 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable PullRequest struct { SuggestedActors struct { Nodes []struct { + TypeName string `graphql:"__typename"` User struct { ID string Login string - Name string - TypeName string `graphql:"__typename"` + Name string } `graphql:"... on User"` Bot struct { ID string Login string - TypeName string `graphql:"__typename"` } `graphql:"... on Bot"` } } `graphql:"suggestedActors(first: 10, query: $query)"` @@ -764,16 +762,15 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable } var nodes []struct { + TypeName string `graphql:"__typename"` 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"` } @@ -789,12 +786,12 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable viewerIncluded := false for _, n := range nodes { - if n.User.TypeName == "User" && n.User.Login != "" { + if n.TypeName == "User" && n.User.Login != "" { actors = append(actors, AssignableUser{id: n.User.ID, login: n.User.Login, name: n.User.Name}) if query == "" && viewerLogin != "" && n.User.Login == viewerLogin { viewerIncluded = true } - } else if n.Bot.TypeName == "Bot" && n.Bot.Login != "" { + } else if n.TypeName == "Bot" && n.Bot.Login != "" { actors = append(actors, AssignableBot{id: n.Bot.ID, login: n.Bot.Login}) if query == "" && viewerLogin != "" && n.Bot.Login == viewerLogin { viewerIncluded = true From 30cfbd9fdd5ef80ed89530e2e68c712674ee4999 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:38:27 -0700 Subject: [PATCH 11/23] Apply suggestions from code review Co-authored-by: Babak K. Shandiz --- pkg/cmd/pr/edit/edit.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 0ced8b58cb5..37ece7cfd17 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -354,8 +354,8 @@ func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable * } } - var logins []string - var displayNames []string + logins := make([]string, 0, len(actors)) + displayNames := make([]string, 0, len(actors)) for _, a := range actors { if a.Login() != "" { From 38f9d7891bbf6705dafc07ecd10ecbd25634e8f9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:14:32 -0700 Subject: [PATCH 12/23] Fix linter and mock prompter signature --- api/queries_pr.go | 40 +++++++++++++++++++-------------------- internal/prompter/test.go | 6 ++---- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 962999f09c7..6f0b65d672d 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -715,15 +715,15 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable Issue struct { SuggestedActors struct { Nodes []struct { - TypeName string `graphql:"__typename"` - User struct { - ID string - Login string - Name string + TypeName string `graphql:"__typename"` + User struct { + ID string + Login string + Name string } `graphql:"... on User"` Bot struct { - ID string - Login string + ID string + Login string } `graphql:"... on Bot"` } } `graphql:"suggestedActors(first: 10, query: $query)"` @@ -731,15 +731,15 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable PullRequest struct { SuggestedActors struct { Nodes []struct { - TypeName string `graphql:"__typename"` - User struct { - ID string - Login string - Name string + TypeName string `graphql:"__typename"` + User struct { + ID string + Login string + Name string } `graphql:"... on User"` Bot struct { - ID string - Login string + ID string + Login string } `graphql:"... on Bot"` } } `graphql:"suggestedActors(first: 10, query: $query)"` @@ -763,14 +763,14 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable var nodes []struct { TypeName string `graphql:"__typename"` - User struct { - ID string - Login string - Name string + User struct { + ID string + Login string + Name string } `graphql:"... on User"` Bot struct { - ID string - Login string + ID string + Login string } `graphql:"... on Bot"` } diff --git a/internal/prompter/test.go b/internal/prompter/test.go index 8d3f64700b4..599fd389358 100644 --- a/internal/prompter/test.go +++ b/internal/prompter/test.go @@ -51,9 +51,7 @@ type markdownEditorStub struct { } type multiSelectWithSearchStub struct { - prompt string - searchPrompt string - fn func(string, string, []string, []string) ([]string, error) + fn func(string, string, []string, []string, func(string) MultiSelectSearchResult) ([]string, error) } func (m *MockPrompter) AuthToken() (string, error) { @@ -106,7 +104,7 @@ func (m *MockPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaul } s = m.multiSelectWithSearchStubs[0] m.multiSelectWithSearchStubs = m.multiSelectWithSearchStubs[1:len(m.multiSelectWithSearchStubs)] - return s.fn(prompt, searchPrompt, defaults, persistentOptions) + return s.fn(prompt, searchPrompt, defaults, persistentOptions, searchFunc) } func (m *MockPrompter) RegisterAuthToken(stub func() (string, error)) { From 346bd8c0020f95aaa3df051939b9d1e874a76dd7 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:33:11 -0700 Subject: [PATCH 13/23] Simplify suggested assignable actors Simplifies SuggestedAssignableActors by no longer including the viewer in the returned actors list when the query is blank. Removes related logic and variables for viewer handling. --- api/queries_pr.go | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 6f0b65d672d..11502071d04 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -780,29 +780,16 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable nodes = result.Node.Issue.SuggestedActors.Nodes } - actors := make([]AssignableActor, 0, len(nodes)+1) // +1 in case we add viewer - viewer := result.Viewer - viewerLogin := viewer.Login - viewerIncluded := false + 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}) - if query == "" && viewerLogin != "" && n.User.Login == viewerLogin { - viewerIncluded = true - } } else if n.TypeName == "Bot" && n.Bot.Login != "" { actors = append(actors, AssignableBot{id: n.Bot.ID, login: n.Bot.Login}) - if query == "" && viewerLogin != "" && n.Bot.Login == viewerLogin { - viewerIncluded = true - } } } - // When query is blank, append viewer if not already present. - if query == "" && viewerLogin != "" && !viewerIncluded { - actors = append(actors, AssignableUser{id: viewer.ID, login: viewer.Login, name: viewer.Name}) - } return actors, nil } From dc105ce7a462eed026bf65533700ac28fea4ead7 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:02:40 -0700 Subject: [PATCH 14/23] Simplify variables map in SuggestedAssignableActors Refactored the construction of the variables map by directly assigning the 'query' key, removing the conditional logic for nil assignment. --- api/queries_pr.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 11502071d04..077d316e990 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -748,12 +748,8 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable } variables := map[string]interface{}{ - "id": githubv4.ID(assignableID), - } - if query != "" { - variables["query"] = githubv4.String(query) - } else { - variables["query"] = (*githubv4.String)(nil) + "id": githubv4.ID(assignableID), + "query": githubv4.String(query), } var result responseData From e3a3a01f2db9be1787aee7b04c241792ba437164 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:20:40 -0700 Subject: [PATCH 15/23] Add comments to assigneeSearchFunc for clarity Added detailed comments to the assigneeSearchFunc explaining its purpose and the importance of updating assignable actors metadata for later ID resolution when mutating assignees with the GraphQL API. --- pkg/cmd/pr/edit/edit.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 37ece7cfd17..2531b7f857a 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -338,6 +338,11 @@ 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, err := api.SuggestedAssignableActors( From 48bea46504fabd0b2152c2cd1ad118748081b369 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:37:39 -0700 Subject: [PATCH 16/23] Remove unused Viewer struct from SuggestedAssignableActors Deleted the Viewer struct from the responseData type in SuggestedAssignableActors as it was not being used. --- api/queries_pr.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 077d316e990..d2915fee856 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -706,11 +706,6 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in // If query is empty, the query variable is passed as null to omit filtering. func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignableID string, query string) ([]AssignableActor, error) { type responseData struct { - Viewer struct { - ID string - Login string - Name string - } `graphql:"viewer"` Node struct { Issue struct { SuggestedActors struct { From fb031b2b43123fcf2f6eb8f123c4eea7f421a1f4 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:48:03 -0700 Subject: [PATCH 17/23] Add test for legacy assignee flow on GHES Introduces a test case to verify that the interactive edit flow on GitHub Enterprise Server uses the legacy assignee selection without search, ensuring correct behavior when editing pull request assignees. --- pkg/cmd/pr/edit/edit_test.go | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 2bd2497f4d4..97a7d4cb400 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -898,6 +898,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{ From af124cd5d29f3efbfc79331f162956f7705279b1 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:56:47 -0700 Subject: [PATCH 18/23] Add test for MultiSelectWithSearch error propagation Introduces a test case to verify that errors returned from the MultiSelectWithSearch search function are properly propagated to the caller. --- internal/prompter/accessible_prompter_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index c944ef6cd6d..2c26a16a0aa 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -420,6 +420,21 @@ func TestAccessiblePrompter(t *testing.T) { 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) From 28e07666f8ce69577d77a29ceb57877738a7fc03 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:28:55 -0700 Subject: [PATCH 19/23] Return total assignee count in SuggestedAssignableActors Updated SuggestedAssignableActors to return the total count of available assignees in the repository. Modified assigneeSearchFunc to use this count to calculate and display the number of additional assignees beyond the current results. --- api/queries_pr.go | 16 +++++++++++++--- pkg/cmd/pr/edit/edit.go | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index d2915fee856..6a37ea0b9b0 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -704,8 +704,14 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in // 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. // If query is empty, the query variable is passed as null to omit filtering. -func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignableID string, query string) ([]AssignableActor, error) { +// 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 { @@ -745,13 +751,17 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable 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, err + return nil, 0, err } + availableAssigneesCount := result.Repository.AssignableUsers.TotalCount + var nodes []struct { TypeName string `graphql:"__typename"` User struct { @@ -781,7 +791,7 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable } } - return actors, nil + return actors, availableAssigneesCount, nil } func UpdatePullRequestBranch(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestBranchInput) error { diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 2531b7f857a..d9082496aff 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -345,7 +345,7 @@ func editRun(opts *EditOptions) error { // 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, err := api.SuggestedAssignableActors( + actors, availableAssigneesCount, err := api.SuggestedAssignableActors( apiClient, repo, assignableID, @@ -382,7 +382,7 @@ func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable * return prompter.MultiSelectSearchResult{ Keys: logins, Labels: displayNames, - MoreResults: 0, + MoreResults: availableAssigneesCount, Err: nil, } } From a8053d6e9357421ef37815ba1d96a44c3d3eaac3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:56:50 -0700 Subject: [PATCH 20/23] Remove redundant comment in editRun test --- pkg/cmd/pr/edit/edit_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 97a7d4cb400..56b868a36b8 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -843,8 +843,6 @@ func Test_editRun(t *testing.T) { EditorRetriever: testEditorRetriever{}, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - // No RepositoryAssignableActors query needed - searchFunc handles dynamic fetching - // (metadata populated in test mock) mockPullRequestUpdate(reg) reg.Register( httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`), From 968a912a07aa6bf32c70aaba84902ca9bc7baf40 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:04:32 -0700 Subject: [PATCH 21/23] Remove outdated TODO comments in survey.go Cleaned up obsolete TODO comments related to assignee and reviewer selection logic in the MetadataSurvey function. --- pkg/cmd/pr/shared/survey.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 9ba600ae4ba..e350671b947 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -209,7 +209,6 @@ 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. - // TODO: KW21 This will need to go away since we're going to dynamically load assignees via search. var assignees []string var assigneesDefault []string if state.ActorAssignees { @@ -267,9 +266,6 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface fmt.Fprintln(io.ErrOut, "warning: no available reviewers") } } - // TODO: KW21 This will need to change to use MultiSelectWithSearch once it's implemented. - // MultiSelectWithSearch will return the selected strings directly instead of indices, - // so the logic here will need to be updated accordingly. if isChosen("Assignees") { if len(assignees) > 0 { selected, err := p.MultiSelect("Assignees", assigneesDefault, assignees) From a33d809c88fac69cb4fc43de9a6422de0d49a178 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:09:16 -0700 Subject: [PATCH 22/23] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/prompter/prompter.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 5fef325d577..2bf49eb5877 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -27,8 +27,8 @@ type Prompter interface { // 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 args and return values are: func(query) (map[keys]labels, moreResultsCount, searchError) - // Where the selected keys are eventually returned by MultiSelectWithSearch and the labels are what is shown to the user in the prompt. + // 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) From 23e80a9d24a197011e5c8a57d512b3150b46e985 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:09:58 -0700 Subject: [PATCH 23/23] Remove outdated comment in SuggestedAssignableActors Deleted a comment about the query variable being passed as null when empty, as it is no longer relevant or necessary. --- api/queries_pr.go | 1 - 1 file changed, 1 deletion(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 6a37ea0b9b0..c806d6bb75b 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -703,7 +703,6 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in // 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. -// If query is empty, the query variable is passed as null to omit filtering. // 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 {