Repository: ankitpokhrel/jira-cli
Pull request #37 (2020-12-22 18:34:51)

feat: Epic create cmd
Epic name is required when creating epic, but Jira uses a custom field for an epic name which is different on each installation. Instead of fetching this dynamic field every time it is good to store it in the config since it is the same for projects on the same Jira instance. This PR implementation assumes that the epic field name is stored in `$HOME/.config/jira/.jira.yml` as `epic.field`.

TODO: Add epic field name in config during initialization.



Patch:
diff --git a/.gitignore b/.gitignore
index 7db7b79..3bcc8fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
 *.out
 *.swp
 tmp/*
+/*.json
 
 .DS_Store
 .idea/
diff --git a/internal/cmd/epic/create/create.go b/internal/cmd/epic/create/create.go
new file mode 100644
index 0000000..2f650e3
--- /dev/null
+++ b/internal/cmd/epic/create/create.go
@@ -0,0 +1,171 @@
+package create
+
+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"
+	"github.com/ankitpokhrel/jira-cli/pkg/surveyext"
+)
+
+const helpText = `Create an epic in a given project with minimal information.
+
+EG:
+	# Create epic in configured project
+	jira epic create -n"Epic epic" -s"Everything" -yHigh -lbug -lurgent -b"Bug description"
+
+	# Create epic in another project
+	jira epic create -pPRJ -n"Amazing epic" -yHigh -s"New Bug" -b$'Bug description\n\nSome more text'
+`
+
+type createParams struct {
+	name     string
+	summary  string
+	body     string
+	priority string
+	labels   []string
+	noInput  bool
+	debug    bool
+}
+
+// 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")
+
+	flags := parseFlags(cmd.Flags())
+	qs := getQuestions(flags)
+
+	if len(qs) > 0 {
+		ans := struct{ Name, Summary, Body string }{}
+		err := survey.Ask(qs, &ans)
+		cmdutil.ExitIfError(err)
+
+		if flags.name == "" {
+			flags.name = ans.Name
+		}
+		if flags.summary == "" {
+			flags.summary = ans.Summary
+		}
+		if flags.body == "" {
+			flags.body = ans.Body
+		}
+	}
+
+	key := func() string {
+		s := cmdutil.Info("Creating an epic...")
+		defer s.Stop()
+
+		resp, err := api.Client(jira.Config{Debug: flags.debug}).Create(&jira.CreateRequest{
+			Project:       project,
+			IssueType:     jira.IssueTypeEpic,
+			Name:          flags.name,
+			Summary:       flags.summary,
+			Body:          flags.body,
+			Priority:      flags.priority,
+			Labels:        flags.labels,
+			EpicFieldName: viper.GetString("epic.field"),
+		})
+		cmdutil.ExitIfError(err)
+
+		return resp.Key
+	}()
+
+	fmt.Printf("\u001B[0;32m✓\u001B[0m Epic 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("name", "n", "", "Epic name")
+	cmd.Flags().StringP("summary", "s", "", "Epic summary or title")
+	cmd.Flags().StringP("body", "b", "", "Epic description")
+	cmd.Flags().StringP("priority", "y", "", "Epic priority")
+	cmd.Flags().StringArrayP("label", "l", []string{}, "Epic labels")
+	cmd.Flags().Bool("web", false, "Open epic in web browser after successful creation")
+	cmd.Flags().Bool("no-input", false, "Disable prompt for non-required fields")
+}
+
+func parseFlags(flags query.FlagParser) *createParams {
+	name, err := flags.GetString("name")
+	cmdutil.ExitIfError(err)
+
+	summary, err := flags.GetString("summary")
+	cmdutil.ExitIfError(err)
+
+	body, err := flags.GetString("body")
+	cmdutil.ExitIfError(err)
+
+	priority, err := flags.GetString("priority")
+	cmdutil.ExitIfError(err)
+
+	labels, err := flags.GetStringArray("label")
+	cmdutil.ExitIfError(err)
+
+	noInput, err := flags.GetBool("no-input")
+	cmdutil.ExitIfError(err)
+
+	debug, err := flags.GetBool("debug")
+	cmdutil.ExitIfError(err)
+
+	return &createParams{
+		name:     name,
+		summary:  summary,
+		body:     body,
+		priority: priority,
+		labels:   labels,
+		noInput:  noInput,
+		debug:    debug,
+	}
+}
+
+func getQuestions(params *createParams) []*survey.Question {
+	var qs []*survey.Question
+
+	if params.name == "" {
+		qs = append(qs, &survey.Question{
+			Name:     "name",
+			Prompt:   &survey.Input{Message: "Epic name"},
+			Validate: survey.Required,
+		})
+	}
+	if params.summary == "" {
+		qs = append(qs, &survey.Question{
+			Name:     "summary",
+			Prompt:   &survey.Input{Message: "Summary"},
+			Validate: survey.Required,
+		})
+	}
+	if !params.noInput && params.body == "" {
+		qs = append(qs, &survey.Question{
+			Name: "body",
+			Prompt: &surveyext.JiraEditor{
+				Editor:       &survey.Editor{Message: "Description", HideDefault: true},
+				BlankAllowed: true,
+			},
+		})
+	}
+
+	return qs
+}
diff --git a/internal/cmd/epic/epic.go b/internal/cmd/epic/epic.go
index 86e7e46..9c3dc4d 100644
--- a/internal/cmd/epic/epic.go
+++ b/internal/cmd/epic/epic.go
@@ -3,6 +3,7 @@ package epic
 import (
 	"github.com/spf13/cobra"
 
+	"github.com/ankitpokhrel/jira-cli/internal/cmd/epic/create"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/epic/list"
 )
 
@@ -19,8 +20,12 @@ func NewCmdEpic() *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/cmd/issue/create/create.go b/internal/cmd/issue/create/create.go
index 05e7a6a..5706722 100644
--- a/internal/cmd/issue/create/create.go
+++ b/internal/cmd/issue/create/create.go
@@ -153,7 +153,6 @@ func getQuestions(params *createParams) []*survey.Question {
 			Validate: survey.Required,
 		})
 	}
-
 	if params.summary == "" {
 		qs = append(qs, &survey.Question{
 			Name:     "summary",
@@ -161,7 +160,6 @@ func getQuestions(params *createParams) []*survey.Question {
 			Validate: survey.Required,
 		})
 	}
-
 	if !params.noInput && params.body == "" {
 		qs = append(qs, &survey.Question{
 			Name: "body",
diff --git a/pkg/jira/create.go b/pkg/jira/create.go
index 89b2aef..5230f23 100644
--- a/pkg/jira/create.go
+++ b/pkg/jira/create.go
@@ -15,31 +15,15 @@ type CreateResponse struct {
 // CreateRequest struct holds request data for create request.
 type CreateRequest struct {
 	Project   string
+	Name      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"`
+	// EpicFieldName is the dynamic epic field name
+	// that changes per jira installation.
+	EpicFieldName string
 }
 
 // Create creates an issue using POST /issue endpoint.
@@ -76,6 +60,52 @@ func (c *Client) Create(req *CreateRequest) (*CreateResponse, error) {
 	return &out, err
 }
 
+type createFields struct {
+	Project struct {
+		Key string `json:"key"`
+	} `json:"project"`
+	IssueType struct {
+		Name string `json:"name"`
+	} `json:"issuetype"`
+	Name        string `json:"name,omitempty"`
+	Summary     string `json:"summary"`
+	Description *ADF   `json:"description,omitempty"`
+	Priority    *struct {
+		Name string `json:"name,omitempty"`
+	} `json:"priority,omitempty"`
+	Labels []string `json:"labels,omitempty"`
+
+	epicFieldName string
+}
+
+type createFieldsMarshaler struct {
+	M createFields
+}
+
+// MarshalJSON is a custom marshaler to handle dynamic field.
+func (cfm createFieldsMarshaler) MarshalJSON() ([]byte, error) {
+	m, err := json.Marshal(cfm.M)
+	if err != nil || cfm.M.Name == "" || cfm.M.epicFieldName == "" {
+		return m, err
+	}
+
+	var temp interface{}
+	if err := json.Unmarshal(m, &temp); err != nil {
+		return nil, err
+	}
+	dm := temp.(map[string]interface{})
+
+	dm[cfm.M.epicFieldName] = dm["name"]
+	delete(dm, "name")
+
+	return json.Marshal(dm)
+}
+
+type createRequest struct {
+	Update struct{}              `json:"update"`
+	Fields createFieldsMarshaler `json:"fields"`
+}
+
 func (c *Client) getRequestData(req *CreateRequest) *createRequest {
 	if req.Labels == nil {
 		req.Labels = []string{}
@@ -83,26 +113,27 @@ func (c *Client) getRequestData(req *CreateRequest) *createRequest {
 
 	data := createRequest{
 		Update: struct{}{},
-		Fields: createFields{
+		Fields: createFieldsMarshaler{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,
-		},
+			Name:          req.Name,
+			Summary:       req.Summary,
+			Labels:        req.Labels,
+			epicFieldName: req.EpicFieldName,
+		}},
 	}
 
 	if req.Priority != "" {
-		data.Fields.Priority = &struct {
+		data.Fields.M.Priority = &struct {
 			Name string `json:"name,omitempty"`
 		}{Name: req.Priority}
 	}
-
 	if req.Body != "" {
-		data.Fields.Description = &ADF{
+		data.Fields.M.Description = &ADF{
 			Version: 1,
 			DocType: "doc",
 			Content: []ADFNode{
diff --git a/pkg/jira/types.go b/pkg/jira/types.go
index e9aae03..3bb5333 100644
--- a/pkg/jira/types.go
+++ b/pkg/jira/types.go
@@ -2,6 +2,9 @@ package jira
 
 import "encoding/json"
 
+// IssueTypeEpic is an epic issue type.
+const IssueTypeEpic = "Epic"
+
 // Project holds project info.
 type Project struct {
 	Key  string `json:"key"`


Test patch:
diff --git a/pkg/jira/create_test.go b/pkg/jira/create_test.go
index d89a45d..0e920a0 100644
--- a/pkg/jira/create_test.go
+++ b/pkg/jira/create_test.go
@@ -11,10 +11,10 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func TestCreate(t *testing.T) {
-	var unexpectedStatusCode bool
+type createTestServer struct{ code int }
 
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+func (c createTestServer) serve(t *testing.T, expectedBody string) *httptest.Server {
+	return 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"))
@@ -23,22 +23,31 @@ func TestCreate(t *testing.T) {
 		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 {
+		if c.code == 201 {
 			resp, err := ioutil.ReadFile("./testdata/create.json")
 			assert.NoError(t, err)
 
 			w.Header().Set("Content-Type", "application/json")
 			w.WriteHeader(201)
 			_, _ = w.Write(resp)
+		} else {
+			w.WriteHeader(c.code)
 		}
 	}))
+}
+
+func (c createTestServer) statusCode(code int) {
+	c.code = code
+}
+
+func TestCreate(t *testing.T) {
+	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"]}}`
+	testServer := createTestServer{code: 201}
+	server := testServer.serve(t, expectedBody)
 	defer server.Close()
 
 	client := NewClient(Config{Server: server.URL}, WithTimeout(3))
@@ -61,7 +70,41 @@ func TestCreate(t *testing.T) {
 
 	assert.Equal(t, expected, actual)
 
-	unexpectedStatusCode = true
+	testServer.statusCode(400)
+
+	_, err = client.Create(&requestData)
+	assert.Error(t, ErrUnexpectedStatusCode, err)
+}
+
+func TestCreateEpic(t *testing.T) {
+	expectedBody := `{"update":{},"fields":{"customfield_10001":"CLI","description":{"content":[{"content":[{"text":` +
+		`"Test description","type":"text"}],"type":"paragraph"}],"type":"doc","version":1},"issuetype":{"name":` +
+		`"Bug"},"priority":{"name":"Normal"},"project":{"key":"TEST"},"summary":"Test bug"}}`
+	testServer := createTestServer{code: 201}
+	server := testServer.serve(t, expectedBody)
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3))
+	requestData := CreateRequest{
+		Project:       "TEST",
+		IssueType:     "Bug",
+		Name:          "CLI",
+		Summary:       "Test bug",
+		Body:          "Test description",
+		Priority:      "Normal",
+		EpicFieldName: "customfield_10001",
+	}
+	actual, err := client.Create(&requestData)
+	assert.NoError(t, err)
+
+	expected := &CreateResponse{
+		ID:  "10057",
+		Key: "TEST-3",
+	}
+
+	assert.Equal(t, expected, actual)
+
+	testServer.statusCode(400)
 
 	_, err = client.Create(&requestData)
 	assert.Error(t, ErrUnexpectedStatusCode, err)