From 022f07df7581b3a5b2234fd745234d4b553e6f97 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Sat, 28 Feb 2026 08:45:06 -0500 Subject: [PATCH 1/5] feat: add task visibility commands to the CLI Add `sem get tasks` to list/describe scheduled tasks and `sem run task` to manually trigger a task run, backed by the v1alpha tasks REST API. --- api/client/tasks_v1_alpha.go | 71 ++++++++++++++++++++++++++++++++ api/models/tasks_v1_alpha.go | 79 ++++++++++++++++++++++++++++++++++++ cmd/get.go | 25 ++++++++++++ cmd/run.go | 43 ++++++++++++++++++++ cmd/tasks/describe.go | 45 ++++++++++++++++++++ cmd/tasks/list.go | 45 ++++++++++++++++++++ cmd/tasks/run.go | 52 ++++++++++++++++++++++++ 7 files changed, 360 insertions(+) create mode 100644 api/client/tasks_v1_alpha.go create mode 100644 api/models/tasks_v1_alpha.go create mode 100644 cmd/run.go create mode 100644 cmd/tasks/describe.go create mode 100644 cmd/tasks/list.go create mode 100644 cmd/tasks/run.go 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..502ae18 --- /dev/null +++ b/api/models/tasks_v1_alpha.go @@ -0,0 +1,79 @@ +package models + +import ( + "encoding/json" +) + +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,omitempty" yaml:"paused,omitempty"` + Suspended bool `json:"suspended,omitempty" yaml:"suspended,omitempty"` + Recurring bool `json:"recurring,omitempty" yaml:"recurring,omitempty"` + Parameters map[string]string `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/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/run.go b/cmd/run.go new file mode 100644 index 0000000..aa88699 --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "github.com/semaphoreci/cli/cmd/tasks" + "github.com/spf13/cobra" +) + +var runCmd = &cobra.Command{ + Use: "run [KIND]", + Short: "Run a task.", + Long: ``, + Args: cobra.ExactArgs(1), +} + +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, _ := cmd.Flags().GetString("branch") + tag, _ := cmd.Flags().GetString("tag") + pipelineFile, _ := cmd.Flags().GetString("pipeline-file") + params, _ := cmd.Flags().GetStringSlice("param") + + 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") + + runCmd.AddCommand(runTaskCmd) +} diff --git a/cmd/tasks/describe.go b/cmd/tasks/describe.go new file mode 100644 index 0000000..5ca3297 --- /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") + + for _, t := range task.Triggers { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + t.TriggeredAt, + t.SchedulingStatus, + t.ScheduledWorkflowID, + t.Branch, + ) + } + + if err := w.Flush(); err != nil { + fmt.Printf("Error flushing when pretty printing triggers: %v\n", err) + } + } +} diff --git a/cmd/tasks/list.go b/cmd/tasks/list.go new file mode 100644 index 0000000..c51d9bb --- /dev/null +++ b/cmd/tasks/list.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" +) + +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 := "" + 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, + ) + } + + if err := w.Flush(); err != nil { + fmt.Printf("Error flushing when pretty printing tasks: %v\n", err) + } +} diff --git a/cmd/tasks/run.go b/cmd/tasks/run.go new file mode 100644 index 0000000..6778855 --- /dev/null +++ b/cmd/tasks/run.go @@ -0,0 +1,52 @@ +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{} + + if branch != "" { + req.Reference = &models.RunTaskReference{ + Type: "BRANCH", + Name: branch, + } + } else if tag != "" { + req.Reference = &models.RunTaskReference{ + Type: "TAG", + 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) + + fmt.Printf("Task '%s' triggered. Workflow started: %s\n", id, resp.WorkflowID) +} From 84b8189ac9bcd1b9625743ccd6fe931236441cb3 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Sat, 28 Feb 2026 08:53:44 -0500 Subject: [PATCH 2/5] test: add thorough tests for task visibility commands Model tests cover JSON deserialization, serialization, empty/invalid inputs, and omitempty behavior. Command tests use httpmock to verify correct API calls for list, describe, run (with all flag combinations), and command aliases. --- api/models/tasks_v1_alpha_test.go | 237 ++++++++++++++++++++++++++++++ cmd/get_tasks_test.go | 197 +++++++++++++++++++++++++ cmd/run_test.go | 229 +++++++++++++++++++++++++++++ 3 files changed, 663 insertions(+) create mode 100644 api/models/tasks_v1_alpha_test.go create mode 100644 cmd/get_tasks_test.go create mode 100644 cmd/run_test.go diff --git a/api/models/tasks_v1_alpha_test.go b/api/models/tasks_v1_alpha_test.go new file mode 100644 index 0000000..f7cda97 --- /dev/null +++ b/api/models/tasks_v1_alpha_test.go @@ -0,0 +1,237 @@ +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__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": { + "ENV": "production" + } + }, + "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.Equal(t, "production", desc.Schedule.Parameters["ENV"]) + 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: "BRANCH", + 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: "TAG", + 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__OmitsEmptyFields(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"]) + + _, 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) +} diff --git a/cmd/get_tasks_test.go b/cmd/get_tasks_test.go new file mode 100644 index 0000000..38ac56d --- /dev/null +++ b/cmd/get_tasks_test.go @@ -0,0 +1,197 @@ +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 + }, + { + "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__EmptyList(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 for empty list") +} + +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_test.go b/cmd/run_test.go new file mode 100644 index 0000000..d311077 --- /dev/null +++ b/cmd/run_test.go @@ -0,0 +1,229 @@ +package cmd + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + httpmock "github.com/jarcoal/httpmock" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" +) + +func resetRunTaskFlags() { + for _, name := range []string{"branch", "tag", "pipeline-file"} { + runTaskCmd.Flags().Lookup(name).Value.Set("") + } + + if f := runTaskCmd.Flags().Lookup("param"); f != nil { + if sv, ok := f.Value.(pflag.SliceValue); ok { + sv.Replace([]string{}) + } + } +} + +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") +} From d38b78d7bd09cba9ad2805da96dc08a3cb8d8f32 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Sat, 28 Feb 2026 09:13:06 -0500 Subject: [PATCH 3/5] fix: address PR review feedback for task visibility commands - Add branch/tag mutual exclusivity via MarkFlagsMutuallyExclusive - Check errors from flag retrieval with utils.Check - Validate non-empty workflow ID in run response - Use constants for reference types (BRANCH/TAG) - Always serialize boolean fields (remove omitempty) - Make parent run command generic (not task-specific) - Use utils.Check for tabwriter Flush errors - Reset flag Changed state in test helpers for mutual exclusivity - Add tests for branch+tag conflict, param with equals, suspended tasks --- api/models/tasks_v1_alpha.go | 11 +++++--- api/models/tasks_v1_alpha_test.go | 17 +++++++++--- cmd/get_tasks_test.go | 20 +++++++++++--- cmd/run.go | 17 +++++++----- cmd/run_test.go | 44 ++++++++++++++++++++++++++++++- cmd/tasks/describe.go | 5 ++-- cmd/tasks/list.go | 5 ++-- cmd/tasks/run.go | 10 +++++-- 8 files changed, 105 insertions(+), 24 deletions(-) diff --git a/api/models/tasks_v1_alpha.go b/api/models/tasks_v1_alpha.go index 502ae18..f0ff025 100644 --- a/api/models/tasks_v1_alpha.go +++ b/api/models/tasks_v1_alpha.go @@ -4,6 +4,11 @@ import ( "encoding/json" ) +const ( + RunTaskRefBranch = "BRANCH" + RunTaskRefTag = "TAG" +) + type TaskV1Alpha struct { ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` @@ -14,9 +19,9 @@ type TaskV1Alpha struct { 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,omitempty" yaml:"paused,omitempty"` - Suspended bool `json:"suspended,omitempty" yaml:"suspended,omitempty"` - Recurring bool `json:"recurring,omitempty" yaml:"recurring,omitempty"` + Paused bool `json:"paused" yaml:"paused"` + Suspended bool `json:"suspended" yaml:"suspended"` + Recurring bool `json:"recurring" yaml:"recurring"` Parameters map[string]string `json:"parameters,omitempty" yaml:"parameters,omitempty"` } diff --git a/api/models/tasks_v1_alpha_test.go b/api/models/tasks_v1_alpha_test.go index f7cda97..67f8ff0 100644 --- a/api/models/tasks_v1_alpha_test.go +++ b/api/models/tasks_v1_alpha_test.go @@ -151,7 +151,7 @@ func TestNewRunTaskResponseFromJSON__InvalidJSON(t *testing.T) { func TestRunTaskRequest__MarshalBranchReference(t *testing.T) { req := RunTaskRequest{ Reference: &RunTaskReference{ - Type: "BRANCH", + Type: RunTaskRefBranch, Name: "main", }, PipelineFile: ".semaphore/custom.yml", @@ -181,7 +181,7 @@ func TestRunTaskRequest__MarshalBranchReference(t *testing.T) { func TestRunTaskRequest__MarshalTagReference(t *testing.T) { req := RunTaskRequest{ Reference: &RunTaskReference{ - Type: "TAG", + Type: RunTaskRefTag, Name: "v1.0", }, } @@ -206,7 +206,7 @@ func TestRunTaskRequest__MarshalEmpty(t *testing.T) { assert.Equal(t, "{}", string(data)) } -func TestTaskV1Alpha__OmitsEmptyFields(t *testing.T) { +func TestTaskV1Alpha__OmitsEmptyOptionalStrings(t *testing.T) { task := TaskV1Alpha{ ID: "abc-123", Name: "deploy", @@ -226,6 +226,7 @@ func TestTaskV1Alpha__OmitsEmptyFields(t *testing.T) { 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"] @@ -234,4 +235,14 @@ func TestTaskV1Alpha__OmitsEmptyFields(t *testing.T) { 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_tasks_test.go b/cmd/get_tasks_test.go index 38ac56d..9263ead 100644 --- a/cmd/get_tasks_test.go +++ b/cmd/get_tasks_test.go @@ -79,7 +79,7 @@ func Test__ListTasks__WithProjectID(t *testing.T) { assert.True(t, received, "Expected the API to receive GET /tasks with project_id flag") } -func Test__ListTasks__EmptyList(t *testing.T) { +func Test__ListTasks__SuspendedTask(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() @@ -88,14 +88,28 @@ func Test__ListTasks__EmptyList(t *testing.T) { 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 + + 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 for empty list") + assert.True(t, received, "Expected the API to receive GET /tasks with suspended task") } func Test__DescribeTask__Response200(t *testing.T) { diff --git a/cmd/run.go b/cmd/run.go index aa88699..8635e6c 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -2,14 +2,14 @@ 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 task.", + Short: "Run a resource.", Long: ``, - Args: cobra.ExactArgs(1), } var runTaskCmd = &cobra.Command{ @@ -22,10 +22,14 @@ var runTaskCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { id := args[0] - branch, _ := cmd.Flags().GetString("branch") - tag, _ := cmd.Flags().GetString("tag") - pipelineFile, _ := cmd.Flags().GetString("pipeline-file") - params, _ := cmd.Flags().GetStringSlice("param") + 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) }, @@ -38,6 +42,7 @@ func init() { 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 index d311077..dc97fc5 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -11,15 +11,20 @@ import ( "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"} { - runTaskCmd.Flags().Lookup(name).Value.Set("") + 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 } } @@ -227,3 +232,40 @@ func Test__RunTask__TasksAlias(t *testing.T) { 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 index 5ca3297..a3e490d 100644 --- a/cmd/tasks/describe.go +++ b/cmd/tasks/describe.go @@ -38,8 +38,7 @@ func Describe(id string) { ) } - if err := w.Flush(); err != nil { - fmt.Printf("Error flushing when pretty printing triggers: %v\n", err) - } + err = w.Flush() + utils.Check(err) } } diff --git a/cmd/tasks/list.go b/cmd/tasks/list.go index c51d9bb..a621e2c 100644 --- a/cmd/tasks/list.go +++ b/cmd/tasks/list.go @@ -39,7 +39,6 @@ func List(projectID string) { ) } - if err := w.Flush(); err != nil { - fmt.Printf("Error flushing when pretty printing tasks: %v\n", err) - } + err = w.Flush() + utils.Check(err) } diff --git a/cmd/tasks/run.go b/cmd/tasks/run.go index 6778855..4e8fac9 100644 --- a/cmd/tasks/run.go +++ b/cmd/tasks/run.go @@ -13,14 +13,16 @@ import ( 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: "BRANCH", + Type: models.RunTaskRefBranch, Name: branch, } } else if tag != "" { req.Reference = &models.RunTaskReference{ - Type: "TAG", + Type: models.RunTaskRefTag, Name: tag, } } @@ -48,5 +50,9 @@ func Run(id string, branch string, tag string, pipelineFile string, params []str 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) } From 97ff8ee5d33110c316597f5d01ba62003fd395a9 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Sat, 28 Feb 2026 09:37:40 -0500 Subject: [PATCH 4/5] fix: model task parameters as array of objects, not map The API returns parameters as an array of objects with name, required, description, default_value, and options fields. Our model had it as map[string]string which failed to deserialize. --- api/models/tasks_v1_alpha.go | 34 +++++++++++------- api/models/tasks_v1_alpha_test.go | 57 ++++++++++++++++++++++++++++--- cmd/get_tasks_test.go | 5 ++- 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/api/models/tasks_v1_alpha.go b/api/models/tasks_v1_alpha.go index f0ff025..1ec9274 100644 --- a/api/models/tasks_v1_alpha.go +++ b/api/models/tasks_v1_alpha.go @@ -9,20 +9,28 @@ const ( 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 map[string]string `json:"parameters,omitempty" yaml:"parameters,omitempty"` + 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 { diff --git a/api/models/tasks_v1_alpha_test.go b/api/models/tasks_v1_alpha_test.go index 67f8ff0..5204e2e 100644 --- a/api/models/tasks_v1_alpha_test.go +++ b/api/models/tasks_v1_alpha_test.go @@ -51,6 +51,46 @@ func TestNewTaskListV1AlphaFromJSON(t *testing.T) { 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) @@ -73,9 +113,14 @@ func TestNewTaskDescribeV1AlphaFromJSON(t *testing.T) { "pipeline_file": ".semaphore/deploy.yml", "recurring": true, "description": "Daily deploy task", - "parameters": { - "ENV": "production" - } + "parameters": [ + { + "name": "ENV", + "required": true, + "default_value": "production", + "options": ["production", "staging"] + } + ] }, "triggers": [ { @@ -101,7 +146,11 @@ func TestNewTaskDescribeV1AlphaFromJSON(t *testing.T) { 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.Equal(t, "production", desc.Schedule.Parameters["ENV"]) + 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) diff --git a/cmd/get_tasks_test.go b/cmd/get_tasks_test.go index 9263ead..8215d09 100644 --- a/cmd/get_tasks_test.go +++ b/cmd/get_tasks_test.go @@ -36,7 +36,10 @@ func Test__ListTasks__Response200(t *testing.T) { "project_id": "758cb945-7495-4e40-a9a1-4b3991c6a8fe", "branch": "main", "pipeline_file": ".semaphore/deploy.yml", - "recurring": false + "recurring": false, + "parameters": [ + {"name": "ENV", "required": true, "default_value": "staging"} + ] }, { "id": "cc3ba294-d4b3-48bc-90a7-12dd56e9424d", From 9002d4ffbbee6c3cea815a47043aa95b949c0786 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Wed, 4 Mar 2026 14:36:16 -0500 Subject: [PATCH 5/5] feat: show error descriptions in trigger table and active status in task list Address PR review feedback: - Add ERROR column to the trigger table in task describe view, surfacing the error_description from the API so users can see why a trigger failed - Show "active" instead of blank for tasks that are neither paused nor suspended, making the STATUS column unambiguous --- cmd/tasks/describe.go | 5 +++-- cmd/tasks/list.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/tasks/describe.go b/cmd/tasks/describe.go index a3e490d..28fc64d 100644 --- a/cmd/tasks/describe.go +++ b/cmd/tasks/describe.go @@ -27,14 +27,15 @@ func Describe(id string) { const padding = 3 w := tabwriter.NewWriter(os.Stdout, 0, 0, padding, ' ', 0) - fmt.Fprintln(w, "TRIGGERED AT\tSTATUS\tWORKFLOW ID\tBRANCH") + 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\n", + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", t.TriggeredAt, t.SchedulingStatus, t.ScheduledWorkflowID, t.Branch, + t.ErrorDescription, ) } diff --git a/cmd/tasks/list.go b/cmd/tasks/list.go index a621e2c..8a1a888 100644 --- a/cmd/tasks/list.go +++ b/cmd/tasks/list.go @@ -22,7 +22,7 @@ func List(projectID string) { for _, t := range taskList { scheduled := fmt.Sprintf("%t", t.Recurring) - status := "" + status := "active" if t.Paused { status = "paused" } else if t.Suspended {