diff --git a/README.md b/README.md index 28a16856..39cef321 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,12 @@ The `list` command lets you search and navigate the issues. The issues are sorte # List recent issues $ jira issue list +# List issues with pagination (default: 100 items) +$ jira issue list --paginate 50 + +# Get 20 items starting from offset 10 +$ jira issue list --paginate 10:20 + # List issues created in last 7 days $ jira issue list --created -7d diff --git a/api/client.go b/api/client.go index 683f4b16..f8ff619e 100644 --- a/api/client.go +++ b/api/client.go @@ -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.Search(jql, from, limit) } return issues, err diff --git a/internal/cmd/epic/list/list.go b/internal/cmd/epic/list/list.go index 5ad6ac99..a01527b0 100644 --- a/internal/cmd/epic/list/list.go +++ b/internal/cmd/epic/list/list.go @@ -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.Search(q.Get(), q.Params().From, q.Params().Limit) } else { resp, err = client.EpicIssues(key, q.Get(), q.Params().From, q.Params().Limit) } @@ -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.Search(q.Get(), q.Params().From, q.Params().Limit) } else { resp, err = client.EpicIssues(key, "", q.Params().From, q.Params().Limit) } diff --git a/pkg/jira/search.go b/pkg/jira/search.go index 71eedd80..8966b4a6 100644 --- a/pkg/jira/search.go +++ b/pkg/jira/search.go @@ -8,6 +8,11 @@ import ( "net/url" ) +const ( + // maxSearchPageSize is the maximum number of results per page for the Jira search API. + maxSearchPageSize = 100 +) + // SearchResult struct holds response from /search endpoint. type SearchResult struct { IsLast bool `json:"isLast"` @@ -15,10 +20,67 @@ type SearchResult struct { Issues []*Issue `json:"issues"` } -// Search searches for issues using v3 version of the Jira GET /search endpoint. -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) +// Search searches for issues using v3 version of the Jira GET /search/jql endpoint. +// +// It supports cursor-based pagination using nextPageToken from the API response. +// The from parameter specifies how many items to skip, and limit specifies the +// maximum number of items to return. For large offsets, multiple API requests +// may be made internally to reach the requested position. +func (c *Client) Search(jql string, from, limit uint) (*SearchResult, error) { + var ( + allIssues []*Issue + skipped uint + nextToken string + ) + + // Determine API page size: use requested limit if no offset, otherwise use max for efficiency. + // When we need to skip items (from > 0), larger pages reduce the number of API calls needed. + pageSize := limit + if from > 0 && pageSize < maxSearchPageSize { + pageSize = maxSearchPageSize + } + + for { + path := fmt.Sprintf("/search/jql?jql=%s&maxResults=%d&fields=*all", url.QueryEscape(jql), pageSize) + if nextToken != "" { + path += "&nextPageToken=" + url.QueryEscape(nextToken) + } + + result, err := c.search(path, apiVersion3) + if err != nil { + return nil, err + } + + for _, issue := range result.Issues { + // Skip items until we reach the 'from' offset + if skipped < from { + skipped++ + continue + } + + allIssues = append(allIssues, issue) + + // Stop if we've collected enough items + if uint(len(allIssues)) >= limit { + return &SearchResult{ + Issues: allIssues, + IsLast: false, // We stopped early, so there may be more results + }, nil + } + } + + // If this is the last page, we're done + if result.IsLast || result.NextPageToken == "" { + break + } + + nextToken = result.NextPageToken + } + + return &SearchResult{ + Issues: allIssues, + IsLast: true, + }, nil } // SearchV2 searches an issues using v2 version of the Jira GET /search endpoint. diff --git a/pkg/jira/search_test.go b/pkg/jira/search_test.go index fb4736e0..e03b391d 100644 --- a/pkg/jira/search_test.go +++ b/pkg/jira/search_test.go @@ -2,6 +2,7 @@ package jira import ( + "encoding/json" "net/http" "net/http/httptest" "net/url" @@ -48,7 +49,7 @@ func TestSearch(t *testing.T) { client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) - actual, err := client.Search("project=TEST AND status=Done ORDER BY created DESC", 100) + actual, err := client.Search("project=TEST AND status=Done ORDER BY created DESC", 0, 100) assert.NoError(t, err) expected := &SearchResult{ @@ -140,3 +141,313 @@ func TestSearch(t *testing.T) { _, err = client.SearchV2("project=TEST", 0, 100) assert.Error(t, &ErrUnexpectedResponse{}, err) } + +func TestSearchPagination(t *testing.T) { + // Helper to create a minimal issue with just a key + makeIssue := func(key string) *Issue { + return &Issue{ + Key: key, + Fields: IssueFields{ + Summary: key + " summary", + }, + } + } + + // Helper to create a search response + makeResponse := func(issues []*Issue, nextPageToken string, isLast bool) []byte { + resp := struct { + Issues []*Issue `json:"issues"` + NextPageToken string `json:"nextPageToken,omitempty"` + IsLast bool `json:"isLast"` + }{ + Issues: issues, + NextPageToken: nextPageToken, + IsLast: isLast, + } + data, _ := json.Marshal(resp) + return data + } + + t.Run("offset skipping within single page", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + // Return 5 issues, client requests from=2, limit=2 + // Should skip first 2 and return TEST-3, TEST-4 + resp := makeResponse([]*Issue{ + makeIssue("TEST-1"), + makeIssue("TEST-2"), + makeIssue("TEST-3"), + makeIssue("TEST-4"), + makeIssue("TEST-5"), + }, "", true) + _, _ = w.Write(resp) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + result, err := client.Search("project=TEST", 2, 2) + + assert.NoError(t, err) + assert.Len(t, result.Issues, 2) + assert.Equal(t, "TEST-3", result.Issues[0].Key) + assert.Equal(t, "TEST-4", result.Issues[1].Key) + }) + + t.Run("multi-page fetching with nextPageToken", func(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + token := r.URL.Query().Get("nextPageToken") + + var resp []byte + switch token { + case "": + // First page + resp = makeResponse([]*Issue{ + makeIssue("TEST-1"), + makeIssue("TEST-2"), + }, "page2token", false) + case "page2token": + // Second page + resp = makeResponse([]*Issue{ + makeIssue("TEST-3"), + makeIssue("TEST-4"), + }, "page3token", false) + case "page3token": + // Third/last page + resp = makeResponse([]*Issue{ + makeIssue("TEST-5"), + }, "", true) + } + _, _ = w.Write(resp) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + result, err := client.Search("project=TEST", 0, 100) + + assert.NoError(t, err) + assert.Equal(t, 3, requestCount, "should make 3 requests to fetch all pages") + assert.Len(t, result.Issues, 5) + assert.Equal(t, "TEST-1", result.Issues[0].Key) + assert.Equal(t, "TEST-5", result.Issues[4].Key) + }) + + t.Run("limit stops fetching early", func(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + // Each page has 3 items, but client only wants 2 + resp := makeResponse([]*Issue{ + makeIssue("TEST-1"), + makeIssue("TEST-2"), + makeIssue("TEST-3"), + }, "nexttoken", false) + _, _ = w.Write(resp) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + result, err := client.Search("project=TEST", 0, 2) + + assert.NoError(t, err) + assert.Equal(t, 1, requestCount, "should stop after first page since limit reached") + assert.Len(t, result.Issues, 2) + assert.Equal(t, "TEST-1", result.Issues[0].Key) + assert.Equal(t, "TEST-2", result.Issues[1].Key) + assert.False(t, result.IsLast, "IsLast should be false when we stop early due to limit") + }) + + t.Run("offset spanning multiple pages", func(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + token := r.URL.Query().Get("nextPageToken") + + var resp []byte + switch token { + case "": + // First page: 2 items + resp = makeResponse([]*Issue{ + makeIssue("TEST-1"), + makeIssue("TEST-2"), + }, "page2token", false) + case "page2token": + // Second page: 2 items + resp = makeResponse([]*Issue{ + makeIssue("TEST-3"), + makeIssue("TEST-4"), + }, "page3token", false) + case "page3token": + // Third page: 2 items + resp = makeResponse([]*Issue{ + makeIssue("TEST-5"), + makeIssue("TEST-6"), + }, "", true) + } + _, _ = w.Write(resp) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + // Skip first 3 items, get next 2 + // Should skip TEST-1, TEST-2, TEST-3 and return TEST-4, TEST-5 + result, err := client.Search("project=TEST", 3, 2) + + assert.NoError(t, err) + assert.Len(t, result.Issues, 2) + assert.Equal(t, "TEST-4", result.Issues[0].Key) + assert.Equal(t, "TEST-5", result.Issues[1].Key) + }) + + t.Run("offset beyond available items returns empty", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + resp := makeResponse([]*Issue{ + makeIssue("TEST-1"), + makeIssue("TEST-2"), + }, "", true) + _, _ = w.Write(resp) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + // Offset 10, but only 2 items exist + result, err := client.Search("project=TEST", 10, 5) + + assert.NoError(t, err) + assert.Len(t, result.Issues, 0) + assert.True(t, result.IsLast) + }) + + t.Run("empty result set", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + resp := makeResponse([]*Issue{}, "", true) + _, _ = w.Write(resp) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + result, err := client.Search("project=TEST", 0, 10) + + assert.NoError(t, err) + assert.Len(t, result.Issues, 0) + assert.True(t, result.IsLast) + }) + + t.Run("error mid-pagination returns error", func(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "application/json") + + if requestCount == 1 { + // First request succeeds + w.WriteHeader(200) + resp := makeResponse([]*Issue{ + makeIssue("TEST-1"), + makeIssue("TEST-2"), + }, "page2token", false) + _, _ = w.Write(resp) + } else { + // Second request fails + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"errorMessages":["Internal server error"]}`)) + } + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + result, err := client.Search("project=TEST", 0, 10) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, 2, requestCount, "should have made 2 requests before error") + }) + + t.Run("nextPageToken is passed in subsequent requests", func(t *testing.T) { + var receivedTokens []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedTokens = append(receivedTokens, r.URL.Query().Get("nextPageToken")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + token := r.URL.Query().Get("nextPageToken") + var resp []byte + switch token { + case "": + resp = makeResponse([]*Issue{makeIssue("TEST-1")}, "token-page-2", false) + case "token-page-2": + resp = makeResponse([]*Issue{makeIssue("TEST-2")}, "token-page-3", false) + case "token-page-3": + resp = makeResponse([]*Issue{makeIssue("TEST-3")}, "", true) + } + _, _ = w.Write(resp) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + _, err := client.Search("project=TEST", 0, 10) + + assert.NoError(t, err) + assert.Equal(t, []string{"", "token-page-2", "token-page-3"}, receivedTokens, + "should pass correct nextPageToken in each request") + }) + + t.Run("IsLast true when all pages exhausted", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + // Single page with all results, isLast=true + resp := makeResponse([]*Issue{ + makeIssue("TEST-1"), + makeIssue("TEST-2"), + }, "", true) + _, _ = w.Write(resp) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + result, err := client.Search("project=TEST", 0, 10) + + assert.NoError(t, err) + assert.Len(t, result.Issues, 2) + assert.True(t, result.IsLast, "IsLast should be true when all pages exhausted") + }) + + t.Run("IsLast false when limit reached before end", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + // Page has more items than requested limit + resp := makeResponse([]*Issue{ + makeIssue("TEST-1"), + makeIssue("TEST-2"), + makeIssue("TEST-3"), + makeIssue("TEST-4"), + }, "more-pages", false) + _, _ = w.Write(resp) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + result, err := client.Search("project=TEST", 0, 2) + + assert.NoError(t, err) + assert.Len(t, result.Issues, 2) + assert.False(t, result.IsLast, "IsLast should be false when we stopped due to limit") + }) +}