Repository: ankitpokhrel/jira-cli
Pull request #33 (2020-12-21 16:06:36)

feat: Initial create command impl
```sh
$ jira issue create -h
Create an issue in a given project with minimal information.

EG:
	# Create issue in configured project
	jira issue create -tBug -s"New Bug" -yHigh -lbug -lurgent -b"Bug description"

	# Create issue in another project
	jira issue create -pPRJ -tBug -yHigh -s"New Bug" -b$'Bug description\n\nSome more text'

Usage:
  jira issue create [flags]

Flags:
  -t, --type string         Issue type
  -s, --summary string      Issue summary or title
  -b, --body string         Issue description
  -y, --priority string     Issue priority
  -l, --label stringArray   Issue labels
      --web                 Open issue in web browser after successful creation
  -h, --help                help for create

Global Flags:
  -c, --config string    Config file (default is $HOME/.config/jira/.jira.yml)
      --debug            Turn on debug output
  -p, --project string   Jira project to look into (defaults to value from $HOME/.config/jira/.jira.yml)
```




Patch:
diff --git a/internal/cmd/issue/create/create.go b/internal/cmd/issue/create/create.go
new file mode 100644
index 0000000..43147af
--- /dev/null
+++ b/internal/cmd/issue/create/create.go
@@ -0,0 +1,94 @@
+package create
+
+import (
+	"fmt"
+
+	"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/pkg/jira"
+)
+
+const helpText = `Create an issue in a given project with minimal information.
+
+EG:
+	# Create issue in configured project
+	jira issue create -tBug -s"New Bug" -yHigh -lbug -lurgent -b"Bug description"
+
+	# Create issue in another project
+	jira issue create -pPRJ -tBug -yHigh -s"New Bug" -b$'Bug description\n\nSome more text'
+`
+
+// NewCmdCreate is a create command.
+func NewCmdCreate() *cobra.Command {
+	return &cobra.Command{
+		Use:   "create",
+		Short: "Create an issue in a project",
+		Long:  helpText,
+		Run:   create,
+	}
+}
+
+func create(cmd *cobra.Command, _ []string) {
+	server := viper.GetString("server")
+	project := viper.GetString("project")
+
+	debug, err := cmd.Flags().GetBool("debug")
+	cmdutil.ExitIfError(err)
+
+	issueType, err := cmd.Flags().GetString("type")
+	cmdutil.ExitIfError(err)
+
+	summary, err := cmd.Flags().GetString("summary")
+	cmdutil.ExitIfError(err)
+
+	body, err := cmd.Flags().GetString("body")
+	cmdutil.ExitIfError(err)
+
+	priority, err := cmd.Flags().GetString("priority")
+	cmdutil.ExitIfError(err)
+
+	labels, err := cmd.Flags().GetStringArray("label")
+	cmdutil.ExitIfError(err)
+
+	key := func() string {
+		s := cmdutil.Info("Creating an issue...")
+		defer s.Stop()
+
+		resp, err := api.Client(jira.Config{Debug: debug}).Create(&jira.CreateRequest{
+			Project:   project,
+			IssueType: issueType,
+			Summary:   summary,
+			Body:      body,
+			Priority:  priority,
+			Labels:    labels,
+		})
+		cmdutil.ExitIfError(err)
+
+		return resp.Key
+	}()
+
+	fmt.Printf("\u001B[0;32m✓\u001B[0m Issue created: %s/browse/%s\n", server, key)
+
+	if web, _ := cmd.Flags().GetBool("web"); web {
+		err := cmdutil.Navigate(server, key)
+		cmdutil.ExitIfError(err)
+	}
+}
+
+// SetFlags sets flags supported by create command.
+func SetFlags(cmd *cobra.Command) {
+	cmd.Flags().SortFlags = false
+
+	cmd.Flags().StringP("type", "t", "", "Issue type")
+	cmd.Flags().StringP("summary", "s", "", "Issue summary or title")
+	cmd.Flags().StringP("body", "b", "", "Issue description")
+	cmd.Flags().StringP("priority", "y", "", "Issue priority")
+	cmd.Flags().StringArrayP("label", "l", []string{}, "Issue labels")
+	cmd.Flags().Bool("web", false, "Open issue in web browser after successful creation")
+
+	cmdutil.ExitIfError(cmd.MarkFlagRequired("type"))
+	cmdutil.ExitIfError(cmd.MarkFlagRequired("summary"))
+}
diff --git a/internal/cmd/issue/issue.go b/internal/cmd/issue/issue.go
index 3dd20b1..9dc5703 100644
--- a/internal/cmd/issue/issue.go
+++ b/internal/cmd/issue/issue.go
@@ -3,6 +3,7 @@ package issue
 import (
 	"github.com/spf13/cobra"
 
+	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/create"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/list"
 )
 
@@ -19,8 +20,12 @@ func NewCmdIssue() *cobra.Command {
 	}
 
 	lc := list.NewCmdList()
-	cmd.AddCommand(lc)
+	cc := create.NewCmdCreate()
+
+	cmd.AddCommand(lc, cc)
+
 	list.SetFlags(lc)
+	create.SetFlags(cc)
 
 	return &cmd
 }
diff --git a/internal/cmdutil/utils.go b/internal/cmdutil/utils.go
index 5fc863c..0c41fbb 100644
--- a/internal/cmdutil/utils.go
+++ b/internal/cmdutil/utils.go
@@ -7,6 +7,7 @@ import (
 
 	"github.com/briandowns/spinner"
 	"github.com/fatih/color"
+	"github.com/pkg/browser"
 
 	"github.com/ankitpokhrel/jira-cli/pkg/jira"
 )
@@ -56,3 +57,9 @@ func Info(msg string) *spinner.Spinner {
 func PrintErrF(msg string, a ...interface{}) {
 	fmt.Fprintf(os.Stderr, fmt.Sprintf("%s\n", msg), a...)
 }
+
+// Navigate navigates to jira issue.
+func Navigate(server, path string) error {
+	url := fmt.Sprintf("%s/browse/%s", server, path)
+	return browser.OpenURL(url)
+}
diff --git a/pkg/jira/client.go b/pkg/jira/client.go
index 316eb93..d81f294 100644
--- a/pkg/jira/client.go
+++ b/pkg/jira/client.go
@@ -1,9 +1,9 @@
 package jira
 
 import (
+	"bytes"
 	"context"
 	"fmt"
-	"io"
 	"net"
 	"net/http"
 	"strings"
@@ -92,7 +92,7 @@ func (c *Client) GetV1(ctx context.Context, path string, headers Header) (*http.
 }
 
 // Post sends POST request to v3 version of the jira api.
-func (c *Client) Post(ctx context.Context, path string, body io.Reader, headers Header) (*http.Response, error) {
+func (c *Client) Post(ctx context.Context, path string, body []byte, headers Header) (*http.Response, error) {
 	res, err := c.request(ctx, http.MethodPost, c.server+baseURLv3+path, body, headers)
 	if err != nil {
 		return res, err
@@ -100,12 +100,14 @@ func (c *Client) Post(ctx context.Context, path string, body io.Reader, headers
 	return res, err
 }
 
-func (c *Client) request(ctx context.Context, method, endpoint string, body io.Reader, headers Header) (*http.Response, error) {
+func (c *Client) request(ctx context.Context, method, endpoint string, body []byte, headers Header) (*http.Response, error) {
 	if c.debug {
-		fmt.Printf("Requesting %s: %s\n", method, endpoint)
+		fmt.Printf("\nRequesting %s: %s\n", method, endpoint)
+		fmt.Printf("With request body: %s\n", string(body))
+		fmt.Printf("With request headers: %v\n", headers)
 	}
 
-	req, err := http.NewRequest(method, endpoint, body)
+	req, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/jira/create.go b/pkg/jira/create.go
new file mode 100644
index 0000000..89b2aef
--- /dev/null
+++ b/pkg/jira/create.go
@@ -0,0 +1,118 @@
+package jira
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+)
+
+// CreateResponse struct holds response from POST /issue endpoint.
+type CreateResponse struct {
+	ID  string `json:"id"`
+	Key string `json:"key"`
+}
+
+// CreateRequest struct holds request data for create request.
+type CreateRequest struct {
+	Project   string
+	IssueType string
+	Summary   string
+	Body      string
+	Priority  string
+	Labels    []string
+}
+
+type createFields struct {
+	Project struct {
+		Key string `json:"key"`
+	} `json:"project"`
+	IssueType struct {
+		Name string `json:"name"`
+	} `json:"issuetype"`
+	Summary     string `json:"summary"`
+	Description *ADF   `json:"description,omitempty"`
+	Priority    *struct {
+		Name string `json:"name,omitempty"`
+	} `json:"priority,omitempty"`
+	Labels []string `json:"labels,omitempty"`
+}
+
+type createRequest struct {
+	Update struct{}     `json:"update"`
+	Fields createFields `json:"fields"`
+}
+
+// Create creates an issue using POST /issue endpoint.
+func (c *Client) Create(req *CreateRequest) (*CreateResponse, error) {
+	data := c.getRequestData(req)
+
+	body, err := json.Marshal(&data)
+	if err != nil {
+		return nil, err
+	}
+
+	res, err := c.Post(context.Background(), "/issue", body, Header{
+		"Accept":       "application/json",
+		"Content-Type": "application/json",
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	if res == nil {
+		return nil, ErrEmptyResponse
+	}
+
+	defer func() { _ = res.Body.Close() }()
+
+	if res.StatusCode != http.StatusCreated {
+		return nil, ErrUnexpectedStatusCode
+	}
+
+	var out CreateResponse
+
+	err = json.NewDecoder(res.Body).Decode(&out)
+
+	return &out, err
+}
+
+func (c *Client) getRequestData(req *CreateRequest) *createRequest {
+	if req.Labels == nil {
+		req.Labels = []string{}
+	}
+
+	data := createRequest{
+		Update: struct{}{},
+		Fields: createFields{
+			Project: struct {
+				Key string `json:"key"`
+			}{Key: req.Project},
+			IssueType: struct {
+				Name string `json:"name"`
+			}{Name: req.IssueType},
+			Summary: req.Summary,
+			Labels:  req.Labels,
+		},
+	}
+
+	if req.Priority != "" {
+		data.Fields.Priority = &struct {
+			Name string `json:"name,omitempty"`
+		}{Name: req.Priority}
+	}
+
+	if req.Body != "" {
+		data.Fields.Description = &ADF{
+			Version: 1,
+			DocType: "doc",
+			Content: []ADFNode{
+				{
+					NodeType: "paragraph",
+					Content:  []ADFNodeValue{{ValueType: "text", Text: req.Body}},
+				},
+			},
+		}
+	}
+
+	return &data
+}
diff --git a/pkg/jira/types.go b/pkg/jira/types.go
index 984ccad..06d5ef2 100644
--- a/pkg/jira/types.go
+++ b/pkg/jira/types.go
@@ -62,3 +62,23 @@ type Sprint struct {
 	CompleteDate string `json:"completeDate,omitempty"`
 	BoardID      int    `json:"originBoardId,omitempty"`
 }
+
+// ADF is an Atlassian document format.
+// See https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
+type ADF struct {
+	Version int       `json:"version"`
+	DocType string    `json:"type"`
+	Content []ADFNode `json:"content"`
+}
+
+// ADFNode is an ADF node.
+type ADFNode struct {
+	NodeType string         `json:"type"`
+	Content  []ADFNodeValue `json:"content"`
+}
+
+// ADFNodeValue is an ADF node value.
+type ADFNodeValue struct {
+	ValueType string `json:"type"`
+	Text      string `json:"text"`
+}


Test patch:
diff --git a/pkg/jira/client_test.go b/pkg/jira/client_test.go
index 7b2ceb7..3743687 100644
--- a/pkg/jira/client_test.go
+++ b/pkg/jira/client_test.go
@@ -5,7 +5,6 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
-	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -65,7 +64,7 @@ func TestPost(t *testing.T) {
 	defer server.Close()
 
 	client := NewClient(Config{Server: server.URL}, WithTimeout(3))
-	resp, err := client.Post(context.Background(), "/issue", strings.NewReader("hello"), Header{
+	resp, err := client.Post(context.Background(), "/issue", []byte("hello"), Header{
 		"Content-Type":   "application/json",
 		"X-Requested-By": "jira-cli",
 	})
diff --git a/pkg/jira/create_test.go b/pkg/jira/create_test.go
new file mode 100644
index 0000000..d89a45d
--- /dev/null
+++ b/pkg/jira/create_test.go
@@ -0,0 +1,68 @@
+package jira
+
+import (
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCreate(t *testing.T) {
+	var unexpectedStatusCode bool
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "/rest/api/3/issue", r.URL.Path)
+		assert.Equal(t, "POST", r.Method)
+		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 := `{"update":{},"fields":{"project":{"key":"TEST"},"issuetype":{"name":"Bug"},` +
+			`"summary":"Test bug","description":{"version":1,"type":"doc","content":[{"type":"paragraph","content":` +
+			`[{"type":"text","text":"Test description"}]}]},"priority":{"name":"Normal"},"labels":["test","dev"]}}`
+		assert.Equal(t, expectedBody, actualBody.String())
+
+		if unexpectedStatusCode {
+			w.WriteHeader(400)
+		} else {
+			resp, err := ioutil.ReadFile("./testdata/create.json")
+			assert.NoError(t, err)
+
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(201)
+			_, _ = w.Write(resp)
+		}
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3))
+
+	requestData := CreateRequest{
+		Project:   "TEST",
+		IssueType: "Bug",
+		Summary:   "Test bug",
+		Body:      "Test description",
+		Priority:  "Normal",
+		Labels:    []string{"test", "dev"},
+	}
+	actual, err := client.Create(&requestData)
+	assert.NoError(t, err)
+
+	expected := &CreateResponse{
+		ID:  "10057",
+		Key: "TEST-3",
+	}
+
+	assert.Equal(t, expected, actual)
+
+	unexpectedStatusCode = true
+
+	_, err = client.Create(&requestData)
+	assert.Error(t, ErrUnexpectedStatusCode, err)
+}
diff --git a/pkg/jira/testdata/create.json b/pkg/jira/testdata/create.json
new file mode 100644
index 0000000..559061f
--- /dev/null
+++ b/pkg/jira/testdata/create.json
@@ -0,0 +1,4 @@
+{
+  "id": "10057",
+  "key": "TEST-3"
+}