diff --git a/api/client/tasks_v1_alpha.go b/api/client/tasks_v1_alpha.go new file mode 100644 index 0000000..77b2293 --- /dev/null +++ b/api/client/tasks_v1_alpha.go @@ -0,0 +1,71 @@ +package client + +import ( + "errors" + "fmt" + "net/url" + + models "github.com/semaphoreci/cli/api/models" +) + +type TasksApiV1AlphaApi struct { + BaseClient BaseClient + ResourceNameSingular string + ResourceNamePlural string +} + +func NewTasksV1AlphaApi() TasksApiV1AlphaApi { + baseClient := NewBaseClientFromConfig() + baseClient.SetApiVersion("v1alpha") + + return TasksApiV1AlphaApi{ + BaseClient: baseClient, + ResourceNamePlural: "tasks", + ResourceNameSingular: "task", + } +} + +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)) + } + + return models.NewTaskListV1AlphaFromJSON(body) +} + +func (c *TasksApiV1AlphaApi) DescribeTask(id string) (*models.TaskDescribeV1Alpha, error) { + body, status, err := c.BaseClient.Get(c.ResourceNamePlural, id) + + 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)) + } + + return models.NewTaskDescribeV1AlphaFromJSON(body) +} + +func (c *TasksApiV1AlphaApi) RunTask(id string, requestBody []byte) (*models.RunTaskResponse, error) { + body, status, err := c.BaseClient.PostAction(c.ResourceNamePlural, id, "run_now", requestBody) + + 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)) + } + + return models.NewRunTaskResponseFromJSON(body) +} diff --git a/api/models/tasks_v1_alpha.go b/api/models/tasks_v1_alpha.go new file mode 100644 index 0000000..1ec9274 --- /dev/null +++ b/api/models/tasks_v1_alpha.go @@ -0,0 +1,92 @@ +package models + +import ( + "encoding/json" +) + +const ( + RunTaskRefBranch = "BRANCH" + RunTaskRefTag = "TAG" +) + +type TaskParameterV1Alpha struct { + Name string `json:"name" yaml:"name"` + Required bool `json:"required" yaml:"required"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + DefaultValue string `json:"default_value,omitempty" yaml:"default_value,omitempty"` + Options []string `json:"options,omitempty" yaml:"options,omitempty"` +} + +type TaskV1Alpha struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + ProjectID string `json:"project_id" yaml:"project_id"` + Branch string `json:"branch,omitempty" yaml:"branch,omitempty"` + At string `json:"at,omitempty" yaml:"at,omitempty"` + PipelineFile string `json:"pipeline_file" yaml:"pipeline_file"` + RequesterID string `json:"requester_id,omitempty" yaml:"requester_id,omitempty"` + UpdatedAt string `json:"updated_at,omitempty" yaml:"updated_at,omitempty"` + Paused bool `json:"paused" yaml:"paused"` + Suspended bool `json:"suspended" yaml:"suspended"` + Recurring bool `json:"recurring" yaml:"recurring"` + Parameters []TaskParameterV1Alpha `json:"parameters,omitempty" yaml:"parameters,omitempty"` +} + +type TriggerV1Alpha struct { + TriggeredAt string `json:"triggered_at" yaml:"triggered_at"` + SchedulingStatus string `json:"scheduling_status" yaml:"scheduling_status"` + ScheduledWorkflowID string `json:"scheduled_workflow_id,omitempty" yaml:"scheduled_workflow_id,omitempty"` + Branch string `json:"branch,omitempty" yaml:"branch,omitempty"` + PipelineFile string `json:"pipeline_file,omitempty" yaml:"pipeline_file,omitempty"` + ErrorDescription string `json:"error_description,omitempty" yaml:"error_description,omitempty"` +} + +type TaskListV1Alpha []TaskV1Alpha + +type TaskDescribeV1Alpha struct { + Schedule TaskV1Alpha `json:"schedule" yaml:"schedule"` + Triggers []TriggerV1Alpha `json:"triggers,omitempty" yaml:"triggers,omitempty"` +} + +type RunTaskReference struct { + Type string `json:"type" yaml:"type"` + Name string `json:"name" yaml:"name"` +} + +type RunTaskRequest struct { + Reference *RunTaskReference `json:"reference,omitempty" yaml:"reference,omitempty"` + PipelineFile string `json:"pipeline_file,omitempty" yaml:"pipeline_file,omitempty"` + Parameters map[string]string `json:"parameters,omitempty" yaml:"parameters,omitempty"` +} + +type RunTaskResponse struct { + WorkflowID string `json:"workflow_id" yaml:"workflow_id"` +} + +func NewTaskListV1AlphaFromJSON(data []byte) (TaskListV1Alpha, error) { + var list TaskListV1Alpha + err := json.Unmarshal(data, &list) + if err != nil { + return nil, err + } + return list, nil +} + +func NewTaskDescribeV1AlphaFromJSON(data []byte) (*TaskDescribeV1Alpha, error) { + t := TaskDescribeV1Alpha{} + err := json.Unmarshal(data, &t) + if err != nil { + return nil, err + } + return &t, nil +} + +func NewRunTaskResponseFromJSON(data []byte) (*RunTaskResponse, error) { + r := RunTaskResponse{} + err := json.Unmarshal(data, &r) + if err != nil { + return nil, err + } + return &r, nil +} diff --git a/api/models/tasks_v1_alpha_test.go b/api/models/tasks_v1_alpha_test.go new file mode 100644 index 0000000..5204e2e --- /dev/null +++ b/api/models/tasks_v1_alpha_test.go @@ -0,0 +1,297 @@ +package models + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewTaskListV1AlphaFromJSON(t *testing.T) { + input := `[ + { + "id": "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + "name": "deploy", + "project_id": "aa1ba294-d4b3-48bc-90a7-12dd56e9424a", + "branch": "main", + "at": "0 0 * * *", + "pipeline_file": ".semaphore/deploy.yml", + "recurring": true, + "paused": false, + "suspended": false + }, + { + "id": "cc3ba294-d4b3-48bc-90a7-12dd56e9424d", + "name": "nightly", + "project_id": "aa1ba294-d4b3-48bc-90a7-12dd56e9424a", + "branch": "main", + "at": "0 2 * * *", + "pipeline_file": ".semaphore/nightly.yml", + "recurring": true, + "paused": true + } + ]` + + list, err := NewTaskListV1AlphaFromJSON([]byte(input)) + assert.Nil(t, err) + assert.Len(t, list, 2) + + assert.Equal(t, "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", list[0].ID) + assert.Equal(t, "deploy", list[0].Name) + assert.Equal(t, "aa1ba294-d4b3-48bc-90a7-12dd56e9424a", list[0].ProjectID) + assert.Equal(t, "main", list[0].Branch) + assert.Equal(t, "0 0 * * *", list[0].At) + assert.Equal(t, ".semaphore/deploy.yml", list[0].PipelineFile) + assert.True(t, list[0].Recurring) + assert.False(t, list[0].Paused) + assert.False(t, list[0].Suspended) + + assert.Equal(t, "cc3ba294-d4b3-48bc-90a7-12dd56e9424d", list[1].ID) + assert.Equal(t, "nightly", list[1].Name) + assert.True(t, list[1].Paused) +} + +func TestNewTaskListV1AlphaFromJSON__WithParameters(t *testing.T) { + input := `[ + { + "id": "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + "name": "deploy", + "project_id": "aa1ba294-d4b3-48bc-90a7-12dd56e9424a", + "branch": "main", + "pipeline_file": ".semaphore/deploy.yml", + "recurring": false, + "paused": false, + "suspended": false, + "parameters": [ + { + "name": "ENV", + "required": true, + "description": "Target environment", + "default_value": "staging", + "options": ["staging", "production"] + }, + { + "name": "REGION", + "required": false, + "description": "AWS region" + } + ] + } + ]` + + list, err := NewTaskListV1AlphaFromJSON([]byte(input)) + assert.Nil(t, err) + assert.Len(t, list, 1) + assert.Len(t, list[0].Parameters, 2) + assert.Equal(t, "ENV", list[0].Parameters[0].Name) + assert.True(t, list[0].Parameters[0].Required) + assert.Equal(t, "staging", list[0].Parameters[0].DefaultValue) + assert.Equal(t, []string{"staging", "production"}, list[0].Parameters[0].Options) + assert.Equal(t, "REGION", list[0].Parameters[1].Name) + assert.False(t, list[0].Parameters[1].Required) +} + +func TestNewTaskListV1AlphaFromJSON__EmptyList(t *testing.T) { + list, err := NewTaskListV1AlphaFromJSON([]byte("[]")) + assert.Nil(t, err) + assert.Len(t, list, 0) +} + +func TestNewTaskListV1AlphaFromJSON__InvalidJSON(t *testing.T) { + _, err := NewTaskListV1AlphaFromJSON([]byte("not json")) + assert.NotNil(t, err) +} + +func TestNewTaskDescribeV1AlphaFromJSON(t *testing.T) { + input := `{ + "schedule": { + "id": "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + "name": "deploy", + "project_id": "aa1ba294-d4b3-48bc-90a7-12dd56e9424a", + "branch": "main", + "at": "0 0 * * *", + "pipeline_file": ".semaphore/deploy.yml", + "recurring": true, + "description": "Daily deploy task", + "parameters": [ + { + "name": "ENV", + "required": true, + "default_value": "production", + "options": ["production", "staging"] + } + ] + }, + "triggers": [ + { + "triggered_at": "2024-01-15 09:00:00", + "scheduling_status": "passed", + "scheduled_workflow_id": "dd4ba294-d4b3-48bc-90a7-12dd56e9424e", + "branch": "main", + "pipeline_file": ".semaphore/deploy.yml" + }, + { + "triggered_at": "2024-01-14 09:00:00", + "scheduling_status": "failed", + "scheduled_workflow_id": "ee5ba294-d4b3-48bc-90a7-12dd56e9424f", + "branch": "main", + "error_description": "pipeline file not found" + } + ] + }` + + desc, err := NewTaskDescribeV1AlphaFromJSON([]byte(input)) + assert.Nil(t, err) + + assert.Equal(t, "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", desc.Schedule.ID) + assert.Equal(t, "deploy", desc.Schedule.Name) + assert.Equal(t, "Daily deploy task", desc.Schedule.Description) + assert.Len(t, desc.Schedule.Parameters, 1) + assert.Equal(t, "ENV", desc.Schedule.Parameters[0].Name) + assert.True(t, desc.Schedule.Parameters[0].Required) + assert.Equal(t, "production", desc.Schedule.Parameters[0].DefaultValue) + assert.Equal(t, []string{"production", "staging"}, desc.Schedule.Parameters[0].Options) + assert.True(t, desc.Schedule.Recurring) + + assert.Len(t, desc.Triggers, 2) + assert.Equal(t, "2024-01-15 09:00:00", desc.Triggers[0].TriggeredAt) + assert.Equal(t, "passed", desc.Triggers[0].SchedulingStatus) + assert.Equal(t, "dd4ba294-d4b3-48bc-90a7-12dd56e9424e", desc.Triggers[0].ScheduledWorkflowID) + assert.Equal(t, "main", desc.Triggers[0].Branch) + + assert.Equal(t, "failed", desc.Triggers[1].SchedulingStatus) + assert.Equal(t, "pipeline file not found", desc.Triggers[1].ErrorDescription) +} + +func TestNewTaskDescribeV1AlphaFromJSON__NoTriggers(t *testing.T) { + input := `{ + "schedule": { + "id": "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + "name": "deploy", + "project_id": "aa1ba294-d4b3-48bc-90a7-12dd56e9424a", + "pipeline_file": ".semaphore/deploy.yml" + } + }` + + desc, err := NewTaskDescribeV1AlphaFromJSON([]byte(input)) + assert.Nil(t, err) + assert.Equal(t, "deploy", desc.Schedule.Name) + assert.Nil(t, desc.Triggers) +} + +func TestNewTaskDescribeV1AlphaFromJSON__InvalidJSON(t *testing.T) { + _, err := NewTaskDescribeV1AlphaFromJSON([]byte("{invalid")) + assert.NotNil(t, err) +} + +func TestNewRunTaskResponseFromJSON(t *testing.T) { + input := `{"workflow_id": "dd4ba294-d4b3-48bc-90a7-12dd56e9424e"}` + + resp, err := NewRunTaskResponseFromJSON([]byte(input)) + assert.Nil(t, err) + assert.Equal(t, "dd4ba294-d4b3-48bc-90a7-12dd56e9424e", resp.WorkflowID) +} + +func TestNewRunTaskResponseFromJSON__InvalidJSON(t *testing.T) { + _, err := NewRunTaskResponseFromJSON([]byte("bad")) + assert.NotNil(t, err) +} + +func TestRunTaskRequest__MarshalBranchReference(t *testing.T) { + req := RunTaskRequest{ + Reference: &RunTaskReference{ + Type: RunTaskRefBranch, + Name: "main", + }, + PipelineFile: ".semaphore/custom.yml", + Parameters: map[string]string{ + "ENV": "staging", + "REGION": "us-east-1", + }, + } + + data, err := json.Marshal(req) + assert.Nil(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + assert.Nil(t, err) + + ref := parsed["reference"].(map[string]interface{}) + assert.Equal(t, "BRANCH", ref["type"]) + assert.Equal(t, "main", ref["name"]) + assert.Equal(t, ".semaphore/custom.yml", parsed["pipeline_file"]) + + params := parsed["parameters"].(map[string]interface{}) + assert.Equal(t, "staging", params["ENV"]) + assert.Equal(t, "us-east-1", params["REGION"]) +} + +func TestRunTaskRequest__MarshalTagReference(t *testing.T) { + req := RunTaskRequest{ + Reference: &RunTaskReference{ + Type: RunTaskRefTag, + Name: "v1.0", + }, + } + + data, err := json.Marshal(req) + assert.Nil(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + assert.Nil(t, err) + + ref := parsed["reference"].(map[string]interface{}) + assert.Equal(t, "TAG", ref["type"]) + assert.Equal(t, "v1.0", ref["name"]) +} + +func TestRunTaskRequest__MarshalEmpty(t *testing.T) { + req := RunTaskRequest{} + + data, err := json.Marshal(req) + assert.Nil(t, err) + assert.Equal(t, "{}", string(data)) +} + +func TestTaskV1Alpha__OmitsEmptyOptionalStrings(t *testing.T) { + task := TaskV1Alpha{ + ID: "abc-123", + Name: "deploy", + ProjectID: "prj-456", + PipelineFile: ".semaphore/deploy.yml", + } + + data, err := json.Marshal(task) + assert.Nil(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + assert.Nil(t, err) + + assert.Equal(t, "abc-123", parsed["id"]) + assert.Equal(t, "deploy", parsed["name"]) + assert.Equal(t, "prj-456", parsed["project_id"]) + assert.Equal(t, ".semaphore/deploy.yml", parsed["pipeline_file"]) + + // Optional string fields are omitted when empty + _, hasBranch := parsed["branch"] + assert.False(t, hasBranch) + _, hasAt := parsed["at"] + assert.False(t, hasAt) + _, hasDescription := parsed["description"] + assert.False(t, hasDescription) + _, hasParams := parsed["parameters"] + assert.False(t, hasParams) + + // Boolean fields are always present (false is meaningful) + assert.Equal(t, false, parsed["paused"]) + assert.Equal(t, false, parsed["suspended"]) + assert.Equal(t, false, parsed["recurring"]) +} + +func TestRunTaskRequest__UsesReferenceConstants(t *testing.T) { + assert.Equal(t, "BRANCH", RunTaskRefBranch) + assert.Equal(t, "TAG", RunTaskRefTag) +} diff --git a/cmd/get.go b/cmd/get.go index b984c8f..cee3f2f 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -12,6 +12,7 @@ import ( "github.com/semaphoreci/cli/api/uuid" "github.com/semaphoreci/cli/cmd/deployment_targets" "github.com/semaphoreci/cli/cmd/pipelines" + "github.com/semaphoreci/cli/cmd/tasks" "github.com/semaphoreci/cli/cmd/utils" "github.com/semaphoreci/cli/cmd/workflows" "github.com/spf13/cobra" @@ -469,6 +470,24 @@ var GetDTCmd = &cobra.Command{ }, } +var GetTaskCmd = &cobra.Command{ + Use: "tasks [id]", + Short: "Get tasks.", + Long: ``, + Aliases: []string{"task"}, + Args: cobra.RangeArgs(0, 1), + + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + projectID := getPrj(cmd) + tasks.List(projectID) + } else { + id := args[0] + tasks.Describe(id) + } + }, +} + func GetProjectID(cmd *cobra.Command) string { projectID, err := cmd.Flags().GetString("project-id") if projectID != "" { @@ -562,6 +581,12 @@ func init() { GetWfCmd.Flags().DurationP("age", "", DefaultListingAge, "list only workflows created in the given duration; it accepts a Go duration. e.g. 24h, 30m, 60s") + GetTaskCmd.Flags().StringP("project-name", "p", "", + "project name; if not specified will be inferred from git origin") + GetTaskCmd.Flags().StringP("project-id", "i", "", + "project id; if not specified will be inferred from git origin") + getCmd.AddCommand(GetTaskCmd) + getCmd.AddCommand(GetDTCmd) GetDTCmd.Flags().StringP("project-name", "p", "", "project name; if not specified will be inferred from git origin") diff --git a/cmd/get_tasks_test.go b/cmd/get_tasks_test.go new file mode 100644 index 0000000..8215d09 --- /dev/null +++ b/cmd/get_tasks_test.go @@ -0,0 +1,214 @@ +package cmd + +import ( + "net/http" + "testing" + + httpmock "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +func Test__ListTasks__Response200(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + received := false + + httpmock.RegisterResponder("GET", "https://org.semaphoretext.xyz/api/v1alpha/projects/test-project", + func(req *http.Request) (*http.Response, error) { + p := `{ + "metadata": { + "id": "758cb945-7495-4e40-a9a1-4b3991c6a8fe" + } + }` + return httpmock.NewStringResponse(200, p), nil + }, + ) + + httpmock.RegisterResponder("GET", "https://org.semaphoretext.xyz/api/v1alpha/tasks?project_id=758cb945-7495-4e40-a9a1-4b3991c6a8fe", + func(req *http.Request) (*http.Response, error) { + received = true + + tasks := `[ + { + "id": "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + "name": "deploy", + "project_id": "758cb945-7495-4e40-a9a1-4b3991c6a8fe", + "branch": "main", + "pipeline_file": ".semaphore/deploy.yml", + "recurring": false, + "parameters": [ + {"name": "ENV", "required": true, "default_value": "staging"} + ] + }, + { + "id": "cc3ba294-d4b3-48bc-90a7-12dd56e9424d", + "name": "nightly", + "project_id": "758cb945-7495-4e40-a9a1-4b3991c6a8fe", + "branch": "main", + "pipeline_file": ".semaphore/nightly.yml", + "recurring": true, + "paused": true + } + ]` + + return httpmock.NewStringResponse(200, tasks), nil + }, + ) + + RootCmd.SetArgs([]string{"get", "tasks", "--project-name", "test-project"}) + RootCmd.Execute() + + assert.True(t, received, "Expected the API to receive GET /tasks?project_id=...") +} + +func Test__ListTasks__WithProjectID(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + received := false + + httpmock.RegisterResponder("GET", "https://org.semaphoretext.xyz/api/v1alpha/tasks?project_id=758cb945-7495-4e40-a9a1-4b3991c6a8fe", + func(req *http.Request) (*http.Response, error) { + received = true + + return httpmock.NewStringResponse(200, "[]"), nil + }, + ) + + RootCmd.SetArgs([]string{"get", "tasks", "--project-id", "758cb945-7495-4e40-a9a1-4b3991c6a8fe"}) + RootCmd.Execute() + + assert.True(t, received, "Expected the API to receive GET /tasks with project_id flag") +} + +func Test__ListTasks__SuspendedTask(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + received := false + + httpmock.RegisterResponder("GET", "https://org.semaphoretext.xyz/api/v1alpha/tasks?project_id=758cb945-7495-4e40-a9a1-4b3991c6a8fe", + func(req *http.Request) (*http.Response, error) { + received = true + + tasks := `[ + { + "id": "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + "name": "deploy", + "project_id": "758cb945-7495-4e40-a9a1-4b3991c6a8fe", + "branch": "main", + "pipeline_file": ".semaphore/deploy.yml", + "recurring": true, + "paused": false, + "suspended": true + } + ]` + + return httpmock.NewStringResponse(200, tasks), nil + }, + ) + + RootCmd.SetArgs([]string{"get", "tasks", "--project-id", "758cb945-7495-4e40-a9a1-4b3991c6a8fe"}) + RootCmd.Execute() + + assert.True(t, received, "Expected the API to receive GET /tasks with suspended task") +} + +func Test__DescribeTask__Response200(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + received := false + + httpmock.RegisterResponder("GET", "https://org.semaphoretext.xyz/api/v1alpha/tasks/bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + func(req *http.Request) (*http.Response, error) { + received = true + + task := `{ + "schedule": { + "id": "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + "name": "deploy", + "project_id": "aa1ba294-d4b3-48bc-90a7-12dd56e9424a", + "branch": "main", + "at": "0 0 * * *", + "pipeline_file": ".semaphore/deploy.yml", + "recurring": true + }, + "triggers": [ + { + "triggered_at": "2024-01-15 09:00:00", + "scheduling_status": "passed", + "scheduled_workflow_id": "dd4ba294-d4b3-48bc-90a7-12dd56e9424e", + "branch": "main" + } + ] + }` + + return httpmock.NewStringResponse(200, task), nil + }, + ) + + RootCmd.SetArgs([]string{"get", "tasks", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c"}) + RootCmd.Execute() + + assert.True(t, received, "Expected the API to receive GET /tasks/:id") +} + +func Test__DescribeTask__NoTriggers(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + received := false + + httpmock.RegisterResponder("GET", "https://org.semaphoretext.xyz/api/v1alpha/tasks/bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + func(req *http.Request) (*http.Response, error) { + received = true + + task := `{ + "schedule": { + "id": "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + "name": "deploy", + "project_id": "aa1ba294-d4b3-48bc-90a7-12dd56e9424a", + "pipeline_file": ".semaphore/deploy.yml" + } + }` + + return httpmock.NewStringResponse(200, task), nil + }, + ) + + RootCmd.SetArgs([]string{"get", "tasks", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c"}) + RootCmd.Execute() + + assert.True(t, received, "Expected the API to receive GET /tasks/:id with no triggers") +} + +func Test__GetTasks__TaskAlias(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + received := false + + httpmock.RegisterResponder("GET", "https://org.semaphoretext.xyz/api/v1alpha/tasks/bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + func(req *http.Request) (*http.Response, error) { + received = true + + task := `{ + "schedule": { + "id": "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + "name": "deploy", + "project_id": "aa1ba294-d4b3-48bc-90a7-12dd56e9424a", + "pipeline_file": ".semaphore/deploy.yml" + } + }` + + return httpmock.NewStringResponse(200, task), nil + }, + ) + + RootCmd.SetArgs([]string{"get", "task", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c"}) + RootCmd.Execute() + + assert.True(t, received, "Expected the 'task' alias to work for describe") +} diff --git a/cmd/run.go b/cmd/run.go new file mode 100644 index 0000000..8635e6c --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "github.com/semaphoreci/cli/cmd/tasks" + "github.com/semaphoreci/cli/cmd/utils" + "github.com/spf13/cobra" +) + +var runCmd = &cobra.Command{ + Use: "run [KIND]", + Short: "Run a resource.", + Long: ``, +} + +var runTaskCmd = &cobra.Command{ + Use: "task [id]", + Short: "Trigger a task run.", + Long: ``, + Aliases: []string{"tasks"}, + Args: cobra.ExactArgs(1), + + Run: func(cmd *cobra.Command, args []string) { + id := args[0] + + branch, err := cmd.Flags().GetString("branch") + utils.Check(err) + tag, err := cmd.Flags().GetString("tag") + utils.Check(err) + pipelineFile, err := cmd.Flags().GetString("pipeline-file") + utils.Check(err) + params, err := cmd.Flags().GetStringSlice("param") + utils.Check(err) + + tasks.Run(id, branch, tag, pipelineFile, params) + }, +} + +func init() { + RootCmd.AddCommand(runCmd) + + runTaskCmd.Flags().String("branch", "", "git branch to use for the task run") + runTaskCmd.Flags().String("tag", "", "git tag to use for the task run") + runTaskCmd.Flags().String("pipeline-file", "", "pipeline file to use for the task run") + runTaskCmd.Flags().StringSlice("param", []string{}, "parameter in KEY=VALUE format; can be specified multiple times") + runTaskCmd.MarkFlagsMutuallyExclusive("branch", "tag") + + runCmd.AddCommand(runTaskCmd) +} diff --git a/cmd/run_test.go b/cmd/run_test.go new file mode 100644 index 0000000..dc97fc5 --- /dev/null +++ b/cmd/run_test.go @@ -0,0 +1,271 @@ +package cmd + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + httpmock "github.com/jarcoal/httpmock" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" +) + +// resetRunTaskFlags clears flag state between tests because cobra's global +// command tree persists flag values across RootCmd.Execute() calls. +func resetRunTaskFlags() { + for _, name := range []string{"branch", "tag", "pipeline-file"} { + f := runTaskCmd.Flags().Lookup(name) + f.Value.Set("") + f.Changed = false + } + + if f := runTaskCmd.Flags().Lookup("param"); f != nil { + if sv, ok := f.Value.(pflag.SliceValue); ok { + sv.Replace([]string{}) + } + f.Changed = false + } +} + +func Test__RunTask__Response200(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + received := false + + httpmock.RegisterResponder("POST", "https://org.semaphoretext.xyz/api/v1alpha/tasks/bb2ba294-d4b3-48bc-90a7-12dd56e9424c/run_now", + func(req *http.Request) (*http.Response, error) { + received = true + + resp := `{"workflow_id": "dd4ba294-d4b3-48bc-90a7-12dd56e9424e"}` + return httpmock.NewStringResponse(200, resp), nil + }, + ) + + resetRunTaskFlags() + RootCmd.SetArgs([]string{"run", "task", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c"}) + RootCmd.Execute() + + assert.True(t, received, "Expected the API to receive POST /tasks/:id/run_now") +} + +func Test__RunTask__NoFlags(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + var receivedBody string + + httpmock.RegisterResponder("POST", "https://org.semaphoretext.xyz/api/v1alpha/tasks/bb2ba294-d4b3-48bc-90a7-12dd56e9424c/run_now", + func(req *http.Request) (*http.Response, error) { + body, _ := ioutil.ReadAll(req.Body) + receivedBody = string(body) + + resp := `{"workflow_id": "dd4ba294-d4b3-48bc-90a7-12dd56e9424e"}` + return httpmock.NewStringResponse(200, resp), nil + }, + ) + + resetRunTaskFlags() + RootCmd.SetArgs([]string{"run", "task", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c"}) + RootCmd.Execute() + + assert.Equal(t, "{}", receivedBody) +} + +func Test__RunTask__WithBranch(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + var receivedBody map[string]interface{} + + httpmock.RegisterResponder("POST", "https://org.semaphoretext.xyz/api/v1alpha/tasks/bb2ba294-d4b3-48bc-90a7-12dd56e9424c/run_now", + func(req *http.Request) (*http.Response, error) { + body, _ := ioutil.ReadAll(req.Body) + json.Unmarshal(body, &receivedBody) + + resp := `{"workflow_id": "dd4ba294-d4b3-48bc-90a7-12dd56e9424e"}` + return httpmock.NewStringResponse(200, resp), nil + }, + ) + + resetRunTaskFlags() + RootCmd.SetArgs([]string{"run", "task", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", "--branch", "main"}) + RootCmd.Execute() + + assert.NotNil(t, receivedBody) + ref := receivedBody["reference"].(map[string]interface{}) + assert.Equal(t, "BRANCH", ref["type"]) + assert.Equal(t, "main", ref["name"]) +} + +func Test__RunTask__WithTag(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + var receivedBody map[string]interface{} + + httpmock.RegisterResponder("POST", "https://org.semaphoretext.xyz/api/v1alpha/tasks/bb2ba294-d4b3-48bc-90a7-12dd56e9424c/run_now", + func(req *http.Request) (*http.Response, error) { + body, _ := ioutil.ReadAll(req.Body) + json.Unmarshal(body, &receivedBody) + + resp := `{"workflow_id": "dd4ba294-d4b3-48bc-90a7-12dd56e9424e"}` + return httpmock.NewStringResponse(200, resp), nil + }, + ) + + resetRunTaskFlags() + RootCmd.SetArgs([]string{"run", "task", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", "--tag", "v1.0"}) + RootCmd.Execute() + + assert.NotNil(t, receivedBody) + ref := receivedBody["reference"].(map[string]interface{}) + assert.Equal(t, "TAG", ref["type"]) + assert.Equal(t, "v1.0", ref["name"]) +} + +func Test__RunTask__WithPipelineFile(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + var receivedBody map[string]interface{} + + httpmock.RegisterResponder("POST", "https://org.semaphoretext.xyz/api/v1alpha/tasks/bb2ba294-d4b3-48bc-90a7-12dd56e9424c/run_now", + func(req *http.Request) (*http.Response, error) { + body, _ := ioutil.ReadAll(req.Body) + json.Unmarshal(body, &receivedBody) + + resp := `{"workflow_id": "dd4ba294-d4b3-48bc-90a7-12dd56e9424e"}` + return httpmock.NewStringResponse(200, resp), nil + }, + ) + + resetRunTaskFlags() + RootCmd.SetArgs([]string{"run", "task", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", "--pipeline-file", ".semaphore/custom.yml"}) + RootCmd.Execute() + + assert.NotNil(t, receivedBody) + assert.Equal(t, ".semaphore/custom.yml", receivedBody["pipeline_file"]) +} + +func Test__RunTask__WithParams(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + var receivedBody map[string]interface{} + + httpmock.RegisterResponder("POST", "https://org.semaphoretext.xyz/api/v1alpha/tasks/bb2ba294-d4b3-48bc-90a7-12dd56e9424c/run_now", + func(req *http.Request) (*http.Response, error) { + body, _ := ioutil.ReadAll(req.Body) + json.Unmarshal(body, &receivedBody) + + resp := `{"workflow_id": "dd4ba294-d4b3-48bc-90a7-12dd56e9424e"}` + return httpmock.NewStringResponse(200, resp), nil + }, + ) + + resetRunTaskFlags() + RootCmd.SetArgs([]string{"run", "task", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", "--param", "ENV=staging", "--param", "REGION=us-east-1"}) + RootCmd.Execute() + + assert.NotNil(t, receivedBody) + params := receivedBody["parameters"].(map[string]interface{}) + assert.Equal(t, "staging", params["ENV"]) + assert.Equal(t, "us-east-1", params["REGION"]) +} + +func Test__RunTask__WithAllFlags(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + var receivedBody map[string]interface{} + + httpmock.RegisterResponder("POST", "https://org.semaphoretext.xyz/api/v1alpha/tasks/bb2ba294-d4b3-48bc-90a7-12dd56e9424c/run_now", + func(req *http.Request) (*http.Response, error) { + body, _ := ioutil.ReadAll(req.Body) + json.Unmarshal(body, &receivedBody) + + resp := `{"workflow_id": "dd4ba294-d4b3-48bc-90a7-12dd56e9424e"}` + return httpmock.NewStringResponse(200, resp), nil + }, + ) + + resetRunTaskFlags() + RootCmd.SetArgs([]string{ + "run", "task", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", + "--branch", "develop", + "--pipeline-file", ".semaphore/custom.yml", + "--param", "ENV=staging", + }) + RootCmd.Execute() + + assert.NotNil(t, receivedBody) + + ref := receivedBody["reference"].(map[string]interface{}) + assert.Equal(t, "BRANCH", ref["type"]) + assert.Equal(t, "develop", ref["name"]) + assert.Equal(t, ".semaphore/custom.yml", receivedBody["pipeline_file"]) + + params := receivedBody["parameters"].(map[string]interface{}) + assert.Equal(t, "staging", params["ENV"]) +} + +func Test__RunTask__TasksAlias(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + received := false + + httpmock.RegisterResponder("POST", "https://org.semaphoretext.xyz/api/v1alpha/tasks/bb2ba294-d4b3-48bc-90a7-12dd56e9424c/run_now", + func(req *http.Request) (*http.Response, error) { + received = true + + resp := `{"workflow_id": "dd4ba294-d4b3-48bc-90a7-12dd56e9424e"}` + return httpmock.NewStringResponse(200, resp), nil + }, + ) + + resetRunTaskFlags() + RootCmd.SetArgs([]string{"run", "tasks", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c"}) + RootCmd.Execute() + + assert.True(t, received, "Expected the 'tasks' alias to work for run task") +} + +func Test__RunTask__BranchAndTagConflict(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + resetRunTaskFlags() + RootCmd.SetArgs([]string{"run", "task", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", "--branch", "main", "--tag", "v1.0"}) + err := RootCmd.Execute() + + assert.NotNil(t, err, "Expected an error when both --branch and --tag are provided") + assert.Contains(t, err.Error(), "if any flags in the group [branch tag] are set none of the others can be") +} + +func Test__RunTask__WithParamContainingEquals(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + var receivedBody map[string]interface{} + + httpmock.RegisterResponder("POST", "https://org.semaphoretext.xyz/api/v1alpha/tasks/bb2ba294-d4b3-48bc-90a7-12dd56e9424c/run_now", + func(req *http.Request) (*http.Response, error) { + body, _ := ioutil.ReadAll(req.Body) + json.Unmarshal(body, &receivedBody) + + resp := `{"workflow_id": "dd4ba294-d4b3-48bc-90a7-12dd56e9424e"}` + return httpmock.NewStringResponse(200, resp), nil + }, + ) + + resetRunTaskFlags() + RootCmd.SetArgs([]string{"run", "task", "bb2ba294-d4b3-48bc-90a7-12dd56e9424c", "--param", "CONN=host=db;port=5432"}) + RootCmd.Execute() + + assert.NotNil(t, receivedBody) + params := receivedBody["parameters"].(map[string]interface{}) + assert.Equal(t, "host=db;port=5432", params["CONN"]) +} diff --git a/cmd/tasks/describe.go b/cmd/tasks/describe.go new file mode 100644 index 0000000..28fc64d --- /dev/null +++ b/cmd/tasks/describe.go @@ -0,0 +1,45 @@ +package tasks + +import ( + "fmt" + "os" + "text/tabwriter" + + client "github.com/semaphoreci/cli/api/client" + "github.com/semaphoreci/cli/cmd/utils" + yaml "gopkg.in/yaml.v2" +) + +func Describe(id string) { + c := client.NewTasksV1AlphaApi() + task, err := c.DescribeTask(id) + utils.Check(err) + + y, err := yaml.Marshal(task.Schedule) + utils.Check(err) + + fmt.Printf("%s", y) + + if len(task.Triggers) > 0 { + fmt.Println() + fmt.Println("RECENT TRIGGERS:") + + const padding = 3 + w := tabwriter.NewWriter(os.Stdout, 0, 0, padding, ' ', 0) + + fmt.Fprintln(w, "TRIGGERED AT\tSTATUS\tWORKFLOW ID\tBRANCH\tERROR") + + for _, t := range task.Triggers { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + t.TriggeredAt, + t.SchedulingStatus, + t.ScheduledWorkflowID, + t.Branch, + t.ErrorDescription, + ) + } + + err = w.Flush() + utils.Check(err) + } +} diff --git a/cmd/tasks/list.go b/cmd/tasks/list.go new file mode 100644 index 0000000..8a1a888 --- /dev/null +++ b/cmd/tasks/list.go @@ -0,0 +1,44 @@ +package tasks + +import ( + "fmt" + "os" + "text/tabwriter" + + client "github.com/semaphoreci/cli/api/client" + "github.com/semaphoreci/cli/cmd/utils" +) + +func List(projectID string) { + c := client.NewTasksV1AlphaApi() + taskList, err := c.ListTasks(projectID) + utils.Check(err) + + const padding = 3 + w := tabwriter.NewWriter(os.Stdout, 0, 0, padding, ' ', 0) + + fmt.Fprintln(w, "ID\tNAME\tSCHEDULED\tBRANCH\tPIPELINE FILE\tSTATUS") + + for _, t := range taskList { + scheduled := fmt.Sprintf("%t", t.Recurring) + + status := "active" + if t.Paused { + status = "paused" + } else if t.Suspended { + status = "suspended" + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + t.ID, + t.Name, + scheduled, + t.Branch, + t.PipelineFile, + status, + ) + } + + err = w.Flush() + utils.Check(err) +} diff --git a/cmd/tasks/run.go b/cmd/tasks/run.go new file mode 100644 index 0000000..4e8fac9 --- /dev/null +++ b/cmd/tasks/run.go @@ -0,0 +1,58 @@ +package tasks + +import ( + "encoding/json" + "fmt" + "strings" + + client "github.com/semaphoreci/cli/api/client" + "github.com/semaphoreci/cli/api/models" + "github.com/semaphoreci/cli/cmd/utils" +) + +func Run(id string, branch string, tag string, pipelineFile string, params []string) { + req := models.RunTaskRequest{} + + // --branch and --tag are mutually exclusive (enforced by cobra); + // branch takes precedence if both somehow arrive. + if branch != "" { + req.Reference = &models.RunTaskReference{ + Type: models.RunTaskRefBranch, + Name: branch, + } + } else if tag != "" { + req.Reference = &models.RunTaskReference{ + Type: models.RunTaskRefTag, + Name: tag, + } + } + + if pipelineFile != "" { + req.PipelineFile = pipelineFile + } + + if len(params) > 0 { + req.Parameters = make(map[string]string) + for _, p := range params { + parts := strings.SplitN(p, "=", 2) + if len(parts) == 2 { + req.Parameters[parts[0]] = parts[1] + } else { + utils.Check(fmt.Errorf("invalid parameter format '%s', expected KEY=VALUE", p)) + } + } + } + + body, err := json.Marshal(req) + utils.Check(err) + + c := client.NewTasksV1AlphaApi() + resp, err := c.RunTask(id, body) + utils.Check(err) + + if resp.WorkflowID == "" { + utils.Fail("task was triggered but the API returned no workflow ID") + } + + fmt.Printf("Task '%s' triggered. Workflow started: %s\n", id, resp.WorkflowID) +}