diff --git a/README.md b/README.md
index 28a16856..955eb519 100644
--- a/README.md
+++ b/README.md
@@ -308,6 +308,22 @@ jira issue list -w -pXYZ
```
+What's in the current sprint? :running:
+
+```sh
+# The --sprint flag accepts a sprint name, numeric ID, or one of the
+# keywords: current/active, closed/previous, future/next.
+jira issue list --sprint current
+
+# Filter by a named sprint or its ID.
+jira issue list --sprint "Sprint 42"
+jira issue list --sprint 123
+
+# Combine multiple sprints (e.g. this sprint and the previous one).
+jira issue list --sprint "Sprint 41" --sprint "Sprint 42"
+```
+
+
#### Create
The `create` command lets you create an issue.
diff --git a/internal/cmd/issue/list/list.go b/internal/cmd/issue/list/list.go
index efae6c49..58afb168 100644
--- a/internal/cmd/issue/list/list.go
+++ b/internal/cmd/issue/list/list.go
@@ -63,7 +63,17 @@ $ jira issue list -tEpic -sDone
$ jira issue list -s~Open -ax
# List issues from all projects
-$ jira issue list -q"project IS NOT EMPTY"`
+$ jira issue list -q"project IS NOT EMPTY"
+
+# List issues in the active sprint(s)
+$ jira issue list --sprint current
+
+# List issues in a specific sprint by name or ID
+$ jira issue list --sprint "Sprint 42"
+$ jira issue list --sprint 123
+
+# List issues across multiple sprints
+$ jira issue list --sprint "Sprint 41" --sprint "Sprint 42"`
)
// NewCmdList is a list command.
@@ -221,6 +231,12 @@ func SetFlags(cmd *cobra.Command) {
cmd.Flags().StringP("component", "C", "", "Filter issues by component")
cmd.Flags().StringArrayP("label", "l", []string{}, "Filter issues by label")
cmd.Flags().StringP("parent", "P", "", "Filter issues by parent")
+ cmd.Flags().StringArray("sprint", []string{}, "Filter issues by sprint.\n"+
+ "Accepts a sprint name, numeric ID, or one of the keywords:\n"+
+ " current / active → sprints currently in progress (openSprints())\n"+
+ " closed / previous → closed sprints (closedSprints())\n"+
+ " future / next → future sprints (futureSprints())\n"+
+ "Can be repeated to filter by multiple sprints.")
cmd.Flags().Bool("history", false, "Issues you accessed recently")
cmd.Flags().BoolP("watching", "w", false, "Issues you are watching")
cmd.Flags().String("created", "", "Filter issues by created date\n"+
diff --git a/internal/cmd/sprint/list/list.go b/internal/cmd/sprint/list/list.go
index ac3098f9..259f0e30 100644
--- a/internal/cmd/sprint/list/list.go
+++ b/internal/cmd/sprint/list/list.go
@@ -307,5 +307,6 @@ func hideFlags(cmd *cobra.Command) {
cmdutil.ExitIfError(cmd.Flags().MarkHidden("created-before"))
cmdutil.ExitIfError(cmd.Flags().MarkHidden("updated-before"))
cmdutil.ExitIfError(cmd.Flags().MarkHidden("label"))
+ cmdutil.ExitIfError(cmd.Flags().MarkHidden("sprint"))
cmdutil.ExitIfError(cmd.Flags().MarkHidden("reverse"))
}
diff --git a/internal/query/issue.go b/internal/query/issue.go
index 73f63146..deb6a664 100644
--- a/internal/query/issue.go
+++ b/internal/query/issue.go
@@ -2,6 +2,7 @@ package query
import (
"fmt"
+ "sort"
"strconv"
"strings"
"time"
@@ -103,6 +104,8 @@ func (i *Issue) Get() string {
if len(negative) > 0 {
q.NotIn("status", negative...)
}
+
+ i.setSprintFilters(q)
})
if i.params.Reverse {
@@ -152,6 +155,62 @@ func (i *Issue) setCreatedFilters(q *jql.JQL) {
}
}
+// sprintKeywordFuncs maps user-facing keywords to their JQL function
+// equivalents used with the `sprint IN ()` form.
+var sprintKeywordFuncs = map[string]string{
+ "current": "openSprints()",
+ "active": "openSprints()",
+ "closed": "closedSprints()",
+ "previous": "closedSprints()",
+ "future": "futureSprints()",
+ "next": "futureSprints()",
+}
+
+func (i *Issue) setSprintFilters(q *jql.JQL) {
+ if len(i.params.Sprints) == 0 {
+ return
+ }
+
+ // Collect keyword-based function filters (deduplicated) and
+ // explicit sprint identifiers (names or numeric IDs) separately.
+ // Numeric values are emitted unquoted so Jira interprets them as
+ // sprint IDs; non-numeric values are quoted as sprint names.
+ fnSet := make(map[string]struct{})
+ values := make([]string, 0, len(i.params.Sprints))
+ for _, s := range i.params.Sprints {
+ v := strings.TrimSpace(s)
+ if v == "" {
+ continue
+ }
+ if fn, ok := sprintKeywordFuncs[strings.ToLower(v)]; ok {
+ fnSet[fn] = struct{}{}
+ continue
+ }
+ values = append(values, v)
+ }
+
+ fns := make([]string, 0, len(fnSet))
+ for fn := range fnSet {
+ fns = append(fns, fn)
+ }
+ sort.Strings(fns)
+ for _, fn := range fns {
+ q.InFunc("sprint", fn)
+ }
+
+ if len(values) > 0 {
+ parts := make([]string, 0, len(values))
+ for _, v := range values {
+ if _, err := strconv.Atoi(v); err == nil {
+ parts = append(parts, v)
+ } else {
+ parts = append(parts, fmt.Sprintf("%q", v))
+ }
+ }
+ q.InFunc("sprint", "("+strings.Join(parts, ", ")+")")
+ }
+}
+
func (i *Issue) setUpdatedFilters(q *jql.JQL) {
if i.params.Updated != "" {
i.setDateFilters(q, "updatedDate", i.params.Updated)
@@ -184,6 +243,7 @@ type IssueParams struct {
CreatedBefore string
UpdatedBefore string
Labels []string
+ Sprints []string
OrderBy string
Reverse bool
From uint
@@ -227,6 +287,11 @@ func (ip *IssueParams) init(flags FlagParser) error {
return err
}
+ sprints, err := flags.GetStringArray("sprint")
+ if err != nil {
+ return err
+ }
+
paginate, err := flags.GetString("paginate")
if err != nil {
return err
@@ -240,6 +305,7 @@ func (ip *IssueParams) init(flags FlagParser) error {
ip.setStringParams(stringParamsMap)
ip.Labels = labels
ip.Status = status
+ ip.Sprints = sprints
ip.From = from
ip.Limit = limit
diff --git a/internal/query/issue_test.go b/internal/query/issue_test.go
index 4c611d76..e3bbaddd 100644
--- a/internal/query/issue_test.go
+++ b/internal/query/issue_test.go
@@ -15,6 +15,7 @@ type issueParamsErr struct {
issueType bool
labels bool
status bool
+ sprints bool
}
type issueFlagParser struct {
@@ -25,6 +26,7 @@ type issueFlagParser struct {
emptyType bool
labels []string
status []string
+ sprints []string
withCreated bool
withUpdated bool
created string
@@ -114,9 +116,15 @@ func (tfp *issueFlagParser) GetStringArray(name string) ([]string, error) {
if tfp.err.status && name == "status" {
return []string{}, fmt.Errorf("oops! couldn't fetch status flag")
}
+ if tfp.err.sprints && name == "sprint" {
+ return []string{}, fmt.Errorf("oops! couldn't fetch sprint flag")
+ }
if name == "status" {
return tfp.status, nil
}
+ if name == "sprint" {
+ return tfp.sprints, nil
+ }
return tfp.labels, nil
}
@@ -421,6 +429,107 @@ func TestIssueGet(t *testing.T) {
`AND parent="test" AND updatedDate>"2020-11-31" AND updatedDate<"2020-12-31" ` +
`ORDER BY updated ASC`,
},
+ {
+ name: "query with error when fetching sprint flag",
+ initialize: func() *Issue {
+ i, err := NewIssue("TEST", &issueFlagParser{err: issueParamsErr{
+ sprints: true,
+ }})
+ assert.Error(t, err)
+ return i
+ },
+ expected: "",
+ },
+ {
+ name: "query with sprint by name",
+ initialize: func() *Issue {
+ i, err := NewIssue("TEST", &issueFlagParser{sprints: []string{"Sprint 42"}})
+ assert.NoError(t, err)
+ return i
+ },
+ expected: `project="TEST" AND issue IN issueHistory() AND issue IN watchedIssues() AND ` +
+ `type="test" AND resolution="test" AND priority="test" AND reporter="test" AND assignee="test" ` +
+ `AND component="test" AND parent="test" AND sprint IN ("Sprint 42") ORDER BY lastViewed ASC`,
+ },
+ {
+ name: "query with sprint by id (numeric, unquoted)",
+ initialize: func() *Issue {
+ i, err := NewIssue("TEST", &issueFlagParser{sprints: []string{"123"}})
+ assert.NoError(t, err)
+ return i
+ },
+ expected: `project="TEST" AND issue IN issueHistory() AND issue IN watchedIssues() AND ` +
+ `type="test" AND resolution="test" AND priority="test" AND reporter="test" AND assignee="test" ` +
+ `AND component="test" AND parent="test" AND sprint IN (123) ORDER BY lastViewed ASC`,
+ },
+ {
+ name: "query with current sprint keyword",
+ initialize: func() *Issue {
+ i, err := NewIssue("TEST", &issueFlagParser{sprints: []string{"current"}})
+ assert.NoError(t, err)
+ return i
+ },
+ expected: `project="TEST" AND issue IN issueHistory() AND issue IN watchedIssues() AND ` +
+ `type="test" AND resolution="test" AND priority="test" AND reporter="test" AND assignee="test" ` +
+ `AND component="test" AND parent="test" AND sprint IN openSprints() ORDER BY lastViewed ASC`,
+ },
+ {
+ name: "query with active sprint keyword is equivalent to current",
+ initialize: func() *Issue {
+ i, err := NewIssue("TEST", &issueFlagParser{sprints: []string{"ACTIVE"}})
+ assert.NoError(t, err)
+ return i
+ },
+ expected: `project="TEST" AND issue IN issueHistory() AND issue IN watchedIssues() AND ` +
+ `type="test" AND resolution="test" AND priority="test" AND reporter="test" AND assignee="test" ` +
+ `AND component="test" AND parent="test" AND sprint IN openSprints() ORDER BY lastViewed ASC`,
+ },
+ {
+ name: "query with closed sprint keyword",
+ initialize: func() *Issue {
+ i, err := NewIssue("TEST", &issueFlagParser{sprints: []string{"closed"}})
+ assert.NoError(t, err)
+ return i
+ },
+ expected: `project="TEST" AND issue IN issueHistory() AND issue IN watchedIssues() AND ` +
+ `type="test" AND resolution="test" AND priority="test" AND reporter="test" AND assignee="test" ` +
+ `AND component="test" AND parent="test" AND sprint IN closedSprints() ORDER BY lastViewed ASC`,
+ },
+ {
+ name: "query with future sprint keyword",
+ initialize: func() *Issue {
+ i, err := NewIssue("TEST", &issueFlagParser{sprints: []string{"future"}})
+ assert.NoError(t, err)
+ return i
+ },
+ expected: `project="TEST" AND issue IN issueHistory() AND issue IN watchedIssues() AND ` +
+ `type="test" AND resolution="test" AND priority="test" AND reporter="test" AND assignee="test" ` +
+ `AND component="test" AND parent="test" AND sprint IN futureSprints() ORDER BY lastViewed ASC`,
+ },
+ {
+ name: "query with multiple sprints mixes quoted names and unquoted ids",
+ initialize: func() *Issue {
+ i, err := NewIssue("TEST", &issueFlagParser{sprints: []string{"Sprint 41", "Sprint 42", "123"}})
+ assert.NoError(t, err)
+ return i
+ },
+ expected: `project="TEST" AND issue IN issueHistory() AND issue IN watchedIssues() AND ` +
+ `type="test" AND resolution="test" AND priority="test" AND reporter="test" AND assignee="test" ` +
+ `AND component="test" AND parent="test" AND sprint IN ("Sprint 41", "Sprint 42", 123) ` +
+ `ORDER BY lastViewed ASC`,
+ },
+ {
+ name: "query mixing sprint keywords and names deduplicates keywords",
+ initialize: func() *Issue {
+ i, err := NewIssue("TEST", &issueFlagParser{sprints: []string{"current", "active", "closed", "Sprint 42"}})
+ assert.NoError(t, err)
+ return i
+ },
+ expected: `project="TEST" AND issue IN issueHistory() AND issue IN watchedIssues() AND ` +
+ `type="test" AND resolution="test" AND priority="test" AND reporter="test" AND assignee="test" ` +
+ `AND component="test" AND parent="test" AND sprint IN closedSprints() AND sprint IN openSprints() ` +
+ `AND sprint IN ("Sprint 42") ORDER BY lastViewed ASC`,
+ },
{
name: "query with jql parameter",
initialize: func() *Issue {
diff --git a/pkg/jql/jql.go b/pkg/jql/jql.go
index d8807d08..babc76b1 100644
--- a/pkg/jql/jql.go
+++ b/pkg/jql/jql.go
@@ -157,6 +157,16 @@ func (j *JQL) NotIn(field string, value ...string) *JQL {
return j
}
+// InFunc constructs a query of the form `field IN fn`, where fn is a
+// JQL function call like `openSprints()`. Unlike In, the right-hand
+// side is emitted verbatim rather than quoted.
+func (j *JQL) InFunc(field, fn string) *JQL {
+ if field != "" && fn != "" {
+ j.filters = append(j.filters, fmt.Sprintf("%s IN %s", field, fn))
+ }
+ return j
+}
+
// OrderBy orders the output in given direction.
func (j *JQL) OrderBy(field, dir string) *JQL {
j.orderBy = fmt.Sprintf("ORDER BY %s %s", field, dir)
diff --git a/pkg/jql/jql_test.go b/pkg/jql/jql_test.go
index 461605a5..5be27463 100644
--- a/pkg/jql/jql_test.go
+++ b/pkg/jql/jql_test.go
@@ -247,6 +247,42 @@ func TestJQL(t *testing.T) {
},
expected: "project=\"TEST\" AND type=\"Story\" AND labels IN (\"first\", \"second\") AND labels NOT IN (\"third\", \"fourth\")",
},
+ {
+ name: "it queries with IN and a JQL function",
+ initialize: func() *JQL {
+ jql := NewJQL("TEST")
+ jql.And(func() {
+ jql.FilterBy("type", "Story").
+ InFunc("sprint", "openSprints()")
+ })
+ return jql
+ },
+ expected: "project=\"TEST\" AND type=\"Story\" AND sprint IN openSprints()",
+ },
+ {
+ name: "InFunc ignores empty field",
+ initialize: func() *JQL {
+ jql := NewJQL("TEST")
+ jql.And(func() {
+ jql.FilterBy("type", "Story").
+ InFunc("", "openSprints()")
+ })
+ return jql
+ },
+ expected: "project=\"TEST\" AND type=\"Story\"",
+ },
+ {
+ name: "InFunc ignores empty function",
+ initialize: func() *JQL {
+ jql := NewJQL("TEST")
+ jql.And(func() {
+ jql.FilterBy("type", "Story").
+ InFunc("sprint", "")
+ })
+ return jql
+ },
+ expected: "project=\"TEST\" AND type=\"Story\"",
+ },
{
name: "it queries with raw jql",
initialize: func() *JQL {