Merge pull request #4451 from cli/repo-delete

gh repo delete
This commit is contained in:
Nate Smith 2021-10-19 11:13:33 -05:00 committed by GitHub
commit 5a3b9ceab0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 313 additions and 0 deletions

View file

@ -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 <repository>",
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
}

View file

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

View file

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

View file

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