Repository: ankitpokhrel/jira-cli
Pull request #336 (2022-03-24 04:16:35)

feat: Ability to delete issue
Resolves #331 


Patch:
diff --git a/README.md b/README.md
index 4d47d98..4af3de0 100644
--- a/README.md
+++ b/README.md
@@ -420,6 +420,20 @@ $ jira issue clone ISSUE-1 -s"Modified summary" -yHigh -a$(jira me)
 $ jira issue clone ISSUE-1 -H"find me:replace with me"
 ```
 
+#### Delete
+The `delete` command lets you delete an issue.
+
+```sh
+# Delete an issue using interactive prompt
+$ jira issue delete
+
+# Pass required parameters to skip prompt
+$ jira issue delete ISSUE-1
+
+# Delete task along with all of its subtasks
+$ jira issue delete ISSUE-1 --cascade
+```
+
 #### Comment
 The `comment` command provides a list of sub-commands to manage issue comments.
 
diff --git a/internal/cmd/issue/delete/delete.go b/internal/cmd/issue/delete/delete.go
new file mode 100644
index 0000000..a0da770
--- /dev/null
+++ b/internal/cmd/issue/delete/delete.go
@@ -0,0 +1,117 @@
+package delete
+
+import (
+	"fmt"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+
+	"github.com/ankitpokhrel/jira-cli/api"
+	"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
+	"github.com/ankitpokhrel/jira-cli/internal/query"
+	"github.com/ankitpokhrel/jira-cli/pkg/jira"
+)
+
+const (
+	helpText = `Delete deletes an issue. To delete a task with subtasks, use '--cascade' flag.`
+	examples = `$ jira issue delete ISSUE-1
+
+# Delete task along with all of its subtasks
+$ jira issue delete ISSUE-1 --cascade`
+)
+
+// NewCmdDelete is a delete command.
+func NewCmdDelete() *cobra.Command {
+	cmd := cobra.Command{
+		Use:     "delete ISSUE-KEY",
+		Short:   "Delete an issue",
+		Long:    helpText,
+		Example: examples,
+		Aliases: []string{"remove", "rm", "del"},
+		Annotations: map[string]string{
+			"help:args": `ISSUE-KEY	Issue key, eg: ISSUE-1`,
+		},
+		Run: del,
+	}
+
+	cmd.Flags().Bool("cascade", false, "Delete issue along with its subtasks")
+
+	return &cmd
+}
+
+func del(cmd *cobra.Command, args []string) {
+	project := viper.GetString("project.key")
+	params := parseArgsAndFlags(cmd.Flags(), args, project)
+	client := api.Client(jira.Config{Debug: params.debug})
+	mc := deleteCmd{
+		client:      client,
+		transitions: nil,
+		params:      params,
+	}
+
+	cmdutil.ExitIfError(mc.setIssueKey(project))
+
+	err := func() error {
+		s := cmdutil.Info(fmt.Sprintf("Removing issue \"%s\"", mc.params.key))
+		defer s.Stop()
+
+		return client.DeleteIssue(mc.params.key, mc.params.cascade)
+	}()
+	cmdutil.ExitIfError(err)
+
+	cmdutil.Success(fmt.Sprintf("Issue \"%s\" removed successfully", mc.params.key))
+}
+
+type deleteParams struct {
+	key     string
+	cascade bool
+	debug   bool
+}
+
+func parseArgsAndFlags(flags query.FlagParser, args []string, project string) *deleteParams {
+	var key string
+
+	nargs := len(args)
+	if nargs >= 1 {
+		key = cmdutil.GetJiraIssueKey(project, args[0])
+	}
+
+	cascade, err := flags.GetBool("cascade")
+	cmdutil.ExitIfError(err)
+
+	debug, err := flags.GetBool("debug")
+	cmdutil.ExitIfError(err)
+
+	return &deleteParams{
+		key:     key,
+		cascade: cascade,
+		debug:   debug,
+	}
+}
+
+type deleteCmd struct {
+	client      *jira.Client
+	transitions []*jira.Transition
+	params      *deleteParams
+}
+
+func (mc *deleteCmd) setIssueKey(project string) error {
+	if mc.params.key != "" {
+		return nil
+	}
+
+	var ans string
+
+	qs := &survey.Question{
+		Name:     "key",
+		Prompt:   &survey.Input{Message: "Issue key"},
+		Validate: survey.Required,
+	}
+	if err := survey.Ask([]*survey.Question{qs}, &ans); err != nil {
+		return err
+	}
+	mc.params.key = cmdutil.GetJiraIssueKey(project, ans)
+
+	return nil
+}
diff --git a/internal/cmd/issue/issue.go b/internal/cmd/issue/issue.go
index c2e7eee..84ce320 100644
--- a/internal/cmd/issue/issue.go
+++ b/internal/cmd/issue/issue.go
@@ -7,6 +7,7 @@ import (
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/clone"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/comment"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/create"
+	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/delete"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/edit"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/link"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/list"
@@ -32,7 +33,7 @@ func NewCmdIssue() *cobra.Command {
 
 	cmd.AddCommand(
 		lc, cc, edit.NewCmdEdit(), move.NewCmdMove(), view.NewCmdView(), assign.NewCmdAssign(),
-		link.NewCmdLink(), comment.NewCmdComment(), clone.NewCmdClone(),
+		link.NewCmdLink(), comment.NewCmdComment(), clone.NewCmdClone(), delete.NewCmdDelete(),
 	)
 
 	list.SetFlags(lc)
diff --git a/pkg/jira/client.go b/pkg/jira/client.go
index edf0bc0..43a97e8 100644
--- a/pkg/jira/client.go
+++ b/pkg/jira/client.go
@@ -215,6 +215,11 @@ func (c *Client) PutV2(ctx context.Context, path string, body []byte, headers He
 	return res, err
 }
 
+// DeleteV2 sends DELETE request to v2 version of the jira api.
+func (c *Client) DeleteV2(ctx context.Context, path string, headers Header) (*http.Response, error) {
+	return c.request(ctx, http.MethodDelete, c.server+baseURLv2+path, nil, headers)
+}
+
 func (c *Client) request(ctx context.Context, method, endpoint string, body []byte, headers Header) (*http.Response, error) {
 	var (
 		req *http.Request
diff --git a/pkg/jira/delete.go b/pkg/jira/delete.go
new file mode 100644
index 0000000..d84ccf7
--- /dev/null
+++ b/pkg/jira/delete.go
@@ -0,0 +1,29 @@
+package jira
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+)
+
+// DeleteIssue deletes an issue using /issue/{key} endpoint.
+func (c *Client) DeleteIssue(key string, cascade bool) error {
+	path := fmt.Sprintf("/issue/%s", key)
+	if cascade {
+		path = fmt.Sprintf("%s?deleteSubtasks=true", path)
+	}
+
+	res, err := c.DeleteV2(context.Background(), path, nil)
+	if err != nil {
+		return err
+	}
+	if res == nil {
+		return ErrEmptyResponse
+	}
+	defer func() { _ = res.Body.Close() }()
+
+	if res.StatusCode != http.StatusNoContent {
+		return formatUnexpectedResponse(res)
+	}
+	return nil
+}


Test patch:
diff --git a/pkg/jira/client_test.go b/pkg/jira/client_test.go
index 8036cdc..cc5abcd 100644
--- a/pkg/jira/client_test.go
+++ b/pkg/jira/client_test.go
@@ -187,3 +187,23 @@ func TestPutV2(t *testing.T) {
 
 	_ = resp.Body.Close()
 }
+
+func TestDeleteV2(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "/rest/api/2/issue/TEST-1", r.URL.Path)
+		assert.Equal(t, "jira-cli", r.Header.Get("X-Requested-By"))
+
+		w.WriteHeader(204)
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second))
+	resp, err := client.DeleteV2(context.Background(), "/issue/TEST-1", Header{
+		"X-Requested-By": "jira-cli",
+	})
+
+	assert.NoError(t, err)
+	assert.Equal(t, 204, resp.StatusCode)
+
+	_ = resp.Body.Close()
+}
diff --git a/pkg/jira/delete_test.go b/pkg/jira/delete_test.go
new file mode 100644
index 0000000..cc98659
--- /dev/null
+++ b/pkg/jira/delete_test.go
@@ -0,0 +1,48 @@
+package jira
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestDeleteIssue(t *testing.T) {
+	var unexpectedStatusCode bool
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if unexpectedStatusCode {
+			assert.Equal(t, "/rest/api/2/issue/BAD", r.URL.RequestURI())
+			w.WriteHeader(400)
+		} else {
+			assert.Equal(t, "/rest/api/2/issue/TEST-1", r.URL.RequestURI())
+			w.WriteHeader(204)
+		}
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second))
+
+	err := client.DeleteIssue("TEST-1", false)
+	assert.NoError(t, err)
+
+	unexpectedStatusCode = true
+
+	err = client.DeleteIssue("BAD", false)
+	assert.Error(t, &ErrUnexpectedResponse{}, err)
+}
+
+func TestDeleteIssueWithCascade(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "/rest/api/2/issue/TEST-1?deleteSubtasks=true", r.URL.RequestURI())
+		w.WriteHeader(204)
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second))
+
+	err := client.DeleteIssue("TEST-1", true)
+	assert.NoError(t, err)
+}