Merge pull request #7537 from kousikmitra/feature/edit-gist-selector

Feature: Add gist selector option in gist edit command
This commit is contained in:
Nate Smith 2023-06-06 15:39:35 -07:00 committed by GitHub
commit 8b48fbc892
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 244 additions and 213 deletions

View file

@ -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,7 +87,33 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
}
func editRun(opts *EditOptions) error {
client, err := opts.HttpClient()
if err != nil {
return err
}
cfg, err := opts.Config()
if err != nil {
return err
}
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)
@ -95,20 +123,8 @@ func editRun(opts *EditOptions) error {
gistID = id
}
client, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(client)
cfg, err := opts.Config()
if err != nil {
return err
}
host, _ := cfg.Authentication().DefaultHost()
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

View file

@ -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)

View file

@ -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
}

View file

@ -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)
})
}
}

View file

@ -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
}

View file

@ -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)
})
}
}