Repository: cli/cli
Pull request #7537 (2023-06-05 17:21:05)

Feature: Add gist selector option in gist edit command
Related: #7000 

- Add gist selector option to gist edit command
- Replace SurveyAskOne with Prompter



Patch:
diff --git a/pkg/cmd/gist/edit/edit.go b/pkg/cmd/gist/edit/edit.go
index 773ea41df..3ad28b6c4 100644
--- a/pkg/cmd/gist/edit/edit.go
+++ b/pkg/cmd/gist/edit/edit.go
@@ -12,21 +12,23 @@ import (
 	"sort"
 	"strings"
 
-	"github.com/AlecAivazis/survey/v2"
 	"github.com/cli/cli/v2/api"
 	"github.com/cli/cli/v2/internal/config"
+	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/pkg/cmd/gist/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/cli/cli/v2/pkg/prompt"
 	"github.com/cli/cli/v2/pkg/surveyext"
 	"github.com/spf13/cobra"
 )
 
+var editNextOptions = []string{"Edit another file", "Submit", "Cancel"}
+
 type EditOptions struct {
 	IO         *iostreams.IOStreams
 	HttpClient func() (*http.Client, error)
 	Config     func() (config.Config, error)
+	Prompter   prompter.Prompter
 
 	Edit func(string, string, string, *iostreams.IOStreams) (string, error)
 
@@ -42,6 +44,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
 		IO:         f.IOStreams,
 		HttpClient: f.HttpClient,
 		Config:     f.Config,
+		Prompter:   f.Prompter,
 		Edit: func(editorCmd, filename, defaultContent string, io *iostreams.IOStreams) (string, error) {
 			return surveyext.Edit(
 				editorCmd,
@@ -55,16 +58,15 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
 		Use:   "edit {<id> | <url>} [<filename>]",
 		Short: "Edit one of your gists",
 		Args: func(cmd *cobra.Command, args []string) error {
-			if len(args) < 1 {
-				return cmdutil.FlagErrorf("cannot edit: gist argument required")
-			}
 			if len(args) > 2 {
 				return cmdutil.FlagErrorf("too many arguments")
 			}
 			return nil
 		},
 		RunE: func(c *cobra.Command, args []string) error {
-			opts.Selector = args[0]
+			if len(args) > 0 {
+				opts.Selector = args[0]
+			}
 			if len(args) > 1 {
 				opts.SourceFile = args[1]
 			}
@@ -85,23 +87,11 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
 }
 
 func editRun(opts *EditOptions) error {
-	gistID := opts.Selector
-
-	if strings.Contains(gistID, "/") {
-		id, err := shared.GistIDFromURL(gistID)
-		if err != nil {
-			return err
-		}
-		gistID = id
-	}
-
 	client, err := opts.HttpClient()
 	if err != nil {
 		return err
 	}
 
-	apiClient := api.NewClientFromHTTP(client)
-
 	cfg, err := opts.Config()
 	if err != nil {
 		return err
@@ -109,6 +99,32 @@ func editRun(opts *EditOptions) error {
 
 	host, _ := cfg.Authentication().DefaultHost()
 
+	gistID := opts.Selector
+	if gistID == "" {
+		cs := opts.IO.ColorScheme()
+		if gistID == "" {
+			gistID, err = shared.PromptGists(opts.Prompter, client, host, cs)
+			if err != nil {
+				return err
+			}
+
+			if gistID == "" {
+				fmt.Fprintln(opts.IO.Out, "No gists found.")
+				return nil
+			}
+		}
+	}
+
+	if strings.Contains(gistID, "/") {
+		id, err := shared.GistIDFromURL(gistID)
+		if err != nil {
+			return err
+		}
+		gistID = id
+	}
+
+	apiClient := api.NewClientFromHTTP(client)
+
 	gist, err := shared.GetGist(client, host, gistID)
 	if err != nil {
 		if errors.Is(err, shared.NotFoundErr) {
@@ -189,15 +205,11 @@ func editRun(opts *EditOptions) error {
 				if !opts.IO.CanPrompt() {
 					return errors.New("unsure what file to edit; either specify --filename or run interactively")
 				}
-				//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
-				err = prompt.SurveyAskOne(&survey.Select{
-					Message: "Edit which file?",
-					Options: candidates,
-				}, &filename)
-
+				result, err := opts.Prompter.Select("Edit which file?", "", candidates)
 				if err != nil {
 					return fmt.Errorf("could not prompt: %w", err)
 				}
+				filename = candidates[result]
 			}
 		}
 
@@ -249,20 +261,11 @@ func editRun(opts *EditOptions) error {
 		}
 
 		choice := ""
-
-		//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
-		err = prompt.SurveyAskOne(&survey.Select{
-			Message: "What next?",
-			Options: []string{
-				"Edit another file",
-				"Submit",
-				"Cancel",
-			},
-		}, &choice)
-
+		result, err := opts.Prompter.Select("What next?", "", editNextOptions)
 		if err != nil {
 			return fmt.Errorf("could not prompt: %w", err)
 		}
+		choice = editNextOptions[result]
 
 		stop := false
 
diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go
index 52c86caa7..66ba6fe0a 100644
--- a/pkg/cmd/gist/shared/shared.go
+++ b/pkg/cmd/gist/shared/shared.go
@@ -5,10 +5,14 @@ import (
 	"fmt"
 	"net/http"
 	"net/url"
+	"sort"
 	"strings"
 	"time"
 
 	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/prompter"
+	"github.com/cli/cli/v2/internal/text"
+	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/gabriel-vasile/mimetype"
 	"github.com/shurcooL/githubv4"
 )
@@ -171,3 +175,48 @@ func IsBinaryContents(contents []byte) bool {
 	}
 	return isBinary
 }
+
+func PromptGists(prompter prompter.Prompter, client *http.Client, host string, cs *iostreams.ColorScheme) (gistID string, err error) {
+	gists, err := ListGists(client, host, 10, "all")
+	if err != nil {
+		return "", err
+	}
+
+	if len(gists) == 0 {
+		return "", nil
+	}
+
+	var opts []string
+	var gistIDs = make([]string, len(gists))
+
+	for i, gist := range gists {
+		gistIDs[i] = gist.ID
+		description := ""
+		gistName := ""
+
+		if gist.Description != "" {
+			description = gist.Description
+		}
+
+		filenames := make([]string, 0, len(gist.Files))
+		for fn := range gist.Files {
+			filenames = append(filenames, fn)
+		}
+		sort.Strings(filenames)
+		gistName = filenames[0]
+
+		gistTime := text.FuzzyAgo(time.Now(), gist.UpdatedAt)
+		// TODO: support dynamic maxWidth
+		description = text.Truncate(100, text.RemoveExcessiveWhitespace(description))
+		opt := fmt.Sprintf("%s %s %s", cs.Bold(gistName), description, cs.Gray(gistTime))
+		opts = append(opts, opt)
+	}
+
+	result, err := prompter.Select("Select a gist", "", opts)
+
+	if err != nil {
+		return "", err
+	}
+
+	return gistIDs[result], nil
+}
diff --git a/pkg/cmd/gist/view/view.go b/pkg/cmd/gist/view/view.go
index 545681522..e4f5c84e1 100644
--- a/pkg/cmd/gist/view/view.go
+++ b/pkg/cmd/gist/view/view.go
@@ -5,17 +5,15 @@ import (
 	"net/http"
 	"sort"
 	"strings"
-	"time"
 
-	"github.com/AlecAivazis/survey/v2"
 	"github.com/cli/cli/v2/internal/config"
 	"github.com/cli/cli/v2/internal/ghinstance"
+	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/internal/text"
 	"github.com/cli/cli/v2/pkg/cmd/gist/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/cli/cli/v2/pkg/markdown"
-	"github.com/cli/cli/v2/pkg/prompt"
 	"github.com/spf13/cobra"
 )
 
@@ -28,6 +26,7 @@ type ViewOptions struct {
 	Config     func() (config.Config, error)
 	HttpClient func() (*http.Client, error)
 	Browser    browser
+	Prompter   prompter.Prompter
 
 	Selector  string
 	Filename  string
@@ -42,6 +41,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
 		Config:     f.Config,
 		HttpClient: f.HttpClient,
 		Browser:    f.Browser,
+		Prompter:   f.Prompter,
 	}
 
 	cmd := &cobra.Command{
@@ -89,7 +89,7 @@ func viewRun(opts *ViewOptions) error {
 
 	cs := opts.IO.ColorScheme()
 	if gistID == "" {
-		gistID, err = promptGists(client, hostname, cs)
+		gistID, err = shared.PromptGists(opts.Prompter, client, hostname, cs)
 		if err != nil {
 			return err
 		}
@@ -204,55 +204,3 @@ func viewRun(opts *ViewOptions) error {
 
 	return nil
 }
-
-func promptGists(client *http.Client, host string, cs *iostreams.ColorScheme) (gistID string, err error) {
-	gists, err := shared.ListGists(client, host, 10, "all")
-	if err != nil {
-		return "", err
-	}
-
-	if len(gists) == 0 {
-		return "", nil
-	}
-
-	var opts []string
-	var result int
-	var gistIDs = make([]string, len(gists))
-
-	for i, gist := range gists {
-		gistIDs[i] = gist.ID
-		description := ""
-		gistName := ""
-
-		if gist.Description != "" {
-			description = gist.Description
-		}
-
-		filenames := make([]string, 0, len(gist.Files))
-		for fn := range gist.Files {
-			filenames = append(filenames, fn)
-		}
-		sort.Strings(filenames)
-		gistName = filenames[0]
-
-		gistTime := text.FuzzyAgo(time.Now(), gist.UpdatedAt)
-		// TODO: support dynamic maxWidth
-		description = text.Truncate(100, text.RemoveExcessiveWhitespace(description))
-		opt := fmt.Sprintf("%s %s %s", cs.Bold(gistName), description, cs.Gray(gistTime))
-		opts = append(opts, opt)
-	}
-
-	questions := &survey.Select{
-		Message: "Select a gist",
-		Options: opts,
-	}
-
-	//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
-	err = prompt.SurveyAskOne(questions, &result)
-
-	if err != nil {
-		return "", err
-	}
-
-	return gistIDs[result], nil
-}


Test patch:
diff --git a/pkg/cmd/gist/edit/edit_test.go b/pkg/cmd/gist/edit/edit_test.go
index 33223ddee..5e52a8ffb 100644
--- a/pkg/cmd/gist/edit/edit_test.go
+++ b/pkg/cmd/gist/edit/edit_test.go
@@ -10,11 +10,11 @@ import (
 	"testing"
 
 	"github.com/cli/cli/v2/internal/config"
+	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/pkg/cmd/gist/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/cli/cli/v2/pkg/prompt"
 	"github.com/google/shlex"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -115,15 +115,15 @@ func Test_editRun(t *testing.T) {
 	require.NoError(t, err)
 
 	tests := []struct {
-		name       string
-		opts       *EditOptions
-		gist       *shared.Gist
-		httpStubs  func(*httpmock.Registry)
-		askStubs   func(*prompt.AskStubber)
-		nontty     bool
-		stdin      string
-		wantErr    string
-		wantParams map[string]interface{}
+		name          string
+		opts          *EditOptions
+		gist          *shared.Gist
+		httpStubs     func(*httpmock.Registry)
+		prompterStubs func(*prompter.MockPrompter)
+		nontty        bool
+		stdin         string
+		wantErr       string
+		wantParams    map[string]interface{}
 	}{
 		{
 			name:    "no such gist",
@@ -161,9 +161,17 @@ func Test_editRun(t *testing.T) {
 		},
 		{
 			name: "multiple files, submit",
-			askStubs: func(as *prompt.AskStubber) {
-				as.StubPrompt("Edit which file?").AnswerWith("unix.md")
-				as.StubPrompt("What next?").AnswerWith("Submit")
+			prompterStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Edit which file?",
+					[]string{"cicada.txt", "unix.md"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "unix.md")
+					})
+				pm.RegisterSelect("What next?",
+					editNextOptions,
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "Submit")
+					})
 			},
 			gist: &shared.Gist{
 				ID:          "1234",
@@ -206,9 +214,17 @@ func Test_editRun(t *testing.T) {
 		},
 		{
 			name: "multiple files, cancel",
-			askStubs: func(as *prompt.AskStubber) {
-				as.StubPrompt("Edit which file?").AnswerWith("unix.md")
-				as.StubPrompt("What next?").AnswerWith("Cancel")
+			prompterStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Edit which file?",
+					[]string{"cicada.txt", "unix.md"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "unix.md")
+					})
+				pm.RegisterSelect("What next?",
+					editNextOptions,
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "Cancel")
+					})
 			},
 			wantErr: "CancelError",
 			gist: &shared.Gist{
@@ -486,11 +502,11 @@ func Test_editRun(t *testing.T) {
 		}
 
 		t.Run(tt.name, func(t *testing.T) {
-			//nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock
-			as := prompt.NewAskStubber(t)
-			if tt.askStubs != nil {
-				tt.askStubs(as)
+			pm := prompter.NewMockPrompter(t)
+			if tt.prompterStubs != nil {
+				tt.prompterStubs(pm)
 			}
+			tt.opts.Prompter = pm
 
 			err := editRun(tt.opts)
 			reg.Verify(t)
diff --git a/pkg/cmd/gist/shared/shared_test.go b/pkg/cmd/gist/shared/shared_test.go
index c55cbea67..4d185ff7d 100644
--- a/pkg/cmd/gist/shared/shared_test.go
+++ b/pkg/cmd/gist/shared/shared_test.go
@@ -1,8 +1,14 @@
 package shared
 
 import (
+	"fmt"
+	"net/http"
 	"testing"
+	"time"
 
+	"github.com/cli/cli/v2/internal/prompter"
+	"github.com/cli/cli/v2/pkg/httpmock"
+	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -85,3 +91,104 @@ func TestIsBinaryContents(t *testing.T) {
 		assert.Equal(t, tt.want, IsBinaryContents(tt.fileContent))
 	}
 }
+
+func TestPromptGists(t *testing.T) {
+	tests := []struct {
+		name          string
+		prompterStubs func(pm *prompter.MockPrompter)
+		response      string
+		wantOut       string
+		gist          *Gist
+		wantErr       bool
+	}{
+		{
+			name: "multiple files, select first gist",
+			prompterStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a gist",
+					[]string{"cool.txt  about 6 hours ago", "gistfile0.txt  about 6 hours ago"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "cool.txt  about 6 hours ago")
+					})
+			},
+			response: `{ "data": { "viewer": { "gists": { "nodes": [
+							{
+								"name": "gistid1",
+								"files": [{ "name": "cool.txt" }],
+								"description": "",
+								"updatedAt": "%[1]v",
+								"isPublic": true
+							},
+							{
+								"name": "gistid2",
+								"files": [{ "name": "gistfile0.txt" }],
+								"description": "",
+								"updatedAt": "%[1]v",
+								"isPublic": true
+							}
+						] } } } }`,
+			wantOut: "gistid1",
+		},
+		{
+			name: "multiple files, select second gist",
+			prompterStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a gist",
+					[]string{"cool.txt  about 6 hours ago", "gistfile0.txt  about 6 hours ago"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "gistfile0.txt  about 6 hours ago")
+					})
+			},
+			response: `{ "data": { "viewer": { "gists": { "nodes": [
+							{
+								"name": "gistid1",
+								"files": [{ "name": "cool.txt" }],
+								"description": "",
+								"updatedAt": "%[1]v",
+								"isPublic": true
+							},
+							{
+								"name": "gistid2",
+								"files": [{ "name": "gistfile0.txt" }],
+								"description": "",
+								"updatedAt": "%[1]v",
+								"isPublic": true
+							}
+						] } } } }`,
+			wantOut: "gistid2",
+		},
+		{
+			name:     "no files",
+			response: `{ "data": { "viewer": { "gists": { "nodes": [] } } } }`,
+			wantOut:  "",
+		},
+	}
+
+	ios, _, _, _ := iostreams.Test()
+
+	for _, tt := range tests {
+		reg := &httpmock.Registry{}
+
+		const query = `query GistList\b`
+		sixHours, _ := time.ParseDuration("6h")
+		sixHoursAgo := time.Now().Add(-sixHours)
+		reg.Register(
+			httpmock.GraphQL(query),
+			httpmock.StringResponse(fmt.Sprintf(
+				tt.response,
+				sixHoursAgo.Format(time.RFC3339),
+			)),
+		)
+		client := &http.Client{Transport: reg}
+
+		t.Run(tt.name, func(t *testing.T) {
+			mockPrompter := prompter.NewMockPrompter(t)
+			if tt.prompterStubs != nil {
+				tt.prompterStubs(mockPrompter)
+			}
+
+			gistID, err := PromptGists(mockPrompter, client, "github.com", ios.ColorScheme())
+			assert.NoError(t, err)
+			assert.Equal(t, tt.wantOut, gistID)
+			reg.Verify(t)
+		})
+	}
+}
diff --git a/pkg/cmd/gist/view/view_test.go b/pkg/cmd/gist/view/view_test.go
index 8dbc9a748..c571422c5 100644
--- a/pkg/cmd/gist/view/view_test.go
+++ b/pkg/cmd/gist/view/view_test.go
@@ -8,11 +8,11 @@ import (
 	"time"
 
 	"github.com/cli/cli/v2/internal/config"
+	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/pkg/cmd/gist/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/cli/cli/v2/pkg/prompt"
 	"github.com/google/shlex"
 	"github.com/stretchr/testify/assert"
 )
@@ -336,6 +336,10 @@ func Test_viewRun(t *testing.T) {
 				httpmock.JSONResponse(tt.gist))
 		}
 
+		if tt.opts == nil {
+			tt.opts = &ViewOptions{}
+		}
+
 		if tt.mockGistList {
 			sixHours, _ := time.ParseDuration("6h")
 			sixHoursAgo := time.Now().Add(-sixHours)
@@ -355,13 +359,11 @@ func Test_viewRun(t *testing.T) {
 				)),
 			)
 
-			//nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock
-			as := prompt.NewAskStubber(t)
-			as.StubPrompt("Select a gist").AnswerDefault()
-		}
-
-		if tt.opts == nil {
-			tt.opts = &ViewOptions{}
+			pm := prompter.NewMockPrompter(t)
+			pm.RegisterSelect("Select a gist", []string{"cool.txt  about 6 hours ago"}, func(_, _ string, opts []string) (int, error) {
+				return 0, nil
+			})
+			tt.opts.Prompter = pm
 		}
 
 		tt.opts.HttpClient = func() (*http.Client, error) {
@@ -389,97 +391,3 @@ func Test_viewRun(t *testing.T) {
 		})
 	}
 }
-
-func Test_promptGists(t *testing.T) {
-	tests := []struct {
-		name     string
-		askStubs func(as *prompt.AskStubber)
-		response string
-		wantOut  string
-		gist     *shared.Gist
-		wantErr  bool
-	}{
-		{
-			name: "multiple files, select first gist",
-			askStubs: func(as *prompt.AskStubber) {
-				as.StubPrompt("Select a gist").AnswerWith("cool.txt  about 6 hours ago")
-			},
-			response: `{ "data": { "viewer": { "gists": { "nodes": [
-							{
-								"name": "gistid1",
-								"files": [{ "name": "cool.txt" }],
-								"description": "",
-								"updatedAt": "%[1]v",
-								"isPublic": true
-							},
-							{
-								"name": "gistid2",
-								"files": [{ "name": "gistfile0.txt" }],
-								"description": "",
-								"updatedAt": "%[1]v",
-								"isPublic": true
-							}
-						] } } } }`,
-			wantOut: "gistid1",
-		},
-		{
-			name: "multiple files, select second gist",
-			askStubs: func(as *prompt.AskStubber) {
-				as.StubPrompt("Select a gist").AnswerWith("gistfile0.txt  about 6 hours ago")
-			},
-			response: `{ "data": { "viewer": { "gists": { "nodes": [
-							{
-								"name": "gistid1",
-								"files": [{ "name": "cool.txt" }],
-								"description": "",
-								"updatedAt": "%[1]v",
-								"isPublic": true
-							},
-							{
-								"name": "gistid2",
-								"files": [{ "name": "gistfile0.txt" }],
-								"description": "",
-								"updatedAt": "%[1]v",
-								"isPublic": true
-							}
-						] } } } }`,
-			wantOut: "gistid2",
-		},
-		{
-			name:     "no files",
-			response: `{ "data": { "viewer": { "gists": { "nodes": [] } } } }`,
-			wantOut:  "",
-		},
-	}
-
-	ios, _, _, _ := iostreams.Test()
-
-	for _, tt := range tests {
-		reg := &httpmock.Registry{}
-
-		const query = `query GistList\b`
-		sixHours, _ := time.ParseDuration("6h")
-		sixHoursAgo := time.Now().Add(-sixHours)
-		reg.Register(
-			httpmock.GraphQL(query),
-			httpmock.StringResponse(fmt.Sprintf(
-				tt.response,
-				sixHoursAgo.Format(time.RFC3339),
-			)),
-		)
-		client := &http.Client{Transport: reg}
-
-		t.Run(tt.name, func(t *testing.T) {
-			//nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock
-			as := prompt.NewAskStubber(t)
-			if tt.askStubs != nil {
-				tt.askStubs(as)
-			}
-
-			gistID, err := promptGists(client, "github.com", ios.ColorScheme())
-			assert.NoError(t, err)
-			assert.Equal(t, tt.wantOut, gistID)
-			reg.Verify(t)
-		})
-	}
-}