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
3 changes: 1 addition & 2 deletions cloud/board_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})

Expand All @@ -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)
Expand Down
22 changes: 9 additions & 13 deletions cloud/examples/pagination/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
60 changes: 37 additions & 23 deletions cloud/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1040,24 +1047,21 @@ 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 != "" {
uv.Add("jql", jql)
}

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 != "" {
Expand All @@ -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))
}
}
}

Expand Down Expand Up @@ -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,
}
}
Expand All @@ -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
Expand Down
92 changes: 46 additions & 46 deletions cloud/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)

Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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) {
Expand Down
Loading
Loading