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
76 changes: 56 additions & 20 deletions cloud/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -1046,28 +1065,43 @@ 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",
Copy link

@Jcparkyn Jcparkyn Apr 29, 2025

Choose a reason for hiding this comment

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

As mentioned in PR discussion, it might be better to stick with v2 for now to avoid the breaking changes from strings to ADF. V2 is still supported (not deprecated), provided that you use the /jql version which uses nextPageToken.

See https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/#version

}
uv := url.Values{}
if jql != "" {
uv.Add("jql", jql)
}

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, ","))
}
}

Expand Down Expand Up @@ -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,
}
}

Expand All @@ -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
Expand Down
40 changes: 20 additions & 20 deletions cloud/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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}}}]}`)
})
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading