The reviewer prompt branch checked reviewerSearchFunc != nil directly instead of useReviewerSearch, making the fetch and prompt decisions inconsistent. This mirrors how the assignee path already uses useAssigneeSearch at both the fetch and prompt gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
452 lines
13 KiB
Go
452 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 selection is available, skip the expensive assignable-users
|
|
// and teams fetch since they are found dynamically via the search function.
|
|
// TODO ApiActorsSupported
|
|
useReviewerSearch := state.ApiActorsSupported && reviewerSearchFunc != nil
|
|
useAssigneeSearch := state.ApiActorsSupported && assigneeSearchFunc != nil
|
|
metadataInput := api.RepoMetadataInput{
|
|
Reviewers: isChosen("Reviewers") && !useReviewerSearch,
|
|
TeamReviewers: isChosen("Reviewers") && !useReviewerSearch,
|
|
Assignees: isChosen("Assignees") && !useAssigneeSearch,
|
|
ApiActorsSupported: state.ApiActorsSupported,
|
|
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 {
|
|
// TODO ApiActorsSupported
|
|
if state.ApiActorsSupported {
|
|
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 useReviewerSearch {
|
|
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 ApiActorsSupported
|
|
// 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 {
|
|
// TODO ApiActorsSupported
|
|
if state.ApiActorsSupported {
|
|
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
|
|
}
|