Repository: ankitpokhrel/jira-cli
Pull request #205 (2021-11-18 17:51:18)

feat: Cmd to add issues to sprint
This PR adds a command to assign issues to the sprint.

```sh
# Add issues to the sprint using interactive prompt
$ jira sprint add

# Pass required parameters to skip prompt
$ jira sprint add SPRINT_ID ISSUE-1 ISSUE-2
```

Fixes #183 


Patch:
diff --git a/README.md b/README.md
index f2d438d..59d85c8 100644
--- a/README.md
+++ b/README.md
@@ -426,7 +426,7 @@ $ jira epic create -n"Epic epic" -s"Everything" -yHigh -lbug -lurgent -b"Epic de
 ```
 
 #### Add
-Add command allows you to add issues to the epic. You can add up to 50 issues to the epic at once.
+The `add` command allows you to add issues to the epic. You can add up to 50 issues to the epic at once.
 
 ```sh
 # Add issues to the epic using interactive prompt
@@ -453,6 +453,9 @@ When viewing sprint issues, you can use all filters available for the issue comm
 
 See [usage](#navigation) to learn more about UI interaction.
 
+#### List
+You can use all flags supported by `issue list` command to filter issues in the sprint.
+
 ```sh
 # List sprints in an explorer view
 $ jira sprint list
@@ -483,6 +486,17 @@ $ jira sprint list SPRINT_ID
 $ jira sprint list SPRINT_ID -yHigh -a$(jira me)
 ```
 
+#### Add
+The `add` command allows you to add issues to the sprint. You can add up to 50 issues to the sprint at once.
+
+```sh
+# Add issues to the sprint using interactive prompt
+$ jira sprint add
+
+# Pass required parameters to skip prompt
+$ jira sprint add SPRINT_ID ISSUE-1 ISSUE-2
+```
+
 ### Other commands
 
 <details><summary>Navigate to the project</summary>
diff --git a/internal/cmd/sprint/add/add.go b/internal/cmd/sprint/add/add.go
new file mode 100644
index 0000000..861b290
--- /dev/null
+++ b/internal/cmd/sprint/add/add.go
@@ -0,0 +1,133 @@
+package add
+
+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/cmdutil"
+	"github.com/ankitpokhrel/jira-cli/internal/query"
+	"github.com/ankitpokhrel/jira-cli/pkg/jira"
+)
+
+const (
+	helpText = `Add issues to sprint.`
+	examples = `$ jira sprint add SPRINT_ID ISSUE-1 ISSUE-2`
+)
+
+// NewCmdAdd is an add command.
+func NewCmdAdd() *cobra.Command {
+	return &cobra.Command{
+		Use:     "add SPRINT_ID ISSUE-1 [...ISSUE-N]",
+		Short:   "Add issues to sprint",
+		Long:    helpText,
+		Example: examples,
+		Aliases: []string{"assign"},
+		Annotations: map[string]string{
+			"help:args": "SPRINT_ID\t\tID of the sprint on which you want to assign issues to, eg: 123\n" +
+				"ISSUE-1 [...ISSUE-N]\tKey of the issues to add to the sprint (max 50 issues at once)",
+		},
+		Run: add,
+	}
+}
+
+func add(cmd *cobra.Command, args []string) {
+	server := viper.GetString("server")
+	project := viper.GetString("project.key")
+	params := parseFlags(cmd.Flags(), args, project)
+	client := api.Client(jira.Config{Debug: params.debug})
+
+	qs := getQuestions(params)
+	if len(qs) > 0 {
+		ans := struct {
+			SprintID string
+			Issues   string
+		}{}
+		err := survey.Ask(qs, &ans)
+		cmdutil.ExitIfError(err)
+
+		if params.sprintID == "" {
+			params.sprintID = ans.SprintID
+		}
+
+		if len(params.issues) == 0 {
+			issues := strings.Split(ans.Issues, ",")
+			for i, iss := range issues {
+				issues[i] = cmdutil.GetJiraIssueKey(project, strings.TrimSpace(iss))
+			}
+			params.issues = issues
+		}
+	}
+
+	err := func() error {
+		s := cmdutil.Info("Adding issues to the sprint...")
+		defer s.Stop()
+
+		return client.SprintIssuesAdd(params.sprintID, params.issues...)
+	}()
+	cmdutil.ExitIfError(err)
+
+	cmdutil.Success(fmt.Sprintf("Issues added to the sprint %s\n%s/browse/%s", params.sprintID, server, project))
+}
+
+func parseFlags(flags query.FlagParser, args []string, project string) *addParams {
+	var (
+		sprintID string
+		issues   []string
+	)
+
+	nArgs := len(args)
+	if nArgs > 0 {
+		sprintID = args[0]
+	}
+	if nArgs > 1 {
+		tickets := args[1:]
+		issues = make([]string, 0, len(tickets))
+		for _, iss := range tickets {
+			issues = append(issues, cmdutil.GetJiraIssueKey(project, iss))
+		}
+	}
+
+	debug, err := flags.GetBool("debug")
+	cmdutil.ExitIfError(err)
+
+	return &addParams{
+		sprintID: sprintID,
+		issues:   issues,
+		debug:    debug,
+	}
+}
+
+func getQuestions(params *addParams) []*survey.Question {
+	var qs []*survey.Question
+
+	if params.sprintID == "" {
+		qs = append(qs, &survey.Question{
+			Name:     "sprintID",
+			Prompt:   &survey.Input{Message: "Sprint ID"},
+			Validate: survey.Required,
+		})
+	}
+	if len(params.issues) == 0 {
+		qs = append(qs, &survey.Question{
+			Name: "issues",
+			Prompt: &survey.Input{
+				Message: "Issues",
+				Help:    "Comma separated list of issues key to add. eg: ISSUE-1, ISSUE-2",
+			},
+			Validate: survey.Required,
+		})
+	}
+
+	return qs
+}
+
+type addParams struct {
+	sprintID string
+	issues   []string
+	debug    bool
+}
diff --git a/internal/cmd/sprint/sprint.go b/internal/cmd/sprint/sprint.go
index 709e7c9..88232f8 100644
--- a/internal/cmd/sprint/sprint.go
+++ b/internal/cmd/sprint/sprint.go
@@ -3,6 +3,7 @@ package sprint
 import (
 	"github.com/spf13/cobra"
 
+	"github.com/ankitpokhrel/jira-cli/internal/cmd/sprint/add"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/sprint/list"
 )
 
@@ -20,7 +21,10 @@ func NewCmdSprint() *cobra.Command {
 	}
 
 	lc := list.NewCmdList()
-	cmd.AddCommand(lc)
+	ac := add.NewCmdAdd()
+
+	cmd.AddCommand(lc, ac)
+
 	list.SetFlags(lc)
 
 	return &cmd
diff --git a/pkg/jira/sprint.go b/pkg/jira/sprint.go
index 05eb677..62f7d73 100644
--- a/pkg/jira/sprint.go
+++ b/pkg/jira/sprint.go
@@ -119,6 +119,37 @@ func (c *Client) SprintIssues(boardID, sprintID int, jql string, limit uint) (*S
 	return &out, err
 }
 
+// SprintIssuesAdd adds issues to the sprint.
+func (c *Client) SprintIssuesAdd(id string, issues ...string) error {
+	path := fmt.Sprintf("/sprint/%s/issue", id)
+
+	data := struct {
+		Issues []string `json:"issues"`
+	}{Issues: issues}
+
+	body, err := json.Marshal(&data)
+	if err != nil {
+		return err
+	}
+
+	res, err := c.PostV1(context.Background(), path, 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
+}
+
 // LastNSprints fetches sprint in descending order.
 //
 // Jira api to get all sprints doesn't provide an option to sort results and


Test patch:
diff --git a/pkg/jira/sprint_test.go b/pkg/jira/sprint_test.go
index 173d692..b41ecba 100644
--- a/pkg/jira/sprint_test.go
+++ b/pkg/jira/sprint_test.go
@@ -2,10 +2,12 @@
 package jira
 
 import (
+	"io"
 	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -309,3 +311,38 @@ func TestSprintIssues(t *testing.T) {
 	_, err = client.SprintIssues(1, 2, "project=TEST", 100)
 	assert.Error(t, &ErrUnexpectedResponse{}, err)
 }
+
+func TestSprintIssuesAdd(t *testing.T) {
+	var unexpectedStatusCode bool
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "/rest/agile/1.0/sprint/5/issue", r.URL.Path)
+
+		if unexpectedStatusCode {
+			w.WriteHeader(400)
+		} else {
+			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"))
+
+			expectedBody := `{"issues":["TEST-1","TEST-2"]}`
+			actualBody := new(strings.Builder)
+			_, _ = io.Copy(actualBody, r.Body)
+
+			assert.Equal(t, expectedBody, actualBody.String())
+
+			w.WriteHeader(204)
+		}
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3))
+
+	err := client.SprintIssuesAdd("5", "TEST-1", "TEST-2")
+	assert.NoError(t, err)
+
+	unexpectedStatusCode = true
+
+	err = client.SprintIssuesAdd("5", "TEST-1")
+	assert.Error(t, &ErrUnexpectedResponse{}, err)
+}