diff --git a/cloud/board.go b/cloud/board.go index 1505831d..4e2f96d9 100644 --- a/cloud/board.go +++ b/cloud/board.go @@ -54,6 +54,9 @@ type BoardListOptions struct { // Relevance meaning that the JQL filter defined in board contains a reference to a project. ProjectKeyOrID string `url:"projectKeyOrId,omitempty"` + // StartAt: The starting index of the returned projects. Base index: 0. + StartAt int `url:"startAt,omitempty"` + SearchOptions } diff --git a/cloud/examples/jql/main.go b/cloud/examples/jql/main.go index c224eaf7..a16483fe 100644 --- a/cloud/examples/jql/main.go +++ b/cloud/examples/jql/main.go @@ -8,25 +8,18 @@ import ( ) func main() { - jiraClient, _ := jira.NewClient("https://issues.apache.org/jira/", nil) + tp := jira.BasicAuthTransport{ + Username: "", + APIToken: ""} + jiraClient, _ := jira.NewClient("https://go-jira-opensource.atlassian.net/", tp.Client()) // Running JQL query - - jql := "project = Mesos and type = Bug and Status NOT IN (Resolved)" + jql := "type = Bug and Status NOT IN (Resolved)" fmt.Printf("Usecase: Running a JQL query '%s'\n", jql) - issues, resp, err := jiraClient.Issue.Search(context.Background(), jql, nil) - if err != nil { - panic(err) + options := &jira.SearchOptions{ + Fields: []string{"*all"}, } - outputResponse(issues, resp) - - fmt.Println("") - fmt.Println("") - - // Running an empty JQL query to get all tickets - jql = "" - fmt.Printf("Usecase: Running an empty JQL query to get all tickets\n") - issues, resp, err = jiraClient.Issue.Search(context.Background(), jql, nil) + issues, resp, err := jiraClient.Issue.Search(context.Background(), jql, options) if err != nil { panic(err) } diff --git a/cloud/examples/pagination/main.go b/cloud/examples/pagination/main.go index 15995e33..f57a6078 100644 --- a/cloud/examples/pagination/main.go +++ b/cloud/examples/pagination/main.go @@ -17,7 +17,6 @@ func GetAllIssues(client *jira.Client, searchString string) ([]jira.Issue, error for { opt := &jira.SearchOptions{ MaxResults: 1000, // Max results can go up to 1000 - StartAt: last, } chunk, resp, err := client.Issue.Search(context.Background(), searchString, opt) diff --git a/cloud/issue.go b/cloud/issue.go index 94699c74..bb4cbd01 100644 --- a/cloud/issue.go +++ b/cloud/issue.go @@ -516,25 +516,76 @@ type CommentVisibility struct { // 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 // Default Pagination options +// +// Docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-jql-get 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. + // 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 `url:"nextPageToken,omitempty"` + + // MaxResults: The maximum number of items to return per page. + // To manage page size, API may return fewer items per page where a large number of fields or properties are requested. + // The greatest number of items returned per page is achieved when requesting id or key only. + // It returns max 5000 issues. + // Default: 50 MaxResults int `url:"maxResults,omitempty"` - // Expand: Expand specific sections in the returned issues - Expand string `url:"expand,omitempty"` + // Fields: A list of fields to return for each issue + + // Fields: A list of fields to return for each issue, use it to retrieve a subset of fields. + // This parameter accepts a comma-separated list. Expand options include: + // + // `*all` Returns all fields. + // `*navigable` Returns navigable fields. + // `id` Returns only issue IDs. + // Any issue field, prefixed with a minus to exclude. + // + // The default is id. + // + // Examples: + // + // `summary,comment` Returns only the summary and comments fields only. + // `-description` Returns all navigable (default) fields except description. + // `*all,-comment` Returns all fields except comments. + // + // Multiple `fields` parameters can be included in a request. + // + // Note: By default, this resource returns IDs only. This differs from GET issue where the default is all fields. 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. + // TODO add proper docs, see https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-jql-get + Expand string `url:"expand,omitempty"` + // A list of up to 5 issue properties to include in the results + Properties []string `url:"properties,omitempty"` + // 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. + // Default false. + 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"` + // IsLast: Indicates whether this is the last page of the paginated response. + IsLast bool `json:"isLast" structs:"isLast"` + // Issues: The list of issues found by the search or reconsiliation. + Issues []Issue `json:"issues" structs:"issues"` + + // TODO Missing + // Field names object + // Field schema object + + // NextPageToken: Continuation token to fetch the next page. + // If this result represents the last or the only page this token will be null. + // This token will expire in 7 days. + NextPageToken string `json:"nextPageToken" structs:"nextPageToken"` } // GetQueryOptions specifies the optional parameters for the Get Issue methods @@ -1046,28 +1097,50 @@ 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 != "" { uv.Add("jql", jql) } + // TODO Check this out if this works with addOptions as well 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 { 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.FieldsByKeys { + uv.Add("fieldsByKeys", "true") + } + if options.FailFast { + uv.Add("failFast", "true") } - if options.ValidateQuery != "" { - uv.Add("validateQuery", options.ValidateQuery) + if len(options.ReconcileIssues) > 50 { + return nil, nil, fmt.Errorf("Search option ReconcileIssue accepts maximum 50 entries") + } + if len(options.ReconcileIssues) > 0 { + // TODO Extract this + // Convert []int to []string for strings.Join + reconcileIssuesStr := make([]string, len(options.ReconcileIssues)) + for i, v := range options.ReconcileIssues { + reconcileIssuesStr[i] = strconv.Itoa(v) + } + uv.Add("reconcileIssues", strings.Join(reconcileIssuesStr, ",")) } } @@ -1095,7 +1168,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,16 +1193,18 @@ func (s *IssueService) SearchPages(ctx context.Context, jql string, options *Sea } } - if resp.StartAt+resp.MaxResults >= resp.Total { - return nil + if len(resp.NextPageToken) == 0 { + 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..94d62dbe 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("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&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) diff --git a/cloud/jira.go b/cloud/jira.go index e0b9bfb3..e45cec93 100644 --- a/cloud/jira.go +++ b/cloud/jira.go @@ -275,6 +275,10 @@ type Response struct { StartAt int MaxResults int Total int + + // *searchResult + IsLast bool + NextPageToken string } func newResponse(r *http.Response, v interface{}) *Response { @@ -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