Repository: ankitpokhrel/jira-cli
Pull request #90 (2021-05-01 10:12:13)

feat: Ability to add comment
Only supports plain text comments at the moment.

```sh
# Add a comment using interactive prompt
$ jira issue comment add

# Pass required parameters to skip prompt
$ jira issue comment add ISSUE-1 "My comment body"

# Supports multi-line comments
$ jira issue comment add ISSUE-1 $'This comment has\n\nNew line'
```

<img width="748" alt="Screenshot 2021-05-01 at 12 09 18" src="https://user-images.githubusercontent.com/2364546/116779353-33fccb00-aa76-11eb-903a-4f8c73fd5b44.png"><img width="361" alt="Screenshot 2021-05-01 at 12 09 46" src="https://user-images.githubusercontent.com/2364546/116779356-3828e880-aa76-11eb-8953-9286980baf8f.png">



Patch:
diff --git a/README.md b/README.md
index c08c318..9017037 100644
--- a/README.md
+++ b/README.md
@@ -249,6 +249,23 @@ $ jira issue link
 $ jira issue link ISSUE-1 ISSUE-2 Blocks
 ```
 
+#### Comment
+The `comment` command provides a list of sub-commands to manage issue comments.
+
+##### Add
+The `add` command lets you add comment to an issue. The command only supports plain text comment at the moment.
+
+```sh
+# Add a comment using interactive prompt
+$ jira issue comment add
+
+# Pass required parameters to skip prompt
+$ jira issue comment add ISSUE-1 "My comment body"
+
+# Supports multi-line comments
+$ jira issue comment add ISSUE-1 $'This comment has\n\nNew line'
+```
+
 ### Epic
 Epics are displayed in an explorer view by default. You can output the results in a table view using the `--table` flag.
 When viewing epic issues, you can use all filters available for the issue command.
diff --git a/internal/cmd/issue/comment/add/add.go b/internal/cmd/issue/comment/add/add.go
new file mode 100644
index 0000000..d0836bb
--- /dev/null
+++ b/internal/cmd/issue/comment/add/add.go
@@ -0,0 +1,190 @@
+package add
+
+import (
+	"fmt"
+	"strings"
+
+	"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/cmdcommon"
+	"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
+	"github.com/ankitpokhrel/jira-cli/internal/query"
+	"github.com/ankitpokhrel/jira-cli/pkg/jira"
+	"github.com/ankitpokhrel/jira-cli/pkg/surveyext"
+)
+
+const (
+	helpText = `Add adds comment to an issue.`
+	examples = `$ jira issue comment add
+
+# Pass required parameters to skip prompt 
+$ jira issue comment add ISSUE-1 "My comment"
+
+# Multi-line comment
+$ jira issue comment add ISSUE-1 $'Supports\n\nNew line'
+`
+)
+
+// NewCmdCommentAdd is a comment add command.
+func NewCmdCommentAdd() *cobra.Command {
+	cmd := cobra.Command{
+		Use:     "add ISSUE_KEY COMMENT_BODY",
+		Short:   "Add adds comment to an issue",
+		Long:    helpText,
+		Example: examples,
+		Annotations: map[string]string{
+			"help:args": "ISSUE_KEY\tIssue key of the source issue, eg: ISSUE-1\n" +
+				"COMMENT_BODY\tBody of the comment you want to add",
+		},
+		Run: add,
+	}
+
+	cmd.Flags().Bool("web", false, "Open issue in web browser after adding comment")
+
+	return &cmd
+}
+
+func add(cmd *cobra.Command, args []string) {
+	params := parseArgsAndFlags(args, cmd.Flags())
+	client := api.Client(jira.Config{Debug: params.debug})
+	ac := addCmd{
+		client:    client,
+		linkTypes: nil,
+		params:    params,
+	}
+
+	cmdutil.ExitIfError(ac.setIssueKey())
+
+	qs := ac.getQuestions()
+	if len(qs) > 0 {
+		ans := struct{ IssueKey, Body string }{}
+		err := survey.Ask(qs, &ans)
+		cmdutil.ExitIfError(err)
+
+		if params.issueKey == "" {
+			params.issueKey = ans.IssueKey
+		}
+		if params.body == "" {
+			params.body = ans.Body
+		}
+
+		answer := struct{ Action string }{}
+		err = survey.Ask([]*survey.Question{ac.getNextAction()}, &answer)
+		cmdutil.ExitIfError(err)
+
+		if answer.Action == cmdcommon.ActionCancel {
+			cmdutil.Errorf("\033[0;31m✗\033[0m Action aborted")
+		}
+	}
+
+	func() {
+		s := cmdutil.Info("Adding comment")
+		defer s.Stop()
+
+		err := client.AddIssueComment(ac.params.issueKey, ac.params.body)
+		cmdutil.ExitIfError(err)
+	}()
+
+	server := viper.GetString("server")
+
+	fmt.Printf("\u001B[0;32m✓\u001B[0m Comment added to issue \"%s\"\n", ac.params.issueKey)
+	fmt.Printf("%s/browse/%s\n", server, ac.params.issueKey)
+
+	if web, _ := cmd.Flags().GetBool("web"); web {
+		err := cmdutil.Navigate(server, ac.params.issueKey)
+		cmdutil.ExitIfError(err)
+	}
+}
+
+type addParams struct {
+	issueKey string
+	body     string
+	debug    bool
+}
+
+func parseArgsAndFlags(args []string, flags query.FlagParser) *addParams {
+	var issueKey, body string
+
+	nargs := len(args)
+	if nargs >= 1 {
+		issueKey = strings.ToUpper(args[0])
+	}
+	if nargs >= 2 {
+		body = args[1]
+	}
+
+	debug, err := flags.GetBool("debug")
+	cmdutil.ExitIfError(err)
+
+	return &addParams{
+		issueKey: issueKey,
+		body:     body,
+		debug:    debug,
+	}
+}
+
+type addCmd struct {
+	client    *jira.Client
+	linkTypes []*jira.IssueLinkType
+	params    *addParams
+}
+
+func (ac *addCmd) setIssueKey() error {
+	if ac.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
+	}
+	ac.params.issueKey = ans
+
+	return nil
+}
+
+func (ac *addCmd) getQuestions() []*survey.Question {
+	var qs []*survey.Question
+
+	if ac.params.issueKey == "" {
+		qs = append(qs, &survey.Question{
+			Name:     "issueKey",
+			Prompt:   &survey.Input{Message: "Issue key"},
+			Validate: survey.Required,
+		})
+	}
+	if ac.params.body == "" {
+		qs = append(qs, &survey.Question{
+			Name: "body",
+			Prompt: &surveyext.JiraEditor{
+				Editor:       &survey.Editor{Message: "Comment body", HideDefault: true},
+				BlankAllowed: false,
+			},
+		})
+	}
+
+	return qs
+}
+
+func (ac *addCmd) getNextAction() *survey.Question {
+	return &survey.Question{
+		Name: "action",
+		Prompt: &survey.Select{
+			Message: "What's next?",
+			Options: []string{
+				cmdcommon.ActionSubmit,
+				cmdcommon.ActionCancel,
+			},
+		},
+		Validate: survey.Required,
+	}
+}
diff --git a/internal/cmd/issue/comment/comment.go b/internal/cmd/issue/comment/comment.go
new file mode 100644
index 0000000..0e6b418
--- /dev/null
+++ b/internal/cmd/issue/comment/comment.go
@@ -0,0 +1,28 @@
+package comment
+
+import (
+	"github.com/spf13/cobra"
+
+	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/comment/add"
+)
+
+const helpText = `Comment command helps you manage issue comments. See available commands below.`
+
+// NewCmdComment is a comment command.
+func NewCmdComment() *cobra.Command {
+	cmd := cobra.Command{
+		Use:     "comment",
+		Short:   "Manage issue comments",
+		Long:    helpText,
+		Aliases: []string{"comments"},
+		RunE:    comment,
+	}
+
+	cmd.AddCommand(add.NewCmdCommentAdd())
+
+	return &cmd
+}
+
+func comment(cmd *cobra.Command, _ []string) error {
+	return cmd.Help()
+}
diff --git a/internal/cmd/issue/issue.go b/internal/cmd/issue/issue.go
index 4dfcbea..a54c90f 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/comment"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/create"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/link"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/list"
@@ -27,7 +28,10 @@ func NewCmdIssue() *cobra.Command {
 	lc := list.NewCmdList()
 	cc := create.NewCmdCreate()
 
-	cmd.AddCommand(lc, cc, move.NewCmdMove(), view.NewCmdView(), assign.NewCmdAssign(), link.NewCmdLink())
+	cmd.AddCommand(
+		lc, cc, move.NewCmdMove(), view.NewCmdView(), assign.NewCmdAssign(), link.NewCmdLink(),
+		comment.NewCmdComment(),
+	)
 
 	list.SetFlags(lc)
 	create.SetFlags(cc)
diff --git a/pkg/jira/issue.go b/pkg/jira/issue.go
index 6d2962b..e3df2f9 100644
--- a/pkg/jira/issue.go
+++ b/pkg/jira/issue.go
@@ -154,6 +154,67 @@ func (c *Client) LinkIssue(inwardIssue, outwardIssue, linkType string) error {
 	return nil
 }
 
+// commentRequestADF is a a trimmed version of Atlassian document format for add comment request.
+// See https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
+type commentRequestADF struct {
+	Version int    `json:"version"`
+	DocType string `json:"type"`
+	Content []struct {
+		NodeType string `json:"type"`
+		Content  []struct {
+			ValueType string `json:"type"`
+			Text      string `json:"text"`
+		} `json:"content"`
+	} `json:"content"`
+}
+
+type issueCommentRequest struct {
+	Body *commentRequestADF `json:"body"`
+}
+
+// AddIssueComment adds comment to an issue using POST /issue/{key}/comment endpoint.
+// It only supports plain text comments at the moment.
+func (c *Client) AddIssueComment(key, comment string) error {
+	body, err := json.Marshal(&issueCommentRequest{Body: &commentRequestADF{
+		Version: 1,
+		DocType: "doc",
+		Content: []struct {
+			NodeType string `json:"type"`
+			Content  []struct {
+				ValueType string `json:"type"`
+				Text      string `json:"text"`
+			} `json:"content"`
+		}{{
+			NodeType: "paragraph",
+			Content: []struct {
+				ValueType string `json:"type"`
+				Text      string `json:"text"`
+			}{{ValueType: "text", Text: comment}},
+		}},
+	}})
+	if err != nil {
+		return err
+	}
+
+	path := fmt.Sprintf("/issue/%s/comment", key)
+	res, err := c.Post(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 ErrUnexpectedStatusCode
+	}
+	return nil
+}
+
 func ifaceToADF(v interface{}) *adf.ADF {
 	if v == nil {
 		return nil


Test patch:
diff --git a/pkg/jira/issue_test.go b/pkg/jira/issue_test.go
index a887cff..6475a77 100644
--- a/pkg/jira/issue_test.go
+++ b/pkg/jira/issue_test.go
@@ -1,9 +1,11 @@
 package jira
 
 import (
+	"io"
 	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -132,6 +134,7 @@ func TestAssignIssue(t *testing.T) {
 	var unexpectedStatusCode bool
 
 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "PUT", r.Method)
 		assert.Equal(t, "/rest/api/3/issue/TEST-1/assignee", r.URL.Path)
 		assert.Equal(t, "application/json", r.Header.Get("Accept"))
 		assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
@@ -235,3 +238,38 @@ func TestLinkIssue(t *testing.T) {
 	err = client.LinkIssue("TEST-1", "TEST-2", "invalid")
 	assert.Error(t, ErrUnexpectedStatusCode, err)
 }
+
+func TestAddIssueComment(t *testing.T) {
+	var unexpectedStatusCode bool
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "POST", r.Method)
+		assert.Equal(t, "/rest/api/3/issue/TEST-1/comment", r.URL.Path)
+		assert.Equal(t, "application/json", r.Header.Get("Accept"))
+		assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
+
+		actualBody := new(strings.Builder)
+		_, _ = io.Copy(actualBody, r.Body)
+
+		expectedBody := `{"body":{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"comment"}]}]}}`
+
+		assert.Equal(t, expectedBody, actualBody.String())
+
+		if unexpectedStatusCode {
+			w.WriteHeader(400)
+		} else {
+			w.WriteHeader(201)
+		}
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3))
+
+	err := client.AddIssueComment("TEST-1", "comment")
+	assert.NoError(t, err)
+
+	unexpectedStatusCode = true
+
+	err = client.AddIssueComment("TEST-1", "comment")
+	assert.Error(t, ErrUnexpectedStatusCode, err)
+}