diff --git a/pkg/cmd/gist/gist.go b/pkg/cmd/gist/gist.go index f637a0ffb..1f7fc9ea0 100644 --- a/pkg/cmd/gist/gist.go +++ b/pkg/cmd/gist/gist.go @@ -7,6 +7,7 @@ import ( gistDeleteCmd "github.com/cli/cli/v2/pkg/cmd/gist/delete" gistEditCmd "github.com/cli/cli/v2/pkg/cmd/gist/edit" gistListCmd "github.com/cli/cli/v2/pkg/cmd/gist/list" + gistRenameCmd "github.com/cli/cli/v2/pkg/cmd/gist/rename" gistViewCmd "github.com/cli/cli/v2/pkg/cmd/gist/view" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -33,6 +34,7 @@ func NewCmdGist(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(gistViewCmd.NewCmdView(f, nil)) cmd.AddCommand(gistEditCmd.NewCmdEdit(f, nil)) cmd.AddCommand(gistDeleteCmd.NewCmdDelete(f, nil)) + cmd.AddCommand(gistRenameCmd.NewCmdRename(f, nil)) return cmd } diff --git a/pkg/cmd/gist/rename/rename.go b/pkg/cmd/gist/rename/rename.go new file mode 100644 index 000000000..c23aca579 --- /dev/null +++ b/pkg/cmd/gist/rename/rename.go @@ -0,0 +1,140 @@ +package rename + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/config" + "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/spf13/cobra" +) + +type RenameOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + HttpClient func() (*http.Client, error) + + Selector string + OldFileName string + NewFileName string +} + +func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Command { + opts := &RenameOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "rename { | } ", + Short: "Rename a file in a gist", + Long: heredoc.Doc(`Rename a file in the given gist ID / URL.`), + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Selector = args[0] + opts.OldFileName = args[1] + opts.NewFileName = args[2] + + if runf != nil { + return runf(opts) + } + + return renameRun(opts) + }, + } + + return cmd +} + +func renameRun(opts *RenameOptions) 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 + } + + host, _ := cfg.Authentication().DefaultHost() + + gist, err := shared.GetGist(client, host, gistID) + if err != nil { + if errors.Is(err, shared.NotFoundErr) { + return fmt.Errorf("gist not found: %s", gistID) + } + return err + } + username, err := api.CurrentLoginName(apiClient, host) + if err != nil { + return err + } + + if username != gist.Owner.Login { + return errors.New("you do not own this gist") + } + + _, ok := gist.Files[opts.OldFileName] + if !ok { + return fmt.Errorf("File %s not found in gist", opts.OldFileName) + } + + _, ok = gist.Files[opts.NewFileName] + if ok { + return fmt.Errorf("File %s already exists in gist", opts.NewFileName) + } + + gist.Files[opts.NewFileName] = gist.Files[opts.OldFileName] + gist.Files[opts.NewFileName].Filename = opts.NewFileName + gist.Files[opts.OldFileName] = &shared.GistFile{} + + return updateGist(apiClient, host, gist) +} + +func updateGist(apiClient *api.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{} + + err = apiClient.REST(hostname, "POST", path, requestBody, &result) + + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/gist/rename/rename_test.go b/pkg/cmd/gist/rename/rename_test.go new file mode 100644 index 000000000..5413d87c9 --- /dev/null +++ b/pkg/cmd/gist/rename/rename_test.go @@ -0,0 +1,185 @@ +package rename + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/config" + "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/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdRename(t *testing.T) { + tests := []struct { + name string + input string + output RenameOptions + errMsg string + tty bool + wantErr bool + }{ + { + name: "no arguments", + input: "", + errMsg: "accepts 3 arg(s), received 0", + wantErr: true, + }, + { + name: "missing old filename and new filename", + input: "123", + errMsg: "accepts 3 arg(s), received 1", + wantErr: true, + }, + { + name: "missing new filename", + input: "123 old.txt", + errMsg: "accepts 3 arg(s), received 2", + wantErr: true, + }, + { + name: "rename", + input: "123 old.txt new.txt", + output: RenameOptions{ + Selector: "123", + OldFileName: "old.txt", + NewFileName: "new.txt", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + var gotOpts *RenameOptions + cmd := NewCmdRename(f, func(opts *RenameOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.output.Selector, gotOpts.Selector) + assert.Equal(t, tt.output.OldFileName, gotOpts.OldFileName) + assert.Equal(t, tt.output.NewFileName, gotOpts.NewFileName) + }) + } +} + +func TestRenameRun(t *testing.T) { + tests := []struct { + name string + opts *RenameOptions + gist *shared.Gist + httpStubs func(*httpmock.Registry) + nontty bool + stdin string + wantOut string + wantParams map[string]interface{} + }{ + { + name: "no such gist", + wantOut: "gist not found: 1234", + }, + { + name: "rename from old.txt to new.txt", + opts: &RenameOptions{ + Selector: "1234", + OldFileName: "old.txt", + NewFileName: "new.txt", + }, + gist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "old.txt": { + Filename: "old.txt", + Type: "text/plain", + }, + }, + Owner: &shared.GistOwner{Login: "octocat"}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "gists/1234"), + httpmock.StatusStringResponse(201, "{}")) + }, + wantParams: map[string]interface{}{ + "files": map[string]interface{}{ + "new.txt": map[string]interface{}{ + "filename": "new.txt", + "type": "text/plain", + }, + }, + }, + }, + } + + 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)) + reg.Register(httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) + } + + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + + if tt.opts == nil { + tt.opts = &RenameOptions{} + } + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + ios, stdin, stdout, stderr := iostreams.Test() + stdin.WriteString(tt.stdin) + ios.SetStdoutTTY(!tt.nontty) + ios.SetStdinTTY(!tt.nontty) + + tt.opts.Selector = "1234" + tt.opts.OldFileName = "old.txt" + tt.opts.NewFileName = "new.txt" + tt.opts.IO = ios + + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + + t.Run(tt.name, func(t *testing.T) { + err := renameRun(tt.opts) + reg.Verify(t) + if tt.wantOut != "" { + assert.EqualError(t, err, tt.wantOut) + return + } + assert.NoError(t, err) + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) + }) + } +}