diff --git a/api/client/tasks_v1_alpha.go b/api/client/tasks_v1_alpha.go index 77b2293..074677d 100644 --- a/api/client/tasks_v1_alpha.go +++ b/api/client/tasks_v1_alpha.go @@ -3,9 +3,11 @@ package client import ( "errors" "fmt" + "net/http" "net/url" models "github.com/semaphoreci/cli/api/models" + retry "github.com/semaphoreci/cli/api/retry" ) type TasksApiV1AlphaApi struct { @@ -25,21 +27,75 @@ func NewTasksV1AlphaApi() TasksApiV1AlphaApi { } } +// ListTasks fetches all tasks for a project using pagination and aggregates them. func (c *TasksApiV1AlphaApi) ListTasks(projectID string) (models.TaskListV1Alpha, error) { query := url.Values{} query.Add("project_id", projectID) - - body, status, _, err := c.BaseClient.ListWithParams(c.ResourceNamePlural, query) - - if err != nil { - return nil, errors.New(fmt.Sprintf("connecting to Semaphore failed '%s'", err)) - } - - if status != 200 { - return nil, errors.New(fmt.Sprintf("http status %d with message \"%s\" received from upstream", status, body)) + query.Add("page_size", "200") + + allTasks := make(models.TaskListV1Alpha, 0) + currentPage := 1 + const maxFailures = 5 + // maxTaskPages caps pagination depth to prevent runaway loops; + // at 200 items/page this allows up to 100k tasks per project. + const maxTaskPages = 500 + + for { + query.Set("page", fmt.Sprintf("%d", currentPage)) + + var page models.TaskListV1Alpha + var headers http.Header + + err := retry.RetryWithMaxFailures(maxFailures, func() error { + page = nil + headers = nil + + body, status, hdrs, err := c.BaseClient.ListWithParams(c.ResourceNamePlural, query) + headers = hdrs + if err != nil { + return fmt.Errorf("connecting to Semaphore failed: %w", err) + } + if status != http.StatusOK { + msg := string(body) + if len(msg) > 200 { + msg = msg[:200] + "...(truncated)" + } + httpErr := fmt.Errorf("http status %d with message \"%s\" received from upstream", status, msg) + if status >= 300 && status < 500 && status != http.StatusTooManyRequests { + return retry.NonRetryable(httpErr) + } + return httpErr + } + + pageList, err := models.NewTaskListV1AlphaFromJSON(body) + if err != nil { + return retry.NonRetryable(fmt.Errorf("failed to deserialize tasks list: %w", err)) + } + page = pageList + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed fetching page %d (after accumulating %d tasks from %d pages): %w", + currentPage, len(allTasks), currentPage-1, err) + } + + if headers == nil { + return nil, fmt.Errorf("internal error: response headers missing after fetching page %d (accumulated %d tasks)", currentPage, len(allTasks)) + } + + allTasks = append(allTasks, page...) + + if !hasNextPage(headers) { + break + } + if currentPage >= maxTaskPages { + return nil, fmt.Errorf("pagination safety limit reached (%d pages); results may be incomplete -- please narrow your query", maxTaskPages) + } + currentPage++ } - return models.NewTaskListV1AlphaFromJSON(body) + return allTasks, nil } func (c *TasksApiV1AlphaApi) DescribeTask(id string) (*models.TaskDescribeV1Alpha, error) { diff --git a/cmd/get_tasks_test.go b/cmd/get_tasks_test.go index 8215d09..cadf45d 100644 --- a/cmd/get_tasks_test.go +++ b/cmd/get_tasks_test.go @@ -2,8 +2,10 @@ package cmd import ( "net/http" + "regexp" "testing" + client "github.com/semaphoreci/cli/api/client" httpmock "github.com/jarcoal/httpmock" "github.com/stretchr/testify/assert" ) @@ -25,7 +27,8 @@ func Test__ListTasks__Response200(t *testing.T) { }, ) - httpmock.RegisterResponder("GET", "https://org.semaphoretext.xyz/api/v1alpha/tasks?project_id=758cb945-7495-4e40-a9a1-4b3991c6a8fe", + httpmock.RegisterRegexpResponder("GET", + regexp.MustCompile(`https://org\.semaphoretext\.xyz/api/v1alpha/tasks\?.*project_id=758cb945-7495-4e40-a9a1-4b3991c6a8fe`), func(req *http.Request) (*http.Response, error) { received = true @@ -68,7 +71,8 @@ func Test__ListTasks__WithProjectID(t *testing.T) { received := false - httpmock.RegisterResponder("GET", "https://org.semaphoretext.xyz/api/v1alpha/tasks?project_id=758cb945-7495-4e40-a9a1-4b3991c6a8fe", + httpmock.RegisterRegexpResponder("GET", + regexp.MustCompile(`https://org\.semaphoretext\.xyz/api/v1alpha/tasks\?.*project_id=758cb945-7495-4e40-a9a1-4b3991c6a8fe`), func(req *http.Request) (*http.Response, error) { received = true @@ -88,7 +92,8 @@ func Test__ListTasks__SuspendedTask(t *testing.T) { received := false - httpmock.RegisterResponder("GET", "https://org.semaphoretext.xyz/api/v1alpha/tasks?project_id=758cb945-7495-4e40-a9a1-4b3991c6a8fe", + httpmock.RegisterRegexpResponder("GET", + regexp.MustCompile(`https://org\.semaphoretext\.xyz/api/v1alpha/tasks\?.*project_id=758cb945-7495-4e40-a9a1-4b3991c6a8fe`), func(req *http.Request) (*http.Response, error) { received = true @@ -212,3 +217,46 @@ func Test__GetTasks__TaskAlias(t *testing.T) { assert.True(t, received, "Expected the 'task' alias to work for describe") } + +func Test__ListTasks__MultiPage(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + page1Received := false + page2Received := false + + httpmock.RegisterRegexpResponder("GET", + regexp.MustCompile(`https://org\.semaphoretext\.xyz/api/v1alpha/tasks.*[?&]page=1(?:&|$)`), + func(req *http.Request) (*http.Response, error) { + page1Received = true + body := `[ + {"id": "11111111-1111-1111-1111-111111111111", "name": "t1", "project_id": "758cb945-7495-4e40-a9a1-4b3991c6a8fe", "branch": "main", "pipeline_file": ".semaphore/t1.yml", "recurring": false} + ]` + resp := httpmock.NewStringResponse(200, body) + resp.Header.Set("Link", `; rel="next"`) + return resp, nil + }, + ) + + httpmock.RegisterRegexpResponder("GET", + regexp.MustCompile(`https://org\.semaphoretext\.xyz/api/v1alpha/tasks.*[?&]page=2(?:&|$)`), + func(req *http.Request) (*http.Response, error) { + page2Received = true + body := `[ + {"id": "22222222-2222-2222-2222-222222222222", "name": "t2", "project_id": "758cb945-7495-4e40-a9a1-4b3991c6a8fe", "branch": "main", "pipeline_file": ".semaphore/t2.yml", "recurring": false} + ]` + return httpmock.NewStringResponse(200, body), nil + }, + ) + + c := client.NewTasksV1AlphaApi() + tasks, err := c.ListTasks("758cb945-7495-4e40-a9a1-4b3991c6a8fe") + + assert.NoError(t, err) + assert.True(t, page1Received, "Expected page 1 to be fetched") + assert.True(t, page2Received, "Expected page 2 to be fetched (pagination must follow Link: rel=next)") + assert.Len(t, tasks, 2, "Expected tasks from both pages to be aggregated") + assert.Equal(t, "t1", tasks[0].Name, "Expected first task from page 1") + assert.Equal(t, "t2", tasks[1].Name, "Expected second task from page 2") + assert.Equal(t, 2, httpmock.GetTotalCallCount(), "Expected exactly two HTTP requests; pagination must stop when Link: rel=next is absent") +}