From 57d5470df912ccf743fa6b88d1cf19fe66ebcd5d Mon Sep 17 00:00:00 2001 From: Kevin Beaulieu Date: Sun, 24 Jan 2021 15:08:19 -0800 Subject: [PATCH] Add `issue delete` command Similar to `issue close`, but for deleting an issue rather than just closing it. Resolves cli/cli#2820. --- api/queries_issue.go | 21 +++++ pkg/cmd/issue/delete/delete.go | 76 +++++++++++++++++ pkg/cmd/issue/delete/delete_test.go | 125 ++++++++++++++++++++++++++++ pkg/cmd/issue/issue.go | 2 + 4 files changed, 224 insertions(+) create mode 100644 pkg/cmd/issue/delete/delete.go create mode 100644 pkg/cmd/issue/delete/delete_test.go diff --git a/api/queries_issue.go b/api/queries_issue.go index dca859831..7cfad883a 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -465,6 +465,27 @@ func IssueReopen(client *Client, repo ghrepo.Interface, issue Issue) error { return err } +func IssueDelete(client *Client, repo ghrepo.Interface, issue Issue) error { + var mutation struct { + DeleteIssue struct { + Repository struct { + ID githubv4.ID + } + } `graphql:"deleteIssue(input: $input)"` + } + + variables := map[string]interface{}{ + "input": githubv4.DeleteIssueInput{ + IssueID: issue.ID, + }, + } + + gql := graphQLClient(client.http, repo.RepoHost()) + err := gql.MutateNamed(context.Background(), "IssueDelete", &mutation, variables) + + return err +} + // milestoneNodeIdToDatabaseId extracts the REST Database ID from the GraphQL Node ID // This conversion is necessary since the GraphQL API requires the use of the milestone's database ID // for querying the related issues. diff --git a/pkg/cmd/issue/delete/delete.go b/pkg/cmd/issue/delete/delete.go new file mode 100644 index 000000000..9b02b70d9 --- /dev/null +++ b/pkg/cmd/issue/delete/delete.go @@ -0,0 +1,76 @@ +package delete + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/issue/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type DeleteOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SelectorArg string +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "delete { | }", + Short: "Delete issue", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return deleteRun(opts) + }, + } + + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + cs := opts.IO.ColorScheme() + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) + if err != nil { + return err + } + + err = api.IssueDelete(apiClient, baseRepo, *issue) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Deleted issue #%d (%s)\n", cs.Red("✔"), issue.Number, issue.Title) + + return nil +} diff --git a/pkg/cmd/issue/delete/delete_test.go b/pkg/cmd/issue/delete/delete_test.go new file mode 100644 index 000000000..f656ae545 --- /dev/null +++ b/pkg/cmd/issue/delete/delete_test.go @@ -0,0 +1,125 @@ +package delete + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdDelete(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestIssueDelete(t *testing.T) { + httpRegistry := &httpmock.Registry{} + defer httpRegistry.Verify(t) + + httpRegistry.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + httpRegistry.Register( + httpmock.GraphQL(`mutation IssueDelete\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["issueId"], "THE-ID") + }), + ) + + output, err := runCommand(httpRegistry, true, "13") + if err != nil { + t.Fatalf("error running command `issue delete`: %v", err) + } + + r := regexp.MustCompile(`Deleted issue #13 \(The title of the issue\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssueDelete_doesNotExist(t *testing.T) { + httpRegistry := &httpmock.Registry{} + defer httpRegistry.Verify(t) + + httpRegistry.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "errors": [ + { "message": "Could not resolve to an Issue with the number of 13." } + ] } + `), + ) + + _, err := runCommand(httpRegistry, true, "13") + if err == nil || err.Error() != "GraphQL error: Could not resolve to an Issue with the number of 13." { + t.Errorf("error running command `issue delete`: %v", err) + } +} + +func TestIssueDelete_issuesDisabled(t *testing.T) { + httpRegistry := &httpmock.Registry{} + defer httpRegistry.Verify(t) + + httpRegistry.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } }`), + ) + + _, err := runCommand(httpRegistry, true, "13") + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Fatalf("got error: %v", err) + } +} diff --git a/pkg/cmd/issue/issue.go b/pkg/cmd/issue/issue.go index 12463b480..24b96fb1b 100644 --- a/pkg/cmd/issue/issue.go +++ b/pkg/cmd/issue/issue.go @@ -5,6 +5,7 @@ import ( cmdClose "github.com/cli/cli/pkg/cmd/issue/close" cmdComment "github.com/cli/cli/pkg/cmd/issue/comment" cmdCreate "github.com/cli/cli/pkg/cmd/issue/create" + cmdDelete "github.com/cli/cli/pkg/cmd/issue/delete" cmdList "github.com/cli/cli/pkg/cmd/issue/list" cmdReopen "github.com/cli/cli/pkg/cmd/issue/reopen" cmdStatus "github.com/cli/cli/pkg/cmd/issue/status" @@ -42,6 +43,7 @@ func NewCmdIssue(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil)) cmd.AddCommand(cmdComment.NewCmdComment(f, nil)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) return cmd }