Wire up MultiSelectWithSearch for assignees in MetadataSurvey, replacing the static MultiSelect that required bulk fetching all assignable actors. This applies to both gh pr create and gh issue create interactive flows when selecting assignees via the 'Add metadata' prompt. Changes: - Add assigneeSearchFunc parameter to MetadataSurvey - Skip assignee bulk fetch when search func is available - New SearchRepoAssignableActors API function for repo-level search (create flows have no issue/PR node ID yet) - New RepoAssigneeSearchFunc in shared editable.go - Refactor actorsToSearchResult helper shared by both search functions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
449 lines
13 KiB
Go
449 lines
13 KiB
Go
package shared
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
|
|
"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"
|
|
)
|
|
|
|
type Action int
|
|
|
|
const (
|
|
SubmitAction Action = iota
|
|
PreviewAction
|
|
CancelAction
|
|
MetadataAction
|
|
EditCommitMessageAction
|
|
EditCommitSubjectAction
|
|
SubmitDraftAction
|
|
|
|
noMilestone = "(none)"
|
|
|
|
submitLabel = "Submit"
|
|
submitDraftLabel = "Submit as draft"
|
|
previewLabel = "Continue in browser"
|
|
metadataLabel = "Add metadata"
|
|
cancelLabel = "Cancel"
|
|
)
|
|
|
|
type Prompt interface {
|
|
Input(prompt string, defaultValue string) (string, error)
|
|
Select(prompt string, defaultValue string, options []string) (int, error)
|
|
MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error)
|
|
Confirm(prompt string, defaultValue bool) (bool, error)
|
|
MultiSelect(prompt string, defaults []string, options []string) ([]int, error)
|
|
MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error)
|
|
}
|
|
|
|
func ConfirmIssueSubmission(p Prompt, allowPreview bool, allowMetadata bool) (Action, error) {
|
|
return confirmSubmission(p, allowPreview, allowMetadata, false, false)
|
|
}
|
|
|
|
func ConfirmPRSubmission(p Prompt, allowPreview, allowMetadata, isDraft bool) (Action, error) {
|
|
return confirmSubmission(p, allowPreview, allowMetadata, true, isDraft)
|
|
}
|
|
|
|
func confirmSubmission(p Prompt, allowPreview, allowMetadata, allowDraft, isDraft bool) (Action, error) {
|
|
var options []string
|
|
if !isDraft {
|
|
options = append(options, submitLabel)
|
|
}
|
|
if allowDraft {
|
|
options = append(options, submitDraftLabel)
|
|
}
|
|
if allowPreview {
|
|
options = append(options, previewLabel)
|
|
}
|
|
if allowMetadata {
|
|
options = append(options, metadataLabel)
|
|
}
|
|
options = append(options, cancelLabel)
|
|
|
|
result, err := p.Select("What's next?", "", options)
|
|
if err != nil {
|
|
return -1, fmt.Errorf("could not prompt: %w", err)
|
|
}
|
|
|
|
switch options[result] {
|
|
case submitLabel:
|
|
return SubmitAction, nil
|
|
case submitDraftLabel:
|
|
return SubmitDraftAction, nil
|
|
case previewLabel:
|
|
return PreviewAction, nil
|
|
case metadataLabel:
|
|
return MetadataAction, nil
|
|
case cancelLabel:
|
|
return CancelAction, nil
|
|
default:
|
|
return -1, fmt.Errorf("invalid index: %d", result)
|
|
}
|
|
}
|
|
|
|
func BodySurvey(p Prompt, state *IssueMetadataState, templateContent string) error {
|
|
if templateContent != "" {
|
|
if state.Body != "" {
|
|
// prevent excessive newlines between default body and template
|
|
state.Body = strings.TrimRight(state.Body, "\n")
|
|
state.Body += "\n\n"
|
|
}
|
|
state.Body += templateContent
|
|
}
|
|
|
|
result, err := p.MarkdownEditor("Body", state.Body, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if state.Body != result {
|
|
state.MarkDirty()
|
|
}
|
|
|
|
state.Body = result
|
|
|
|
return nil
|
|
}
|
|
|
|
func TitleSurvey(p Prompt, io *iostreams.IOStreams, state *IssueMetadataState) error {
|
|
var err error
|
|
result := ""
|
|
for result == "" {
|
|
result, err = p.Input("Title (required)", state.Title)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if result == "" {
|
|
fmt.Fprintf(io.ErrOut, "%s Title cannot be blank\n", io.ColorScheme().FailureIcon())
|
|
}
|
|
}
|
|
|
|
if result != state.Title {
|
|
state.MarkDirty()
|
|
}
|
|
|
|
state.Title = result
|
|
|
|
return nil
|
|
}
|
|
|
|
type MetadataFetcher struct {
|
|
IO *iostreams.IOStreams
|
|
APIClient *api.Client
|
|
Repo ghrepo.Interface
|
|
State *IssueMetadataState
|
|
}
|
|
|
|
func (mf *MetadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput) (*api.RepoMetadataResult, error) {
|
|
mf.IO.StartProgressIndicator()
|
|
metadataResult, err := api.RepoMetadata(mf.APIClient, mf.Repo, input)
|
|
mf.IO.StopProgressIndicator()
|
|
mf.State.MetadataResult = metadataResult
|
|
return metadataResult, err
|
|
}
|
|
|
|
type RepoMetadataFetcher interface {
|
|
RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error)
|
|
}
|
|
|
|
func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState, projectsV1Support gh.ProjectsV1Support, reviewerSearchFunc func(string) prompter.MultiSelectSearchResult, assigneeSearchFunc func(string) prompter.MultiSelectSearchResult) error {
|
|
isChosen := func(m string) bool {
|
|
for _, c := range state.Metadata {
|
|
if m == c {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
allowReviewers := state.Type == PRMetadata
|
|
|
|
extraFieldsOptions := []string{}
|
|
if allowReviewers {
|
|
extraFieldsOptions = append(extraFieldsOptions, "Reviewers")
|
|
}
|
|
extraFieldsOptions = append(extraFieldsOptions, "Assignees", "Labels", "Projects", "Milestone")
|
|
|
|
selected, err := p.MultiSelect("What would you like to add?", nil, extraFieldsOptions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, i := range selected {
|
|
state.Metadata = append(state.Metadata, extraFieldsOptions[i])
|
|
}
|
|
|
|
// Retrieve and process data for survey prompts based on the extra fields selected.
|
|
// When search-based reviewer selection is available, skip the expensive assignable-users
|
|
// and teams fetch since reviewers are found dynamically via the search function.
|
|
useReviewerSearch := state.ActorReviewers && reviewerSearchFunc != nil
|
|
useAssigneeSearch := state.ActorAssignees && assigneeSearchFunc != nil
|
|
metadataInput := api.RepoMetadataInput{
|
|
Reviewers: isChosen("Reviewers") && !useReviewerSearch,
|
|
TeamReviewers: isChosen("Reviewers") && !useReviewerSearch,
|
|
Assignees: isChosen("Assignees") && !useAssigneeSearch,
|
|
ActorAssignees: isChosen("Assignees") && !useAssigneeSearch && state.ActorAssignees,
|
|
Labels: isChosen("Labels"),
|
|
ProjectsV1: isChosen("Projects") && projectsV1Support == gh.ProjectsV1Supported,
|
|
ProjectsV2: isChosen("Projects"),
|
|
Milestones: isChosen("Milestone"),
|
|
}
|
|
metadataResult, err := fetcher.RepoMetadataFetch(metadataInput)
|
|
if err != nil {
|
|
return fmt.Errorf("error fetching metadata options: %w", err)
|
|
}
|
|
|
|
var reviewers []string
|
|
if !useReviewerSearch {
|
|
for _, u := range metadataResult.AssignableUsers {
|
|
if u.Login() != metadataResult.CurrentLogin {
|
|
reviewers = append(reviewers, u.DisplayName())
|
|
}
|
|
}
|
|
for _, t := range metadataResult.Teams {
|
|
reviewers = append(reviewers, fmt.Sprintf("%s/%s", baseRepo.RepoOwner(), t.Slug))
|
|
}
|
|
}
|
|
|
|
// Populate the list of selectable assignees and their default selections.
|
|
// When search-based selection is available, skip building the static list.
|
|
var assignees []string
|
|
var assigneesDefault []string
|
|
if !useAssigneeSearch {
|
|
if state.ActorAssignees {
|
|
for _, u := range metadataResult.AssignableActors {
|
|
assignees = append(assignees, u.DisplayName())
|
|
|
|
if slices.Contains(state.Assignees, u.Login()) {
|
|
assigneesDefault = append(assigneesDefault, u.DisplayName())
|
|
}
|
|
}
|
|
} else {
|
|
for _, u := range metadataResult.AssignableUsers {
|
|
assignees = append(assignees, u.DisplayName())
|
|
|
|
if slices.Contains(state.Assignees, u.Login()) {
|
|
assigneesDefault = append(assigneesDefault, u.DisplayName())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
var labels []string
|
|
for _, l := range metadataResult.Labels {
|
|
labels = append(labels, l.Name)
|
|
}
|
|
var projects []string
|
|
for _, p := range metadataResult.Projects {
|
|
projects = append(projects, p.Name)
|
|
}
|
|
for _, p := range metadataResult.ProjectsV2 {
|
|
projects = append(projects, p.Title)
|
|
}
|
|
milestones := []string{noMilestone}
|
|
for _, m := range metadataResult.Milestones {
|
|
milestones = append(milestones, m.Title)
|
|
}
|
|
|
|
// Prompt user for additional metadata based on selected fields
|
|
values := struct {
|
|
Reviewers []string
|
|
Assignees []string
|
|
Labels []string
|
|
Projects []string
|
|
Milestone string
|
|
}{}
|
|
|
|
if isChosen("Reviewers") {
|
|
if reviewerSearchFunc != nil {
|
|
selectedReviewers, err := p.MultiSelectWithSearch(
|
|
"Reviewers",
|
|
"Search reviewers",
|
|
state.Reviewers,
|
|
[]string{},
|
|
reviewerSearchFunc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
values.Reviewers = selectedReviewers
|
|
} else if len(reviewers) > 0 {
|
|
// TODO requestReviewsByLoginCleanup
|
|
// The static MultiSelect path can be removed once GHES supports
|
|
// requestReviewsByLogin and search-based selection is always used.
|
|
selected, err := p.MultiSelect("Reviewers", state.Reviewers, reviewers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, i := range selected {
|
|
values.Reviewers = append(values.Reviewers, reviewers[i])
|
|
}
|
|
} else {
|
|
fmt.Fprintln(io.ErrOut, "warning: no available reviewers")
|
|
}
|
|
}
|
|
if isChosen("Assignees") {
|
|
if useAssigneeSearch {
|
|
selectedAssignees, err := p.MultiSelectWithSearch(
|
|
"Assignees",
|
|
"Search assignees",
|
|
state.Assignees,
|
|
[]string{},
|
|
assigneeSearchFunc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
values.Assignees = selectedAssignees
|
|
} else if len(assignees) > 0 {
|
|
selected, err := p.MultiSelect("Assignees", assigneesDefault, assignees)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, i := range selected {
|
|
if state.ActorAssignees {
|
|
values.Assignees = append(values.Assignees, metadataResult.AssignableActors[i].Login())
|
|
} else {
|
|
values.Assignees = append(values.Assignees, metadataResult.AssignableUsers[i].Login())
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Fprintln(io.ErrOut, "warning: no assignable users")
|
|
}
|
|
}
|
|
if isChosen("Labels") {
|
|
if len(labels) > 0 {
|
|
selected, err := p.MultiSelect("Labels", state.Labels, labels)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, i := range selected {
|
|
values.Labels = append(values.Labels, labels[i])
|
|
}
|
|
} else {
|
|
fmt.Fprintln(io.ErrOut, "warning: no labels in the repository")
|
|
}
|
|
}
|
|
if isChosen("Projects") {
|
|
if len(projects) > 0 {
|
|
selected, err := p.MultiSelect("Projects", state.ProjectTitles, projects)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, i := range selected {
|
|
values.Projects = append(values.Projects, projects[i])
|
|
}
|
|
} else {
|
|
fmt.Fprintln(io.ErrOut, "warning: no projects to choose from")
|
|
}
|
|
}
|
|
if isChosen("Milestone") {
|
|
if len(milestones) > 1 {
|
|
var milestoneDefault string
|
|
if len(state.Milestones) > 0 {
|
|
milestoneDefault = state.Milestones[0]
|
|
} else {
|
|
milestoneDefault = milestones[1]
|
|
}
|
|
selected, err := p.Select("Milestone", milestoneDefault, milestones)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
values.Milestone = milestones[selected]
|
|
} else {
|
|
fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository")
|
|
}
|
|
}
|
|
|
|
// Update issue / pull request metadata state
|
|
if isChosen("Reviewers") {
|
|
var logins []string
|
|
for _, r := range values.Reviewers {
|
|
// Extract user login from display name
|
|
logins = append(logins, (strings.Split(r, " "))[0])
|
|
}
|
|
state.Reviewers = logins
|
|
}
|
|
if isChosen("Assignees") {
|
|
state.Assignees = values.Assignees
|
|
}
|
|
if isChosen("Labels") {
|
|
state.Labels = values.Labels
|
|
}
|
|
if isChosen("Projects") {
|
|
state.ProjectTitles = values.Projects
|
|
}
|
|
if isChosen("Milestone") {
|
|
if values.Milestone != "" && values.Milestone != noMilestone {
|
|
state.Milestones = []string{values.Milestone}
|
|
} else {
|
|
state.Milestones = []string{}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type Editor interface {
|
|
Edit(filename, initialValue string) (string, error)
|
|
}
|
|
|
|
type UserEditor struct {
|
|
IO *iostreams.IOStreams
|
|
Config func() (gh.Config, error)
|
|
}
|
|
|
|
func (e *UserEditor) Edit(filename, initialValue string) (string, error) {
|
|
editorCommand, err := cmdutil.DetermineEditor(e.Config)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return surveyext.Edit(editorCommand, filename, initialValue, e.IO.In, e.IO.Out, e.IO.ErrOut)
|
|
}
|
|
|
|
const editorHintMarker = "------------------------ >8 ------------------------"
|
|
const editorHint = `
|
|
Please Enter the title on the first line and the body on subsequent lines.
|
|
Lines below dotted lines will be ignored, and an empty title aborts the creation process.`
|
|
|
|
func TitledEditSurvey(editor Editor) func(string, string) (string, string, error) {
|
|
return func(initialTitle, initialBody string) (string, string, error) {
|
|
initialValue := strings.Join([]string{initialTitle, initialBody, editorHintMarker, editorHint}, "\n")
|
|
titleAndBody, err := editor.Edit("*.md", initialValue)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
titleAndBody = strings.ReplaceAll(titleAndBody, "\r\n", "\n")
|
|
titleAndBody, _, _ = strings.Cut(titleAndBody, editorHintMarker)
|
|
title, body, _ := strings.Cut(titleAndBody, "\n")
|
|
return title, strings.TrimSuffix(body, "\n"), nil
|
|
}
|
|
}
|
|
|
|
func InitEditorMode(f *cmdutil.Factory, editorMode bool, webMode bool, canPrompt bool) (bool, error) {
|
|
if err := cmdutil.MutuallyExclusive(
|
|
"specify only one of `--editor` or `--web`",
|
|
editorMode,
|
|
webMode,
|
|
); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
config, err := f.Config()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
editorMode = !webMode && (editorMode || config.PreferEditorPrompt("").Value == "enabled")
|
|
|
|
if editorMode && !canPrompt {
|
|
return false, errors.New("--editor or enabled prefer_editor_prompt configuration are not supported in non-tty mode")
|
|
}
|
|
|
|
return editorMode, nil
|
|
}
|