From d7a9aa4c119d8b84e0b872df67a57096b9735c2e Mon Sep 17 00:00:00 2001 From: piekstra Date: Tue, 24 Feb 2026 15:53:56 -0500 Subject: [PATCH] jtk: Add pagination and lightweight fields to issues list/search - Use cursor-based pagination (nextPageToken/isLast) matching Jira's /search/jql endpoint instead of the deprecated offset-based approach - Add SearchPage() for single-page fetches with pagination metadata - Add JQLSearchResult type for the cursor-based /search/jql response - Add ListSearchFields (excludes description) for compact list output - Default page size 25, with --next-page-token for subsequent pages - Add --full flag to include all fields when needed - JSON output always wrapped with pagination metadata - Reduces typical list output from ~746K to ~43K characters --- tools/jtk/api/search.go | 94 ++++++++++++++++++------- tools/jtk/api/types.go | 32 ++++++++- tools/jtk/api/types_test.go | 21 ++++++ tools/jtk/internal/cmd/issues/list.go | 52 ++++++++++---- tools/jtk/internal/cmd/issues/search.go | 49 ++++++++++--- 5 files changed, 197 insertions(+), 51 deletions(-) diff --git a/tools/jtk/api/search.go b/tools/jtk/api/search.go index b96a8d0..0a227fc 100644 --- a/tools/jtk/api/search.go +++ b/tools/jtk/api/search.go @@ -6,23 +6,23 @@ import ( "fmt" ) -// SearchOptions contains options for JQL search +// SearchOptions contains options for JQL search. type SearchOptions struct { - JQL string - StartAt int - MaxResults int - Fields []string + JQL string + MaxResults int + Fields []string + NextPageToken string } -// SearchRequest is the request body for the new JQL search API +// SearchRequest is the request body for the /search/jql endpoint. type SearchRequest struct { - JQL string `json:"jql"` - StartAt int `json:"startAt,omitempty"` - MaxResults int `json:"maxResults,omitempty"` - Fields []string `json:"fields,omitempty"` + JQL string `json:"jql"` + MaxResults int `json:"maxResults,omitempty"` + Fields []string `json:"fields,omitempty"` + NextPageToken string `json:"nextPageToken,omitempty"` } -// DefaultSearchFields are the fields returned by default in search results +// DefaultSearchFields are the fields returned by default in search results. var DefaultSearchFields = []string{ "summary", "status", @@ -39,22 +39,35 @@ var DefaultSearchFields = []string{ "parent", } -// Search searches for issues using JQL (uses new /search/jql endpoint) -func (c *Client) Search(ctx context.Context, opts SearchOptions) (*SearchResult, error) { +// ListSearchFields are lightweight fields for list/search commands (no description). +var ListSearchFields = []string{ + "summary", + "status", + "assignee", + "issuetype", + "priority", + "project", + "labels", + "created", + "updated", +} + +// Search searches for issues using JQL (uses /search/jql endpoint). +func (c *Client) Search(ctx context.Context, opts SearchOptions) (*JQLSearchResult, error) { req := SearchRequest{ JQL: opts.JQL, } - if opts.StartAt > 0 { - req.StartAt = opts.StartAt - } - if opts.MaxResults > 0 { req.MaxResults = opts.MaxResults } else { req.MaxResults = 50 } + if opts.NextPageToken != "" { + req.NextPageToken = opts.NextPageToken + } + // Use default fields if none specified - new API requires explicit field selection if len(opts.Fields) > 0 { req.Fields = opts.Fields @@ -68,7 +81,7 @@ func (c *Client) Search(ctx context.Context, opts SearchOptions) (*SearchResult, return nil, fmt.Errorf("searching issues: %w", err) } - var result SearchResult + var result JQLSearchResult if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parsing search results: %w", err) } @@ -76,15 +89,15 @@ func (c *Client) Search(ctx context.Context, opts SearchOptions) (*SearchResult, return &result, nil } -// SearchAll searches for all issues matching JQL (handles pagination) +// SearchAll searches for all issues matching JQL (handles cursor-based pagination). func (c *Client) SearchAll(ctx context.Context, jql string, maxResults int) ([]Issue, error) { if maxResults <= 0 { maxResults = 1000 } var allIssues []Issue - startAt := 0 pageSize := 100 + nextPageToken := "" for { if err := ctx.Err(); err != nil { @@ -92,22 +105,22 @@ func (c *Client) SearchAll(ctx context.Context, jql string, maxResults int) ([]I } result, err := c.Search(ctx, SearchOptions{ - JQL: jql, - StartAt: startAt, - MaxResults: pageSize, + JQL: jql, + MaxResults: pageSize, + NextPageToken: nextPageToken, }) if err != nil { - return nil, fmt.Errorf("searching all issues (offset %d): %w", startAt, err) + return nil, fmt.Errorf("searching all issues: %w", err) } allIssues = append(allIssues, result.Issues...) - if len(allIssues) >= result.Total || len(allIssues) >= maxResults { + if result.IsLast || len(allIssues) >= maxResults { break } - startAt += len(result.Issues) - if len(result.Issues) == 0 { + nextPageToken = result.NextPageToken + if nextPageToken == "" || len(result.Issues) == 0 { break } } @@ -118,3 +131,30 @@ func (c *Client) SearchAll(ctx context.Context, jql string, maxResults int) ([]I return allIssues, nil } + +// SearchPage searches for a single page of issues and returns results with pagination metadata. +func (c *Client) SearchPage(ctx context.Context, opts SearchPageOptions) (*PaginatedIssues, error) { + pageSize := opts.PageSize + if pageSize <= 0 { + pageSize = 25 + } + + result, err := c.Search(ctx, SearchOptions{ + JQL: opts.JQL, + MaxResults: pageSize, + Fields: opts.Fields, + NextPageToken: opts.NextPageToken, + }) + if err != nil { + return nil, err + } + + return &PaginatedIssues{ + Issues: result.Issues, + Pagination: PaginationInfo{ + PageSize: pageSize, + IsLast: result.IsLast, + NextPageToken: result.NextPageToken, + }, + }, nil +} diff --git a/tools/jtk/api/types.go b/tools/jtk/api/types.go index a0f9088..3dfe2a0 100644 --- a/tools/jtk/api/types.go +++ b/tools/jtk/api/types.go @@ -327,7 +327,8 @@ type FieldSchema struct { CustomID int `json:"customId,omitempty"` } -// SearchResult represents search results from JQL +// SearchResult represents search results from Jira APIs +// that use offset-based pagination (e.g., Agile API sprint issues). type SearchResult struct { StartAt int `json:"startAt"` MaxResults int `json:"maxResults"` @@ -335,6 +336,35 @@ type SearchResult struct { Issues []Issue `json:"issues"` } +// JQLSearchResult represents results from the /search/jql endpoint, +// which uses cursor-based pagination. +type JQLSearchResult struct { + Issues []Issue `json:"issues"` + NextPageToken string `json:"nextPageToken,omitempty"` + IsLast bool `json:"isLast"` +} + +// SearchPageOptions contains options for a single-page search. +type SearchPageOptions struct { + JQL string + PageSize int + Fields []string + NextPageToken string +} + +// PaginatedIssues wraps issues with cursor-based pagination metadata. +type PaginatedIssues struct { + Issues []Issue `json:"issues"` + Pagination PaginationInfo `json:"pagination"` +} + +// PaginationInfo contains cursor-based pagination metadata. +type PaginationInfo struct { + PageSize int `json:"pageSize"` + IsLast bool `json:"isLast"` + NextPageToken string `json:"nextPageToken,omitempty"` +} + // BoardsResponse represents the response from listing boards type BoardsResponse struct { MaxResults int `json:"maxResults"` diff --git a/tools/jtk/api/types_test.go b/tools/jtk/api/types_test.go index 661e7eb..74daaa7 100644 --- a/tools/jtk/api/types_test.go +++ b/tools/jtk/api/types_test.go @@ -311,6 +311,27 @@ func TestIssue_UnmarshalJSON(t *testing.T) { testutil.Equal(t, issue.Fields.Labels, []string{"bug", "urgent"}) } +func TestJQLSearchResult_UnmarshalJSON(t *testing.T) { + input := `{ + "issues": [ + {"id": "1", "key": "PROJ-1", "fields": {"summary": "Issue 1"}}, + {"id": "2", "key": "PROJ-2", "fields": {"summary": "Issue 2"}} + ], + "nextPageToken": "abc123", + "isLast": false + }` + + var result JQLSearchResult + err := json.Unmarshal([]byte(input), &result) + testutil.RequireNoError(t, err) + + testutil.Len(t, result.Issues, 2) + testutil.Equal(t, result.Issues[0].Key, "PROJ-1") + testutil.Equal(t, result.Issues[1].Key, "PROJ-2") + testutil.Equal(t, result.NextPageToken, "abc123") + testutil.Equal(t, result.IsLast, false) +} + func TestSearchResult_UnmarshalJSON(t *testing.T) { input := `{ "startAt": 0, diff --git a/tools/jtk/internal/cmd/issues/list.go b/tools/jtk/internal/cmd/issues/list.go index 204f276..3e3d080 100644 --- a/tools/jtk/internal/cmd/issues/list.go +++ b/tools/jtk/internal/cmd/issues/list.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" + "github.com/open-cli-collective/jira-ticket-cli/api" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" ) @@ -13,6 +14,8 @@ func newListCmd(opts *root.Options) *cobra.Command { var project string var sprint string var maxResults int + var nextPageToken string + var full bool cmd := &cobra.Command{ Use: "list", @@ -24,21 +27,26 @@ func newListCmd(opts *root.Options) *cobra.Command { # List issues in the current sprint jtk issues list --project MYPROJECT --sprint current - # List issues with custom limit - jtk issues list --project MYPROJECT --max 100`, + # List next page using token from previous result + jtk issues list --project MYPROJECT --next-page-token + + # List with full details (includes description) + jtk issues list --project MYPROJECT --full`, RunE: func(cmd *cobra.Command, _ []string) error { - return runList(cmd.Context(), opts, project, sprint, maxResults) + return runList(cmd.Context(), opts, project, sprint, maxResults, nextPageToken, full) }, } cmd.Flags().StringVarP(&project, "project", "p", "", "Filter by project key") cmd.Flags().StringVarP(&sprint, "sprint", "s", "", "Filter by sprint (use 'current' for active sprint)") - cmd.Flags().IntVarP(&maxResults, "max", "m", 50, "Maximum number of results") + cmd.Flags().IntVarP(&maxResults, "max", "m", 25, "Page size (number of results per page)") + cmd.Flags().StringVar(&nextPageToken, "next-page-token", "", "Token for next page of results") + cmd.Flags().BoolVar(&full, "full", false, "Include all fields (e.g. description)") return cmd } -func runList(ctx context.Context, opts *root.Options, project, sprint string, maxResults int) error { +func runList(ctx context.Context, opts *root.Options, project, sprint string, maxResults int, nextPageToken string, full bool) error { v := opts.View() client, err := opts.APIClient() @@ -73,25 +81,36 @@ func runList(ctx context.Context, opts *root.Options, project, sprint string, ma jql += " ORDER BY updated DESC" } - issues, err := client.SearchAll(ctx, jql, maxResults) + // Select fields based on --full flag + fields := api.ListSearchFields + if full { + fields = api.DefaultSearchFields + } + + result, err := client.SearchPage(ctx, api.SearchPageOptions{ + JQL: jql, + PageSize: maxResults, + Fields: fields, + NextPageToken: nextPageToken, + }) if err != nil { return err } - if len(issues) == 0 { + if len(result.Issues) == 0 { v.Info("No issues found") return nil } - // For JSON output + // For JSON output, return the paginated wrapper if opts.Output == "json" { - return v.JSON(issues) + return v.JSON(result) } headers := []string{"KEY", "SUMMARY", "STATUS", "ASSIGNEE", "TYPE"} - rows := make([][]string, 0, len(issues)) + rows := make([][]string, 0, len(result.Issues)) - for _, issue := range issues { + for _, issue := range result.Issues { status := "" if issue.Fields.Status != nil { status = issue.Fields.Status.Name @@ -110,5 +129,14 @@ func runList(ctx context.Context, opts *root.Options, project, sprint string, ma rows = append(rows, formatIssueRow(issue.Key, issue.Fields.Summary, status, assignee, issueType)) } - return v.Table(headers, rows) + if err := v.Table(headers, rows); err != nil { + return err + } + + // Print pagination footer on stderr when there are more results + if !result.Pagination.IsLast { + v.Info("More results available (use --next-page-token to fetch next page)") + } + + return nil } diff --git a/tools/jtk/internal/cmd/issues/search.go b/tools/jtk/internal/cmd/issues/search.go index 3b05ac2..50cbf3d 100644 --- a/tools/jtk/internal/cmd/issues/search.go +++ b/tools/jtk/internal/cmd/issues/search.go @@ -5,12 +5,15 @@ import ( "github.com/spf13/cobra" + "github.com/open-cli-collective/jira-ticket-cli/api" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" ) func newSearchCmd(opts *root.Options) *cobra.Command { var jql string var maxResults int + var nextPageToken string + var full bool cmd := &cobra.Command{ Use: "search", @@ -22,21 +25,26 @@ func newSearchCmd(opts *root.Options) *cobra.Command { # Search for recent issues jtk issues search --jql "project = MYPROJECT AND updated >= -7d" - # Search issues assigned to current user - jtk issues search --jql "assignee = currentUser() AND resolution = Unresolved"`, + # Search with pagination + jtk issues search --jql "project = MYPROJECT" --next-page-token + + # Search with full details (includes description) + jtk issues search --jql "project = MYPROJECT" --full`, RunE: func(cmd *cobra.Command, _ []string) error { - return runSearch(cmd.Context(), opts, jql, maxResults) + return runSearch(cmd.Context(), opts, jql, maxResults, nextPageToken, full) }, } cmd.Flags().StringVar(&jql, "jql", "", "JQL query string (required)") - cmd.Flags().IntVarP(&maxResults, "max", "m", 50, "Maximum number of results") + cmd.Flags().IntVarP(&maxResults, "max", "m", 25, "Page size (number of results per page)") + cmd.Flags().StringVar(&nextPageToken, "next-page-token", "", "Token for next page of results") + cmd.Flags().BoolVar(&full, "full", false, "Include all fields (e.g. description)") _ = cmd.MarkFlagRequired("jql") return cmd } -func runSearch(ctx context.Context, opts *root.Options, jql string, maxResults int) error { +func runSearch(ctx context.Context, opts *root.Options, jql string, maxResults int, nextPageToken string, full bool) error { v := opts.View() client, err := opts.APIClient() @@ -44,24 +52,35 @@ func runSearch(ctx context.Context, opts *root.Options, jql string, maxResults i return err } - issues, err := client.SearchAll(ctx, jql, maxResults) + // Select fields based on --full flag + fields := api.ListSearchFields + if full { + fields = api.DefaultSearchFields + } + + result, err := client.SearchPage(ctx, api.SearchPageOptions{ + JQL: jql, + PageSize: maxResults, + Fields: fields, + NextPageToken: nextPageToken, + }) if err != nil { return err } - if len(issues) == 0 { + if len(result.Issues) == 0 { v.Info("No issues found") return nil } if opts.Output == "json" { - return v.JSON(issues) + return v.JSON(result) } headers := []string{"KEY", "SUMMARY", "STATUS", "ASSIGNEE", "TYPE"} - rows := make([][]string, 0, len(issues)) + rows := make([][]string, 0, len(result.Issues)) - for _, issue := range issues { + for _, issue := range result.Issues { status := "" if issue.Fields.Status != nil { status = issue.Fields.Status.Name @@ -80,5 +99,13 @@ func runSearch(ctx context.Context, opts *root.Options, jql string, maxResults i rows = append(rows, formatIssueRow(issue.Key, issue.Fields.Summary, status, assignee, issueType)) } - return v.Table(headers, rows) + if err := v.Table(headers, rows); err != nil { + return err + } + + if !result.Pagination.IsLast { + v.Info("More results available (use --next-page-token to fetch next page)") + } + + return nil }