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
2 changes: 1 addition & 1 deletion api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func ProxySearch(c *jira.Client, jql string, from, limit uint) (*jira.SearchResu
if it == jira.InstallationTypeLocal {
issues, err = c.SearchV2(jql, from, limit)
} else {
issues, err = c.Search(jql, limit)
issues, err = c.SearchAll(jql, limit)
}

return issues, err
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/epic/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func singleEpicView(flags query.FlagParser, key, project, projectType, server st
q.Params().Parent = key
q.Params().IssueType = ""

resp, err = client.Search(q.Get(), q.Params().Limit)
resp, err = client.SearchAll(q.Get(), q.Params().Limit)
} else {
resp, err = client.EpicIssues(key, q.Get(), q.Params().From, q.Params().Limit)
}
Expand Down Expand Up @@ -209,7 +209,7 @@ func epicExplorerView(cmd *cobra.Command, flags query.FlagParser, project, proje
q.Params().Parent = key
q.Params().IssueType = ""

resp, err = client.Search(q.Get(), q.Params().Limit)
resp, err = client.SearchAll(q.Get(), q.Params().Limit)
} else {
resp, err = client.EpicIssues(key, "", q.Params().From, q.Params().Limit)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/issue/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ func SetFlags(cmd *cobra.Command) {
cmd.Flags().StringP("jql", "q", "", "Run a raw JQL query in a given project context")
cmd.Flags().String("order-by", "created", "Field to order the list with")
cmd.Flags().Bool("reverse", false, "Reverse the display order (default \"DESC\")")
cmd.Flags().String("paginate", "0:100", "Paginate the result. Max 100 at a time, format: <from>:<limit> where <from> is optional")
cmd.Flags().String("paginate", "0:100", "Paginate the result, format: <from>:<limit> where <from> is optional. Limits > 100 are auto-paginated")
cmd.Flags().Bool("plain", false, "Display output in plain mode")
cmd.Flags().Bool("no-headers", false, "Don't display table headers in plain mode. Works only with --plain")
cmd.Flags().Bool("no-truncate", false, "Show all available columns in plain mode. Works only with --plain")
Expand Down
11 changes: 4 additions & 7 deletions internal/query/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,10 +326,6 @@ func getPaginateParams(paginate string) (uint, uint, error) {
errInvalidPaginateArg = fmt.Errorf(
"invalid argument for paginate: must be a positive integer in format <from>:<limit>, where <from> is optional",
)
Comment on lines 326 to 328
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updated validation allows from == 0 (and the CLI default uses 0:100), but both error messages say 'positive' which usually excludes 0. To avoid confusing users, update the wording to match the actual constraints (e.g., ' must be a non-negative integer and must be a positive integer'), and consider aligning errInvalidPaginateArg wording similarly.

Copilot uses AI. Check for mistakes.
errOutOfBounds = fmt.Errorf(
"invalid argument for paginate: Format <from>:<limit>, where <from> is optional and "+
"<limit> must be between %d and %d (inclusive)", 1, defaultLimit,
)
)

paginate = strings.TrimSpace(paginate)
Expand Down Expand Up @@ -360,12 +356,13 @@ func getPaginateParams(paginate string) (uint, uint, error) {
}
}

errOutOfBounds := fmt.Errorf(
"invalid argument for paginate: <from> must be a non-negative integer and <limit> must be a positive integer",
)

if from < 0 || limit <= 0 {
return 0, 0, errOutOfBounds
}
Comment thread
dreamingbinary marked this conversation as resolved.
if limit > defaultLimit {
return 0, 0, errOutOfBounds
}

return uint(from), uint(limit), nil
}
116 changes: 116 additions & 0 deletions internal/query/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,119 @@ func TestIssueGet(t *testing.T) {
})
}
}

func TestGetPaginateParams(t *testing.T) {
t.Parallel()

cases := []struct {
name string
input string
expectedFrom uint
expectedLimit uint
expectError bool
}{
{
name: "empty string returns defaults",
input: "",
expectedFrom: 0,
expectedLimit: defaultLimit,
},
{
name: "limit only",
input: "50",
expectedFrom: 0,
expectedLimit: 50,
},
{
name: "from and limit",
input: "10:50",
expectedFrom: 10,
expectedLimit: 50,
},
{
// Verifies that limits larger than 100 are accepted. SearchAll handles
// pagination internally, so the query layer should not cap the limit.
name: "limit 200 accepted",
input: "200",
expectedFrom: 0,
expectedLimit: 200,
},
{
name: "limit 500 accepted",
input: "500",
expectedFrom: 0,
expectedLimit: 500,
},
{
name: "limit 1000 accepted",
input: "1000",
expectedFrom: 0,
expectedLimit: 1000,
},
{
name: "from 0 and limit 100",
input: "0:100",
expectedFrom: 0,
expectedLimit: 100,
},
{
// Whitespace should be trimmed before parsing.
name: "whitespace trimmed",
input: " 50 ",
expectedFrom: 0,
expectedLimit: 50,
},
{
name: "non-numeric input",
input: "abc",
expectError: true,
},
{
name: "negative limit",
input: "-1",
expectError: true,
},
{
name: "zero limit",
input: "0",
expectError: true,
},
{
name: "negative from",
input: "-1:50",
expectError: true,
},
{
name: "invalid format with multiple colons",
input: "1:2:3",
expectError: true,
},
{
name: "non-numeric from",
input: "abc:50",
expectError: true,
},
{
name: "non-numeric limit in pair",
input: "10:abc",
expectError: true,
},
}

for _, tc := range cases {
tc := tc

t.Run(tc.name, func(t *testing.T) {
t.Parallel()

from, limit, err := getPaginateParams(tc.input)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedFrom, from)
assert.Equal(t, tc.expectedLimit, limit)
}
})
}
}
58 changes: 58 additions & 0 deletions pkg/jira/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,70 @@ type SearchResult struct {
Issues []*Issue `json:"issues"`
}

const maxPageSize uint = 100

// Search searches for issues using v3 version of the Jira GET /search endpoint.
// This performs a single request and returns up to `limit` results.
func (c *Client) Search(jql string, limit uint) (*SearchResult, error) {
path := fmt.Sprintf("/search/jql?jql=%s&maxResults=%d&fields=*all", url.QueryEscape(jql), limit)
return c.search(path, apiVersion3)
}

// SearchAll searches for issues using v3 version of the Jira GET /search endpoint
// with automatic cursor-based pagination via nextPageToken. It fetches pages of up
// to 100 issues at a time until `totalLimit` issues are collected or all results
// are exhausted (isLast == true).
func (c *Client) SearchAll(jql string, totalLimit uint) (*SearchResult, error) {
var allIssues []*Issue

pageSize := totalLimit
if pageSize == 0 || pageSize > maxPageSize {
pageSize = maxPageSize
}

nextPageToken := ""
for {
path := fmt.Sprintf("/search/jql?jql=%s&maxResults=%d&fields=*all", url.QueryEscape(jql), pageSize)
if nextPageToken != "" {
path += fmt.Sprintf("&nextPageToken=%s", url.QueryEscape(nextPageToken))
}

result, err := c.search(path, apiVersion3)
if err != nil {
return nil, err
}

allIssues = append(allIssues, result.Issues...)

if result.IsLast || result.NextPageToken == "" {
break
}
if totalLimit > 0 && uint(len(allIssues)) >= totalLimit {
break
}

nextPageToken = result.NextPageToken

// Adjust page size for the last page if needed.
if totalLimit > 0 {
remaining := totalLimit - uint(len(allIssues))
if remaining < pageSize {
pageSize = remaining
}
}
}
Comment on lines +31 to +69
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SearchAll() behaves incorrectly when totalLimit == 0: it will send maxResults=0, and remaining := totalLimit - uint(len(allIssues)) can underflow, producing a huge remaining value. This should be fixed by defining the semantics for totalLimit==0 (commonly: fetch all), e.g., initialize pageSize to maxPageSize when totalLimit==0 and skip the remaining/pageSize adjustment logic (and limit checks) unless totalLimit > 0.

Copilot uses AI. Check for mistakes.

// Trim to totalLimit if we overshot.
if totalLimit > 0 && uint(len(allIssues)) > totalLimit {
allIssues = allIssues[:totalLimit]
}

return &SearchResult{
IsLast: true,
Issues: allIssues,
}, nil
Comment on lines +76 to +79
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SearchAll() always returns IsLast: true and drops NextPageToken, even when it stops early due to totalLimit being reached (or due to the defensive NextPageToken == \"\" condition). Since SearchResult.IsLast/NextPageToken are API-derived fields, this can mislead callers into thinking the server had no more results. Consider returning IsLast/NextPageToken that reflect the server state from the last fetched page (e.g., keep the last page's IsLast, and if stopping due to limit, set IsLast=false and preserve NextPageToken so callers can resume if needed), or document clearly that SearchAll() returns a synthetic SearchResult where IsLast means 'complete for this request' rather than 'no more results on the server'.

Copilot uses AI. Check for mistakes.
}

// SearchV2 searches an issues using v2 version of the Jira GET /search endpoint.
func (c *Client) SearchV2(jql string, from, limit uint) (*SearchResult, error) {
path := fmt.Sprintf("/search?jql=%s&startAt=%d&maxResults=%d", url.QueryEscape(jql), from, limit)
Expand Down
Loading
Loading