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: 66 additions & 10 deletions api/client/tasks_v1_alpha.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
54 changes: 51 additions & 3 deletions cmd/get_tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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

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

Expand All @@ -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

Expand Down Expand Up @@ -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", `<https://org.semaphoretext.xyz/api/v1alpha/tasks?page=2>; 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")
}