diff --git a/internal/api/client.go b/internal/api/client.go index 82ecd84..e07ab80 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -248,6 +248,43 @@ type TeamsResult struct { RawBody []byte } +// Workflow represents a Rootly workflow. +type Workflow struct { + ID string + Name string + Slug string + Description string + Enabled bool + CreatedAt time.Time + UpdatedAt time.Time + RawBody []byte +} + +// WorkflowsResult contains workflows and pagination info. +type WorkflowsResult struct { + Workflows []Workflow + Pagination PaginationInfo + RawBody []byte +} + +// WorkflowRun represents an execution of a Rootly workflow. +type WorkflowRun struct { + ID string + WorkflowID string + IncidentID string + Status string + StatusMessage string + TriggeredBy string + RawBody []byte `json:"-"` +} + +// WorkflowRunOpts contains optional controls for an incident-scoped workflow run. +type WorkflowRunOpts struct { + IncidentID string + Immediate *bool + CheckConditions *bool +} + // KeyValue represents a key-value pair for pulse labels and refs type KeyValue struct { Key string @@ -1264,6 +1301,216 @@ func (c *Client) DeleteIncident(ctx context.Context, id string) error { return nil } +// ListWorkflowsCLI lists workflows with filters and pagination for CLI usage. +func (c *Client) ListWorkflowsCLI(ctx context.Context, page, pageSize int, sort string, filters map[string]string) (*WorkflowsResult, error) { + if page < 1 { + return nil, fmt.Errorf("page must be at least 1") + } + if pageSize < 0 { + return nil, fmt.Errorf("page size must not be negative") + } + if pageSize == 0 { + pageSize = 25 + } + if pageSize > 100 { + pageSize = 100 + } + + url := fmt.Sprintf("%s/v1/workflows?page[number]=%d&page[size]=%d", c.endpoint, page, pageSize) + if sort != "" { + url += "&sort=" + neturl.QueryEscape(sort) + } + for key, value := range filters { + url += fmt.Sprintf("&filter[%s]=%s", key, neturl.QueryEscape(value)) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/vnd.api+json") + + httpResp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to list workflows: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if httpResp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("invalid API token") + } + if httpResp.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("access denied: API key lacks 'read workflows' permission") + } + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", httpResp.StatusCode) + } + + var response struct { + Data []struct { + ID string `json:"id"` + Attributes struct { + Name string `json:"name"` + Slug *string `json:"slug"` + Description *string `json:"description"` + Enabled *bool `json:"enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } `json:"attributes"` + } `json:"data"` + Meta struct { + CurrentPage int `json:"current_page"` + NextPage *int `json:"next_page"` + PrevPage *int `json:"prev_page"` + TotalCount int `json:"total_count"` + TotalPages int `json:"total_pages"` + } `json:"meta"` + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + workflows := make([]Workflow, 0, len(response.Data)) + for _, item := range response.Data { + workflow := Workflow{ID: item.ID, Name: item.Attributes.Name} + if item.Attributes.Slug != nil { + workflow.Slug = *item.Attributes.Slug + } + if item.Attributes.Description != nil { + workflow.Description = *item.Attributes.Description + } + if item.Attributes.Enabled != nil { + workflow.Enabled = *item.Attributes.Enabled + } + workflow.CreatedAt, _ = time.Parse(time.RFC3339, item.Attributes.CreatedAt) + workflow.UpdatedAt, _ = time.Parse(time.RFC3339, item.Attributes.UpdatedAt) + workflows = append(workflows, workflow) + } + + currentPage := response.Meta.CurrentPage + if currentPage == 0 { + currentPage = page + } + return &WorkflowsResult{ + Workflows: workflows, + Pagination: PaginationInfo{ + CurrentPage: currentPage, + TotalPages: response.Meta.TotalPages, + TotalCount: response.Meta.TotalCount, + HasNext: response.Meta.NextPage != nil, + HasPrev: response.Meta.PrevPage != nil, + }, + RawBody: body, + }, nil +} + +// ResolveWorkflowID resolves an exact workflow slug and otherwise returns the supplied ID. +func (c *Client) ResolveWorkflowID(ctx context.Context, idOrSlug string) (string, error) { + if workflowIDPattern.MatchString(idOrSlug) { + return idOrSlug, nil + } + result, err := c.ListWorkflowsCLI(ctx, 1, 2, "", map[string]string{"slug][eq": idOrSlug}) + if err != nil { + return "", err + } + if len(result.Workflows) == 1 && result.Workflows[0].Slug == idOrSlug { + return result.Workflows[0].ID, nil + } + return idOrSlug, nil +} + +var workflowIDPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +// RunWorkflowCLI starts an incident-scoped workflow run. +func (c *Client) RunWorkflowCLI(ctx context.Context, workflowID string, opts WorkflowRunOpts) (*WorkflowRun, error) { + attributes := map[string]interface{}{ + "incident_id": opts.IncidentID, + } + if opts.Immediate != nil { + attributes["immediate"] = *opts.Immediate + } + if opts.CheckConditions != nil { + attributes["check_conditions"] = *opts.CheckConditions + } + requestBody := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "workflow_runs", + "attributes": attributes, + }, + } + bodyBytes, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + + url := fmt.Sprintf("%s/v1/workflows/%s/workflow_runs", c.endpoint, neturl.PathEscape(workflowID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/vnd.api+json") + + httpResp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to run workflow: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if httpResp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("invalid API token") + } + if httpResp.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("access denied: API key lacks permission to run workflows") + } + if httpResp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("workflow or incident not found") + } + if httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", httpResp.StatusCode) + } + + var response struct { + Data struct { + ID string `json:"id"` + Attributes struct { + WorkflowID string `json:"workflow_id"` + IncidentID *string `json:"incident_id"` + Status string `json:"status"` + StatusMessage *string `json:"status_message"` + TriggeredBy string `json:"triggered_by"` + } `json:"attributes"` + } `json:"data"` + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + run := &WorkflowRun{ + ID: response.Data.ID, + WorkflowID: response.Data.Attributes.WorkflowID, + Status: response.Data.Attributes.Status, + TriggeredBy: response.Data.Attributes.TriggeredBy, + RawBody: body, + } + if response.Data.Attributes.IncidentID != nil { + run.IncidentID = *response.Data.Attributes.IncidentID + } + if response.Data.Attributes.StatusMessage != nil { + run.StatusMessage = *response.Data.Attributes.StatusMessage + } + return run, nil +} + // alertResponseData represents the structure of alert data from the API response type alertResponseData struct { ID string `json:"id"` diff --git a/internal/api/client_workflows_test.go b/internal/api/client_workflows_test.go new file mode 100644 index 0000000..ff8f489 --- /dev/null +++ b/internal/api/client_workflows_test.go @@ -0,0 +1,147 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestListWorkflowsCLI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if got := r.URL.Query().Get("filter[slug]"); got != "retrospective" { + t.Errorf("slug filter = %q, want retrospective", got) + } + w.Header().Set("Content-Type", "application/vnd.api+json") + _, _ = w.Write([]byte(`{ + "data": [{ + "id": "workflow-1", + "attributes": { + "name": "Create retrospective", + "slug": "retrospective", + "description": "Create the retrospective document", + "enabled": true, + "created_at": "2026-08-12T12:00:00Z", + "updated_at": "2026-08-12T12:00:00Z" + } + }], + "meta": {"current_page": 1, "total_pages": 1, "total_count": 1} + }`)) + })) + defer server.Close() + + client := newTestClient(t, server.URL) + result, err := client.ListWorkflowsCLI(context.Background(), 1, 25, "-created_at", map[string]string{"slug": "retrospective"}) + if err != nil { + t.Fatalf("ListWorkflowsCLI returned error: %v", err) + } + if len(result.Workflows) != 1 || result.Workflows[0].Slug != "retrospective" { + t.Fatalf("workflows = %+v, want retrospective", result.Workflows) + } + if !result.Workflows[0].Enabled { + t.Error("workflow should be enabled") + } +} + +func TestListWorkflowsCLIRejectsNegativePagination(t *testing.T) { + client := &Client{} + if _, err := client.ListWorkflowsCLI(context.Background(), -1, 25, "", nil); err == nil || !strings.Contains(err.Error(), "page must be at least 1") { + t.Fatalf("page error = %v, want minimum page validation", err) + } + if _, err := client.ListWorkflowsCLI(context.Background(), 1, -1, "", nil); err == nil || !strings.Contains(err.Error(), "page size must not be negative") { + t.Fatalf("page-size error = %v, want non-negative validation", err) + } +} + +func TestResolveWorkflowIDBySlug(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("filter[slug][eq]"); got != "retrospective" { + t.Errorf("exact slug filter = %q, want retrospective", got) + } + _, _ = w.Write([]byte(`{ + "data": [{"id": "workflow-1", "attributes": {"name": "Retrospective", "slug": "retrospective"}}], + "meta": {"current_page": 1, "total_pages": 1, "total_count": 1} + }`)) + })) + defer server.Close() + + client := newTestClient(t, server.URL) + id, err := client.ResolveWorkflowID(context.Background(), "retrospective") + if err != nil { + t.Fatalf("ResolveWorkflowID returned error: %v", err) + } + if id != "workflow-1" { + t.Errorf("id = %q, want workflow-1", id) + } +} + +func TestResolveWorkflowIDSkipsLookupForUUID(t *testing.T) { + client := &Client{} + id := "e5923856-6fe8-4a2c-b0eb-cb783e811d06" + got, err := client.ResolveWorkflowID(context.Background(), id) + if err != nil { + t.Fatalf("ResolveWorkflowID returned error: %v", err) + } + if got != id { + t.Errorf("id = %q, want %q", got, id) + } +} + +func TestRunWorkflowCLI(t *testing.T) { + var requestBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/v1/workflows/workflow-1/workflow_runs") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &requestBody) + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "data": { + "id": "run-1", + "attributes": { + "workflow_id": "workflow-1", + "incident_id": "42", + "status": "pending", + "triggered_by": "api" + } + } + }`)) + })) + defer server.Close() + + client := newTestClient(t, server.URL) + immediate := false + checkConditions := true + run, err := client.RunWorkflowCLI(context.Background(), "workflow-1", WorkflowRunOpts{ + IncidentID: "incident-uuid", + Immediate: &immediate, + CheckConditions: &checkConditions, + }) + if err != nil { + t.Fatalf("RunWorkflowCLI returned error: %v", err) + } + attributes := requestBody["data"].(map[string]interface{})["attributes"].(map[string]interface{}) + if attributes["incident_id"] != "incident-uuid" { + t.Errorf("incident_id = %v, want incident-uuid", attributes["incident_id"]) + } + if attributes["immediate"] != false { + t.Errorf("immediate = %v, want false", attributes["immediate"]) + } + if attributes["check_conditions"] != true { + t.Errorf("check_conditions = %v, want true", attributes["check_conditions"]) + } + if run.ID != "run-1" || run.Status != "pending" { + t.Errorf("run = %+v, want run-1 pending", run) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c780c32..d231f84 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -36,11 +36,12 @@ Start here (for AI agents): rootly alerts list --format=json List alerts as JSON rootly services list --format=json List services as JSON rootly teams list --format=json List teams as JSON + rootly workflows list --format=json List workflows as JSON rootly oncall who --format=json Who is on-call right now rootly pulse create "msg" --source=ci Send a deployment pulse Discovery: run "rootly --help" to see available verbs and flags. - Resources: incidents, alerts, services, teams, oncall, pulse`, + Resources: incidents, alerts, services, teams, workflows, oncall, pulse`, Example: ` # List incidents rootly incidents list diff --git a/internal/cmd/workflows/cmd_test.go b/internal/cmd/workflows/cmd_test.go new file mode 100644 index 0000000..46c8727 --- /dev/null +++ b/internal/cmd/workflows/cmd_test.go @@ -0,0 +1,130 @@ +package workflows + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func newTestCmd() *cobra.Command { + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + return cmd +} + +func setupTestServer(t *testing.T, handler http.HandlerFunc) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + viper.Set("api_key", "test-token") + viper.Set("api_host", server.URL) + t.Cleanup(viper.Reset) +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + original := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + fn() + _ = w.Close() + os.Stdout = original + output, _ := io.ReadAll(r) + _ = r.Close() + return string(output) +} + +func TestRunList(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "data": [{"id": "workflow-1", "attributes": { + "name": "Create retrospective", "slug": "retrospective", "enabled": true, + "created_at": "2026-08-12T12:00:00Z", "updated_at": "2026-08-12T12:00:00Z" + }}], + "meta": {"current_page": 1, "total_pages": 1, "total_count": 1} + }`)) + }) + viper.Set("format", "table") + cmd := newTestCmd() + cmd.Flags().Int("page", 1, "") + cmd.Flags().Int("page-size", 25, "") + cmd.Flags().String("sort", "-created_at", "") + cmd.Flags().String("name", "", "") + cmd.Flags().String("slug", "", "") + + output := captureStdout(t, func() { + if err := runList(cmd, nil); err != nil { + t.Fatalf("runList returned error: %v", err) + } + }) + if !strings.Contains(output, "Create retrospective") { + t.Errorf("expected workflow in output, got: %s", output) + } +} + +func TestRunWorkflowResolvesSlugAndNormalizesIncident(t *testing.T) { + requestCount := 0 + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestCount++ + if r.URL.Path == "/v1/workflows" { + _, _ = w.Write([]byte(`{ + "data": [{"id": "workflow-1", "attributes": {"name": "Retrospective", "slug": "retrospective"}}], + "meta": {"current_page": 1, "total_pages": 1, "total_count": 1} + }`)) + return + } + if r.URL.Path == "/v1/incidents/42" { + _, _ = w.Write([]byte(`{ + "data": {"id": "incident-uuid", "attributes": {"sequential_id": 42, "title": "Test incident"}} + }`)) + return + } + if r.URL.Path != "/v1/workflows/workflow-1/workflow_runs" { + t.Errorf("unexpected run path: %s", r.URL.Path) + } + var requestBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Fatalf("failed to decode run request: %v", err) + } + attributes := requestBody["data"].(map[string]interface{})["attributes"].(map[string]interface{}) + if attributes["incident_id"] != "incident-uuid" { + t.Errorf("incident_id = %v, want incident-uuid", attributes["incident_id"]) + } + if attributes["immediate"] != true { + t.Errorf("immediate = %v, want true", attributes["immediate"]) + } + _, _ = w.Write([]byte(`{ + "data": {"id": "run-1", "attributes": { + "workflow_id": "workflow-1", "incident_id": "42", "status": "pending", "triggered_by": "api" + }} + }`)) + }) + viper.Set("format", "json") + cmd := newTestCmd() + cmd.Flags().String("incident", "INC-42", "") + cmd.Flags().Bool("immediate", true, "") + cmd.Flags().Bool("check-conditions", false, "") + + output := captureStdout(t, func() { + if err := runWorkflow(cmd, []string{"retrospective"}); err != nil { + t.Fatalf("runWorkflow returned error: %v", err) + } + }) + if requestCount != 3 { + t.Errorf("request count = %d, want 3", requestCount) + } + if !strings.Contains(output, "run-1") { + t.Errorf("expected run response, got: %s", output) + } +} diff --git a/internal/cmd/workflows/list.go b/internal/cmd/workflows/list.go new file mode 100644 index 0000000..bc14209 --- /dev/null +++ b/internal/cmd/workflows/list.go @@ -0,0 +1,82 @@ +package workflows + +import ( + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/rootlyhq/rootly-cli/internal/printer" + "github.com/rootlyhq/rootly-cli/internal/timeformat" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List workflows", + Example: ` rootly workflows list + rootly workflows list --name=retrospective + rootly workflows list --slug=create-a-google-docs-retrospective --format=json`, + RunE: runList, +} + +func init() { + listCmd.Flags().Int("page", 1, "Page number") + listCmd.Flags().Int("page-size", 25, "Results per page (max 100)") + listCmd.Flags().String("sort", "-created_at", "Sort order") + listCmd.Flags().String("name", "", "Filter by workflow name") + listCmd.Flags().String("slug", "", "Filter by workflow slug") + WorkflowsCmd.AddCommand(listCmd) +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, err := getAPIClient() + if err != nil { + return err + } + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + sort, _ := cmd.Flags().GetString("sort") + name, _ := cmd.Flags().GetString("name") + slug, _ := cmd.Flags().GetString("slug") + filters := make(map[string]string) + if name != "" { + filters["name"] = name + } + if slug != "" { + filters["slug"] = slug + } + + result, err := apiClient.ListWorkflowsCLI(cmd.Context(), page, pageSize, sort, filters) + if err != nil { + return fmt.Errorf("failed to list workflows: %w", err) + } + format := viper.GetString("format") + p, err := printer.NewPrinter(format) + if err != nil { + return err + } + if format == "json" || format == "yaml" { + return p.PrintRawJSON(result.RawBody, os.Stdout) + } + + rows := make([][]string, 0, len(result.Workflows)) + for _, workflow := range result.Workflows { + rows = append(rows, []string{ + workflow.ID, + workflow.Name, + workflow.Slug, + strconv.FormatBool(workflow.Enabled), + timeformat.FormatTime(workflow.CreatedAt), + }) + } + if err := p.PrintList([]string{"ID", "Name", "Slug", "Enabled", "Created"}, rows, os.Stdout); err != nil { + return fmt.Errorf("failed to print output: %w", err) + } + if result.Pagination.TotalPages > 1 { + fmt.Fprintf(os.Stderr, "\nPage %d of %d (%d total workflows)\n", + result.Pagination.CurrentPage, result.Pagination.TotalPages, result.Pagination.TotalCount) + } + return nil +} diff --git a/internal/cmd/workflows/run.go b/internal/cmd/workflows/run.go new file mode 100644 index 0000000..4ccf331 --- /dev/null +++ b/internal/cmd/workflows/run.go @@ -0,0 +1,72 @@ +package workflows + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/rootlyhq/rootly-cli/internal/api" + "github.com/rootlyhq/rootly-cli/internal/printer" +) + +var runCmd = &cobra.Command{ + Use: "run ", + Short: "Run a workflow for an incident", + Example: ` rootly workflows run create-a-google-docs-retrospective --incident=INC-123 + rootly workflows run --incident=INC-123 --check-conditions + rootly workflows run --incident=INC-123 --immediate=false --format=json`, + Args: cobra.ExactArgs(1), + RunE: runWorkflow, +} + +func init() { + runCmd.Flags().String("incident", "", "Incident ID, such as INC-123 or a UUID (required)") + runCmd.Flags().Bool("immediate", true, "Run immediately instead of respecting the workflow wait time") + runCmd.Flags().Bool("check-conditions", false, "Only run when the workflow conditions match the incident") + _ = runCmd.MarkFlagRequired("incident") + WorkflowsCmd.AddCommand(runCmd) +} + +func runWorkflow(cmd *cobra.Command, args []string) error { + apiClient, err := getAPIClient() + if err != nil { + return err + } + workflowRef := args[0] + incidentRef, _ := cmd.Flags().GetString("incident") + incidentID := api.NormalizeIncidentID(incidentRef) + incident, err := apiClient.GetIncidentByID(cmd.Context(), incidentID) + if err != nil { + return fmt.Errorf("failed to resolve incident %s: %w", incidentID, err) + } + + workflowID, err := apiClient.ResolveWorkflowID(cmd.Context(), workflowRef) + if err != nil { + return fmt.Errorf("failed to resolve workflow: %w", err) + } + immediate, _ := cmd.Flags().GetBool("immediate") + opts := api.WorkflowRunOpts{IncidentID: incident.ID, Immediate: &immediate} + if cmd.Flags().Changed("check-conditions") { + checkConditions, _ := cmd.Flags().GetBool("check-conditions") + opts.CheckConditions = &checkConditions + } + run, err := apiClient.RunWorkflowCLI(cmd.Context(), workflowID, opts) + if err != nil { + return fmt.Errorf("failed to run workflow: %w", err) + } + if !viper.GetBool("quiet") { + fmt.Fprintf(os.Stderr, "Started workflow %s for incident %s\n", workflowRef, incidentRef) + } + + format := viper.GetString("format") + p, err := printer.NewPrinter(format) + if err != nil { + return err + } + if format == "json" || format == "yaml" { + return p.PrintRawJSON(run.RawBody, os.Stdout) + } + return p.PrintObj(run, os.Stdout) +} diff --git a/internal/cmd/workflows/workflows.go b/internal/cmd/workflows/workflows.go new file mode 100644 index 0000000..3f5c214 --- /dev/null +++ b/internal/cmd/workflows/workflows.go @@ -0,0 +1,36 @@ +package workflows + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/rootlyhq/rootly-cli/internal/api" + "github.com/rootlyhq/rootly-cli/internal/config" + "github.com/rootlyhq/rootly-cli/internal/oauth" +) + +// WorkflowsCmd is the parent command for workflow operations. +var WorkflowsCmd = &cobra.Command{ + Use: "workflows", + Aliases: []string{"workflow"}, + Short: "List and run workflows", + Long: "Discover Rootly workflows and start incident-scoped workflow runs.", +} + +func getAPIClient() (*api.Client, error) { + token := viper.GetString("api_key") + if token == "" && !oauth.HasTokens() { + return nil, fmt.Errorf("authentication required: run 'rootly login' or set ROOTLY_API_KEY") + } + endpoint := viper.GetString("api_host") + if endpoint == "" { + endpoint = config.DefaultEndpoint + } + return api.NewClient(&config.Config{ + APIKey: token, + Endpoint: endpoint, + Debug: viper.GetBool("debug"), + }) +} diff --git a/internal/cmd/workflows_register.go b/internal/cmd/workflows_register.go new file mode 100644 index 0000000..f394825 --- /dev/null +++ b/internal/cmd/workflows_register.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/rootlyhq/rootly-cli/internal/cmd/workflows" + +func init() { + rootCmd.AddCommand(workflows.WorkflowsCmd) +}