Repository: ankitpokhrel/jira-cli
Pull request #119 (2021-08-08 15:40:11)

feat: Add cmd to edit issue
This PR adds a command to edit issues.

```sh
$ jira issue edit ISSUE-1
```
<img width="643" alt="Screen Shot 2021-08-08 at 5 31 17 PM" src="https://user-images.githubusercontent.com/2364546/128637348-46cc949b-f2e1-4578-9f23-5ab60ca60329.png">


### Usage

```sh
Edit an issue in a given project with minimal information.

USAGE
  jira issue edit ISSUE-KEY [flags]

FLAGS
  -s, --summary string          Edit summary or title
  -b, --body string             Edit description
  -y, --priority string         Edit priority
  -a, --assignee string         Edit assignee (email or display name)
  -l, --label stringArray       Edit labels
  -C, --component stringArray   Edit components
      --web                     Open in web browser after successful update
      --no-input                Disable prompt for non-required fields
  -h, --help                    help for edit

INHERITED FLAGS
  -c, --config string    Config file (default is $HOME/.jira/.config.yml)
      --debug            Turn on debug output
  -p, --project string   Jira project to look into (defaults to value from $HOME/.jira/.config.yml)

ARGUMENTS
  ISSUE-KEY	Issue key, eg: ISSUE-1

EXAMPLES
  $ jira issue edit ISSUE-1

  # Edit issue in the configured project
  $ jira issue edit ISSUE-1 -s"New Bug" -yHigh -lbug -lurgent -b"Bug description"

  # Edit issue in another project
  $ jira issue edit ISSUE-1 -pPRJ -yHigh -s"New Bug" -b$'Bug description\n\nSome more text'

ALIASES
  update
  modify

LEARN MORE
  Use 'jira <command> <subcommand> --help' for more information about a command.
```


Patch:
diff --git a/README.md b/README.md
index 21ffff1..9760ccd 100644
--- a/README.md
+++ b/README.md
@@ -241,6 +241,19 @@ $ echo "Description from stdin" | jira issue create -s"Summary" -tTask
 ![Markdown render preview](.github/assets/markdown.jpg)
 > The preview above shows markdown template passed in Jira CLI and how it is rendered in the Jira UI.
 
+#### Edit
+The `edit` command lets you edit an issue.
+
+```sh
+$ jira issue edit ISSUE-1
+
+# Edit issue in the configured project
+$ jira issue edit ISSUE-1 -s"New Bug" -yHigh -lbug -lurgent -CBackend -b"Bug description"
+
+# Use --no-input option to disable interactive prompt
+$ jira issue edit ISSUE-1 -s"New updated summary" --no-input`
+```
+
 #### Assign
 The `assign` command lets you assign user to an issue.
 
@@ -538,6 +551,11 @@ Sprint 1:   3
 ```
 </details>
 
+## Known Issues
+
+1. [Panels](https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/panel/) are not properly translated
+   at the moment which can cause formatting issue when creating/editing/cloning issues with panels.
+
 ## Development
 1. Clone the repo.
    ```sh
diff --git a/internal/cmd/issue/edit/edit.go b/internal/cmd/issue/edit/edit.go
new file mode 100644
index 0000000..fd91eaf
--- /dev/null
+++ b/internal/cmd/issue/edit/edit.go
@@ -0,0 +1,307 @@
+package edit
+
+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/adf"
+	"github.com/ankitpokhrel/jira-cli/pkg/jira"
+	"github.com/ankitpokhrel/jira-cli/pkg/md"
+	"github.com/ankitpokhrel/jira-cli/pkg/surveyext"
+)
+
+const (
+	helpText = `Edit an issue in a given project with minimal information.`
+	examples = `$ jira issue edit ISSUE-1
+
+# Edit issue in the configured project
+$ jira issue edit ISSUE-1 -s"New Bug" -yHigh -lbug -lurgent -CBackend -b"Bug description"
+
+# Use --no-input option to disable interactive prompt
+$ jira issue edit ISSUE-1 -s"New updated summary" --no-input`
+)
+
+// NewCmdEdit is an edit command.
+func NewCmdEdit() *cobra.Command {
+	cmd := cobra.Command{
+		Use:     "edit ISSUE-KEY",
+		Short:   "Edit an issue in a project",
+		Long:    helpText,
+		Example: examples,
+		Aliases: []string{"update", "modify"},
+		Annotations: map[string]string{
+			"help:args": `ISSUE-KEY	Issue key, eg: ISSUE-1`,
+		},
+		Args: cobra.MinimumNArgs(1),
+		Run:  edit,
+	}
+
+	setFlags(&cmd)
+
+	return &cmd
+}
+
+func edit(cmd *cobra.Command, args []string) {
+	server := viper.GetString("server")
+	project := viper.GetString("project")
+
+	params := parseArgsAndFlags(cmd.Flags(), args, project)
+	client := api.Client(jira.Config{Debug: params.debug})
+	ec := editCmd{
+		client: client,
+		params: params,
+	}
+
+	issue := func() *jira.Issue {
+		s := cmdutil.Info(fmt.Sprintf("Fetching issue %s...", params.issueKey))
+		defer s.Stop()
+
+		issue, err := client.GetIssue(params.issueKey)
+		cmdutil.ExitIfError(err)
+
+		return issue
+	}()
+
+	cmdutil.ExitIfError(ec.askQuestions(issue))
+
+	if !params.noInput {
+		answer := struct{ Action string }{}
+		for answer.Action != cmdcommon.ActionSubmit {
+			err := survey.Ask([]*survey.Question{cmdcommon.GetNextAction()}, &answer)
+			cmdutil.ExitIfError(err)
+
+			switch answer.Action {
+			case cmdcommon.ActionCancel:
+				cmdutil.Failed("Action aborted")
+			case cmdcommon.ActionMetadata:
+				ans := struct{ Metadata []string }{}
+				err := survey.Ask(cmdcommon.GetMetadata(), &ans)
+				cmdutil.ExitIfError(err)
+
+				if len(ans.Metadata) > 0 {
+					qs := getMetadataQuestions(ans.Metadata, issue)
+					ans := struct {
+						Priority   string
+						Labels     string
+						Components string
+					}{}
+					err := survey.Ask(qs, &ans)
+					cmdutil.ExitIfError(err)
+
+					if ans.Priority != "" {
+						params.priority = ans.Priority
+					}
+					if len(ans.Labels) > 0 {
+						params.labels = strings.Split(ans.Labels, ",")
+					}
+					if len(ans.Components) > 0 {
+						params.components = strings.Split(ans.Components, ",")
+					}
+				}
+			}
+		}
+	}
+
+	var userAccountID string
+
+	if params.assignee != "" {
+		func() {
+			s := cmdutil.Info("Looking for assignee...")
+			defer s.Stop()
+
+			user, err := client.UserSearch(&jira.UserSearchOptions{
+				Query: params.assignee,
+			})
+			if err != nil || len(user) == 0 {
+				cmdutil.Failed("Unable to find assignee")
+			}
+			userAccountID = user[0].AccountID
+		}()
+	}
+
+	func() {
+		s := cmdutil.Info("Updating an issue...")
+		defer s.Stop()
+
+		er := jira.EditRequest{
+			Summary:    params.summary,
+			Body:       md.JiraToGithubFlavored(params.body),
+			Assignee:   userAccountID,
+			Priority:   params.priority,
+			Labels:     params.labels,
+			Components: params.components,
+		}
+
+		err := client.Edit(params.issueKey, &er)
+		cmdutil.ExitIfError(err)
+	}()
+
+	cmdutil.Success("Issue updated\n%s/browse/%s", server, params.issueKey)
+
+	if web, _ := cmd.Flags().GetBool("web"); web {
+		err := cmdutil.Navigate(server, params.issueKey)
+		cmdutil.ExitIfError(err)
+	}
+}
+
+type editCmd struct {
+	client *jira.Client
+	params *editParams
+}
+
+func (ec *editCmd) askQuestions(issue *jira.Issue) error {
+	if ec.params.noInput {
+		return nil
+	}
+
+	var qs []*survey.Question
+
+	if ec.params.summary == "" {
+		qs = append(qs, &survey.Question{
+			Name: "summary",
+			Prompt: &survey.Input{
+				Message: "Summary",
+				Default: issue.Fields.Summary,
+			},
+			Validate: survey.Required,
+		})
+	}
+
+	if ec.params.body == "" {
+		body := ""
+		if issue.Fields.Description != "" {
+			body = adf.NewTranslator(issue.Fields.Description.(*adf.ADF), adf.NewMarkdownTranslator()).Translate()
+		}
+		qs = append(qs, &survey.Question{
+			Name: "body",
+			Prompt: &surveyext.JiraEditor{
+				Editor: &survey.Editor{
+					Message:       "Description",
+					Default:       body,
+					HideDefault:   true,
+					AppendDefault: true,
+				},
+				BlankAllowed: true,
+			},
+		})
+	}
+
+	ans := struct{ Summary, Body string }{}
+	err := survey.Ask(qs, &ans)
+	if err != nil {
+		return err
+	}
+
+	if ec.params.summary == "" {
+		ec.params.summary = ans.Summary
+	}
+	if ec.params.body == "" {
+		ec.params.body = ans.Body
+	}
+
+	return nil
+}
+
+type editParams struct {
+	issueKey   string
+	summary    string
+	body       string
+	priority   string
+	assignee   string
+	labels     []string
+	components []string
+	noInput    bool
+	debug      bool
+}
+
+func parseArgsAndFlags(flags query.FlagParser, args []string, project string) *editParams {
+	summary, err := flags.GetString("summary")
+	cmdutil.ExitIfError(err)
+
+	body, err := flags.GetString("body")
+	cmdutil.ExitIfError(err)
+
+	priority, err := flags.GetString("priority")
+	cmdutil.ExitIfError(err)
+
+	assignee, err := flags.GetString("assignee")
+	cmdutil.ExitIfError(err)
+
+	labels, err := flags.GetStringArray("label")
+	cmdutil.ExitIfError(err)
+
+	components, err := flags.GetStringArray("component")
+	cmdutil.ExitIfError(err)
+
+	noInput, err := flags.GetBool("no-input")
+	cmdutil.ExitIfError(err)
+
+	debug, err := flags.GetBool("debug")
+	cmdutil.ExitIfError(err)
+
+	return &editParams{
+		issueKey:   cmdutil.GetJiraIssueKey(project, args[0]),
+		summary:    summary,
+		body:       body,
+		priority:   priority,
+		assignee:   assignee,
+		labels:     labels,
+		components: components,
+		noInput:    noInput,
+		debug:      debug,
+	}
+}
+
+func getMetadataQuestions(meta []string, issue *jira.Issue) []*survey.Question {
+	var qs []*survey.Question
+
+	for _, m := range meta {
+		switch m {
+		case "Priority":
+			qs = append(qs, &survey.Question{
+				Name:   "priority",
+				Prompt: &survey.Input{Message: "Priority", Default: issue.Fields.Priority.Name},
+			})
+		case "Components":
+			qs = append(qs, &survey.Question{
+				Name: "components",
+				Prompt: &survey.Input{
+					Message: "Components",
+					Help:    "Comma separated list of valid components. For eg: BE,FE",
+				},
+			})
+		case "Labels":
+			qs = append(qs, &survey.Question{
+				Name: "labels",
+				Prompt: &survey.Input{
+					Message: "Labels",
+					Help:    "Comma separated list of labels. For eg: backend,urgent",
+					Default: strings.Join(issue.Fields.Labels, ","),
+				},
+			})
+		}
+	}
+
+	return qs
+}
+
+func setFlags(cmd *cobra.Command) {
+	cmd.Flags().SortFlags = false
+
+	cmd.Flags().StringP("summary", "s", "", "Edit summary or title")
+	cmd.Flags().StringP("body", "b", "", "Edit description")
+	cmd.Flags().StringP("priority", "y", "", "Edit priority")
+	cmd.Flags().StringP("assignee", "a", "", "Edit assignee (email or display name)")
+	cmd.Flags().StringArrayP("label", "l", []string{}, "Replace labels")
+	cmd.Flags().StringArrayP("component", "C", []string{}, "Replace components")
+	cmd.Flags().Bool("web", false, "Open in web browser after successful update")
+	cmd.Flags().Bool("no-input", false, "Disable prompt for non-required fields")
+}
diff --git a/internal/cmd/issue/issue.go b/internal/cmd/issue/issue.go
index 9e3c023..c2e7eee 100644
--- a/internal/cmd/issue/issue.go
+++ b/internal/cmd/issue/issue.go
@@ -7,6 +7,7 @@ import (
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/clone"
 	"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/edit"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/link"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/list"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/move"
@@ -30,8 +31,8 @@ func NewCmdIssue() *cobra.Command {
 	cc := create.NewCmdCreate()
 
 	cmd.AddCommand(
-		lc, cc, move.NewCmdMove(), view.NewCmdView(), assign.NewCmdAssign(), link.NewCmdLink(),
-		comment.NewCmdComment(), clone.NewCmdClone(),
+		lc, cc, edit.NewCmdEdit(), move.NewCmdMove(), view.NewCmdView(), assign.NewCmdAssign(),
+		link.NewCmdLink(), comment.NewCmdComment(), clone.NewCmdClone(),
 	)
 
 	list.SetFlags(lc)
diff --git a/pkg/jira/client.go b/pkg/jira/client.go
index 19fd52b..3a2c30b 100644
--- a/pkg/jira/client.go
+++ b/pkg/jira/client.go
@@ -173,6 +173,15 @@ func (c *Client) Put(ctx context.Context, path string, body []byte, headers Head
 	return res, err
 }
 
+// PutV2 sends PUT request to v2 version of the jira api.
+func (c *Client) PutV2(ctx context.Context, path string, body []byte, headers Header) (*http.Response, error) {
+	res, err := c.request(ctx, http.MethodPut, c.server+baseURLv2+path, body, headers)
+	if err != nil {
+		return res, err
+	}
+	return res, err
+}
+
 func (c *Client) request(ctx context.Context, method, endpoint string, body []byte, headers Header) (*http.Response, error) {
 	var (
 		req *http.Request
diff --git a/pkg/jira/edit.go b/pkg/jira/edit.go
new file mode 100644
index 0000000..2482d28
--- /dev/null
+++ b/pkg/jira/edit.go
@@ -0,0 +1,170 @@
+package jira
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+)
+
+// EditResponse struct holds response from POST /issue endpoint.
+type EditResponse struct {
+	ID  string `json:"id"`
+	Key string `json:"key"`
+}
+
+// EditRequest struct holds request data for edit request.
+// Setting an Assignee requires an account ID.
+type EditRequest struct {
+	IssueType  string
+	Summary    string
+	Body       string
+	Assignee   string
+	Priority   string
+	Labels     []string
+	Components []string
+}
+
+// Edit updates an issue using POST /issue endpoint.
+func (c *Client) Edit(key string, req *EditRequest) error {
+	data := c.getRequestDataForEdit(req)
+
+	body, err := json.Marshal(&data)
+	if err != nil {
+		return err
+	}
+
+	res, err := c.PutV2(context.Background(), "/issue/"+key, 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.StatusNoContent {
+		return formatUnexpectedResponse(res)
+	}
+
+	return nil
+}
+
+type editFields struct {
+	Summary []struct {
+		Set string `json:"set,omitempty"`
+	} `json:"summary,omitempty"`
+	Description []struct {
+		Set string `json:"set,omitempty"`
+	} `json:"description,omitempty"`
+	Assignee []struct {
+		Set struct {
+			AccountID string `json:"accountId,omitempty"`
+		} `json:"set,omitempty"`
+	} `json:"assignee,omitempty"`
+	Priority []struct {
+		Set struct {
+			Name string `json:"name,omitempty"`
+		} `json:"set,omitempty"`
+	} `json:"priority,omitempty"`
+	Labels []struct {
+		Set []string `json:"set,omitempty"`
+	} `json:"labels,omitempty"`
+	Components []struct {
+		Set []struct {
+			Name string `json:"name,omitempty"`
+		} `json:"set,omitempty"`
+	} `json:"components,omitempty"`
+}
+
+type editFieldsMarshaler struct {
+	M editFields
+}
+
+// MarshalJSON is a custom marshaler to handle empty fields.
+func (cfm editFieldsMarshaler) MarshalJSON() ([]byte, error) {
+	if len(cfm.M.Summary) == 0 || cfm.M.Summary[0].Set == "" {
+		cfm.M.Summary = nil
+	}
+	if len(cfm.M.Description) == 0 || cfm.M.Description[0].Set == "" {
+		cfm.M.Description = nil
+	}
+	if len(cfm.M.Assignee) == 0 || cfm.M.Assignee[0].Set.AccountID == "" {
+		cfm.M.Assignee = nil
+	}
+	if len(cfm.M.Priority) == 0 || cfm.M.Priority[0].Set.Name == "" {
+		cfm.M.Priority = nil
+	}
+	if len(cfm.M.Components) == 0 || len(cfm.M.Components[0].Set) == 0 {
+		cfm.M.Components = nil
+	}
+	if len(cfm.M.Labels) == 0 || len(cfm.M.Labels[0].Set) == 0 {
+		cfm.M.Labels = nil
+	}
+
+	return json.Marshal(cfm.M)
+}
+
+type editRequest struct {
+	Update editFieldsMarshaler `json:"update"`
+	Fields struct{}            `json:"fields"`
+}
+
+func (c *Client) getRequestDataForEdit(req *EditRequest) *editRequest {
+	if req.Labels == nil {
+		req.Labels = []string{}
+	}
+
+	ef := editFieldsMarshaler{editFields{
+		Summary: []struct {
+			Set string `json:"set,omitempty"`
+		}{{Set: req.Summary}},
+		Description: []struct {
+			Set string `json:"set,omitempty"`
+		}{{Set: req.Body}},
+		Assignee: []struct {
+			Set struct {
+				AccountID string `json:"accountId,omitempty"`
+			} `json:"set,omitempty"`
+		}{{Set: struct {
+			AccountID string `json:"accountId,omitempty"`
+		}{AccountID: req.Assignee}}},
+		Priority: []struct {
+			Set struct {
+				Name string `json:"name,omitempty"`
+			} `json:"set,omitempty"`
+		}{{Set: struct {
+			Name string `json:"name,omitempty"`
+		}{Name: req.Priority}}},
+		Labels: []struct {
+			Set []string `json:"set,omitempty"`
+		}{{Set: req.Labels}},
+	}}
+
+	if len(req.Components) > 0 {
+		cmp := make([]struct {
+			Name string `json:"name,omitempty"`
+		}, 0, len(req.Components))
+
+		for _, c := range req.Components {
+			cmp = append(cmp, struct {
+				Name string `json:"name,omitempty"`
+			}{Name: c})
+		}
+
+		ef.M.Components = []struct {
+			Set []struct {
+				Name string `json:"name,omitempty"`
+			} `json:"set,omitempty"`
+		}{{Set: cmp}}
+	}
+
+	data := editRequest{
+		Update: ef,
+		Fields: struct{}{},
+	}
+
+	return &data
+}


Test patch:
diff --git a/pkg/jira/client_test.go b/pkg/jira/client_test.go
index b2395f2..72e2601 100644
--- a/pkg/jira/client_test.go
+++ b/pkg/jira/client_test.go
@@ -164,3 +164,25 @@ func TestPut(t *testing.T) {
 
 	_ = resp.Body.Close()
 }
+
+func TestPutV2(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "/rest/api/2/issue/TEST-1/assignee", r.URL.Path)
+		assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
+		assert.Equal(t, "jira-cli", r.Header.Get("X-Requested-By"))
+
+		w.WriteHeader(204)
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3))
+	resp, err := client.PutV2(context.Background(), "/issue/TEST-1/assignee", []byte("jon"), Header{
+		"Content-Type":   "application/json",
+		"X-Requested-By": "jira-cli",
+	})
+
+	assert.NoError(t, err)
+	assert.Equal(t, 204, resp.StatusCode)
+
+	_ = resp.Body.Close()
+}