diff --git a/cloud/issue.go b/cloud/issue.go index 94699c74..aa2a79ae 100644 --- a/cloud/issue.go +++ b/cloud/issue.go @@ -515,17 +515,35 @@ type CommentVisibility struct { // Pagination is used for the Jira REST APIs to conserve server resources and limit // response size for resources that return potentially large collection of items. // A request to a pages API will result in a values array wrapped in a JSON object with some paging metadata +// https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-jql-get // Default Pagination options type SearchOptions struct { - // StartAt: The starting index of the returned projects. Base index: 0. - StartAt int `url:"startAt,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 string `url:"expand,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. + // Note: The nextPageToken field is not included in the response for + // the last page, indicating there is no next page. + NextPageToken string + // MaxResults: The maximum number of projects to return per page. + // Default: 50. + MaxResults integer + // Fields: A list of fields to return for each issue 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"` + // Expand: Use expand to include additional information about issues in + // the response. + Expand string + // 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 boolean + // FailFast: Fail this request early if we can't retrieve all field + // data. Default false. + FailFast boolean + // ReconcileIssues: Strong consistency issue ids to be reconciled with + // search results. Accepts max 50 ids + ReconcileIssues []integer } // searchResult is only a small wrapper around the Search (with JQL) method @@ -535,6 +553,7 @@ type searchResult struct { StartAt int `json:"startAt" structs:"startAt"` MaxResults int `json:"maxResults" structs:"maxResults"` Total int `json:"total" structs:"total"` + NextPageToken string `json:"nextPageToken" structs:"nextPageToken"` } // GetQueryOptions specifies the optional parameters for the Get Issue methods @@ -1046,7 +1065,7 @@ func (s *IssueService) AddLink(ctx context.Context, issueLink *IssueLink) (*Resp // This double check effort is done for v2 - Remove this two lines if this is completed. 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,20 +1073,35 @@ 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 != nil { + uv.Add("nextPageToken", options.NextPageToken) } if options.MaxResults != 0 { uv.Add("maxResults", strconv.Itoa(options.MaxResults)) } + if strings.Join(options.Fields, ",") != "" { + uv.Add("fields", strings.Join(options.Fields, ",")) + } if options.Expand != "" { uv.Add("expand", options.Expand) } - if strings.Join(options.Fields, ",") != "" { - uv.Add("fields", strings.Join(options.Fields, ",")) + if len(options.Properties) > 5 { + return nil, nil, fmt.Errorf("Search option Properties accepts maximum five entries") + } + if strings.Join(options.Properties, ",") != "" { + uv.Add("properties", strings.Join(options.Properties, ",")) } - if options.ValidateQuery != "" { - uv.Add("validateQuery", options.ValidateQuery) + if options.FieldsByKeys { + uv.Add("fieldsByKeys", options.FieldsByKeys) + } + if options.FailFast { + uv.Add("failFast", options.FailFast) + } + if len(options.ReconcileIssues) > 50 { + return nil, nil, fmt.Errorf("Search option ReconcileIssue accepts maximum 50 entries") + } + if strings.Join(options.ReconcileIssues, ",") != "" { + uv.Add("reconcileIssues", strings.Join(options.ReconcileIssues, ",")) } } @@ -1095,8 +1129,7 @@ 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, + MaxResults: 50, } } @@ -1121,16 +1154,19 @@ func (s *IssueService) SearchPages(ctx context.Context, jql string, options *Sea } } - if resp.StartAt+resp.MaxResults >= resp.Total { - return nil + if resp.nextPageToken == "" { + break } - options.StartAt += resp.MaxResults + options.NextPageToken = resp.nextPageToken + issues, resp, err = s.Search(ctx, jql, options) if err != nil { return err } } + + return nil } // GetCustomFields returns a map of customfield_* keys with string values diff --git a/cloud/issue_test.go b/cloud/issue_test.go index 09d83578..b5a44420 100644 --- a/cloud/issue_test.go +++ b/cloud/issue_test.go @@ -620,14 +620,14 @@ 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("", 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&startAt=1") 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, `{"expand": "schema,names","maxResults": 40,"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}}}]}`) }) - opt := &SearchOptions{StartAt: 1, MaxResults: 40, Expand: "foo"} + opt := &SearchOptions{MaxResults: 40} _, resp, err := testClient.Issue.Search(context.Background(), "type = Bug and Status NOT IN (Resolved)", opt) if resp == nil { @@ -651,14 +651,14 @@ func TestIssueService_Search(t *testing.T) { 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&startAt=1") 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, `{"expand": "schema,names","maxResults": 40,"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}}}]}`) }) - opt := &SearchOptions{StartAt: 1, MaxResults: 40, Expand: "foo"} + opt := &SearchOptions{MaxResults: 40} _, resp, err := testClient.Issue.Search(context.Background(), "", opt) if resp == nil { @@ -682,9 +682,9 @@ func TestIssueService_SearchEmptyJQL(t *testing.T) { 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}}}]}`) }) @@ -711,26 +711,26 @@ func TestIssueService_Search_WithoutPaging(t *testing.T) { 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&startAt=1&validateQuery=warn" { 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}}}]}`) 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&startAt=3&validateQuery=warn" { 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}}}]}`) 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&startAt=5&validateQuery=warn" { 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, `{"expand": "schema,names","maxResults": 2,"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}}]}`) return } t.Errorf("Unexpected URL: %v", r.URL) }) - opt := &SearchOptions{StartAt: 1, MaxResults: 2, Expand: "foo", ValidateQuery: "warn"} + opt := &SearchOptions{MaxResults: 2} issues := make([]Issue, 0) err := testClient.Issue.SearchPages(context.Background(), "something", opt, func(issue Issue) error { issues = append(issues, issue) @@ -749,19 +749,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&startAt=1&validateQuery=warn" { 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": []}`) + fmt.Fprint(w, `{"expand": "schema,names","maxResults": 0,"issues": []}`) return } t.Errorf("Unexpected URL: %v", r.URL) }) - opt := &SearchOptions{StartAt: 1, MaxResults: 50, Expand: "foo", ValidateQuery: "warn"} + opt := &SearchOptions{MaxResults: 50} issues := make([]Issue, 0) err := testClient.Issue.SearchPages(context.Background(), "something", opt, func(issue Issue) error { issues = append(issues, issue)