Repository: ankitpokhrel/jira-cli
Pull request #104 (2021-06-01 03:19:00)

feat: Add cmd to clone issue
The `clone` command lets you clone an issue. You can update fields like summary, priority, assignee, labels, and
components when cloning the issue. The command also allows you to replace a part of the string (case-sensitive)
in summary and description using `--replace/-H` option.

```sh
# Clone an issue
$ jira issue clone ISSUE-1

# Clone issue and modify the summary, priority and assignee
$ jira issue clone ISSUE-1 -s"Modified summary" -yHigh -a$(jira me)

# Clone issue and replace text from summary and description
$ jira issue clone ISSUE-1 -H"find me:replace with me"
```




Patch:
diff --git a/README.md b/README.md
index 75ef073..1899cd8 100644
--- a/README.md
+++ b/README.md
@@ -275,6 +275,22 @@ $ jira issue link
 $ jira issue link ISSUE-1 ISSUE-2 Blocks
 ```
 
+#### Clone
+The `clone` command lets you clone an issue. You can update fields like summary, priority, assignee, labels, and
+components when cloning the issue. The command also allows you to replace a part of the string (case-sensitive)
+in summary and description using `--replace/-H` option.
+
+```sh
+# Clone an issue
+$ jira issue clone ISSUE-1
+
+# Clone issue and modify the summary, priority and assignee
+$ jira issue clone ISSUE-1 -s"Modified summary" -yHigh -a$(jira me)
+
+# Clone issue and replace text from summary and description
+$ jira issue clone ISSUE-1 -H"find me:replace with me"
+```
+
 #### Comment
 The `comment` command provides a list of sub-commands to manage issue comments.
 
diff --git a/internal/cmd/issue/clone/clone.go b/internal/cmd/issue/clone/clone.go
new file mode 100644
index 0000000..50d31c8
--- /dev/null
+++ b/internal/cmd/issue/clone/clone.go
@@ -0,0 +1,258 @@
+package clone
+
+import (
+	"fmt"
+	"os"
+	"strings"
+	"sync"
+
+	"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 = `Clone duplicates an issue and also allow you to override some of the metadata when doing so.`
+	examples = `$ jira issue clone ISSUE-1
+
+# Clone issue and modify the summary, priority and assignee
+$ jira issue clone ISSUE-1 -s"Modified summary" -yHigh -a$(jira me)
+
+# Clone issue and replace text from summary and description
+$ jira issue clone ISSUE-1 -H"find me:replace with me"`
+)
+
+// NewCmdClone is a clone command.
+func NewCmdClone() *cobra.Command {
+	cmd := cobra.Command{
+		Use:     "clone ISSUE_KEY",
+		Short:   "Clone duplicates an issue",
+		Long:    helpText,
+		Example: examples,
+		Annotations: map[string]string{
+			"help:args": "ISSUE_KEY\tKey of the issue to clone, eg: ISSUE-1",
+		},
+		Args: cobra.MinimumNArgs(1),
+		Run:  clone,
+	}
+
+	setFlags(&cmd)
+
+	return &cmd
+}
+
+func clone(cmd *cobra.Command, args []string) {
+	server := viper.GetString("server")
+	project := viper.GetString("project")
+
+	params := parseFlags(cmd.Flags())
+	client := api.Client(jira.Config{Debug: params.debug})
+	cc := cloneCmd{
+		client: client,
+		params: params,
+	}
+
+	key := args[0]
+	issue := func() *jira.Issue {
+		s := cmdutil.Info("Fetching issue details...")
+		defer s.Stop()
+
+		issue, err := api.Client(jira.Config{Debug: cc.params.debug}).GetIssueV2(key)
+		cmdutil.ExitIfError(err)
+
+		return issue
+	}()
+
+	cp := cc.getActualCreateParams(issue)
+
+	clonedIssueKey := func() string {
+		s := cmdutil.Info(fmt.Sprintf("Cloning %s...", key))
+		defer s.Stop()
+
+		cr := jira.CreateRequest{
+			Project:    project,
+			IssueType:  issue.Fields.IssueType.Name,
+			Summary:    cp.summary,
+			Body:       cp.body,
+			Priority:   cp.priority,
+			Labels:     cp.labels,
+			Components: cp.components,
+		}
+
+		resp, err := client.Create(&cr)
+		cmdutil.ExitIfError(err)
+
+		return resp.Key
+	}()
+
+	fmt.Printf("\033[0;32m✓\033[0m Issue cloned\n%s/browse/%s\n", server, clonedIssueKey)
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+
+	go func() {
+		defer wg.Done()
+
+		if err := client.LinkIssue(key, clonedIssueKey, "Cloners"); err != nil {
+			cmdutil.Errorf("\n\033[0;31m✗\033[0m Unable to link cloned issue")
+		}
+	}()
+
+	if cp.assignee != "" {
+		wg.Add(1)
+
+		go func() {
+			defer wg.Done()
+
+			user, err := client.UserSearch(&jira.UserSearchOptions{
+				Query: cp.assignee,
+			})
+			if err != nil || len(user) == 0 {
+				cmdutil.Errorf("\n\033[0;31m✗\033[0m Unable to find assignee")
+			}
+			if err = client.AssignIssue(clonedIssueKey, user[0].AccountID); err != nil {
+				cmdutil.Errorf("\n\033[0;31m✗\033[0m Unable to set assignee: %s", err.Error())
+			}
+		}()
+	}
+
+	s := cmdutil.Info("Updating metadata...")
+	defer s.Stop()
+
+	if web, _ := cmd.Flags().GetBool("web"); web {
+		err := cmdutil.Navigate(server, clonedIssueKey)
+		cmdutil.ExitIfError(err)
+	}
+
+	wg.Wait()
+}
+
+type createParams struct {
+	summary    string
+	body       string
+	priority   string
+	assignee   string
+	labels     []string
+	components []string
+	replace    string
+}
+
+type cloneCmd struct {
+	client  *jira.Client
+	params  *cloneParams
+	cParams *createParams
+}
+
+func (cc *cloneCmd) getActualCreateParams(issue *jira.Issue) *createParams {
+	cp := createParams{}
+
+	cp.summary = issue.Fields.Summary
+	if cc.params.summary != "" {
+		cp.summary = cc.params.summary
+	}
+
+	cp.priority = issue.Fields.Priority.Name
+	if cc.params.priority != "" {
+		cp.priority = cc.params.priority
+	}
+
+	cp.assignee = issue.Fields.Assignee.Name
+	if cc.params.assignee != "" {
+		cp.assignee = cc.params.assignee
+	}
+
+	cp.labels = issue.Fields.Labels
+	if len(cc.params.labels) > 0 {
+		cp.labels = cc.params.labels
+	}
+
+	components := make([]string, 0, len(issue.Fields.Components))
+	for _, v := range issue.Fields.Components {
+		components = append(components, v.Name)
+	}
+	cp.components = components
+	if len(cc.params.components) > 0 {
+		cp.components = cc.params.components
+	}
+
+	var ok bool
+	if cp.body, ok = issue.Fields.Description.(string); !ok {
+		cp.body = ""
+	}
+
+	if cc.params.replace != "" {
+		pieces := strings.Split(cc.params.replace, ":")
+		if len(pieces) != 2 {
+			fmt.Fprintf(
+				os.Stderr,
+				"\u001B[0;31m✗\u001B[0m Invalid replace string, must be in format <find>:<replace>. Skipping replacement...",
+			)
+		} else {
+			from, to := pieces[0], pieces[1]
+
+			cp.summary = strings.ReplaceAll(cp.summary, from, to)
+			cp.body = strings.ReplaceAll(cp.body, from, to)
+		}
+	}
+
+	return &cp
+}
+
+type cloneParams struct {
+	summary    string
+	priority   string
+	assignee   string
+	labels     []string
+	components []string
+	replace    string
+	debug      bool
+}
+
+func parseFlags(flags query.FlagParser) *cloneParams {
+	summary, err := flags.GetString("summary")
+	cmdutil.ExitIfError(err)
+
+	priority, err := flags.GetString("priority")
+	cmdutil.ExitIfError(err)
+
+	assignee, err := flags.GetString("assignee")
+	cmdutil.ExitIfError(err)
+
+	labels, err := flags.GetStringArray("label")
+	cmdutil.ExitIfError(err)
+
+	components, err := flags.GetStringArray("component")
+	cmdutil.ExitIfError(err)
+
+	replace, err := flags.GetString("replace")
+	cmdutil.ExitIfError(err)
+
+	debug, err := flags.GetBool("debug")
+	cmdutil.ExitIfError(err)
+
+	return &cloneParams{
+		summary:    summary,
+		priority:   priority,
+		assignee:   assignee,
+		labels:     labels,
+		components: components,
+		replace:    replace,
+		debug:      debug,
+	}
+}
+
+func setFlags(cmd *cobra.Command) {
+	cmd.Flags().SortFlags = false
+
+	cmd.Flags().StringP("summary", "s", "", "Issue summary or title")
+	cmd.Flags().StringP("priority", "y", "", "Issue priority")
+	cmd.Flags().StringP("assignee", "a", "", "Issue assignee (email or display name)")
+	cmd.Flags().StringArrayP("label", "l", []string{}, "Issue labels")
+	cmd.Flags().StringArrayP("component", "C", []string{}, "Issue components")
+	cmd.Flags().StringP("replace", "H", "", "Replace strings in summary and body. Format <search>:<replace>, eg: \"find me:replace with me\"")
+	cmd.Flags().Bool("web", false, "Open in web browser after successful cloning")
+}
diff --git a/internal/cmd/issue/issue.go b/internal/cmd/issue/issue.go
index a54c90f..9e3c023 100644
--- a/internal/cmd/issue/issue.go
+++ b/internal/cmd/issue/issue.go
@@ -4,6 +4,7 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/assign"
+	"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/link"
@@ -30,7 +31,7 @@ func NewCmdIssue() *cobra.Command {
 
 	cmd.AddCommand(
 		lc, cc, move.NewCmdMove(), view.NewCmdView(), assign.NewCmdAssign(), link.NewCmdLink(),
-		comment.NewCmdComment(),
+		comment.NewCmdComment(), clone.NewCmdClone(),
 	)
 
 	list.SetFlags(lc)
diff --git a/pkg/jira/client.go b/pkg/jira/client.go
index caa7611..19fd52b 100644
--- a/pkg/jira/client.go
+++ b/pkg/jira/client.go
@@ -127,6 +127,11 @@ func (c *Client) Get(ctx context.Context, path string, headers Header) (*http.Re
 	return c.request(ctx, http.MethodGet, c.server+baseURLv3+path, nil, headers)
 }
 
+// GetV2 sends GET request to v2 version of the jira api.
+func (c *Client) GetV2(ctx context.Context, path string, headers Header) (*http.Response, error) {
+	return c.request(ctx, http.MethodGet, c.server+baseURLv2+path, nil, headers)
+}
+
 // GetV1 sends get request to v1 version of the jira api.
 func (c *Client) GetV1(ctx context.Context, path string, headers Header) (*http.Response, error) {
 	return c.request(ctx, http.MethodGet, c.server+baseURLv1+path, nil, headers)
diff --git a/pkg/jira/issue.go b/pkg/jira/issue.go
index 0c8ba99..9c16a90 100644
--- a/pkg/jira/issue.go
+++ b/pkg/jira/issue.go
@@ -47,6 +47,31 @@ func (c *Client) GetIssue(key string) (*Issue, error) {
 	return &out, nil
 }
 
+// GetIssueV2 fetches issue details using v2 version of Jira GET /issue/{key} endpoint.
+func (c *Client) GetIssueV2(key string) (*Issue, error) {
+	path := fmt.Sprintf("/issue/%s", key)
+
+	res, err := c.GetV2(context.Background(), path, nil)
+	if err != nil {
+		return nil, err
+	}
+	if res == nil {
+		return nil, ErrEmptyResponse
+	}
+	defer func() { _ = res.Body.Close() }()
+
+	if res.StatusCode != http.StatusOK {
+		return nil, formatUnexpectedResponse(res)
+	}
+
+	var out Issue
+	if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
+		return nil, err
+	}
+
+	return &out, nil
+}
+
 // AssignIssue assigns issue to the user using PUT /issue/{key}/assignee endpoint.
 func (c *Client) AssignIssue(key, accountID string) error {
 	aid := new(string)


Test patch:
diff --git a/pkg/jira/client_test.go b/pkg/jira/client_test.go
index 2eef7ce..b2395f2 100644
--- a/pkg/jira/client_test.go
+++ b/pkg/jira/client_test.go
@@ -54,6 +54,29 @@ func TestGetV1(t *testing.T) {
 	_ = resp.Body.Close()
 }
 
+func TestGetV2(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "/rest/api/2/search", r.URL.Path)
+		assert.Equal(t, url.Values{
+			"jql": []string{"project=TEST AND status=Done"},
+		}, r.URL.Query())
+		assert.Equal(t, "text/plain", r.Header.Get("Content-Type"))
+
+		w.WriteHeader(200)
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3))
+	resp, err := client.GetV2(context.Background(), "/search?jql=project=TEST%20AND%20status=Done", Header{
+		"Content-Type": "text/plain",
+	})
+
+	assert.NoError(t, err)
+	assert.Equal(t, 200, resp.StatusCode)
+
+	_ = resp.Body.Close()
+}
+
 func TestPost(t *testing.T) {
 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		assert.Equal(t, "/rest/api/3/issue", r.URL.Path)
diff --git a/pkg/jira/issue_test.go b/pkg/jira/issue_test.go
index 8023c23..8721245 100644
--- a/pkg/jira/issue_test.go
+++ b/pkg/jira/issue_test.go
@@ -130,6 +130,64 @@ func TestGetIssueWithoutDescription(t *testing.T) {
 	assert.Equal(t, expected, actual)
 }
 
+func TestGetIssueV2(t *testing.T) {
+	var unexpectedStatusCode bool
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "/rest/api/2/issue/TEST-1", r.URL.Path)
+
+		if unexpectedStatusCode {
+			w.WriteHeader(400)
+		} else {
+			resp, err := ioutil.ReadFile("./testdata/issue-2.json")
+			assert.NoError(t, err)
+
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(200)
+			_, _ = w.Write(resp)
+		}
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3))
+
+	actual, err := client.GetIssueV2("TEST-1")
+	assert.NoError(t, err)
+
+	expected := &Issue{
+		Key: "TEST-1",
+		Fields: IssueFields{
+			Summary:     "Bug summary",
+			Description: "Test description",
+			Labels:      []string{},
+			IssueType: struct {
+				Name string `json:"name"`
+			}{Name: "Bug"},
+			Priority: struct {
+				Name string `json:"name"`
+			}{Name: "Medium"},
+			Reporter: struct {
+				Name string `json:"displayName"`
+			}{Name: "Person A"},
+			Watches: struct {
+				IsWatching bool `json:"isWatching"`
+				WatchCount int  `json:"watchCount"`
+			}{IsWatching: true, WatchCount: 1},
+			Status: struct {
+				Name string `json:"name"`
+			}{Name: "To Do"},
+			Created: "2020-12-03T14:05:20.974+0100",
+			Updated: "2020-12-03T14:05:20.974+0100",
+		},
+	}
+	assert.Equal(t, expected, actual)
+
+	unexpectedStatusCode = true
+
+	_, err = client.GetIssueV2("TEST-1")
+	assert.Error(t, &ErrUnexpectedResponse{}, err)
+}
+
 func TestAssignIssue(t *testing.T) {
 	var unexpectedStatusCode bool
 
diff --git a/pkg/jira/testdata/issue-2.json b/pkg/jira/testdata/issue-2.json
new file mode 100644
index 0000000..c26e9d2
--- /dev/null
+++ b/pkg/jira/testdata/issue-2.json
@@ -0,0 +1,28 @@
+{
+  "key": "TEST-1",
+  "fields": {
+    "issuetype": {
+      "name": "Bug"
+    },
+    "resolution": null,
+    "created": "2020-12-03T14:05:20.974+0100",
+    "priority": {
+      "name": "Medium"
+    },
+    "labels": [],
+    "assignee": null,
+    "updated": "2020-12-03T14:05:20.974+0100",
+    "status": {
+      "name": "To Do"
+    },
+    "summary": "Bug summary",
+    "description": "Test description",
+    "reporter": {
+      "displayName": "Person A"
+    },
+    "watches": {
+      "watchCount": 1,
+      "isWatching": true
+    }
+  }
+}