Repository: ankitpokhrel/jira-cli
Pull request #194 (2021-11-14 18:12:48)

feat: Allow to assign epic on create


Patch:
diff --git a/README.md b/README.md
index 7447687..f2d438d 100644
--- a/README.md
+++ b/README.md
@@ -244,6 +244,14 @@ $ jira issue create
 $ jira issue create -tBug -s"New Bug" -yHigh -lbug -lurgent -b"Bug description"
 ```
 
+To attach epic to an issue on creation, you can use `-P/--parent` field. We call it parent because the semantics of epic
+has been changed in `next-gen` project.
+
+```sh
+# Create an issue of type Story and attach it to epic with key EPIC-42
+$  jira issue create -tStory -s"Epic during creation" -PEPIC-42
+```
+
 ![Create an issue](.github/assets/create.gif)
 
 The command supports both [Github-flavored](https://github.github.com/gfm/)
diff --git a/internal/cmd/epic/create/create.go b/internal/cmd/epic/create/create.go
index 07eb9f9..8e24b84 100644
--- a/internal/cmd/epic/create/create.go
+++ b/internal/cmd/epic/create/create.go
@@ -127,14 +127,14 @@ func create(cmd *cobra.Command, _ []string) {
 		defer s.Stop()
 
 		cr := jira.CreateRequest{
-			Project:       project,
-			IssueType:     jira.IssueTypeEpic,
-			Summary:       params.summary,
-			Body:          params.body,
-			Priority:      params.priority,
-			Labels:        params.labels,
-			Components:    params.components,
-			EpicFieldName: viper.GetString("epic.name"),
+			Project:    project,
+			IssueType:  jira.IssueTypeEpic,
+			Summary:    params.summary,
+			Body:       params.body,
+			Priority:   params.priority,
+			Labels:     params.labels,
+			Components: params.components,
+			EpicField:  viper.GetString("epic.name"),
 		}
 		if projectType != jira.ProjectTypeNextGen {
 			cr.Name = params.name
diff --git a/internal/cmd/issue/create/create.go b/internal/cmd/issue/create/create.go
index aa533c7..32d13c5 100644
--- a/internal/cmd/issue/create/create.go
+++ b/internal/cmd/issue/create/create.go
@@ -46,6 +46,7 @@ func SetFlags(cmd *cobra.Command) {
 func create(cmd *cobra.Command, _ []string) {
 	server := viper.GetString("server")
 	project := viper.GetString("project.key")
+	projectType := viper.GetString("project.type")
 
 	params := parseFlags(cmd.Flags())
 	client := api.Client(jira.Config{Debug: params.debug})
@@ -118,7 +119,9 @@ func create(cmd *cobra.Command, _ []string) {
 			Priority:       params.priority,
 			Labels:         params.labels,
 			Components:     params.components,
+			EpicField:      viper.GetString("epic.link"),
 		}
+		cr.ForProjectType(projectType)
 
 		resp, err := client.CreateV2(&cr)
 		if err != nil {
@@ -225,9 +228,14 @@ func (cc *createCmd) askQuestions() error {
 		return err
 	}
 
+	project := viper.GetString("project.key")
+
 	if cc.params.parentIssueKey == "" {
-		cc.params.parentIssueKey = ans.ParentIssueKey
+		cc.params.parentIssueKey = cmdutil.GetJiraIssueKey(project, ans.ParentIssueKey)
+	} else {
+		cc.params.parentIssueKey = cmdutil.GetJiraIssueKey(project, cc.params.parentIssueKey)
 	}
+
 	if cc.params.summary == "" {
 		cc.params.summary = ans.Summary
 	}
diff --git a/internal/cmdcommon/create.go b/internal/cmdcommon/create.go
index 8bc529d..afa6c51 100644
--- a/internal/cmdcommon/create.go
+++ b/internal/cmdcommon/create.go
@@ -22,7 +22,8 @@ func SetCreateFlags(cmd *cobra.Command, prefix string) {
 		cmd.Flags().StringP("name", "n", "", "Epic name")
 	} else {
 		cmd.Flags().StringP("type", "t", "", "Issue type")
-		cmd.Flags().StringP("parent", "P", "", "Parent issue key (Valid only if issue type is sub-task)")
+		cmd.Flags().StringP("parent", "P", "", `Parent issue key can be used to attach epic to an issue.
+And, this field is mandatory when creating a sub-task.`)
 	}
 	cmd.Flags().StringP("summary", "s", "", prefix+" summary or title")
 	cmd.Flags().StringP("body", "b", "", prefix+" description")
diff --git a/pkg/jira/create.go b/pkg/jira/create.go
index 5a0ac9f..848d132 100644
--- a/pkg/jira/create.go
+++ b/pkg/jira/create.go
@@ -20,16 +20,24 @@ type CreateRequest struct {
 	Project   string
 	Name      string
 	IssueType string
-	// ParentIssueKey is required for sub-task.
+	// ParentIssueKey is required when creating a sub-task for classic project.
+	// This can also be used to attach epic for next-gen project.
 	ParentIssueKey string
 	Summary        string
 	Body           interface{} // string in v1/v2 and adf.ADF in v3
 	Priority       string
 	Labels         []string
 	Components     []string
-	// EpicFieldName is the dynamic epic field name
+	// EpicField is the dynamic epic field name
 	// that changes per jira installation.
-	EpicFieldName string
+	EpicField string
+
+	projectType string
+}
+
+// ForProjectType set jira project type.
+func (cr *CreateRequest) ForProjectType(pt string) {
+	cr.projectType = pt
 }
 
 // Create creates an issue using v3 version of the POST /issue endpoint.
@@ -104,7 +112,7 @@ type createFields struct {
 		Name string `json:"name,omitempty"`
 	} `json:"components,omitempty"`
 
-	epicFieldName string
+	epicField string
 }
 
 type createFieldsMarshaler struct {
@@ -114,7 +122,7 @@ type createFieldsMarshaler struct {
 // 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 == "" {
+	if err != nil {
 		return m, err
 	}
 
@@ -124,7 +132,9 @@ func (cfm createFieldsMarshaler) MarshalJSON() ([]byte, error) {
 	}
 	dm := temp.(map[string]interface{})
 
-	dm[cfm.M.epicFieldName] = dm["name"]
+	if cfm.M.epicField != "" {
+		dm[cfm.M.epicField] = dm["name"]
+	}
 	delete(dm, "name")
 
 	return json.Marshal(dm)
@@ -147,10 +157,10 @@ func (c *Client) getRequestData(req *CreateRequest) *createRequest {
 		IssueType: struct {
 			Name string `json:"name"`
 		}{Name: req.IssueType},
-		Name:          req.Name,
-		Summary:       req.Summary,
-		Labels:        req.Labels,
-		epicFieldName: req.EpicFieldName,
+		Name:      req.Name,
+		Summary:   req.Summary,
+		Labels:    req.Labels,
+		epicField: req.EpicField,
 	}
 
 	switch v := req.Body.(type) {
@@ -166,9 +176,13 @@ func (c *Client) getRequestData(req *CreateRequest) *createRequest {
 	}
 
 	if req.ParentIssueKey != "" {
-		data.Fields.M.Parent = &struct {
-			Key string `json:"key"`
-		}{Key: req.ParentIssueKey}
+		if req.projectType == ProjectTypeNextGen || data.Fields.M.IssueType.Name == IssueTypeSubTask {
+			data.Fields.M.Parent = &struct {
+				Key string `json:"key"`
+			}{Key: req.ParentIssueKey}
+		} else {
+			data.Fields.M.Name = req.ParentIssueKey
+		}
 	}
 	if req.Priority != "" {
 		data.Fields.M.Priority = &struct {
diff --git a/pkg/jira/issue.go b/pkg/jira/issue.go
index 02a8d1a..4d8e90d 100644
--- a/pkg/jira/issue.go
+++ b/pkg/jira/issue.go
@@ -13,6 +13,8 @@ import (
 const (
 	// IssueTypeEpic is an epic issue type.
 	IssueTypeEpic = "Epic"
+	// IssueTypeSubTask is a sub-task issue type.
+	IssueTypeSubTask = "Sub-task"
 	// AssigneeNone is an empty assignee.
 	AssigneeNone = "none"
 	// AssigneeDefault is a default assignee.


Test patch:
diff --git a/pkg/jira/create_test.go b/pkg/jira/create_test.go
index 6b3946f..b4f4fe4 100644
--- a/pkg/jira/create_test.go
+++ b/pkg/jira/create_test.go
@@ -23,7 +23,7 @@ func (c createTestServer) serve(t *testing.T, expectedBody string) *httptest.Ser
 		actualBody := new(strings.Builder)
 		_, _ = io.Copy(actualBody, r.Body)
 
-		assert.Equal(t, expectedBody, actualBody.String())
+		assert.JSONEq(t, expectedBody, actualBody.String())
 
 		if c.code == 201 {
 			resp, err := ioutil.ReadFile("./testdata/create.json")
@@ -77,6 +77,38 @@ func TestCreate(t *testing.T) {
 	assert.Error(t, &ErrUnexpectedResponse{}, err)
 }
 
+func TestCreateSubtask(t *testing.T) {
+	expectedBody := `{"update":{},"fields":{"project":{"key":"TEST"},"issuetype":{"name":"Sub-task"},` +
+		`"parent":{"key":"TEST-123"},"summary":"Test sub-task","description":"Test description"}}`
+	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:      "Sub-task",
+		Summary:        "Test sub-task",
+		Body:           "Test description",
+		ParentIssueKey: "TEST-123",
+	}
+	actual, err := client.CreateV2(&requestData)
+	assert.NoError(t, err)
+
+	expected := &CreateResponse{
+		ID:  "10057",
+		Key: "TEST-3",
+	}
+
+	assert.Equal(t, expected, actual)
+
+	testServer.statusCode(500)
+
+	_, err = client.CreateV2(&requestData)
+	assert.Error(t, &ErrUnexpectedResponse{}, err)
+}
+
 func TestCreateEpic(t *testing.T) {
 	expectedBody := `{"update":{},"fields":{"customfield_10001":"CLI","description":"Test description","issuetype":{"name":` +
 		`"Bug"},"priority":{"name":"Normal"},"project":{"key":"TEST"},"summary":"Test bug"}}`
@@ -86,13 +118,13 @@ func TestCreateEpic(t *testing.T) {
 
 	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",
+		Project:   "TEST",
+		IssueType: "Bug",
+		Name:      "CLI",
+		Summary:   "Test bug",
+		Body:      "Test description",
+		Priority:  "Normal",
+		EpicField: "customfield_10001",
 	}
 	actual, err := client.CreateV2(&requestData)
 	assert.NoError(t, err)
@@ -108,3 +140,36 @@ func TestCreateEpic(t *testing.T) {
 	_, err = client.CreateV2(&requestData)
 	assert.Error(t, &ErrUnexpectedResponse{}, err)
 }
+
+func TestCreateEpicNextGen(t *testing.T) {
+	expectedBody := `{"update":{},"fields":{"description":"Test description","issuetype":{"name":"Bug"},` +
+		`"parent":{"key":"TEST-123"},"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",
+		ParentIssueKey: "TEST-123",
+	}
+	requestData.ForProjectType(ProjectTypeNextGen)
+
+	actual, err := client.CreateV2(&requestData)
+	assert.NoError(t, err)
+
+	expected := &CreateResponse{
+		ID:  "10057",
+		Key: "TEST-3",
+	}
+	assert.Equal(t, expected, actual)
+
+	testServer.statusCode(401)
+
+	_, err = client.CreateV2(&requestData)
+	assert.Error(t, &ErrUnexpectedResponse{}, err)
+}