Repository: ankitpokhrel/jira-cli
Pull request #584 (2023-02-28 20:56:00)

feat: Support for adding watcher to Issue
Continuation of #581 
Related #452 

Original work by @abhishekbiyala 


Patch:
diff --git a/api/client.go b/api/client.go
index 7dcad2c..4682653 100644
--- a/api/client.go
+++ b/api/client.go
@@ -183,3 +183,26 @@ func ProxyTransitions(c *jira.Client, key string) ([]*jira.Transition, error) {
 
 	return transitions, err
 }
+
+// ProxyWatchIssue uses either a v2 or v3 version of the PUT /issue/{key}/watchers
+// endpoint to assign an issue to the user. Defaults to v3 if installation type is
+// not defined in the config.
+func ProxyWatchIssue(c *jira.Client, key string, user *jira.User) error {
+	it := viper.GetString("installation")
+
+	var assignee string
+
+	if user != nil {
+		switch it {
+		case jira.InstallationTypeLocal:
+			assignee = user.Name
+		default:
+			assignee = user.AccountID
+		}
+	}
+
+	if it == jira.InstallationTypeLocal {
+		return c.WatchIssueV2(key, assignee)
+	}
+	return c.WatchIssue(key, assignee)
+}
diff --git a/internal/cmd/issue/issue.go b/internal/cmd/issue/issue.go
index d667b7b..6d0607c 100644
--- a/internal/cmd/issue/issue.go
+++ b/internal/cmd/issue/issue.go
@@ -14,6 +14,7 @@ import (
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/move"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/unlink"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/view"
+	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/watch"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/worklog"
 )
 
@@ -36,7 +37,7 @@ func NewCmdIssue() *cobra.Command {
 	cmd.AddCommand(
 		lc, cc, edit.NewCmdEdit(), move.NewCmdMove(), view.NewCmdView(), assign.NewCmdAssign(),
 		link.NewCmdLink(), unlink.NewCmdUnlink(), comment.NewCmdComment(), clone.NewCmdClone(),
-		delete.NewCmdDelete(), worklog.NewCmdWorklog(),
+		delete.NewCmdDelete(), watch.NewCmdWatch(), worklog.NewCmdWorklog(),
 	)
 
 	list.SetFlags(lc)
diff --git a/internal/cmd/issue/watch/watch.go b/internal/cmd/issue/watch/watch.go
new file mode 100644
index 0000000..74888c9
--- /dev/null
+++ b/internal/cmd/issue/watch/watch.go
@@ -0,0 +1,301 @@
+package watch
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/AlecAivazis/survey/v2/core"
+	"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"
+)
+
+const (
+	helpText = `Adds user to issue watchers.`
+	examples = `$ jira issue watch ISSUE-1 jon@domain.tld
+
+# Watcher name or email needs to be an exact match
+$ jira issue watch ISSUE-1 "Jon Doe"
+
+# Add self to watchers
+$ jira issue watch ISSUE-1 $(jira me)`
+
+	maxResults = 100
+	lineBreak  = "----------"
+
+	optionSearch = "[Search...]"
+	optionCancel = "Cancel"
+)
+
+// NewCmdWatch is an watch command.
+func NewCmdWatch() *cobra.Command {
+	return &cobra.Command{
+		Use:     "watch ISSUE-KEY WATCHER",
+		Short:   "Add user to issue watchers",
+		Long:    helpText,
+		Example: examples,
+		Aliases: []string{"wat"},
+		Annotations: map[string]string{
+			"help:args": `ISSUE-KEY	Issue key, eg: ISSUE-1
+WATCHER	Email or display name of the user to add to issue watchers`,
+		},
+		Run: watch,
+	}
+}
+
+func watch(cmd *cobra.Command, args []string) {
+	project := viper.GetString("project.key")
+	params := parseArgsAndFlags(cmd.Flags(), args, project)
+	client := api.DefaultClient(params.debug)
+	ac := watchCmd{
+		client: client,
+		users:  nil,
+		params: params,
+	}
+
+	cmdutil.ExitIfError(ac.setIssueKey(project))
+
+	cmdutil.ExitIfError(ac.setAvailableUsers(project))
+	cmdutil.ExitIfError(ac.setWatcher(project))
+
+	u, err := ac.verifyWatcher()
+	if err != nil {
+		cmdutil.Failed("Error: %s", err.Error())
+		return
+	}
+
+	uname := getQueryableName(u.DisplayName, u.Name)
+
+	err = func() error {
+		s := cmdutil.Info(fmt.Sprintf("Adding user %q as watcher of issue %q...", uname, ac.params.key))
+		defer s.Stop()
+
+		return api.ProxyWatchIssue(client, ac.params.key, u)
+	}()
+	cmdutil.ExitIfError(err)
+
+	cmdutil.Success("User %q added as watcher of issue %q", uname, ac.params.key)
+	fmt.Printf("%s/browse/%s\n", viper.GetString("server"), ac.params.key)
+}
+
+type watchParams struct {
+	key   string
+	user  string
+	debug bool
+}
+
+func parseArgsAndFlags(flags query.FlagParser, args []string, project string) *watchParams {
+	var key, user string
+
+	nargs := len(args)
+	if nargs >= 1 {
+		key = cmdutil.GetJiraIssueKey(project, args[0])
+	}
+	if nargs >= 2 {
+		user = args[1]
+	}
+
+	debug, err := flags.GetBool("debug")
+	cmdutil.ExitIfError(err)
+
+	return &watchParams{
+		key:   key,
+		user:  user,
+		debug: debug,
+	}
+}
+
+type watchCmd struct {
+	client *jira.Client
+	users  []*jira.User
+	params *watchParams
+}
+
+func (ac *watchCmd) setIssueKey(project string) error {
+	if ac.params.key != "" {
+		return nil
+	}
+
+	var ans string
+
+	qs := &survey.Question{
+		Name:     "key",
+		Prompt:   &survey.Input{Message: "Issue key"},
+		Validate: survey.Required,
+	}
+	if err := survey.Ask([]*survey.Question{qs}, &ans); err != nil {
+		return err
+	}
+	ac.params.key = cmdutil.GetJiraIssueKey(project, ans)
+
+	return nil
+}
+
+func (ac *watchCmd) setWatcher(project string) error {
+	if ac.params.user != "" && len(ac.users) == 1 {
+		ac.params.user = getQueryableName(ac.users[0].Name, ac.users[0].DisplayName)
+		return nil
+	}
+
+	var (
+		ans  string
+		last bool
+	)
+	if ac.params.user != "" && len(ac.users) > 0 {
+		last = true
+	}
+
+	for {
+		qs := &survey.Question{
+			Name: "user",
+			Prompt: &survey.Select{
+				Message: "User to add to watchers:",
+				Help:    "Can't find the user? Select search and look for a keyword or cancel to abort",
+				Options: ac.getOptions(last),
+			},
+			Validate: func(val interface{}) error {
+				errInvalidSelection := fmt.Errorf("invalid selection")
+
+				ans, ok := val.(core.OptionAnswer)
+				if !ok {
+					return errInvalidSelection
+				}
+				if ans.Value == "" || ans.Value == lineBreak {
+					return errInvalidSelection
+				}
+
+				return nil
+			},
+		}
+
+		if err := survey.Ask([]*survey.Question{qs}, &ans); err != nil {
+			return err
+		}
+		if ans == optionCancel {
+			cmdutil.Fail("Action aborted")
+			os.Exit(0)
+		}
+		if ans != optionSearch {
+			break
+		}
+		if err := ac.getSearchKeyword(); err != nil {
+			return err
+		}
+		if err := ac.searchAndSetUser(project); err != nil {
+			return err
+		}
+		last = true
+	}
+	ac.params.user = ans
+
+	return nil
+}
+
+func (ac *watchCmd) getOptions(last bool) []string {
+	var validUsers []string
+
+	for _, t := range ac.users {
+		if t.Active {
+			name := t.DisplayName
+			if t.Name != "" {
+				name += fmt.Sprintf(" (%s)", t.Name)
+			}
+			validUsers = append(validUsers, name)
+		}
+	}
+	options := []string{optionSearch}
+
+	if last {
+		options = append(options, validUsers...)
+		options = append(options, lineBreak)
+	} else {
+		options = append(options, lineBreak)
+		options = append(options, validUsers...)
+	}
+
+	return options
+}
+
+func (ac *watchCmd) getSearchKeyword() error {
+	qs := &survey.Question{
+		Name: "user",
+		Prompt: &survey.Input{
+			Message: "Search user:",
+			Help:    "Type user email or display name to search for a user",
+		},
+		Validate: func(val interface{}) error {
+			errInvalidKeyword := fmt.Errorf("enter atleast 3 characters to search")
+
+			str, ok := val.(string)
+			if !ok {
+				return errInvalidKeyword
+			}
+			if len(str) < 3 {
+				return errInvalidKeyword
+			}
+
+			return nil
+		},
+	}
+	return survey.Ask([]*survey.Question{qs}, &ac.params.user)
+}
+
+func (ac *watchCmd) searchAndSetUser(project string) error {
+	u, err := api.ProxyUserSearch(ac.client, &jira.UserSearchOptions{
+		Query:      ac.params.user,
+		Project:    project,
+		MaxResults: maxResults,
+	})
+	if err != nil {
+		return err
+	}
+	ac.users = u
+	return nil
+}
+
+func (ac *watchCmd) setAvailableUsers(project string) error {
+	s := cmdutil.Info("Fetching available users. Please wait...")
+	defer s.Stop()
+
+	return ac.searchAndSetUser(project)
+}
+
+func (ac *watchCmd) verifyWatcher() (*jira.User, error) {
+	watcher := strings.ToLower(ac.params.user)
+
+	var user *jira.User
+
+	for _, u := range ac.users {
+		name := strings.ToLower(getQueryableName(u.Name, u.DisplayName))
+		if name == watcher || strings.ToLower(u.Email) == watcher {
+			user = u
+		}
+		if strings.ToLower(fmt.Sprintf("%s (%s)", u.DisplayName, u.Name)) == watcher {
+			user = u
+		}
+		if user != nil {
+			break
+		}
+	}
+
+	if user == nil {
+		return nil, fmt.Errorf("invalid watcher %q", ac.params.user)
+	}
+	if !user.Active {
+		return nil, fmt.Errorf("user %q is not active", getQueryableName(user.Name, user.DisplayName))
+	}
+	return user, nil
+}
+
+func getQueryableName(name, displayName string) string {
+	if name != "" {
+		return name
+	}
+	return displayName
+}
diff --git a/pkg/jira/issue.go b/pkg/jira/issue.go
index 493c9a9..79e160d 100644
--- a/pkg/jira/issue.go
+++ b/pkg/jira/issue.go
@@ -419,3 +419,53 @@ func (c *Client) RemoteLinkIssue(issueID, title, url string) error {
 	}
 	return nil
 }
+
+// WatchIssue adds user as a watcher using v2 version of the POST /issue/{key}/watchers endpoint.
+func (c *Client) WatchIssue(key, watcher string) error {
+	return c.watchIssue(key, watcher, apiVersion3)
+}
+
+// WatchIssueV2 adds user as a watcher using using v2 version of the POST /issue/{key}/watchers endpoint.
+func (c *Client) WatchIssueV2(key, watcher string) error {
+	return c.watchIssue(key, watcher, apiVersion2)
+}
+
+func (c *Client) watchIssue(key, watcher, ver string) error {
+	path := fmt.Sprintf("/issue/%s/watchers", key)
+
+	var (
+		res  *http.Response
+		err  error
+		body []byte
+	)
+
+	body, err = json.Marshal(watcher)
+	if err != nil {
+		return err
+	}
+
+	header := Header{
+		"Accept":       "application/json",
+		"Content-Type": "application/json",
+	}
+
+	switch ver {
+	case apiVersion2:
+		res, err = c.PostV2(context.Background(), path, body, header)
+	default:
+		res, err = c.Post(context.Background(), path, body, header)
+	}
+
+	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
+}


Test patch:
diff --git a/pkg/jira/issue_test.go b/pkg/jira/issue_test.go
index e885b4c..3ea3804 100644
--- a/pkg/jira/issue_test.go
+++ b/pkg/jira/issue_test.go
@@ -554,3 +554,41 @@ func TestRemoteLinkIssue(t *testing.T) {
 	err = client.RemoteLinkIssue("TEST-1", "weblink title", "https://weblink.com")
 	assert.Error(t, &ErrUnexpectedResponse{}, err)
 }
+
+func TestWatchIssue(t *testing.T) {
+	var (
+		apiVersion2          bool
+		unexpectedStatusCode bool
+	)
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		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"))
+
+		if apiVersion2 {
+			assert.Equal(t, "/rest/api/2/issue/TEST-1/watchers", r.URL.Path)
+		} else {
+			assert.Equal(t, "/rest/api/3/issue/TEST-1/watchers", r.URL.Path)
+		}
+
+		if unexpectedStatusCode {
+			w.WriteHeader(400)
+		} else {
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(204)
+		}
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second))
+
+	err := client.WatchIssue("TEST-1", "a12b3")
+	assert.NoError(t, err)
+
+	apiVersion2 = true
+	unexpectedStatusCode = true
+
+	err = client.WatchIssueV2("TEST-1", "a12b3")
+	assert.Error(t, &ErrUnexpectedResponse{}, err)
+}