Skip to content
Merged
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
94 changes: 67 additions & 27 deletions tools/jtk/api/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -68,46 +81,46 @@ 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)
}

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 {
return nil, fmt.Errorf("searching all issues: %w", err)
}

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
}
}
Expand All @@ -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
}
32 changes: 31 additions & 1 deletion tools/jtk/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,14 +327,44 @@ 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"`
Total int `json:"total"`
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"`
Expand Down
21 changes: 21 additions & 0 deletions tools/jtk/api/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 40 additions & 12 deletions tools/jtk/internal/cmd/issues/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ 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 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",
Expand All @@ -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 <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()
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Loading
Loading