Repository: ankitpokhrel/jira-cli
Pull request #56 (2021-01-08 09:15:32)

feat: Add quick sprint flags
This PR adds `--current`, `--prev` and `--next` flags for quick sprint navigation

```sh
$ jira sprint list --current
$ jira sprint list --prev
$ jira sprint list --next
```


Patch:
diff --git a/README.md b/README.md
index d6cec37..329b842 100644
--- a/README.md
+++ b/README.md
@@ -26,8 +26,7 @@ updating a ticket status, and so on.
 #### Note
 The tool:
 - Doesn't yet support pagination and returns 100 records by default.
-- Only returns 25 recent sprints. 
-- Is only tested with the latest Jira cloud. 
+- Is only tested with the latest Jira cloud.
 
 ## Installation
 Install the runnable binary to your `$GOPATH/bin`.
diff --git a/internal/cmd/sprint/list/list.go b/internal/cmd/sprint/list/list.go
index 6ac31f5..b48f0ef 100644
--- a/internal/cmd/sprint/list/list.go
+++ b/internal/cmd/sprint/list/list.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
@@ -17,7 +18,7 @@ import (
 )
 
 const (
-	numSprints = 25
+	numSprints = 50 // This is the maximum result returned by Jira API at once.
 	helpText   = `
 Sprints are displayed in an explorer view by default. You can use --list
 and --plain flags to display output in different modes.`
@@ -83,11 +84,11 @@ func sprintList(cmd *cobra.Command, args []string) {
 		sprintID, err := strconv.Atoi(args[0])
 		cmdutil.ExitIfError(err)
 
-		singleSprintView(cmd.Flags(), boardID, sprintID, project, server, client)
+		singleSprintView(cmd.Flags(), boardID, sprintID, project, server, client, nil)
 	}
 }
 
-func singleSprintView(flags query.FlagParser, boardID, sprintID int, project, server string, client *jira.Client) {
+func singleSprintView(flags query.FlagParser, boardID, sprintID int, project, server string, client *jira.Client, sprint *jira.Sprint) {
 	issues, total := func() ([]*jira.Issue, int) {
 		s := cmdutil.Info("Fetching sprint issues...")
 		defer s.Stop()
@@ -117,11 +118,27 @@ func singleSprintView(flags query.FlagParser, boardID, sprintID int, project, se
 	columns, err := flags.GetString("columns")
 	cmdutil.ExitIfError(err)
 
+	var ft string
+	if sprint != nil {
+		ft = fmt.Sprintf(
+			"Showing %d of %d results for project \"%s\" in sprint #%d - %s (%s - %s)",
+			len(issues), total, project, sprint.ID, sprint.Name,
+			cmdutil.FormatDateTimeHuman(sprint.StartDate, time.RFC3339),
+			cmdutil.FormatDateTimeHuman(sprint.EndDate, time.RFC3339),
+		)
+	} else {
+		ft = fmt.Sprintf(
+			"Showing %d of %d results for project \"%s\" in sprint #%d",
+			len(issues), total, project, sprintID,
+		)
+	}
+
 	v := view.IssueList{
-		Project: project,
-		Server:  server,
-		Total:   total,
-		Data:    issues,
+		Project:    project,
+		Server:     server,
+		Total:      total,
+		Data:       issues,
+		FooterText: ft,
 		Display: view.DisplayFormat{
 			Plain:      plain,
 			NoHeaders:  noHeaders,
@@ -139,21 +156,13 @@ func singleSprintView(flags query.FlagParser, boardID, sprintID int, project, se
 }
 
 func sprintExplorerView(flags query.FlagParser, boardID int, project, server string, client *jira.Client) {
+	q, err := query.NewSprint(flags)
+	cmdutil.ExitIfError(err)
+
 	sprints := func() []*jira.Sprint {
 		s := cmdutil.Info("Fetching sprints...")
 		defer s.Stop()
 
-		resp, err := client.Boards(project, jira.BoardTypeScrum)
-		cmdutil.ExitIfError(err)
-
-		boardIDs := make([]int, 0, resp.Total)
-		for _, board := range resp.Boards {
-			boardIDs = append(boardIDs, board.ID)
-		}
-
-		q, err := query.NewSprint(flags)
-		cmdutil.ExitIfError(err)
-
 		return client.SprintsInBoards([]int{boardID}, q.Get(), numSprints)
 	}()
 	if len(sprints) == 0 {
@@ -161,6 +170,15 @@ func sprintExplorerView(flags query.FlagParser, boardID int, project, server str
 		return
 	}
 
+	if q.Params().Current || q.Params().Prev || q.Params().Next {
+		sprint := sprints[0]
+		if q.Params().Next {
+			sprint = sprints[len(sprints)-1]
+		}
+		singleSprintView(flags, boardID, sprint.ID, project, server, client, sprint)
+		return
+	}
+
 	plain, err := flags.GetBool("plain")
 	cmdutil.ExitIfError(err)
 
@@ -208,9 +226,12 @@ func setFlags(cmd *cobra.Command) {
 	cmd.Flags().String("state", "", "Filter sprint by its state (comma separated).\n"+
 		"Valid values are future, active and closed.\n"+
 		`Defaults to "active,closed"`)
-	cmd.Flags().Bool("table", false, "Display sprints in table view")
+	cmd.Flags().Bool("table", false, "Display sprints in a table view")
 	cmd.Flags().String("columns", "", "Comma separated list of columns to display in the plain mode.\n"+
 		fmt.Sprintf("Accepts: %s", strings.Join(view.ValidSprintColumns(), ", ")))
+	cmd.Flags().Bool("current", false, "List issues in current active sprint")
+	cmd.Flags().Bool("prev", false, "List issues in previous sprint")
+	cmd.Flags().Bool("next", false, "List issues in next planned sprint")
 }
 
 func hideFlags(cmd *cobra.Command) {
diff --git a/internal/cmdutil/utils.go b/internal/cmdutil/utils.go
index dc80b35..d4a54dd 100644
--- a/internal/cmdutil/utils.go
+++ b/internal/cmdutil/utils.go
@@ -57,3 +57,12 @@ func Navigate(server, path string) error {
 	url := fmt.Sprintf("%s/browse/%s", server, path)
 	return browser.OpenURL(url)
 }
+
+// FormatDateTimeHuman formats date time in human readable format.
+func FormatDateTimeHuman(dt, format string) string {
+	t, err := time.Parse(format, dt)
+	if err != nil {
+		return dt
+	}
+	return t.Format("Mon, 02 Jan 06")
+}
diff --git a/internal/query/sprint.go b/internal/query/sprint.go
index 78def35..fc41f13 100644
--- a/internal/query/sprint.go
+++ b/internal/query/sprint.go
@@ -7,12 +7,12 @@ import (
 // Sprint is a query type for sprint command.
 type Sprint struct {
 	Flags  FlagParser
-	params *sprintParams
+	params *SprintParams
 }
 
 // NewSprint creates and initializes a new Sprint type.
 func NewSprint(flags FlagParser) (*Sprint, error) {
-	sp := sprintParams{}
+	sp := SprintParams{}
 	if err := sp.init(flags); err != nil {
 		return nil, err
 	}
@@ -24,31 +24,71 @@ func NewSprint(flags FlagParser) (*Sprint, error) {
 
 // Get returns constructed query params.
 func (s *Sprint) Get() string {
-	state := "state=active,closed"
-	if s.params.status != "" {
-		state = fmt.Sprintf("state=%s", s.params.status)
+	var state string
+
+	switch {
+	case s.params.Status != "":
+		state = fmt.Sprintf("state=%s", s.params.Status)
+	case s.params.Current:
+		state = "state=active"
+	case s.params.Prev:
+		state = "state=closed"
+	case s.params.Next:
+		state = "state=future"
+	default:
+		state = "state=active,closed"
 	}
 	if s.params.debug {
 		fmt.Printf("JQL: %s\n", state)
 	}
+
 	return state
 }
 
-type sprintParams struct {
-	status string
-	debug  bool
+// Params returns sprint command params.
+func (s *Sprint) Params() *SprintParams {
+	return s.params
 }
 
-func (sp *sprintParams) init(flags FlagParser) error {
+// SprintParams is sprint command parameters.
+type SprintParams struct {
+	Status  string
+	Current bool
+	Prev    bool
+	Next    bool
+	debug   bool
+}
+
+func (sp *SprintParams) init(flags FlagParser) error {
 	status, err := flags.GetString("state")
 	if err != nil {
 		return err
 	}
+	sp.Status = status
+
+	current, err := flags.GetBool("current")
+	if err != nil {
+		return err
+	}
+	sp.Current = current
+
+	prev, err := flags.GetBool("prev")
+	if err != nil {
+		return err
+	}
+	sp.Prev = prev
+
+	next, err := flags.GetBool("next")
+	if err != nil {
+		return err
+	}
+	sp.Next = next
+
 	debug, err := flags.GetBool("debug")
 	if err != nil {
 		return err
 	}
-	sp.status = status
 	sp.debug = debug
+
 	return nil
 }
diff --git a/internal/view/helper.go b/internal/view/helper.go
index 84b26f6..416d4b4 100644
--- a/internal/view/helper.go
+++ b/internal/view/helper.go
@@ -105,14 +105,6 @@ func formatDateTime(dt, format string) string {
 	return t.Format("2006-01-02 15:04:05")
 }
 
-func formatDateTimeHuman(dt, format string) string {
-	t, err := time.Parse(format, dt)
-	if err != nil {
-		return dt
-	}
-	return t.Format("Mon, 02 Jan 06")
-}
-
 func prepareTitle(text string) string {
 	text = strings.TrimSpace(text)
 
diff --git a/internal/view/issue.go b/internal/view/issue.go
index 410eecd..e8214d0 100644
--- a/internal/view/issue.go
+++ b/internal/view/issue.go
@@ -8,6 +8,7 @@ import (
 
 	"github.com/charmbracelet/glamour"
 
+	"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
 	"github.com/ankitpokhrel/jira-cli/pkg/adf"
 	"github.com/ankitpokhrel/jira-cli/pkg/jira"
 	"github.com/ankitpokhrel/jira-cli/pkg/tui"
@@ -60,9 +61,9 @@ func (i Issue) String() string {
 	}
 	return fmt.Sprintf(
 		"%s %s  %s %s  ⌛ %s  👷 %s\n# %s\n⏱️  %s  🔎 %s  🚀 %s  🏷️  %s\n\n-----------\n%s",
-		iti, it, sti, st, formatDateTimeHuman(i.Data.Fields.Updated, jira.RFC3339), as,
+		iti, it, sti, st, cmdutil.FormatDateTimeHuman(i.Data.Fields.Updated, jira.RFC3339), as,
 		i.Data.Fields.Summary,
-		formatDateTimeHuman(i.Data.Fields.Created, jira.RFC3339), i.Data.Fields.Reporter.Name,
+		cmdutil.FormatDateTimeHuman(i.Data.Fields.Created, jira.RFC3339), i.Data.Fields.Reporter.Name,
 		i.Data.Fields.Priority.Name, lbl,
 		desc,
 	)
diff --git a/internal/view/issues.go b/internal/view/issues.go
index 918424f..7d0d7c1 100644
--- a/internal/view/issues.go
+++ b/internal/view/issues.go
@@ -27,11 +27,12 @@ type DisplayFormat struct {
 
 // IssueList is a list view for issues.
 type IssueList struct {
-	Total   int
-	Project string
-	Server  string
-	Data    []*jira.Issue
-	Display DisplayFormat
+	Total      int
+	Project    string
+	Server     string
+	Data       []*jira.Issue
+	Display    DisplayFormat
+	FooterText string
 }
 
 // Render renders the view.
@@ -47,10 +48,14 @@ func (l IssueList) Render() error {
 	}
 
 	data := l.data()
+	if l.FooterText == "" {
+		l.FooterText = fmt.Sprintf("Showing %d of %d results for project \"%s\"", len(data)-1, l.Total, l.Project)
+	}
+
 	view := tui.NewTable(
 		tui.WithColPadding(colPadding),
 		tui.WithMaxColWidth(maxColWidth),
-		tui.WithTableFooterText(fmt.Sprintf("Showing %d of %d results for project \"%s\"", len(data)-1, l.Total, l.Project)),
+		tui.WithTableFooterText(l.FooterText),
 		tui.WithSelectedFunc(navigate(l.Server)),
 		tui.WithViewModeFunc(func(r, c int, _ interface{}) (func() interface{}, func(interface{}) error) {
 			dataFn := func() interface{} {
diff --git a/internal/view/sprint.go b/internal/view/sprint.go
index 3e96a55..1386572 100644
--- a/internal/view/sprint.go
+++ b/internal/view/sprint.go
@@ -9,6 +9,7 @@ import (
 	"time"
 
 	"github.com/ankitpokhrel/jira-cli/api"
+	"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
 	"github.com/ankitpokhrel/jira-cli/pkg/jira"
 	"github.com/ankitpokhrel/jira-cli/pkg/tui"
 )
@@ -110,8 +111,8 @@ func (sl SprintList) data() []tui.PreviewData {
 				"➤ #%d %s: ⦗%s - %s⦘",
 				s.ID,
 				prepareTitle(s.Name),
-				formatDateTimeHuman(s.StartDate, time.RFC3339),
-				formatDateTimeHuman(s.EndDate, time.RFC3339),
+				cmdutil.FormatDateTimeHuman(s.StartDate, time.RFC3339),
+				cmdutil.FormatDateTimeHuman(s.EndDate, time.RFC3339),
 			),
 			Contents: func(key string) interface{} {
 				issues := sl.Issues(bid, sid)
diff --git a/pkg/jira/sprint.go b/pkg/jira/sprint.go
index c6804ea..284020f 100644
--- a/pkg/jira/sprint.go
+++ b/pkg/jira/sprint.go
@@ -136,15 +136,17 @@ func (c *Client) lastNSprints(boardID int, qp string, limit int) (*SprintResult,
 		n += limit
 	}
 
+	if err != nil {
+		return nil, err
+	}
 	if total == 0 {
 		return nil, ErrNoResult
 	}
 
 	n = total - limit
 	if n < 0 {
-		n = 0
+		return s, err
 	}
-
 	return c.Sprints(boardID, qp, n, limit)
 }
 


Test patch:
diff --git a/internal/cmdutil/utils_test.go b/internal/cmdutil/utils_test.go
new file mode 100644
index 0000000..198e1ae
--- /dev/null
+++ b/internal/cmdutil/utils_test.go
@@ -0,0 +1,57 @@
+package cmdutil
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/ankitpokhrel/jira-cli/pkg/jira"
+)
+
+func TestFormatDateTimeHuman(t *testing.T) {
+	cases := []struct {
+		name     string
+		format   func() string
+		expected string
+	}{
+		{
+			name: "it returns input date for invalid date input",
+			format: func() string {
+				return FormatDateTimeHuman("2020-12-03 10:00:00", jira.RFC3339)
+			},
+			expected: "2020-12-03 10:00:00",
+		},
+		{
+			name: "it returns input date for invalid input format",
+			format: func() string {
+				return FormatDateTimeHuman("2020-12-03 10:00:00", "invalid")
+			},
+			expected: "2020-12-03 10:00:00",
+		},
+		{
+			name: "it format input date from jira date format",
+			format: func() string {
+				return FormatDateTimeHuman("2020-12-13T14:05:20.974+0100", jira.RFC3339)
+			},
+			expected: "Sun, 13 Dec 20",
+		},
+		{
+			name: "it format input date from RFC3339 date format",
+			format: func() string {
+				return FormatDateTimeHuman("2020-12-13T16:12:00.000Z", time.RFC3339)
+			},
+			expected: "Sun, 13 Dec 20",
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			assert.Equal(t, tc.expected, tc.format())
+		})
+	}
+}
diff --git a/internal/query/sprint_test.go b/internal/query/sprint_test.go
index 9cd67f3..e01b07a 100644
--- a/internal/query/sprint_test.go
+++ b/internal/query/sprint_test.go
@@ -8,27 +8,39 @@ import (
 )
 
 type sprintParamsErr struct {
-	state bool
+	state   bool
+	current bool
 }
 
 type sprintFlagParser struct {
-	err        sprintParamsErr
-	state      string
-	emptyState bool
+	err     sprintParamsErr
+	state   string
+	current bool
+	prev    bool
+	next    bool
 }
 
-func (tfp sprintFlagParser) GetBool(string) (bool, error) {
-	return true, nil
+func (tfp sprintFlagParser) GetBool(name string) (bool, error) {
+	if tfp.err.current && name == "current" {
+		return true, fmt.Errorf("oops! couldn't fetch current flag")
+	}
+	if tfp.current && name == "current" {
+		return true, nil
+	}
+	if tfp.prev && name == "prev" {
+		return true, nil
+	}
+	if tfp.next && name == "next" {
+		return true, nil
+	}
+	return false, nil
 }
 
 func (tfp sprintFlagParser) GetString(name string) (string, error) {
 	if tfp.err.state && name == "state" {
 		return "", fmt.Errorf("oops! couldn't fetch state flag")
 	}
-	if tfp.emptyState && name == "state" {
-		return "", nil
-	}
-	return "future", nil
+	return tfp.state, nil
 }
 
 func (tfp sprintFlagParser) GetStringArray(string) ([]string, error) {
@@ -48,9 +60,7 @@ func TestSprintGet(t *testing.T) {
 		{
 			name: "query with default parameters",
 			initialize: func() *Sprint {
-				s, err := NewSprint(&sprintFlagParser{
-					emptyState: true,
-				})
+				s, err := NewSprint(&sprintFlagParser{})
 				assert.NoError(t, err)
 				return s
 			},
@@ -59,7 +69,7 @@ func TestSprintGet(t *testing.T) {
 		{
 			name: "query with state parameter",
 			initialize: func() *Sprint {
-				s, err := NewSprint(&sprintFlagParser{})
+				s, err := NewSprint(&sprintFlagParser{state: "future"})
 				assert.NoError(t, err)
 				return s
 			},
@@ -68,14 +78,48 @@ func TestSprintGet(t *testing.T) {
 		{
 			name: "query with error when fetching state flag",
 			initialize: func() *Sprint {
-				s, err := NewSprint(&sprintFlagParser{err: sprintParamsErr{
-					state: true,
-				}})
+				s, err := NewSprint(&sprintFlagParser{err: sprintParamsErr{state: true}})
 				assert.Error(t, err)
 				return s
 			},
 			expected: "",
 		},
+		{
+			name: "query with current parameter",
+			initialize: func() *Sprint {
+				s, err := NewSprint(&sprintFlagParser{current: true})
+				assert.NoError(t, err)
+				return s
+			},
+			expected: "state=active",
+		},
+		{
+			name: "query with error when fetching current flag",
+			initialize: func() *Sprint {
+				s, err := NewSprint(&sprintFlagParser{err: sprintParamsErr{current: true}})
+				assert.Error(t, err)
+				return s
+			},
+			expected: "",
+		},
+		{
+			name: "query with prev parameter",
+			initialize: func() *Sprint {
+				s, err := NewSprint(&sprintFlagParser{prev: true})
+				assert.NoError(t, err)
+				return s
+			},
+			expected: "state=closed",
+		},
+		{
+			name: "query with next parameter",
+			initialize: func() *Sprint {
+				s, err := NewSprint(&sprintFlagParser{next: true})
+				assert.NoError(t, err)
+				return s
+			},
+			expected: "state=future",
+		},
 	}
 
 	for _, tc := range cases {
diff --git a/internal/view/helper_test.go b/internal/view/helper_test.go
index 8dc90b7..60177af 100644
--- a/internal/view/helper_test.go
+++ b/internal/view/helper_test.go
@@ -58,53 +58,6 @@ func TestFormatDateTime(t *testing.T) {
 	}
 }
 
-func TestFormatDateTimeHuman(t *testing.T) {
-	cases := []struct {
-		name     string
-		format   func() string
-		expected string
-	}{
-		{
-			name: "it returns input date for invalid date input",
-			format: func() string {
-				return formatDateTimeHuman("2020-12-03 10:00:00", jira.RFC3339)
-			},
-			expected: "2020-12-03 10:00:00",
-		},
-		{
-			name: "it returns input date for invalid input format",
-			format: func() string {
-				return formatDateTimeHuman("2020-12-03 10:00:00", "invalid")
-			},
-			expected: "2020-12-03 10:00:00",
-		},
-		{
-			name: "it format input date from jira date format",
-			format: func() string {
-				return formatDateTimeHuman("2020-12-13T14:05:20.974+0100", jira.RFC3339)
-			},
-			expected: "Sun, 13 Dec 20",
-		},
-		{
-			name: "it format input date from RFC3339 date format",
-			format: func() string {
-				return formatDateTimeHuman("2020-12-13T16:12:00.000Z", time.RFC3339)
-			},
-			expected: "Sun, 13 Dec 20",
-		},
-	}
-
-	for _, tc := range cases {
-		tc := tc
-
-		t.Run(tc.name, func(t *testing.T) {
-			t.Parallel()
-
-			assert.Equal(t, tc.expected, tc.format())
-		})
-	}
-}
-
 func TestPrepareTitle(t *testing.T) {
 	cases := []struct {
 		name     string