diff --git a/cloud/board_test.go b/cloud/board_test.go index c37978a8..b4ebd3e7 100644 --- a/cloud/board_test.go +++ b/cloud/board_test.go @@ -45,7 +45,7 @@ func TestBoardService_GetAllBoards_WithFilter(t *testing.T) { testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, http.MethodGet) testRequestURL(t, r, testapiEndpoint) - testRequestParams(t, r, map[string]string{"type": "scrum", "name": "Test", "startAt": "1", "maxResults": "10", "projectKeyOrId": "TE"}) + testRequestParams(t, r, map[string]string{"type": "scrum", "name": "Test", "maxResults": "10", "projectKeyOrId": "TE"}) fmt.Fprint(w, string(raw)) }) @@ -54,7 +54,6 @@ func TestBoardService_GetAllBoards_WithFilter(t *testing.T) { Name: "Test", ProjectKeyOrID: "TE", } - boardsListOptions.StartAt = 1 boardsListOptions.MaxResults = 10 projects, _, err := testClient.Board.GetAllBoards(context.Background(), boardsListOptions) diff --git a/cloud/examples/pagination/main.go b/cloud/examples/pagination/main.go index 15995e33..dcdd6919 100644 --- a/cloud/examples/pagination/main.go +++ b/cloud/examples/pagination/main.go @@ -12,30 +12,26 @@ import ( // You may have usecase where you need to get all the issues according to jql // This is where this example comes in. func GetAllIssues(client *jira.Client, searchString string) ([]jira.Issue, error) { - last := 0 var issues []jira.Issue - for { - opt := &jira.SearchOptions{ - MaxResults: 1000, // Max results can go up to 1000 - StartAt: last, - } + opt := &jira.SearchOptions{ + MaxResults: 1000, // Max results can go up to 1000 + } + for { chunk, resp, err := client.Issue.Search(context.Background(), searchString, opt) if err != nil { return nil, err } - total := resp.Total - if issues == nil { - issues = make([]jira.Issue, 0, total) - } issues = append(issues, chunk...) - last = resp.StartAt + len(chunk) - if last >= total { + + if resp.IsLast { return issues, nil } - } + // Set the next page token for the next iteration + opt.NextPageToken = resp.NextPageToken + } } func main() { diff --git a/cloud/issue.go b/cloud/issue.go index 94699c74..e38c1e54 100644 --- a/cloud/issue.go +++ b/cloud/issue.go @@ -517,24 +517,31 @@ type CommentVisibility struct { // A request to a pages API will result in a values array wrapped in a JSON object with some paging metadata // Default Pagination options type SearchOptions struct { - // StartAt: The starting index of the returned projects. Base index: 0. - StartAt int `url:"startAt,omitempty"` + // NextPageToken: The token for a page to fetch that is not the first page. + // The first page has a nextPageToken of null. Use the nextPageToken to fetch the next page of issues. + NextPageToken string `url:"nextPageToken,omitempty"` // MaxResults: The maximum number of projects to return per page. Default: 50. MaxResults int `url:"maxResults,omitempty"` - // Expand: Expand specific sections in the returned issues + // Expand: Expand specific sections in the returned issues. Expand string `url:"expand,omitempty"` - Fields []string - // ValidateQuery: The validateQuery param offers control over whether to validate and how strictly to treat the validation. Default: strict. - ValidateQuery string `url:"validateQuery,omitempty"` + // Fields: A list of fields to return for each issue, use it to retrieve a subset of fields. + Fields []string // comma-joined + // Properties: A list of up to 5 issue properties to include in the results. + Properties []string + // FieldsByKeys: Reference fields by their key (rather than ID). The default is false. + FieldsByKeys bool `url:"fieldsByKeys,omitempty"` + // FailFast: Fail this request early if we can't retrieve all field data. + FailFast bool `url:"failFast,omitempty"` + // ReconcileIssues: Strong consistency issue ids to be reconciled with search results. Accepts max 50 ids. + ReconcileIssues []int `url:"reconcileIssues,omitempty"` } // searchResult is only a small wrapper around the Search (with JQL) method // to be able to parse the results type searchResult struct { - Issues []Issue `json:"issues" structs:"issues"` - StartAt int `json:"startAt" structs:"startAt"` - MaxResults int `json:"maxResults" structs:"maxResults"` - Total int `json:"total" structs:"total"` + Issues []Issue `json:"issues"` + IsLast bool `json:"isLast"` + NextPageToken string `json:"nextPageToken,omitempty"` } // GetQueryOptions specifies the optional parameters for the Get Issue methods @@ -1040,13 +1047,10 @@ func (s *IssueService) AddLink(ctx context.Context, issueLink *IssueLink) (*Resp // Search will search for tickets according to the jql // -// Jira API docs: https://developer.atlassian.com/jiradev/jira-apis/jira-rest-apis/jira-rest-api-tutorials/jira-rest-api-example-query-issues -// -// TODO Double check this method if this works as expected, is using the latest API and the response is complete -// This double check effort is done for v2 - Remove this two lines if this is completed. +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-jql-get func (s *IssueService) Search(ctx context.Context, jql string, options *SearchOptions) ([]Issue, *Response, error) { u := url.URL{ - Path: "rest/api/2/search", + Path: "rest/api/3/search/jql", } uv := url.Values{} if jql != "" { @@ -1054,10 +1058,10 @@ func (s *IssueService) Search(ctx context.Context, jql string, options *SearchOp } if options != nil { - if options.StartAt != 0 { - uv.Add("startAt", strconv.Itoa(options.StartAt)) + if options.NextPageToken != "" { + uv.Add("nextPageToken", options.NextPageToken) } - if options.MaxResults != 0 { + if options.MaxResults > 0 { uv.Add("maxResults", strconv.Itoa(options.MaxResults)) } if options.Expand != "" { @@ -1066,8 +1070,19 @@ func (s *IssueService) Search(ctx context.Context, jql string, options *SearchOp if strings.Join(options.Fields, ",") != "" { uv.Add("fields", strings.Join(options.Fields, ",")) } - if options.ValidateQuery != "" { - uv.Add("validateQuery", options.ValidateQuery) + if len(options.Properties) > 0 { + uv.Add("properties", strings.Join(options.Properties, ",")) + } + if options.FieldsByKeys { + uv.Add("fieldsByKeys", "true") + } + if options.FailFast { + uv.Add("failFast", "true") + } + if len(options.ReconcileIssues) > 0 { + for _, id := range options.ReconcileIssues { + uv.Add("reconcileIssues", strconv.Itoa(id)) + } } } @@ -1095,7 +1110,6 @@ func (s *IssueService) Search(ctx context.Context, jql string, options *SearchOp func (s *IssueService) SearchPages(ctx context.Context, jql string, options *SearchOptions, f func(Issue) error) error { if options == nil { options = &SearchOptions{ - StartAt: 0, MaxResults: 50, } } @@ -1121,11 +1135,11 @@ func (s *IssueService) SearchPages(ctx context.Context, jql string, options *Sea } } - if resp.StartAt+resp.MaxResults >= resp.Total { + if resp == nil || resp.IsLast || resp.NextPageToken == "" { return nil } - options.StartAt += resp.MaxResults + options.NextPageToken = resp.NextPageToken issues, resp, err = s.Search(ctx, jql, options) if err != nil { return err diff --git a/cloud/issue_test.go b/cloud/issue_test.go index 09d83578..b40c69c8 100644 --- a/cloud/issue_test.go +++ b/cloud/issue_test.go @@ -620,15 +620,15 @@ func TestIssueService_DeleteLink(t *testing.T) { func TestIssueService_Search(t *testing.T) { setup() defer teardown() - testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { + testMux.HandleFunc("/rest/api/3/search/jql", func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, http.MethodGet) - testRequestURL(t, r, "/rest/api/2/search?expand=foo&jql=type+%3D+Bug+and+Status+NOT+IN+%28Resolved%29&maxResults=40&startAt=1") + testRequestURL(t, r, "/rest/api/3/search/jql?expand=foo&jql=type+%3D+Bug+and+Status+NOT+IN+%28Resolved%29&maxResults=40") w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"expand": "schema,names","startAt": 1,"maxResults": 40,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) + fmt.Fprint(w, `{"issues": [{"id": "10068"},{"id": "10067"},{"id": "10066"}],"nextPageToken": "CAEaAggD"}`) }) - opt := &SearchOptions{StartAt: 1, MaxResults: 40, Expand: "foo"} - _, resp, err := testClient.Issue.Search(context.Background(), "type = Bug and Status NOT IN (Resolved)", opt) + opt := &SearchOptions{MaxResults: 40, Expand: "foo"} + issues, resp, err := testClient.Issue.Search(context.Background(), "type = Bug and Status NOT IN (Resolved)", opt) if resp == nil { t.Errorf("Response given: %+v", resp) @@ -637,29 +637,31 @@ func TestIssueService_Search(t *testing.T) { t.Errorf("Error given: %s", err) } - if resp.StartAt != 1 { - t.Errorf("StartAt should populate with 1, %v given", resp.StartAt) + if len(issues) != 3 { + t.Errorf("Expected 3 issues, got %d", len(issues)) } - if resp.MaxResults != 40 { - t.Errorf("MaxResults should populate with 40, %v given", resp.MaxResults) + + if resp.NextPageToken != "CAEaAggD" { + t.Errorf("NextPageToken should be 'CAEaAggD', got %v", resp.NextPageToken) } - if resp.Total != 6 { - t.Errorf("Total should populate with 6, %v given", resp.Total) + + if resp.IsLast != false { + t.Errorf("IsLast should be false when nextPageToken is present, got %v", resp.IsLast) } } func TestIssueService_SearchEmptyJQL(t *testing.T) { setup() defer teardown() - testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { + testMux.HandleFunc("/rest/api/3/search/jql", func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, http.MethodGet) - testRequestURL(t, r, "/rest/api/2/search?expand=foo&maxResults=40&startAt=1") + testRequestURL(t, r, "/rest/api/3/search/jql?expand=foo&maxResults=40") w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"expand": "schema,names","startAt": 1,"maxResults": 40,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) + fmt.Fprint(w, `{"issues": [{"id": "10230"},{"id": "10004"}],"isLast": true}`) }) - opt := &SearchOptions{StartAt: 1, MaxResults: 40, Expand: "foo"} - _, resp, err := testClient.Issue.Search(context.Background(), "", opt) + opt := &SearchOptions{MaxResults: 40, Expand: "foo"} + issues, resp, err := testClient.Issue.Search(context.Background(), "", opt) if resp == nil { t.Errorf("Response given: %+v", resp) @@ -668,25 +670,23 @@ func TestIssueService_SearchEmptyJQL(t *testing.T) { t.Errorf("Error given: %s", err) } - if resp.StartAt != 1 { - t.Errorf("StartAt should populate with 1, %v given", resp.StartAt) - } - if resp.MaxResults != 40 { - t.Errorf("StartAt should populate with 40, %v given", resp.MaxResults) + if len(issues) != 2 { + t.Errorf("Expected 2 issues, got %d", len(issues)) } - if resp.Total != 6 { - t.Errorf("StartAt should populate with 6, %v given", resp.Total) + + if resp.IsLast != true { + t.Errorf("IsLast should be true when no nextPageToken, got %v", resp.IsLast) } } func TestIssueService_Search_WithoutPaging(t *testing.T) { setup() defer teardown() - testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { + testMux.HandleFunc("/rest/api/3/search/jql", func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, http.MethodGet) - testRequestURL(t, r, "/rest/api/2/search?jql=something") + testRequestURL(t, r, "/rest/api/3/search/jql?jql=something") w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"expand": "schema,names","startAt": 0,"maxResults": 50,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) + fmt.Fprint(w, `{"issues": [{"id": "10230"},{"id": "10004"}],"isLast": true}`) }) _, resp, err := testClient.Issue.Search(context.Background(), "something", nil) @@ -697,40 +697,37 @@ func TestIssueService_Search_WithoutPaging(t *testing.T) { t.Errorf("Error given: %s", err) } - if resp.StartAt != 0 { - t.Errorf("StartAt should populate with 0, %v given", resp.StartAt) - } - if resp.MaxResults != 50 { - t.Errorf("StartAt should populate with 50, %v given", resp.MaxResults) + if !resp.IsLast { + t.Errorf("IsLast should populate with true, %v given", resp.IsLast) } - if resp.Total != 6 { - t.Errorf("StartAt should populate with 6, %v given", resp.Total) + if resp.NextPageToken != "" { + t.Errorf("NextPageToken should be empty when isLast=true, %v given", resp.NextPageToken) } } func TestIssueService_SearchPages(t *testing.T) { setup() defer teardown() - testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { + testMux.HandleFunc("/rest/api/3/search/jql", func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, http.MethodGet) - if r.URL.String() == "/rest/api/2/search?expand=foo&jql=something&maxResults=2&startAt=1&validateQuery=warn" { + if r.URL.String() == "/rest/api/3/search/jql?expand=foo&jql=something&maxResults=2" { w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"expand": "schema,names","startAt": 1,"maxResults": 2,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) + fmt.Fprint(w, `{"issues": [{"id": "10001"},{"id": "10002"}],"nextPageToken": "page2token"}`) return - } else if r.URL.String() == "/rest/api/2/search?expand=foo&jql=something&maxResults=2&startAt=3&validateQuery=warn" { + } else if r.URL.String() == "/rest/api/3/search/jql?expand=foo&jql=something&maxResults=2&nextPageToken=page2token" { w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"expand": "schema,names","startAt": 3,"maxResults": 2,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}},{"expand": "html","id": "10004","self": "http://kelpie9:8081/rest/api/2/issue/BULK-47","key": "BULK-47","fields": {"summary": "Cheese v1 2.0 issue","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/3","id": "3","description": "A task that needs to be done.","iconUrl": "http://kelpie9:8081/images/icons/task.gif","name": "Task","subtask": false}}}]}`) + fmt.Fprint(w, `{"issues": [{"id": "10003"},{"id": "10004"}],"nextPageToken": "page3token"}`) return - } else if r.URL.String() == "/rest/api/2/search?expand=foo&jql=something&maxResults=2&startAt=5&validateQuery=warn" { + } else if r.URL.String() == "/rest/api/3/search/jql?expand=foo&jql=something&maxResults=2&nextPageToken=page3token" { w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"expand": "schema,names","startAt": 5,"maxResults": 2,"total": 6,"issues": [{"expand": "html","id": "10230","self": "http://kelpie9:8081/rest/api/2/issue/BULK-62","key": "BULK-62","fields": {"summary": "testing","timetracking": null,"issuetype": {"self": "http://kelpie9:8081/rest/api/2/issuetype/5","id": "5","description": "The sub-task of the issue","iconUrl": "http://kelpie9:8081/images/icons/issue_subtask.gif","name": "Sub-task","subtask": true},"customfield_10071": null}}]}`) + fmt.Fprint(w, `{"issues": [{"id": "10005"}],"isLast": true}`) return } t.Errorf("Unexpected URL: %v", r.URL) }) - opt := &SearchOptions{StartAt: 1, MaxResults: 2, Expand: "foo", ValidateQuery: "warn"} + opt := &SearchOptions{MaxResults: 2, Expand: "foo"} issues := make([]Issue, 0) err := testClient.Issue.SearchPages(context.Background(), "something", opt, func(issue Issue) error { issues = append(issues, issue) @@ -749,19 +746,19 @@ func TestIssueService_SearchPages(t *testing.T) { func TestIssueService_SearchPages_EmptyResult(t *testing.T) { setup() defer teardown() - testMux.HandleFunc("/rest/api/2/search", func(w http.ResponseWriter, r *http.Request) { + testMux.HandleFunc("/rest/api/3/search/jql", func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, http.MethodGet) - if r.URL.String() == "/rest/api/2/search?expand=foo&jql=something&maxResults=50&startAt=1&validateQuery=warn" { + if r.URL.String() == "/rest/api/3/search/jql?expand=foo&jql=something&maxResults=50" { w.WriteHeader(http.StatusOK) - // This is what Jira outputs when the &maxResult= issue occurs. It used to cause SearchPages to go into an endless loop. - fmt.Fprint(w, `{"expand": "schema,names","startAt": 0,"maxResults": 0,"total": 6,"issues": []}`) + // This is what Jira outputs for empty results in API v3. This test ensures SearchPages handles empty results correctly. + fmt.Fprint(w, `{"issues": [],"isLast": true}`) return } t.Errorf("Unexpected URL: %v", r.URL) }) - opt := &SearchOptions{StartAt: 1, MaxResults: 50, Expand: "foo", ValidateQuery: "warn"} + opt := &SearchOptions{MaxResults: 50, Expand: "foo"} issues := make([]Issue, 0) err := testClient.Issue.SearchPages(context.Background(), "something", opt, func(issue Issue) error { issues = append(issues, issue) @@ -772,6 +769,9 @@ func TestIssueService_SearchPages_EmptyResult(t *testing.T) { t.Errorf("Error given: %s", err) } + if len(issues) != 0 { + t.Errorf("Expected 0 issues for empty result, %v given", len(issues)) + } } func TestIssueService_GetCustomFields(t *testing.T) { diff --git a/cloud/jira.go b/cloud/jira.go index e0b9bfb3..7588fbde 100644 --- a/cloud/jira.go +++ b/cloud/jira.go @@ -272,6 +272,10 @@ func CheckResponse(r *http.Response) error { type Response struct { *http.Response + // v3 paging fields + IsLast bool + NextPageToken string + // v2 paging fields StartAt int MaxResults int Total int @@ -288,9 +292,8 @@ func newResponse(r *http.Response, v interface{}) *Response { func (r *Response) populatePageValues(v interface{}) { switch value := v.(type) { case *searchResult: - r.StartAt = value.StartAt - r.MaxResults = value.MaxResults - r.Total = value.Total + r.IsLast = value.IsLast + r.NextPageToken = value.NextPageToken case *groupMembersResult: r.StartAt = value.StartAt r.MaxResults = value.MaxResults