Repository: ankitpokhrel/jira-cli (go)

Issue:
jira issue unlink
Is it possible to unlink two issues?.

```
jira issue unlink ISSUE-1 ISSUE-2 
```
Is there a plan to add this feature?.

Pull request:
feat: Add issues unlink command
Resolves #339.

Patch:
diff --git a/internal/cmd/issue/issue.go b/internal/cmd/issue/issue.go
index 84ce320..3a4976d 100644
--- a/internal/cmd/issue/issue.go
+++ b/internal/cmd/issue/issue.go
@@ -12,6 +12,7 @@ import (
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/link"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/list"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/move"
+	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/unlink"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/view"
 )
 
@@ -33,7 +34,8 @@ func NewCmdIssue() *cobra.Command {
 
 	cmd.AddCommand(
 		lc, cc, edit.NewCmdEdit(), move.NewCmdMove(), view.NewCmdView(), assign.NewCmdAssign(),
-		link.NewCmdLink(), comment.NewCmdComment(), clone.NewCmdClone(), delete.NewCmdDelete(),
+		link.NewCmdLink(), unlink.NewCmdUnlink(), comment.NewCmdComment(), clone.NewCmdClone(),
+		delete.NewCmdDelete(),
 	)
 
 	list.SetFlags(lc)
diff --git a/internal/cmd/issue/unlink/unlink.go b/internal/cmd/issue/unlink/unlink.go
new file mode 100644
index 0000000..88e1bf8
--- /dev/null
+++ b/internal/cmd/issue/unlink/unlink.go
@@ -0,0 +1,147 @@
+package unlink
+
+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 = `Unlink disconnects two issues from each other, if already connected.`
+	examples = `$ jira issue unlink ISSUE-1 ISSUE-2`
+)
+
+// NewCmdUnlink is an unlink command.
+func NewCmdUnlink() *cobra.Command {
+	cmd := cobra.Command{
+		Use:     "unlink INWARD_ISSUE_KEY OUTWARD_ISSUE_KEY",
+		Short:   "Unlink disconnects two issues from each other",
+		Long:    helpText,
+		Example: examples,
+		Aliases: []string{"uln"},
+		Annotations: map[string]string{
+			"help:args": "INWARD_ISSUE_KEY\tIssue key of the source issue, eg: ISSUE-1\n" +
+				"OUTWARD_ISSUE_KEY\tIssue key of the target issue, eg: ISSUE-2.",
+		},
+		Run: unlink,
+	}
+
+	cmd.Flags().Bool("web", false, "Open inward issue in web browser after successful unlinking")
+
+	return &cmd
+}
+
+func unlink(cmd *cobra.Command, args []string) {
+	project := viper.GetString("project.key")
+	params := parseArgsAndFlags(cmd.Flags(), args, project)
+	client := api.Client(jira.Config{Debug: params.debug})
+	uc := unlinkCmd{
+		client: client,
+		params: params,
+	}
+
+	cmdutil.ExitIfError(uc.setInwardIssueKey(project))
+	cmdutil.ExitIfError(uc.setOutwardIssueKey(project))
+
+	err := func() error {
+		s := cmdutil.Info("Unlinking issues")
+		defer s.Stop()
+
+		linkID, err := client.GetLinkID(uc.params.inwardIssueKey, uc.params.outwardIssueKey)
+		if err != nil {
+			return err
+		}
+
+		return client.UnlinkIssue(linkID)
+	}()
+	cmdutil.ExitIfError(err)
+
+	server := viper.GetString("server")
+
+	cmdutil.Success("Issues unlinked")
+	fmt.Printf("%s/browse/%s\n", server, uc.params.inwardIssueKey)
+
+	if web, _ := cmd.Flags().GetBool("web"); web {
+		err := cmdutil.Navigate(server, uc.params.inwardIssueKey)
+		cmdutil.ExitIfError(err)
+	}
+}
+
+type unlinkParams struct {
+	inwardIssueKey  string
+	outwardIssueKey string
+	debug           bool
+}
+
+func parseArgsAndFlags(flags query.FlagParser, args []string, project string) *unlinkParams {
+	var inwardIssueKey, outwardIssueKey string
+
+	nargs := len(args)
+	if nargs >= 1 {
+		inwardIssueKey = cmdutil.GetJiraIssueKey(project, args[0])
+	}
+	if nargs >= 2 {
+		outwardIssueKey = cmdutil.GetJiraIssueKey(project, args[1])
+	}
+
+	debug, err := flags.GetBool("debug")
+	cmdutil.ExitIfError(err)
+
+	return &unlinkParams{
+		inwardIssueKey:  inwardIssueKey,
+		outwardIssueKey: outwardIssueKey,
+		debug:           debug,
+	}
+}
+
+type unlinkCmd struct {
+	client *jira.Client
+	params *unlinkParams
+}
+
+func (uc *unlinkCmd) setInwardIssueKey(project string) error {
+	if uc.params.inwardIssueKey != "" {
+		return nil
+	}
+
+	var ans string
+
+	qs := &survey.Question{
+		Name:     "inwardIssueKey",
+		Prompt:   &survey.Input{Message: "Inward issue key"},
+		Validate: survey.Required,
+	}
+	if err := survey.Ask([]*survey.Question{qs}, &ans); err != nil {
+		return err
+	}
+	uc.params.inwardIssueKey = cmdutil.GetJiraIssueKey(project, ans)
+
+	return nil
+}
+
+func (uc *unlinkCmd) setOutwardIssueKey(project string) error {
+	if uc.params.outwardIssueKey != "" {
+		return nil
+	}
+
+	var ans string
+
+	qs := &survey.Question{
+		Name:     "outwardIssueKey",
+		Prompt:   &survey.Input{Message: "Outward issue key"},
+		Validate: survey.Required,
+	}
+	if err := survey.Ask([]*survey.Question{qs}, &ans); err != nil {
+		return err
+	}
+	uc.params.outwardIssueKey = cmdutil.GetJiraIssueKey(project, ans)
+
+	return nil
+}
diff --git a/pkg/jira/issue.go b/pkg/jira/issue.go
index 668e6f5..a0df25a 100644
--- a/pkg/jira/issue.go
+++ b/pkg/jira/issue.go
@@ -228,6 +228,46 @@ func (c *Client) LinkIssue(inwardIssue, outwardIssue, linkType string) error {
 	return nil
 }
 
+// UnlinkIssue disconnects two issues using DELETE /issueLink/{linkId} endpoint.
+func (c *Client) UnlinkIssue(linkID string) error {
+	deleteLinkURL := fmt.Sprintf("/issueLink/%s", linkID)
+	res, err := c.DeleteV2(context.Background(), deleteLinkURL, Header{
+		"Accept":       "application/json",
+		"Content-Type": "application/json",
+	})
+	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
+}
+
+// GetLinkID gets linkID between two issues.
+func (c *Client) GetLinkID(inwardIssue, outwardIssue string) (string, error) {
+	i, err := c.GetIssueV2(inwardIssue)
+	if err != nil {
+		return "", err
+	}
+
+	for _, link := range i.Fields.IssueLinks {
+		if link.InwardIssue != nil && link.InwardIssue.Key == outwardIssue {
+			return link.ID, nil
+		}
+
+		if link.OutwardIssue != nil && link.OutwardIssue.Key == outwardIssue {
+			return link.ID, nil
+		}
+	}
+	return "", fmt.Errorf("no link found between provided issues")
+}
+
 type issueCommentRequest struct {
 	Body string `json:"body"`
 }
diff --git a/pkg/jira/types.go b/pkg/jira/types.go
index 729ad98..c7d3588 100644
--- a/pkg/jira/types.go
+++ b/pkg/jira/types.go
@@ -94,6 +94,7 @@ type IssueFields struct {
 		Total int `json:"total"`
 	} `json:"comment"`
 	IssueLinks []struct {
+		ID       string `json:"id"`
 		LinkType struct {
 			Name    string `json:"name"`
 			Inward  string `json:"inward"`


Test patch:
diff --git a/internal/view/issue_test.go b/internal/view/issue_test.go
index 30558c8..0217a8c 100644
--- a/internal/view/issue_test.go
+++ b/internal/view/issue_test.go
@@ -138,6 +138,7 @@ func TestIssueDetailsWithV2Description(t *testing.T) {
 				Total: 3,
 			},
 			IssueLinks: []struct {
+				ID       string `json:"id"`
 				LinkType struct {
 					Name    string `json:"name"`
 					Inward  string `json:"inward"`
diff --git a/pkg/jira/issue_test.go b/pkg/jira/issue_test.go
index 09c877f..165aa61 100644
--- a/pkg/jira/issue_test.go
+++ b/pkg/jira/issue_test.go
@@ -71,6 +71,25 @@ func TestGetIssue(t *testing.T) {
 			}{Name: "To Do"},
 			Created: "2020-12-03T14:05:20.974+0100",
 			Updated: "2020-12-03T14:05:20.974+0100",
+			IssueLinks: []struct {
+				ID       string `json:"id"`
+				LinkType struct {
+					Name    string `json:"name"`
+					Inward  string `json:"inward"`
+					Outward string `json:"outward"`
+				} `json:"type"`
+				InwardIssue  *Issue `json:"inwardIssue,omitempty"`
+				OutwardIssue *Issue `json:"outwardIssue,omitempty"`
+			}{
+				{
+					ID:           "10001",
+					OutwardIssue: &Issue{Key: "TEST-2"},
+				},
+				{
+					ID:           "10002",
+					OutwardIssue: &Issue{},
+				},
+			},
 		},
 	}
 	assert.Equal(t, expected, actual)
@@ -301,6 +320,60 @@ func TestLinkIssue(t *testing.T) {
 	assert.Error(t, &ErrUnexpectedResponse{}, err)
 }
 
+func TestUnlinkIssue(t *testing.T) {
+	var unexpectedStatusCode bool
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "/rest/api/2/issueLink/123", r.URL.Path)
+		assert.Equal(t, http.MethodDelete, r.Method)
+		assert.Equal(t, "application/json", r.Header.Get("Accept"))
+		assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
+
+		if unexpectedStatusCode {
+			w.WriteHeader(400)
+		} else {
+			w.WriteHeader(204)
+		}
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second))
+
+	err := client.UnlinkIssue("123")
+	assert.NoError(t, err)
+
+	unexpectedStatusCode = true
+
+	err = client.UnlinkIssue("123")
+	assert.Error(t, &ErrUnexpectedResponse{}, err)
+}
+
+func TestGetLinkID(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)
+
+		resp, err := ioutil.ReadFile("./testdata/issue.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*time.Second))
+
+	actual, err := client.GetLinkID("TEST-1", "TEST-2")
+	assert.NoError(t, err)
+
+	expected := "10001"
+	assert.Equal(t, expected, actual)
+
+	_, err = client.GetLinkID("TEST-1", "TEST-1234")
+	assert.NotNil(t, err)
+	assert.Equal(t, "no link found between provided issues", err.Error())
+}
+
 func TestAddIssueComment(t *testing.T) {
 	var unexpectedStatusCode bool
 
diff --git a/pkg/jira/testdata/issue.json b/pkg/jira/testdata/issue.json
index b54931e..7040207 100644
--- a/pkg/jira/testdata/issue.json
+++ b/pkg/jira/testdata/issue.json
@@ -31,6 +31,18 @@
         }
       ]
     },
+    "issuelinks": [
+      {
+        "id": "10001",
+        "outwardIssue": {
+          "key": "TEST-2"
+        }
+      },
+      {
+        "id": "10002",
+        "outwardIssue": {}
+      }
+    ],
     "reporter": {
       "displayName": "Person A"
     },