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 {