diff --git a/pkg/cmd/gist/list/http.go b/pkg/cmd/gist/list/http.go deleted file mode 100644 index 435f19d4d..000000000 --- a/pkg/cmd/gist/list/http.go +++ /dev/null @@ -1,88 +0,0 @@ -package list - -import ( - "context" - "net/http" - "strings" - "time" - - "github.com/cli/cli/internal/ghinstance" - "github.com/cli/cli/pkg/cmd/gist/shared" - "github.com/shurcooL/githubv4" - "github.com/shurcooL/graphql" -) - -func listGists(client *http.Client, hostname string, limit int, visibility string) ([]shared.Gist, error) { - type response struct { - Viewer struct { - Gists struct { - Nodes []struct { - Description string - Files []struct { - Name string - } - IsPublic bool - Name string - UpdatedAt time.Time - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"gists(first: $per_page, after: $endCursor, privacy: $visibility, orderBy: {field: CREATED_AT, direction: DESC})"` - } - } - - perPage := limit - if perPage > 100 { - perPage = 100 - } - - variables := map[string]interface{}{ - "per_page": githubv4.Int(perPage), - "endCursor": (*githubv4.String)(nil), - "visibility": githubv4.GistPrivacy(strings.ToUpper(visibility)), - } - - gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) - - gists := []shared.Gist{} -pagination: - for { - var result response - err := gql.QueryNamed(context.Background(), "GistList", &result, variables) - if err != nil { - return nil, err - } - - for _, gist := range result.Viewer.Gists.Nodes { - files := map[string]*shared.GistFile{} - for _, file := range gist.Files { - files[file.Name] = &shared.GistFile{ - Filename: file.Name, - } - } - - gists = append( - gists, - shared.Gist{ - ID: gist.Name, - Description: gist.Description, - Files: files, - UpdatedAt: gist.UpdatedAt, - Public: gist.IsPublic, - }, - ) - if len(gists) == limit { - break pagination - } - } - - if !result.Viewer.Gists.PageInfo.HasNextPage { - break - } - variables["endCursor"] = githubv4.String(result.Viewer.Gists.PageInfo.EndCursor) - } - - return gists, nil -} diff --git a/pkg/cmd/gist/list/list.go b/pkg/cmd/gist/list/list.go index 375c08372..abe407827 100644 --- a/pkg/cmd/gist/list/list.go +++ b/pkg/cmd/gist/list/list.go @@ -7,6 +7,7 @@ import ( "time" "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/cmd/gist/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/text" @@ -67,7 +68,7 @@ func listRun(opts *ListOptions) error { return err } - gists, err := listGists(client, ghinstance.OverridableDefault(), opts.Limit, opts.Visibility) + gists, err := shared.ListGists(client, ghinstance.OverridableDefault(), opts.Limit, opts.Visibility) if err != nil { return err } diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go index 04fe2c33b..56bb3bb20 100644 --- a/pkg/cmd/gist/shared/shared.go +++ b/pkg/cmd/gist/shared/shared.go @@ -1,6 +1,7 @@ package shared import ( + "context" "errors" "fmt" "net/http" @@ -8,6 +9,10 @@ import ( "strings" "time" + "github.com/cli/cli/internal/ghinstance" + "github.com/shurcooL/githubv4" + "github.com/shurcooL/graphql" + "github.com/cli/cli/api" ) @@ -67,3 +72,78 @@ func GistIDFromURL(gistURL string) (string, error) { return "", fmt.Errorf("Invalid gist URL %s", u) } + +func ListGists(client *http.Client, hostname string, limit int, visibility string) ([]Gist, error) { + type response struct { + Viewer struct { + Gists struct { + Nodes []struct { + Description string + Files []struct { + Name string + } + IsPublic bool + Name string + UpdatedAt time.Time + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"gists(first: $per_page, after: $endCursor, privacy: $visibility, orderBy: {field: CREATED_AT, direction: DESC})"` + } + } + + perPage := limit + if perPage > 100 { + perPage = 100 + } + + variables := map[string]interface{}{ + "per_page": githubv4.Int(perPage), + "endCursor": (*githubv4.String)(nil), + "visibility": githubv4.GistPrivacy(strings.ToUpper(visibility)), + } + + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) + + gists := []Gist{} +pagination: + for { + var result response + err := gql.QueryNamed(context.Background(), "GistList", &result, variables) + if err != nil { + return nil, err + } + + for _, gist := range result.Viewer.Gists.Nodes { + files := map[string]*GistFile{} + for _, file := range gist.Files { + files[file.Name] = &GistFile{ + Filename: file.Name, + } + } + + gists = append( + gists, + Gist{ + ID: gist.Name, + Description: gist.Description, + Files: files, + UpdatedAt: gist.UpdatedAt, + Public: gist.IsPublic, + }, + ) + if len(gists) == limit { + break pagination + } + } + + if !result.Viewer.Gists.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(result.Viewer.Gists.PageInfo.EndCursor) + } + + return gists, nil +} diff --git a/pkg/cmd/gist/view/view.go b/pkg/cmd/gist/view/view.go index e1e9408cf..176d565ef 100644 --- a/pkg/cmd/gist/view/view.go +++ b/pkg/cmd/gist/view/view.go @@ -5,12 +5,16 @@ import ( "net/http" "sort" "strings" + "time" + "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/pkg/cmd/gist/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/markdown" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/pkg/text" "github.com/cli/cli/utils" "github.com/spf13/cobra" ) @@ -33,11 +37,14 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } cmd := &cobra.Command{ - Use: "view { | }", + Use: "view [ | ]", Short: "View a gist", - Args: cmdutil.ExactArgs(1, "cannot view: gist argument required"), + Long: `View the given gist or select from recent gists.`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.Selector = args[0] + if len(args) == 1 { + opts.Selector = args[0] + } if !opts.IO.IsStdoutTTY() { opts.Raw = true @@ -60,6 +67,23 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman func viewRun(opts *ViewOptions) error { gistID := opts.Selector + client, err := opts.HttpClient() + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + if gistID == "" { + gistID, err = promptGists(client, cs) + if err != nil { + return err + } + + if gistID == "" { + fmt.Fprintln(opts.IO.Out, "No gists found.") + return nil + } + } if opts.Web { gistURL := gistID @@ -81,11 +105,6 @@ func viewRun(opts *ViewOptions) error { gistID = id } - client, err := opts.HttpClient() - if err != nil { - return err - } - gist, err := shared.GetGist(client, ghinstance.OverridableDefault(), gistID) if err != nil { return err @@ -126,8 +145,6 @@ func viewRun(opts *ViewOptions) error { return render(gistFile) } - cs := opts.IO.ColorScheme() - if gist.Description != "" && !opts.ListFiles { fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Bold(gist.Description)) } @@ -160,3 +177,54 @@ func viewRun(opts *ViewOptions) error { return nil } + +func promptGists(client *http.Client, cs *iostreams.ColorScheme) (gistID string, err error) { + gists, err := shared.ListGists(client, ghinstance.OverridableDefault(), 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 := utils.FuzzyAgo(time.Since(gist.UpdatedAt)) + // TODO: support dynamic maxWidth + description = text.Truncate(100, text.ReplaceExcessiveWhitespace(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, + } + + err = prompt.SurveyAskOne(questions, &result) + + if err != nil { + return "", err + } + + return gistIDs[result], nil +} diff --git a/pkg/cmd/gist/view/view_test.go b/pkg/cmd/gist/view/view_test.go index 23571e837..87e12c9f2 100644 --- a/pkg/cmd/gist/view/view_test.go +++ b/pkg/cmd/gist/view/view_test.go @@ -2,13 +2,16 @@ package view import ( "bytes" + "fmt" "net/http" "testing" + "time" "github.com/cli/cli/pkg/cmd/gist/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) @@ -60,6 +63,16 @@ func TestNewCmdView(t *testing.T) { ListFiles: true, }, }, + { + name: "tty no ID supplied", + cli: "", + tty: true, + wants: ViewOptions{ + Raw: false, + Selector: "", + ListFiles: true, + }, + }, } for _, tt := range tests { @@ -96,11 +109,12 @@ func TestNewCmdView(t *testing.T) { func Test_viewRun(t *testing.T) { tests := []struct { - name string - opts *ViewOptions - wantOut string - gist *shared.Gist - wantErr bool + name string + opts *ViewOptions + wantOut string + gist *shared.Gist + wantErr bool + mockGistList bool }{ { name: "no such gist", @@ -126,6 +140,23 @@ func Test_viewRun(t *testing.T) { }, wantOut: "bwhiizzzbwhuiiizzzz\n", }, + { + name: "one file, no ID supplied", + opts: &ViewOptions{ + Selector: "", + ListFiles: false, + }, + mockGistList: true, + gist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Content: "test interactive mode", + Type: "text/plain", + }, + }, + }, + wantOut: "test interactive mode\n", + }, { name: "filename selected", opts: &ViewOptions{ @@ -304,6 +335,30 @@ func Test_viewRun(t *testing.T) { httpmock.JSONResponse(tt.gist)) } + if tt.mockGistList { + sixHours, _ := time.ParseDuration("6h") + sixHoursAgo := time.Now().Add(-sixHours) + reg.Register( + httpmock.GraphQL(`query GistList\b`), + httpmock.StringResponse(fmt.Sprintf( + `{ "data": { "viewer": { "gists": { "nodes": [ + { + "name": "1234", + "files": [{ "name": "cool.txt" }], + "description": "", + "updatedAt": "%s", + "isPublic": true + } + ] } } } }`, + sixHoursAgo.Format(time.RFC3339), + )), + ) + + as, surveyteardown := prompt.InitAskStubber() + defer surveyteardown() + as.StubOne(0) + } + if tt.opts == nil { tt.opts = &ViewOptions{} } @@ -328,3 +383,92 @@ func Test_viewRun(t *testing.T) { }) } } + +func Test_promptGists(t *testing.T) { + tests := []struct { + name string + gistIndex int + response string + wantOut string + gist *shared.Gist + wantErr bool + }{ + { + name: "multiple files, select first gist", + gistIndex: 0, + 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", + gistIndex: 1, + 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: "", + }, + } + + io, _, _, _ := iostreams.Test() + cs := iostreams.NewColorScheme(io.ColorEnabled(), io.ColorSupport256()) + + 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} + + as, surveyteardown := prompt.InitAskStubber() + defer surveyteardown() + as.StubOne(tt.gistIndex) + + t.Run(tt.name, func(t *testing.T) { + gistID, err := promptGists(client, cs) + assert.NoError(t, err) + assert.Equal(t, tt.wantOut, gistID) + reg.Verify(t) + }) + } +}