Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,22 @@ jira issue list -w -pXYZ
```
</details>

<details><summary>What's in the current sprint? :running:</summary>

```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"
```
</details>

#### Create
The `create` command lets you create an issue.

Expand Down
18 changes: 17 additions & 1 deletion internal/cmd/issue/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"+
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/sprint/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
66 changes: 66 additions & 0 deletions internal/query/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package query

import (
"fmt"
"sort"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -103,6 +104,8 @@ func (i *Issue) Get() string {
if len(negative) > 0 {
q.NotIn("status", negative...)
}

i.setSprintFilters(q)
})

if i.params.Reverse {
Expand Down Expand Up @@ -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 <fn>()` 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)
Expand Down Expand Up @@ -184,6 +243,7 @@ type IssueParams struct {
CreatedBefore string
UpdatedBefore string
Labels []string
Sprints []string
OrderBy string
Reverse bool
From uint
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
109 changes: 109 additions & 0 deletions internal/query/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type issueParamsErr struct {
issueType bool
labels bool
status bool
sprints bool
}

type issueFlagParser struct {
Expand All @@ -25,6 +26,7 @@ type issueFlagParser struct {
emptyType bool
labels []string
status []string
sprints []string
withCreated bool
withUpdated bool
created string
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions pkg/jql/jql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions pkg/jql/jql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading