diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index 8da05c6c062..fe5c4038a84 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -219,7 +219,7 @@ func TestRemoveWorkflows(t *testing.T) { } func TestStatusWorkflows(t *testing.T) { - err := StatusWorkflows("test-pattern", false, false, "") + err := StatusWorkflows("test-pattern", false, false, "", "") // Should not error since it's a stub implementation if err != nil { @@ -422,7 +422,7 @@ Test workflow for command existence.` return err }, false, "CompileWorkflows"}, {func() error { return RemoveWorkflows("nonexistent", false) }, false, "RemoveWorkflows"}, // Should handle missing directory gracefully - {func() error { return StatusWorkflows("nonexistent", false, false, "") }, false, "StatusWorkflows"}, // Should handle missing directory gracefully + {func() error { return StatusWorkflows("nonexistent", false, false, "", "") }, false, "StatusWorkflows"}, // Should handle missing directory gracefully {func() error { return EnableWorkflows("nonexistent") }, true, "EnableWorkflows"}, // Should now error when no workflows found to enable {func() error { return DisableWorkflows("nonexistent") }, true, "DisableWorkflows"}, // Should now also error when no workflows found to disable {func() error { return RunWorkflowOnGitHub("", false, "", "", "", false, false, false, false) }, true, "RunWorkflowOnGitHub"}, // Should error with empty workflow name diff --git a/pkg/cli/status.go b/pkg/cli/status.go index 89412fc3c21..432dc9c9a68 100644 --- a/pkg/cli/status.go +++ b/pkg/cli/status.go @@ -21,7 +21,8 @@ Examples: ` + constants.CLIExtensionPrefix + ` status # Show all workflow status ` + constants.CLIExtensionPrefix + ` status ci- # Show workflows with 'ci-' in name ` + constants.CLIExtensionPrefix + ` status --json # Output in JSON format - ` + constants.CLIExtensionPrefix + ` status --ref main # Show latest run status for main branch`, + ` + constants.CLIExtensionPrefix + ` status --ref main # Show latest run status for main branch + ` + constants.CLIExtensionPrefix + ` status --label automation # Show workflows with 'automation' label`, RunE: func(cmd *cobra.Command, args []string) error { var pattern string if len(args) > 0 { @@ -30,12 +31,14 @@ Examples: verbose, _ := cmd.Flags().GetBool("verbose") jsonFlag, _ := cmd.Flags().GetBool("json") ref, _ := cmd.Flags().GetString("ref") - return StatusWorkflows(pattern, verbose, jsonFlag, ref) + labelFilter, _ := cmd.Flags().GetString("label") + return StatusWorkflows(pattern, verbose, jsonFlag, ref, labelFilter) }, } addJSONFlag(cmd) cmd.Flags().String("ref", "", "Filter runs by branch or tag name (e.g., main, v1.0.0)") + cmd.Flags().String("label", "", "Filter workflows by label") // Register completions for status command cmd.ValidArgsFunction = CompleteWorkflowNames diff --git a/pkg/cli/status_command.go b/pkg/cli/status_command.go index 21fcd541458..b96ebbdf923 100644 --- a/pkg/cli/status_command.go +++ b/pkg/cli/status_command.go @@ -20,18 +20,19 @@ var statusLog = logger.New("cli:status_command") // WorkflowStatus represents the status of a single workflow for JSON output type WorkflowStatus struct { - Workflow string `json:"workflow" console:"header:Workflow"` - EngineID string `json:"engine_id" console:"header:Engine"` - Compiled string `json:"compiled" console:"header:Compiled"` - Status string `json:"status" console:"header:Status"` - TimeRemaining string `json:"time_remaining" console:"header:Time Remaining"` - On any `json:"on,omitempty" console:"-"` - RunStatus string `json:"run_status,omitempty" console:"header:Run Status,omitempty"` - RunConclusion string `json:"run_conclusion,omitempty" console:"header:Run Conclusion,omitempty"` + Workflow string `json:"workflow" console:"header:Workflow"` + EngineID string `json:"engine_id" console:"header:Engine"` + Compiled string `json:"compiled" console:"header:Compiled"` + Status string `json:"status" console:"header:Status"` + TimeRemaining string `json:"time_remaining" console:"header:Time Remaining"` + Labels []string `json:"labels,omitempty" console:"header:Labels,omitempty"` + On any `json:"on,omitempty" console:"-"` + RunStatus string `json:"run_status,omitempty" console:"header:Run Status,omitempty"` + RunConclusion string `json:"run_conclusion,omitempty" console:"header:Run Conclusion,omitempty"` } -func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string) error { - statusLog.Printf("Checking workflow status: pattern=%s, jsonOutput=%v, ref=%s", pattern, jsonOutput, ref) +func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, labelFilter string) error { + statusLog.Printf("Checking workflow status: pattern=%s, jsonOutput=%v, ref=%s, labelFilter=%s", pattern, jsonOutput, ref, labelFilter) if verbose && !jsonOutput { fmt.Printf("Checking status of workflow files\n") if pattern != "" { @@ -154,16 +155,41 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string) } } - // Extract "on" field from frontmatter for JSON output + // Extract "on" field and labels from frontmatter for JSON output var onField any + var labels []string if content, err := os.ReadFile(file); err == nil { if result, err := parser.ExtractFrontmatterFromContent(string(content)); err == nil { if result.Frontmatter != nil { onField = result.Frontmatter["on"] + // Extract labels field if present + if labelsField, ok := result.Frontmatter["labels"]; ok { + if labelsArray, ok := labelsField.([]any); ok { + for _, label := range labelsArray { + if labelStr, ok := label.(string); ok { + labels = append(labels, labelStr) + } + } + } + } } } } + // Skip if label filter specified and workflow doesn't have the label + if labelFilter != "" { + hasLabel := false + for _, label := range labels { + if strings.EqualFold(label, labelFilter) { + hasLabel = true + break + } + } + if !hasLabel { + continue + } + } + // Get run status for ref if available var runStatus, runConclusion string if latestRunsByWorkflow != nil { @@ -180,6 +206,7 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string) Compiled: compiled, Status: status, TimeRemaining: timeRemaining, + Labels: labels, On: onField, RunStatus: runStatus, RunConclusion: runConclusion, @@ -250,6 +277,38 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string) } } + // Extract labels from frontmatter + var labels []string + if content, err := os.ReadFile(file); err == nil { + if result, err := parser.ExtractFrontmatterFromContent(string(content)); err == nil { + if result.Frontmatter != nil { + if labelsField, ok := result.Frontmatter["labels"]; ok { + if labelsArray, ok := labelsField.([]any); ok { + for _, label := range labelsArray { + if labelStr, ok := label.(string); ok { + labels = append(labels, labelStr) + } + } + } + } + } + } + } + + // Skip if label filter specified and workflow doesn't have the label + if labelFilter != "" { + hasLabel := false + for _, label := range labels { + if strings.EqualFold(label, labelFilter) { + hasLabel = true + break + } + } + if !hasLabel { + continue + } + } + // Build status object statuses = append(statuses, WorkflowStatus{ Workflow: name, @@ -257,6 +316,7 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string) Compiled: compiled, Status: status, TimeRemaining: timeRemaining, + Labels: labels, RunStatus: runStatus, RunConclusion: runConclusion, }) diff --git a/pkg/cli/status_command_test.go b/pkg/cli/status_command_test.go index 7aa03efe9f0..b46ea2900ec 100644 --- a/pkg/cli/status_command_test.go +++ b/pkg/cli/status_command_test.go @@ -28,7 +28,7 @@ func TestStatusWorkflows_JSONOutput(t *testing.T) { // Test JSON output without pattern t.Run("JSON output without pattern", func(t *testing.T) { - err := StatusWorkflows("", false, true, "") + err := StatusWorkflows("", false, true, "", "") if err != nil { t.Errorf("StatusWorkflows with JSON flag failed: %v", err) } @@ -38,7 +38,7 @@ func TestStatusWorkflows_JSONOutput(t *testing.T) { // Test JSON output with pattern t.Run("JSON output with pattern", func(t *testing.T) { - err := StatusWorkflows("smoke", false, true, "") + err := StatusWorkflows("smoke", false, true, "", "") if err != nil { t.Errorf("StatusWorkflows with JSON flag and pattern failed: %v", err) } @@ -400,6 +400,74 @@ func TestWorkflowStatus_JSONMarshalingWithEmptyRunStatus(t *testing.T) { } } +// TestWorkflowStatus_JSONMarshalingWithLabels tests that labels are included in JSON output +func TestWorkflowStatus_JSONMarshalingWithLabels(t *testing.T) { + // Test that WorkflowStatus with labels can be marshaled to JSON + status := WorkflowStatus{ + Workflow: "test-workflow", + EngineID: "copilot", + Compiled: "Yes", + Status: "active", + TimeRemaining: "N/A", + Labels: []string{"automation", "testing"}, + } + + jsonBytes, err := json.Marshal(status) + if err != nil { + t.Fatalf("Failed to marshal WorkflowStatus: %v", err) + } + + // Verify JSON contains labels field + var unmarshaled map[string]any + if err := json.Unmarshal(jsonBytes, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + labels, ok := unmarshaled["labels"].([]any) + if !ok { + t.Fatalf("Expected labels to be an array, got %T", unmarshaled["labels"]) + } + + if len(labels) != 2 { + t.Errorf("Expected 2 labels, got %d", len(labels)) + } + + if labels[0] != "automation" { + t.Errorf("Expected first label to be 'automation', got %v", labels[0]) + } + if labels[1] != "testing" { + t.Errorf("Expected second label to be 'testing', got %v", labels[1]) + } +} + +// TestWorkflowStatus_JSONMarshalingWithEmptyLabels tests that empty labels are omitted +func TestWorkflowStatus_JSONMarshalingWithEmptyLabels(t *testing.T) { + // Test that WorkflowStatus without labels omits the field + status := WorkflowStatus{ + Workflow: "test-workflow", + EngineID: "copilot", + Compiled: "Yes", + Status: "active", + TimeRemaining: "N/A", + // Labels is empty/nil + } + + jsonBytes, err := json.Marshal(status) + if err != nil { + t.Fatalf("Failed to marshal WorkflowStatus: %v", err) + } + + // Verify JSON omits empty labels field (due to omitempty) + var unmarshaled map[string]any + if err := json.Unmarshal(jsonBytes, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if _, exists := unmarshaled["labels"]; exists { + t.Errorf("Expected labels to be omitted when empty, but it was present with value: %v", unmarshaled["labels"]) + } +} + // TestWorkflowStatus_ConsoleRenderingWithRunStatus tests that RunStatus and RunConclusion are rendered when present func TestWorkflowStatus_ConsoleRenderingWithRunStatus(t *testing.T) { // Create test data with run status diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 4e5dfb424c4..147e691ca23 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -29,6 +29,19 @@ "description": "Optional tracker identifier to tag all created assets (issues, discussions, comments, pull requests). Must be at least 8 characters and contain only alphanumeric characters, hyphens, and underscores. This identifier will be inserted in the body/description of all created assets to enable searching and retrieving assets associated with this workflow.", "examples": ["workflow-2024-q1", "team-alpha-bot", "security_audit_v2"] }, + "labels": { + "type": "array", + "description": "Optional array of labels to categorize and organize workflows. Labels can be used to filter workflows in status/list commands.", + "items": { + "type": "string", + "minLength": 1 + }, + "examples": [ + ["automation", "security"], + ["docs", "maintenance"], + ["ci", "testing"] + ] + }, "imports": { "type": "array", "description": "Optional array of workflow specifications to import (similar to @include directives but defined in frontmatter). Format: owner/repo/path@ref (e.g., githubnext/agentics/workflows/shared/common.md@v1.0.0). Can be strings or objects with path and inputs. Any markdown files under .github/agents directory are treated as custom agent files and only one agent file is allowed per workflow.",