Repository: ankitpokhrel/jira-cli
Pull request #483 (2022-10-07 03:16:41)

feat: add weblink to issue (#446)
PR adds the possibility to create a weblink for an issue. #446


Patch:
diff --git a/README.md b/README.md
index 8ab1cb3..2c3a962 100644
--- a/README.md
+++ b/README.md
@@ -455,6 +455,17 @@ $ jira issue link
 $ jira issue link ISSUE-1 ISSUE-2 Blocks
 ```
 
+##### Remote
+The `remote` command lets you add a remote web link to an issue.
+
+```sh
+# Adds a remote web link using an interactive prompt
+$ jira issue link remote
+
+# Pass required parameters to skip prompt
+$ jira issue link remote ISSUE-1 https://example.com "Example text"
+```
+
 #### Unlink
 The `unlink` command lets you unlink two linked issues.
 
diff --git a/internal/cmd/issue/link/link.go b/internal/cmd/issue/link/link.go
index 61008d9..32b82c8 100644
--- a/internal/cmd/issue/link/link.go
+++ b/internal/cmd/issue/link/link.go
@@ -10,6 +10,7 @@ import (
 	"github.com/spf13/viper"
 
 	"github.com/ankitpokhrel/jira-cli/api"
+	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/link/remote"
 	"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
 	"github.com/ankitpokhrel/jira-cli/internal/query"
 	"github.com/ankitpokhrel/jira-cli/pkg/jira"
@@ -37,7 +38,8 @@ func NewCmdLink() *cobra.Command {
 		Run: link,
 	}
 
-	cmd.Flags().Bool("web", false, "Open inward issue in web browser after successful linking")
+	cmd.AddCommand(remote.NewCmdRemoteLink())
+	cmd.PersistentFlags().Bool("web", false, "Open issue in web browser after successful linking")
 
 	return &cmd
 }
diff --git a/internal/cmd/issue/link/remote/remote.go b/internal/cmd/issue/link/remote/remote.go
new file mode 100644
index 0000000..977a02b
--- /dev/null
+++ b/internal/cmd/issue/link/remote/remote.go
@@ -0,0 +1,166 @@
+package remote
+
+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 = `Adds a remote web link to an issue`
+	examples = `$ jira issue link remote ISSUE-1 http://weblink.com weblink-title`
+)
+
+// NewCmdRemoteLink is a link command.
+func NewCmdRemoteLink() *cobra.Command {
+	cmd := cobra.Command{
+		Use:     "remote ISSUE_KEY WEBLINK_URL WEBLINK_TITLE",
+		Short:   "Adds a remote web link to an issue",
+		Long:    helpText,
+		Example: examples,
+		Aliases: []string{"rmln"},
+		Annotations: map[string]string{
+			"help:args": "ISSUE_KEY\tIssue key, eg: ISSUE-1\n" +
+				"WEBLINK_URL\tUrl of the weblink\n" +
+				"WEBLINK_TITLE\tTitle of the weblink",
+		},
+		Run: remotelink,
+	}
+
+	return &cmd
+}
+
+func remotelink(cmd *cobra.Command, args []string) {
+	project := viper.GetString("project.key")
+	params := parseArgsAndFlags(cmd.Flags(), args, project)
+	client := api.Client(jira.Config{Debug: params.debug})
+	lc := linkCmd{
+		client: client,
+		params: params,
+	}
+
+	cmdutil.ExitIfError(lc.setIssueKey(project))
+	cmdutil.ExitIfError(lc.setRemoteLinkURL())
+	cmdutil.ExitIfError(lc.setRemoteLinkTitle())
+
+	err := func() error {
+		s := cmdutil.Info("Creating remote web link for issue")
+		defer s.Stop()
+
+		return client.RemoteLinkIssue(lc.params.issueKey, lc.params.title, lc.params.url)
+	}()
+	cmdutil.ExitIfError(err)
+
+	server := viper.GetString("server")
+
+	cmdutil.Success("Remote web link created for Issue %s", lc.params.issueKey)
+	fmt.Printf("%s/browse/%s\n", server, lc.params.issueKey)
+
+	if web, _ := cmd.Flags().GetBool("web"); web {
+		err := cmdutil.Navigate(server, lc.params.issueKey)
+		cmdutil.ExitIfError(err)
+	}
+}
+
+type linkParams struct {
+	issueKey string
+	url      string
+	title    string
+	debug    bool
+}
+
+func parseArgsAndFlags(flags query.FlagParser, args []string, project string) *linkParams {
+	var issueKey, url, title string
+	nargs := len(args)
+	if nargs >= 1 {
+		issueKey = cmdutil.GetJiraIssueKey(project, args[0])
+	}
+	if nargs >= 2 {
+		url = args[1]
+	}
+	if nargs >= 3 {
+		title = args[2]
+	}
+
+	debug, err := flags.GetBool("debug")
+	cmdutil.ExitIfError(err)
+
+	return &linkParams{
+		issueKey: issueKey,
+		url:      url,
+		title:    title,
+		debug:    debug,
+	}
+}
+
+type linkCmd struct {
+	client *jira.Client
+	params *linkParams
+}
+
+func (lc *linkCmd) setIssueKey(project string) error {
+	if lc.params.issueKey != "" {
+		return nil
+	}
+
+	var ans string
+
+	qs := &survey.Question{
+		Name:     "issueKey",
+		Prompt:   &survey.Input{Message: "Issue key"},
+		Validate: survey.Required,
+	}
+	if err := survey.Ask([]*survey.Question{qs}, &ans); err != nil {
+		return err
+	}
+	lc.params.issueKey = cmdutil.GetJiraIssueKey(project, ans)
+
+	return nil
+}
+
+func (lc *linkCmd) setRemoteLinkURL() error {
+	if lc.params.url != "" {
+		return nil
+	}
+
+	var ans string
+
+	qs := &survey.Question{
+		Name:     "remoteLinkURL",
+		Prompt:   &survey.Input{Message: "Remote link URL"},
+		Validate: survey.Required,
+	}
+	if err := survey.Ask([]*survey.Question{qs}, &ans); err != nil {
+		return err
+	}
+	lc.params.url = ans
+
+	return nil
+}
+
+func (lc *linkCmd) setRemoteLinkTitle() error {
+	if lc.params.title != "" {
+		return nil
+	}
+
+	var ans string
+
+	qs := &survey.Question{
+		Name:     "remoteLinkTitle",
+		Prompt:   &survey.Input{Message: "Remote link title"},
+		Validate: survey.Required,
+	}
+	if err := survey.Ask([]*survey.Question{qs}, &ans); err != nil {
+		return err
+	}
+	lc.params.title = ans
+
+	return nil
+}
diff --git a/pkg/jira/create.go b/pkg/jira/create.go
index 93acd2d..835ec11 100644
--- a/pkg/jira/create.go
+++ b/pkg/jira/create.go
@@ -205,6 +205,8 @@ func constructCustomFields(fields map[string]string, data *createRequest) {
 			switch configured.Schema.DataType {
 			case customFieldFormatOption:
 				data.Fields.M.customFields[configured.Key] = customFieldTypeOption{Value: val}
+			case customFieldFormatProject:
+				data.Fields.M.customFields[configured.Key] = customFieldTypeProject{Value: val}
 			case customFieldFormatArray:
 				pieces := strings.Split(strings.TrimSpace(val), ",")
 				if configured.Schema.Items == customFieldFormatOption {
diff --git a/pkg/jira/customfield.go b/pkg/jira/customfield.go
index d7bee5e..92f3c67 100644
--- a/pkg/jira/customfield.go
+++ b/pkg/jira/customfield.go
@@ -1,9 +1,10 @@
 package jira
 
 const (
-	customFieldFormatOption = "option"
-	customFieldFormatArray  = "array"
-	customFieldFormatNumber = "number"
+	customFieldFormatOption  = "option"
+	customFieldFormatArray   = "array"
+	customFieldFormatNumber  = "number"
+	customFieldFormatProject = "project"
 )
 
 type customField map[string]interface{}
@@ -30,3 +31,11 @@ type customFieldTypeOptionAddRemove struct {
 	Add    *customFieldTypeOption `json:"add,omitempty"`
 	Remove *customFieldTypeOption `json:"remove,omitempty"`
 }
+
+type customFieldTypeProject struct {
+	Value string `json:"key"`
+}
+
+type customFieldTypeProjectSet struct {
+	Set customFieldTypeProject `json:"set"`
+}
diff --git a/pkg/jira/edit.go b/pkg/jira/edit.go
index 76654ce..36aefd1 100644
--- a/pkg/jira/edit.go
+++ b/pkg/jira/edit.go
@@ -323,6 +323,8 @@ func constructCustomFieldsForEdit(fields map[string]string, data *editRequest) {
 			switch configured.Schema.DataType {
 			case customFieldFormatOption:
 				data.Update.M.customFields[configured.Key] = []customFieldTypeOptionSet{{Set: customFieldTypeOption{Value: val}}}
+			case customFieldFormatProject:
+				data.Update.M.customFields[configured.Key] = []customFieldTypeProjectSet{{Set: customFieldTypeProject{Value: val}}}
 			case customFieldFormatArray:
 				pieces := strings.Split(strings.TrimSpace(val), ",")
 				if configured.Schema.Items == customFieldFormatOption {
diff --git a/pkg/jira/issue.go b/pkg/jira/issue.go
index a702a51..493c9a9 100644
--- a/pkg/jira/issue.go
+++ b/pkg/jira/issue.go
@@ -380,3 +380,42 @@ func ifaceToADF(v interface{}) *adf.ADF {
 
 	return doc
 }
+
+type remotelinkRequest struct {
+	RemoteObject struct {
+		URL   string `json:"url"`
+		Title string `json:"title"`
+	} `json:"object"`
+}
+
+// RemoteLinkIssue adds a remote link to an issue using POST /issue/{issueId}/remotelink endpoint.
+func (c *Client) RemoteLinkIssue(issueID, title, url string) error {
+	body, err := json.Marshal(remotelinkRequest{
+		RemoteObject: struct {
+			URL   string `json:"url"`
+			Title string `json:"title"`
+		}{Title: title, URL: url},
+	})
+	if err != nil {
+		return err
+	}
+
+	path := fmt.Sprintf("/issue/%s/remotelink", issueID)
+
+	res, err := c.PostV2(context.Background(), path, body, 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.StatusCreated {
+		return formatUnexpectedResponse(res)
+	}
+	return nil
+}


Test patch:
diff --git a/pkg/jira/issue_test.go b/pkg/jira/issue_test.go
index 609857c..e885b4c 100644
--- a/pkg/jira/issue_test.go
+++ b/pkg/jira/issue_test.go
@@ -526,3 +526,31 @@ func TestGetField(t *testing.T) {
 	_, err = client.GetField()
 	assert.NotNil(t, err)
 }
+
+func TestRemoteLinkIssue(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/remotelink", r.URL.Path)
+		assert.Equal(t, http.MethodPost, 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(201)
+		}
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second))
+
+	err := client.RemoteLinkIssue("TEST-1", "weblink title", "http://weblink.com")
+	assert.NoError(t, err)
+
+	unexpectedStatusCode = true
+
+	err = client.RemoteLinkIssue("TEST-1", "weblink title", "https://weblink.com")
+	assert.Error(t, &ErrUnexpectedResponse{}, err)
+}