diff --git a/README.md b/README.md index e3aa83aa4..c5a14ddc2 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ We'd love to hear your feedback about `gh`. If you spot bugs or have features th - `gh pr [status, list, view, checkout, create]` - `gh issue [status, list, view, create]` - `gh repo [view, create, clone, fork]` +- `gh gist [create, list, view, edit]` - `gh auth [login, logout, refresh, status]` - `gh config [get, set]` - `gh help` diff --git a/internal/ghinstance/host.go b/internal/ghinstance/host.go index 560cc9c5c..642dd0846 100644 --- a/internal/ghinstance/host.go +++ b/internal/ghinstance/host.go @@ -55,3 +55,10 @@ func RESTPrefix(hostname string) string { } return "https://api.github.com/" } + +func GistPrefix(hostname string) string { + if IsEnterprise(hostname) { + return fmt.Sprintf("https://%s/gist/", hostname) + } + return fmt.Sprintf("https://gist.%s/", hostname) +} diff --git a/pkg/cmd/alias/list/list.go b/pkg/cmd/alias/list/list.go index b9d67c2ae..a8576db5c 100644 --- a/pkg/cmd/alias/list/list.go +++ b/pkg/cmd/alias/list/list.go @@ -69,12 +69,7 @@ func listRun(opts *ListOptions) error { sort.Strings(keys) for _, alias := range keys { - if tp.IsTTY() { - // ensure that screen readers pause - tp.AddField(alias+":", nil, nil) - } else { - tp.AddField(alias, nil, nil) - } + tp.AddField(alias+":", nil, nil) tp.AddField(aliasMap[alias], nil, nil) tp.EndRow() } diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index e2d6975b8..8024cfc24 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -23,9 +23,10 @@ import ( type CreateOptions struct { IO *iostreams.IOStreams - Description string - Public bool - Filenames []string + Description string + Public bool + Filenames []string + FilenameOverride string HttpClient func() (*http.Client, error) } @@ -84,6 +85,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringVarP(&opts.Description, "desc", "d", "", "A description for this gist") cmd.Flags().BoolVarP(&opts.Public, "public", "p", false, "List the gist publicly (default: private)") + cmd.Flags().StringVarP(&opts.FilenameOverride, "filename", "f", "", "Provide a filename to be used when reading from STDIN") return cmd } @@ -93,7 +95,7 @@ func createRun(opts *CreateOptions) error { fileArgs = []string{"-"} } - files, err := processFiles(opts.IO.In, fileArgs) + files, err := processFiles(opts.IO.In, opts.FilenameOverride, fileArgs) if err != nil { return fmt.Errorf("failed to collect files for posting: %w", err) } @@ -137,7 +139,7 @@ func createRun(opts *CreateOptions) error { return nil } -func processFiles(stdin io.ReadCloser, filenames []string) (map[string]string, error) { +func processFiles(stdin io.ReadCloser, filenameOverride string, filenames []string) (map[string]string, error) { fs := map[string]string{} if len(filenames) == 0 { @@ -149,7 +151,11 @@ func processFiles(stdin io.ReadCloser, filenames []string) (map[string]string, e var content []byte var err error if f == "-" { - filename = fmt.Sprintf("gistfile%d.txt", i) + if filenameOverride != "" { + filename = filenameOverride + } else { + filename = fmt.Sprintf("gistfile%d.txt", i) + } content, err = ioutil.ReadAll(stdin) if err != nil { return fs, fmt.Errorf("failed to read from stdin: %w", err) diff --git a/pkg/cmd/gist/create/create_test.go b/pkg/cmd/gist/create/create_test.go index bd57cfa2e..043b6f2d1 100644 --- a/pkg/cmd/gist/create/create_test.go +++ b/pkg/cmd/gist/create/create_test.go @@ -21,7 +21,7 @@ const ( func Test_processFiles(t *testing.T) { fakeStdin := strings.NewReader("hey cool how is it going") - files, err := processFiles(ioutil.NopCloser(fakeStdin), []string{"-"}) + files, err := processFiles(ioutil.NopCloser(fakeStdin), "", []string{"-"}) if err != nil { t.Fatalf("unexpected error processing files: %s", err) } diff --git a/pkg/cmd/gist/edit/edit.go b/pkg/cmd/gist/edit/edit.go new file mode 100644 index 000000000..554e9363d --- /dev/null +++ b/pkg/cmd/gist/edit/edit.go @@ -0,0 +1,210 @@ +package edit + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "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/prompt" + "github.com/cli/cli/pkg/surveyext" + "github.com/spf13/cobra" +) + +type EditOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + + Edit func(string, string, string, *iostreams.IOStreams) (string, error) + + Selector string + Filename string +} + +func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command { + opts := EditOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Edit: func(editorCmd, filename, defaultContent string, io *iostreams.IOStreams) (string, error) { + return surveyext.Edit( + editorCmd, + "*."+filename, + defaultContent, + io.In, io.Out, io.ErrOut, nil) + }, + } + + cmd := &cobra.Command{ + Use: "edit { | }", + Short: "Edit one of your gists", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + opts.Selector = args[0] + + if runF != nil { + return runF(&opts) + } + + return editRun(&opts) + }, + } + cmd.Flags().StringVarP(&opts.Filename, "filename", "f", "", "a specific file to edit") + + return cmd +} + +func editRun(opts *EditOptions) error { + gistID := opts.Selector + + u, err := url.Parse(opts.Selector) + if err == nil { + if strings.HasPrefix(u.Path, "/") { + gistID = u.Path[1:] + } + } + + client, err := opts.HttpClient() + if err != nil { + return err + } + + gist, err := shared.GetGist(client, ghinstance.OverridableDefault(), gistID) + if err != nil { + return err + } + + filesToUpdate := map[string]string{} + + for { + filename := opts.Filename + candidates := []string{} + for filename := range gist.Files { + candidates = append(candidates, filename) + } + + sort.Strings(candidates) + + if filename == "" { + if len(candidates) == 1 { + filename = candidates[0] + } else { + if !opts.IO.CanPrompt() { + return errors.New("unsure what file to edit; either specify --filename or run interactively") + } + err = prompt.SurveyAskOne(&survey.Select{ + Message: "Edit which file?", + Options: candidates, + }, &filename) + + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + } + } + + if _, ok := gist.Files[filename]; !ok { + return fmt.Errorf("gist has no file %q", filename) + } + + editorCommand, err := cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + text, err := opts.Edit(editorCommand, filename, gist.Files[filename].Content, opts.IO) + + if err != nil { + return err + } + + if text != gist.Files[filename].Content { + gistFile := gist.Files[filename] + gistFile.Content = text // so it appears if they re-edit + filesToUpdate[filename] = text + } + + if !opts.IO.CanPrompt() { + break + } + + if len(candidates) == 1 { + break + } + + choice := "" + + err = prompt.SurveyAskOne(&survey.Select{ + Message: "What next?", + Options: []string{ + "Edit another file", + "Submit", + "Cancel", + }, + }, &choice) + + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + stop := false + + switch choice { + case "Edit another file": + continue + case "Submit": + stop = true + case "Cancel": + return cmdutil.SilentError + } + + if stop { + break + } + } + + err = updateGist(client, ghinstance.OverridableDefault(), gist) + if err != nil { + return err + } + + return nil +} + +func updateGist(client *http.Client, hostname string, gist *shared.Gist) error { + body := shared.Gist{ + Description: gist.Description, + Files: gist.Files, + } + + path := "gists/" + gist.ID + + requestByte, err := json.Marshal(body) + if err != nil { + return err + } + + requestBody := bytes.NewReader(requestByte) + + result := shared.Gist{} + + apiClient := api.NewClientFromHTTP(client) + err = apiClient.REST(hostname, "POST", path, requestBody, &result) + + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/gist/edit/edit_test.go b/pkg/cmd/gist/edit/edit_test.go new file mode 100644 index 000000000..d7fffd958 --- /dev/null +++ b/pkg/cmd/gist/edit/edit_test.go @@ -0,0 +1,244 @@ +package edit + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "github.com/cli/cli/internal/config" + "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" +) + +func TestNewCmdEdit(t *testing.T) { + tests := []struct { + name string + cli string + wants EditOptions + }{ + { + name: "no flags", + cli: "123", + wants: EditOptions{ + Selector: "123", + }, + }, + { + name: "filename", + cli: "123 --filename cool.md", + wants: EditOptions{ + Selector: "123", + Filename: "cool.md", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *EditOptions + cmd := NewCmdEdit(f, func(opts *EditOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Filename, gotOpts.Filename) + assert.Equal(t, tt.wants.Selector, gotOpts.Selector) + }) + } +} + +func Test_editRun(t *testing.T) { + tests := []struct { + name string + opts *EditOptions + gist *shared.Gist + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + nontty bool + wantErr bool + wantParams map[string]interface{} + }{ + { + name: "no such gist", + wantErr: true, + }, + { + name: "one file", + gist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Filename: "cicada.txt", + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + }, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "gists/1234"), + httpmock.StatusStringResponse(201, "{}")) + }, + wantParams: map[string]interface{}{ + "description": "", + "updated_at": "0001-01-01T00:00:00Z", + "public": false, + "files": map[string]interface{}{ + "cicada.txt": map[string]interface{}{ + "content": "new file content", + "filename": "cicada.txt", + "type": "text/plain", + }, + }, + }, + }, + { + name: "multiple files, submit", + askStubs: func(as *prompt.AskStubber) { + as.StubOne("unix.md") + as.StubOne("Submit") + }, + gist: &shared.Gist{ + ID: "1234", + Description: "catbug", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Filename: "cicada.txt", + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + "unix.md": { + Filename: "unix.md", + Content: "meow", + Type: "application/markdown", + }, + }, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "gists/1234"), + httpmock.StatusStringResponse(201, "{}")) + }, + wantParams: map[string]interface{}{ + "description": "catbug", + "updated_at": "0001-01-01T00:00:00Z", + "public": false, + "files": map[string]interface{}{ + "cicada.txt": map[string]interface{}{ + "content": "bwhiizzzbwhuiiizzzz", + "filename": "cicada.txt", + "type": "text/plain", + }, + "unix.md": map[string]interface{}{ + "content": "new file content", + "filename": "unix.md", + "type": "application/markdown", + }, + }, + }, + }, + { + name: "multiple files, cancel", + askStubs: func(as *prompt.AskStubber) { + as.StubOne("unix.md") + as.StubOne("Cancel") + }, + wantErr: true, + gist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Filename: "cicada.txt", + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + "unix.md": { + Filename: "unix.md", + Content: "meow", + Type: "application/markdown", + }, + }, + }, + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + if tt.gist == nil { + reg.Register(httpmock.REST("GET", "gists/1234"), + httpmock.StatusStringResponse(404, "Not Found")) + } else { + reg.Register(httpmock.REST("GET", "gists/1234"), + httpmock.JSONResponse(tt.gist)) + } + + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + if tt.opts == nil { + tt.opts = &EditOptions{} + } + + tt.opts.Edit = func(_, _, _ string, _ *iostreams.IOStreams) (string, error) { + return "new file content", nil + } + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(!tt.nontty) + io.SetStdinTTY(!tt.nontty) + tt.opts.IO = io + + tt.opts.Selector = "1234" + + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + + t.Run(tt.name, func(t *testing.T) { + err := editRun(tt.opts) + reg.Verify(t) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + if tt.wantParams != nil { + bodyBytes, _ := ioutil.ReadAll(reg.Requests[1].Body) + reqBody := make(map[string]interface{}) + err = json.Unmarshal(bodyBytes, &reqBody) + if err != nil { + t.Fatalf("error decoding JSON: %v", err) + } + assert.Equal(t, tt.wantParams, reqBody) + } + }) + } +} diff --git a/pkg/cmd/gist/gist.go b/pkg/cmd/gist/gist.go index ea10a8c40..029fab588 100644 --- a/pkg/cmd/gist/gist.go +++ b/pkg/cmd/gist/gist.go @@ -1,7 +1,11 @@ package gist import ( + "github.com/MakeNowJust/heredoc" gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create" + gistEditCmd "github.com/cli/cli/pkg/cmd/gist/edit" + gistListCmd "github.com/cli/cli/pkg/cmd/gist/list" + gistViewCmd "github.com/cli/cli/pkg/cmd/gist/view" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -11,9 +15,20 @@ func NewCmdGist(f *cmdutil.Factory) *cobra.Command { Use: "gist", Short: "Create gists", Long: `Work with GitHub gists.`, + Annotations: map[string]string{ + "IsCore": "true", + "help:arguments": heredoc.Doc(` + A gist can be supplied as argument in either of the following formats: + - by ID, e.g. 5b0e0062eb8e9654adad7bb1d81cc75f + - by URL, e.g. "https://gist.github.com/OWNER/5b0e0062eb8e9654adad7bb1d81cc75f" + `), + }, } cmd.AddCommand(gistCreateCmd.NewCmdCreate(f, nil)) + cmd.AddCommand(gistListCmd.NewCmdList(f, nil)) + cmd.AddCommand(gistViewCmd.NewCmdView(f, nil)) + cmd.AddCommand(gistEditCmd.NewCmdEdit(f, nil)) return cmd } diff --git a/pkg/cmd/gist/list/http.go b/pkg/cmd/gist/list/http.go new file mode 100644 index 000000000..aa75eef25 --- /dev/null +++ b/pkg/cmd/gist/list/http.go @@ -0,0 +1,46 @@ +package list + +import ( + "fmt" + "net/http" + "net/url" + "sort" + + "github.com/cli/cli/api" + "github.com/cli/cli/pkg/cmd/gist/shared" +) + +func listGists(client *http.Client, hostname string, limit int, visibility string) ([]shared.Gist, error) { + result := []shared.Gist{} + + query := url.Values{} + if visibility == "all" { + query.Add("per_page", fmt.Sprintf("%d", limit)) + } else { + query.Add("per_page", "100") + } + + // TODO switch to graphql + apiClient := api.NewClientFromHTTP(client) + err := apiClient.REST(hostname, "GET", "gists?"+query.Encode(), nil, &result) + if err != nil { + return nil, err + } + + gists := []shared.Gist{} + + for _, gist := range result { + if len(gists) == limit { + break + } + if visibility == "all" || (visibility == "secret" && !gist.Public) || (visibility == "public" && gist.Public) { + gists = append(gists, gist) + } + } + + sort.SliceStable(gists, func(i, j int) bool { + return gists[i].UpdatedAt.After(gists[j].UpdatedAt) + }) + + return gists, nil +} diff --git a/pkg/cmd/gist/list/list.go b/pkg/cmd/gist/list/list.go new file mode 100644 index 000000000..e1142bdb7 --- /dev/null +++ b/pkg/cmd/gist/list/list.go @@ -0,0 +1,116 @@ +package list + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + + Limit int + Visibility string // all, secret, public +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List your gists", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.Limit < 1 { + return &cmdutil.FlagError{Err: fmt.Errorf("invalid limit: %v", opts.Limit)} + } + + pub := cmd.Flags().Changed("public") + secret := cmd.Flags().Changed("secret") + + opts.Visibility = "all" + if pub && !secret { + opts.Visibility = "public" + } else if secret && !pub { + opts.Visibility = "secret" + } + + if runF != nil { + return runF(opts) + } + + return listRun(opts) + }, + } + + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 10, "Maximum number of gists to fetch") + cmd.Flags().Bool("public", false, "Show only public gists") + cmd.Flags().Bool("secret", false, "Show only secret gists") + + return cmd +} + +func listRun(opts *ListOptions) error { + client, err := opts.HttpClient() + if err != nil { + return err + } + + gists, err := listGists(client, ghinstance.OverridableDefault(), opts.Limit, opts.Visibility) + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + + tp := utils.NewTablePrinter(opts.IO) + + for _, gist := range gists { + fileCount := 0 + for range gist.Files { + fileCount++ + } + + visibility := "public" + visColor := cs.Green + if !gist.Public { + visibility = "secret" + visColor = cs.Red + } + + description := gist.Description + if description == "" { + for filename := range gist.Files { + if !strings.HasPrefix(filename, "gistfile") { + description = filename + break + } + } + } + + tp.AddField(gist.ID, nil, nil) + tp.AddField(description, nil, cs.Bold) + tp.AddField(utils.Pluralize(fileCount, "file"), nil, nil) + tp.AddField(visibility, nil, visColor) + if tp.IsTTY() { + updatedAt := utils.FuzzyAgo(time.Since(gist.UpdatedAt)) + tp.AddField(updatedAt, nil, cs.Gray) + } else { + tp.AddField(gist.UpdatedAt.String(), nil, nil) + } + tp.EndRow() + } + + return tp.Render() +} diff --git a/pkg/cmd/gist/list/list_test.go b/pkg/cmd/gist/list/list_test.go new file mode 100644 index 000000000..497f68edc --- /dev/null +++ b/pkg/cmd/gist/list/list_test.go @@ -0,0 +1,224 @@ +package list + +import ( + "bytes" + "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/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wants ListOptions + }{ + { + name: "no arguments", + wants: ListOptions{ + Limit: 10, + Visibility: "all", + }, + }, + { + name: "public", + cli: "--public", + wants: ListOptions{ + Limit: 10, + Visibility: "public", + }, + }, + { + name: "secret", + cli: "--secret", + wants: ListOptions{ + Limit: 10, + Visibility: "secret", + }, + }, + { + name: "public and secret", + cli: "--secret --public", + wants: ListOptions{ + Limit: 10, + Visibility: "all", + }, + }, + { + name: "limit", + cli: "--limit 30", + wants: ListOptions{ + Limit: 30, + Visibility: "all", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility) + assert.Equal(t, tt.wants.Limit, gotOpts.Limit) + }) + } +} + +func Test_listRun(t *testing.T) { + tests := []struct { + name string + opts *ListOptions + wantOut string + stubs func(*httpmock.Registry) + nontty bool + updatedAt *time.Time + }{ + { + name: "no gists", + opts: &ListOptions{}, + stubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "gists"), + httpmock.JSONResponse([]shared.Gist{})) + + }, + wantOut: "", + }, + { + name: "default behavior", + opts: &ListOptions{}, + wantOut: "1234567890 cool.txt 1 file public about 6 hours ago\n4567890123 1 file public about 6 hours ago\n2345678901 tea leaves thwart... 2 files secret about 6 hours ago\n3456789012 short desc 11 files secret about 6 hours ago\n", + }, + { + name: "with public filter", + opts: &ListOptions{Visibility: "public"}, + wantOut: "1234567890 cool.txt 1 file public about 6 hours ago\n4567890123 1 file public about 6 hours ago\n", + }, + { + name: "with secret filter", + opts: &ListOptions{Visibility: "secret"}, + wantOut: "2345678901 tea leaves thwart... 2 files secret about 6 hours ago\n3456789012 short desc 11 files secret about 6 hours ago\n", + }, + { + name: "with limit", + opts: &ListOptions{Limit: 1}, + wantOut: "1234567890 cool.txt 1 file public about 6 hours ago\n", + }, + { + name: "nontty output", + opts: &ListOptions{}, + updatedAt: &time.Time{}, + wantOut: "1234567890\tcool.txt\t1 file\tpublic\t0001-01-01 00:00:00 +0000 UTC\n4567890123\t\t1 file\tpublic\t0001-01-01 00:00:00 +0000 UTC\n2345678901\ttea leaves thwart those who court catastrophe\t2 files\tsecret\t0001-01-01 00:00:00 +0000 UTC\n3456789012\tshort desc\t11 files\tsecret\t0001-01-01 00:00:00 +0000 UTC\n", + nontty: true, + }, + } + + for _, tt := range tests { + sixHoursAgo, _ := time.ParseDuration("-6h") + updatedAt := time.Now().Add(sixHoursAgo) + if tt.updatedAt != nil { + updatedAt = *tt.updatedAt + } + + reg := &httpmock.Registry{} + if tt.stubs == nil { + reg.Register(httpmock.REST("GET", "gists"), + httpmock.JSONResponse([]shared.Gist{ + { + ID: "1234567890", + UpdatedAt: updatedAt, + Description: "", + Files: map[string]*shared.GistFile{ + "cool.txt": {}, + }, + Public: true, + }, + { + ID: "4567890123", + UpdatedAt: updatedAt, + Description: "", + Files: map[string]*shared.GistFile{ + "gistfile0.txt": {}, + }, + Public: true, + }, + { + ID: "2345678901", + UpdatedAt: updatedAt, + Description: "tea leaves thwart those who court catastrophe", + Files: map[string]*shared.GistFile{ + "gistfile0.txt": {}, + "gistfile1.txt": {}, + }, + Public: false, + }, + { + ID: "3456789012", + UpdatedAt: updatedAt, + Description: "short desc", + Files: map[string]*shared.GistFile{ + "gistfile0.txt": {}, + "gistfile1.txt": {}, + "gistfile2.txt": {}, + "gistfile3.txt": {}, + "gistfile4.txt": {}, + "gistfile5.txt": {}, + "gistfile6.txt": {}, + "gistfile7.txt": {}, + "gistfile8.txt": {}, + "gistfile9.txt": {}, + "gistfile10.txt": {}, + }, + Public: false, + }, + })) + } else { + tt.stubs(reg) + } + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(!tt.nontty) + tt.opts.IO = io + + if tt.opts.Limit == 0 { + tt.opts.Limit = 10 + } + + if tt.opts.Visibility == "" { + tt.opts.Visibility = "all" + } + t.Run(tt.name, func(t *testing.T) { + err := listRun(tt.opts) + assert.NoError(t, err) + + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go new file mode 100644 index 000000000..95d8bae31 --- /dev/null +++ b/pkg/cmd/gist/shared/shared.go @@ -0,0 +1,39 @@ +package shared + +import ( + "fmt" + "net/http" + "time" + + "github.com/cli/cli/api" +) + +// TODO make gist create use this file + +type GistFile struct { + Filename string `json:"filename"` + Type string `json:"type,omitempty"` + Language string `json:"language,omitempty"` + Content string `json:"content"` +} + +type Gist struct { + ID string `json:"id,omitempty"` + Description string `json:"description"` + Files map[string]*GistFile `json:"files"` + UpdatedAt time.Time `json:"updated_at"` + Public bool `json:"public"` +} + +func GetGist(client *http.Client, hostname, gistID string) (*Gist, error) { + gist := Gist{} + path := fmt.Sprintf("gists/%s", gistID) + + apiClient := api.NewClientFromHTTP(client) + err := apiClient.REST(hostname, "GET", path, nil, &gist) + if err != nil { + return nil, err + } + + return &gist, nil +} diff --git a/pkg/cmd/gist/view/view.go b/pkg/cmd/gist/view/view.go new file mode 100644 index 000000000..fecb69adb --- /dev/null +++ b/pkg/cmd/gist/view/view.go @@ -0,0 +1,135 @@ +package view + +import ( + "fmt" + "net/http" + "net/url" + "sort" + "strings" + + "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/utils" + "github.com/spf13/cobra" +) + +type ViewOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + + Selector string + Filename string + Raw bool + Web bool +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "view { | }", + Short: "View a gist", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Selector = args[0] + + if !opts.IO.IsStdoutTTY() { + opts.Raw = true + } + + if runF != nil { + return runF(opts) + } + return viewRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.Raw, "raw", "r", false, "do not try and render markdown") + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "open gist in browser") + cmd.Flags().StringVarP(&opts.Filename, "filename", "f", "", "display a single file of the gist") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + gistID := opts.Selector + + if opts.Web { + gistURL := gistID + if !strings.Contains(gistURL, "/") { + hostname := ghinstance.OverridableDefault() + gistURL = ghinstance.GistPrefix(hostname) + gistID + } + if opts.IO.IsStderrTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(gistURL)) + } + return utils.OpenInBrowser(gistURL) + } + + u, err := url.Parse(opts.Selector) + if err == nil { + if strings.HasPrefix(u.Path, "/") { + gistID = u.Path[1:] + } + } + + client, err := opts.HttpClient() + if err != nil { + return err + } + + gist, err := shared.GetGist(client, ghinstance.OverridableDefault(), gistID) + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + if gist.Description != "" { + fmt.Fprintf(opts.IO.Out, "%s\n", cs.Bold(gist.Description)) + } + + if opts.Filename != "" { + gistFile, ok := gist.Files[opts.Filename] + if !ok { + return fmt.Errorf("gist has no such file %q", opts.Filename) + } + + gist.Files = map[string]*shared.GistFile{ + opts.Filename: gistFile, + } + } + + showFilenames := len(gist.Files) > 1 + + outs := []string{} // to ensure consistent ordering + + for filename, gistFile := range gist.Files { + out := "" + if showFilenames { + out += fmt.Sprintf("%s\n\n", cs.Gray(filename)) + } + content := gistFile.Content + if strings.Contains(gistFile.Type, "markdown") && !opts.Raw { + rendered, err := utils.RenderMarkdown(gistFile.Content) + if err == nil { + content = rendered + } + } + out += fmt.Sprintf("%s\n\n", content) + + outs = append(outs, out) + } + + sort.Strings(outs) + + for _, out := range outs { + fmt.Fprint(opts.IO.Out, out) + } + + return nil +} diff --git a/pkg/cmd/gist/view/view_test.go b/pkg/cmd/gist/view/view_test.go new file mode 100644 index 000000000..0ddc33181 --- /dev/null +++ b/pkg/cmd/gist/view/view_test.go @@ -0,0 +1,217 @@ +package view + +import ( + "bytes" + "net/http" + "testing" + + "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/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdView(t *testing.T) { + tests := []struct { + name string + cli string + wants ViewOptions + tty bool + }{ + { + name: "tty no arguments", + tty: true, + cli: "123", + wants: ViewOptions{ + Raw: false, + Selector: "123", + }, + }, + { + name: "nontty no arguments", + cli: "123", + wants: ViewOptions{ + Raw: true, + Selector: "123", + }, + }, + { + name: "filename passed", + cli: "-fcool.txt 123", + tty: true, + wants: ViewOptions{ + Raw: false, + Selector: "123", + Filename: "cool.txt", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(tt.tty) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *ViewOptions + cmd := NewCmdView(f, func(opts *ViewOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Raw, gotOpts.Raw) + assert.Equal(t, tt.wants.Selector, gotOpts.Selector) + assert.Equal(t, tt.wants.Filename, gotOpts.Filename) + }) + } +} + +func Test_viewRun(t *testing.T) { + tests := []struct { + name string + opts *ViewOptions + wantOut string + gist *shared.Gist + wantErr bool + }{ + { + name: "no such gist", + wantErr: true, + }, + { + name: "one file", + gist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + }, + }, + wantOut: "bwhiizzzbwhuiiizzzz\n\n", + }, + { + name: "filename selected", + opts: &ViewOptions{ + Filename: "cicada.txt", + }, + gist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + "foo.md": { + Content: "# foo", + Type: "application/markdown", + }, + }, + }, + wantOut: "bwhiizzzbwhuiiizzzz\n\n", + }, + { + name: "multiple files, no description", + gist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + "foo.md": { + Content: "# foo", + Type: "application/markdown", + }, + }, + }, + wantOut: "cicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n # foo \n\n\n\n", + }, + { + name: "multiple files, description", + gist: &shared.Gist{ + Description: "some files", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + "foo.md": { + Content: "- foo", + Type: "application/markdown", + }, + }, + }, + wantOut: "some files\ncicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n \n • foo \n\n\n\n", + }, + { + name: "raw", + opts: &ViewOptions{ + Raw: true, + }, + gist: &shared.Gist{ + Description: "some files", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + "foo.md": { + Content: "- foo", + Type: "application/markdown", + }, + }, + }, + wantOut: "some files\ncicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n- foo\n\n", + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + if tt.gist == nil { + reg.Register(httpmock.REST("GET", "gists/1234"), + httpmock.StatusStringResponse(404, "Not Found")) + } else { + reg.Register(httpmock.REST("GET", "gists/1234"), + httpmock.JSONResponse(tt.gist)) + } + + if tt.opts == nil { + tt.opts = &ViewOptions{} + } + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(true) + tt.opts.IO = io + + tt.opts.Selector = "1234" + + t.Run(tt.name, func(t *testing.T) { + err := viewRun(tt.opts) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +}