diff --git a/pkg/cmd/repo/delete/delete.go b/pkg/cmd/repo/delete/delete.go new file mode 100644 index 000000000..9dda14e2d --- /dev/null +++ b/pkg/cmd/repo/delete/delete.go @@ -0,0 +1,113 @@ +package delete + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/prompt" + + "github.com/AlecAivazis/survey/v2" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type DeleteOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + RepoArg string + Confirmed bool +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a repository", + Long: `Delete a GitHub repository. + +Deletion requires authorization with the "delete_repo" scope. +To authorize, run "gh auth refresh -s delete_repo"`, + Args: cmdutil.ExactArgs(1, "cannot delete: repository argument required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.RepoArg = args[0] + if !opts.IO.CanPrompt() && !opts.Confirmed { + return &cmdutil.FlagError{ + Err: errors.New("could not prompt: confirmation with prompt or --confirm flag required")} + } + if runF != nil { + return runF(opts) + } + return deleteRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.Confirmed, "confirm", false, "confirm deletion without prompting") + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + repoSelector := opts.RepoArg + var toDelete ghrepo.Interface + + if !strings.Contains(repoSelector, "/") { + currentUser, err := api.CurrentLoginName(apiClient, ghinstance.Default()) + if err != nil { + return err + } + repoSelector = currentUser + "/" + repoSelector + } + toDelete, err = ghrepo.FromFullName(repoSelector) + if err != nil { + return fmt.Errorf("argument error: %w", err) + } + + fullName := ghrepo.FullName(toDelete) + + if !opts.Confirmed { + var valid string + err := prompt.SurveyAskOne( + &survey.Input{Message: fmt.Sprintf("Type %s to confirm deletion:", fullName)}, + &valid, + survey.WithValidator( + func(val interface{}) error { + if str := val.(string); !strings.EqualFold(str, fullName) { + return fmt.Errorf("You entered %s", str) + } + return nil + })) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + } + + err = deleteRepo(httpClient, toDelete) + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, + "%s Deleted repository %s\n", + cs.SuccessIcon(), + fullName) + } + + return nil +} diff --git a/pkg/cmd/repo/delete/delete_test.go b/pkg/cmd/repo/delete/delete_test.go new file mode 100644 index 000000000..d561a3269 --- /dev/null +++ b/pkg/cmd/repo/delete/delete_test.go @@ -0,0 +1,165 @@ +package delete + +import ( + "bytes" + "net/http" + "testing" + + "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" +) + +func TestNewCmdDelete(t *testing.T) { + tests := []struct { + name string + input string + tty bool + output DeleteOptions + wantErr bool + errMsg string + }{ + { + name: "confirm flag", + input: "OWNER/REPO --confirm", + output: DeleteOptions{RepoArg: "OWNER/REPO", Confirmed: true}, + }, + { + name: "no confirmation no tty", + input: "OWNER/REPO", + output: DeleteOptions{RepoArg: "OWNER/REPO"}, + wantErr: true, + errMsg: "could not prompt: confirmation with prompt or --confirm flag required"}, + { + name: "no argument", + input: "", + wantErr: true, + errMsg: "cannot delete: repository argument required", + tty: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + f := &cmdutil.Factory{ + IOStreams: io, + } + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + var gotOpts *DeleteOptions + cmd := NewCmdDelete(f, func(opts *DeleteOptions) 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.Error(t, err) + assert.Equal(t, tt.errMsg, err.Error()) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.output.RepoArg, gotOpts.RepoArg) + }) + } +} + +func Test_deleteRun(t *testing.T) { + tests := []struct { + name string + tty bool + opts *DeleteOptions + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + wantStdout string + wantErr bool + errMsg string + }{ + { + name: "prompting confirmation tty", + tty: true, + opts: &DeleteOptions{RepoArg: "OWNER/REPO"}, + wantStdout: "✓ Deleted repository OWNER/REPO\n", + askStubs: func(q *prompt.AskStubber) { + // TODO: survey stubber doesn't have WithValidator support + // so this always passes regardless of prompt input + q.StubOne("OWNER/REPO") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO"), + httpmock.StatusStringResponse(204, "{}")) + }, + }, + { + name: "confimation no tty", + opts: &DeleteOptions{ + RepoArg: "OWNER/REPO", + Confirmed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO"), + httpmock.StatusStringResponse(204, "{}")) + }, + }, + { + name: "short repo name", + opts: &DeleteOptions{RepoArg: "REPO"}, + wantStdout: "✓ Deleted repository OWNER/REPO\n", + tty: true, + askStubs: func(q *prompt.AskStubber) { + q.StubOne("OWNER/REPO") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"OWNER"}}}`)) + reg.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO"), + httpmock.StatusStringResponse(204, "{}")) + }, + }, + } + for _, tt := range tests { + q, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(q) + } + + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + io, _, stdout, _ := iostreams.Test() + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + tt.opts.IO = io + + t.Run(tt.name, func(t *testing.T) { + defer reg.Verify(t) + err := deleteRun(tt.opts) + if tt.wantErr { + assert.Error(t, err) + assert.Equal(t, tt.errMsg, err.Error()) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantStdout, stdout.String()) + }) + } +} diff --git a/pkg/cmd/repo/delete/http.go b/pkg/cmd/repo/delete/http.go new file mode 100644 index 000000000..bd77328ac --- /dev/null +++ b/pkg/cmd/repo/delete/http.go @@ -0,0 +1,33 @@ +package delete + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" +) + +func deleteRepo(client *http.Client, repo ghrepo.Interface) error { + url := fmt.Sprintf("%srepos/%s", + ghinstance.RESTPrefix(repo.RepoHost()), + ghrepo.FullName(repo)) + + request, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + + resp, err := client.Do(request) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + + return nil +} diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index 11c36b012..5b2f467d9 100644 --- a/pkg/cmd/repo/repo.go +++ b/pkg/cmd/repo/repo.go @@ -6,6 +6,7 @@ import ( repoCloneCmd "github.com/cli/cli/v2/pkg/cmd/repo/clone" repoCreateCmd "github.com/cli/cli/v2/pkg/cmd/repo/create" creditsCmd "github.com/cli/cli/v2/pkg/cmd/repo/credits" + repoDeleteCmd "github.com/cli/cli/v2/pkg/cmd/repo/delete" repoForkCmd "github.com/cli/cli/v2/pkg/cmd/repo/fork" gardenCmd "github.com/cli/cli/v2/pkg/cmd/repo/garden" repoListCmd "github.com/cli/cli/v2/pkg/cmd/repo/list" @@ -43,6 +44,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(repoSyncCmd.NewCmdSync(f, nil)) cmd.AddCommand(creditsCmd.NewCmdRepoCredits(f, nil)) cmd.AddCommand(gardenCmd.NewCmdGarden(f, nil)) + cmd.AddCommand(repoDeleteCmd.NewCmdDelete(f, nil)) cmd.AddCommand(repoArchiveCmd.NewCmdArchive(f, nil)) return cmd