diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index b786eb341e..53d4bc0b20 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -541,6 +541,9 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all // Create and setup status command statusCmd := cli.NewStatusCommand() + // Create and setup list command + listCmd := cli.NewListCommand() + // Create commands that need group assignment mcpCmd := cli.NewMCPCommand() logsCmd := cli.NewLogsCommand() @@ -567,6 +570,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all compileCmd.GroupID = "development" mcpCmd.GroupID = "development" statusCmd.GroupID = "development" + listCmd.GroupID = "development" fixCmd.GroupID = "development" // Execution Commands @@ -598,6 +602,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all rootCmd.AddCommand(runCmd) rootCmd.AddCommand(removeCmd) rootCmd.AddCommand(statusCmd) + rootCmd.AddCommand(listCmd) rootCmd.AddCommand(enableCmd) rootCmd.AddCommand(disableCmd) rootCmd.AddCommand(logsCmd) diff --git a/pkg/cli/list_workflows_command.go b/pkg/cli/list_workflows_command.go new file mode 100644 index 0000000000..3f4eb60ddf --- /dev/null +++ b/pkg/cli/list_workflows_command.go @@ -0,0 +1,188 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/constants" + "github.com/githubnext/gh-aw/pkg/logger" + "github.com/githubnext/gh-aw/pkg/parser" + "github.com/githubnext/gh-aw/pkg/stringutil" + "github.com/spf13/cobra" +) + +var listWorkflowsLog = logger.New("cli:list_workflows") + +// WorkflowListItem represents a single workflow for list output +type WorkflowListItem struct { + Workflow string `json:"workflow" console:"header:Workflow"` + EngineID string `json:"engine_id" console:"header:Engine"` + Compiled string `json:"compiled" console:"header:Compiled"` + Labels []string `json:"labels,omitempty" console:"header:Labels,omitempty"` + On any `json:"on,omitempty" console:"-"` +} + +// NewListCommand creates the list command +func NewListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list [pattern]", + Short: "List agentic workflows in the repository", + Long: `List all agentic workflows in the repository without checking their status. + +Displays a simplified table with workflow name, AI engine, and compilation status. +Unlike 'status', this command does not check GitHub workflow state or time remaining. + +The optional pattern argument filters workflows by name (case-insensitive substring match). + +Examples: + ` + string(constants.CLIExtensionPrefix) + ` list # List all workflows + ` + string(constants.CLIExtensionPrefix) + ` list ci- # List workflows with 'ci-' in name + ` + string(constants.CLIExtensionPrefix) + ` list --json # Output in JSON format + ` + string(constants.CLIExtensionPrefix) + ` list --label automation # List workflows with 'automation' label`, + RunE: func(cmd *cobra.Command, args []string) error { + var pattern string + if len(args) > 0 { + pattern = args[0] + } + verbose, _ := cmd.Flags().GetBool("verbose") + jsonFlag, _ := cmd.Flags().GetBool("json") + labelFilter, _ := cmd.Flags().GetString("label") + return RunListWorkflows(pattern, verbose, jsonFlag, labelFilter) + }, + } + + addJSONFlag(cmd) + cmd.Flags().String("label", "", "Filter workflows by label") + + // Register completions for list command + cmd.ValidArgsFunction = CompleteWorkflowNames + + return cmd +} + +// RunListWorkflows lists workflows without checking GitHub status +func RunListWorkflows(pattern string, verbose bool, jsonOutput bool, labelFilter string) error { + listWorkflowsLog.Printf("Listing workflows: pattern=%s, jsonOutput=%v, labelFilter=%s", pattern, jsonOutput, labelFilter) + if verbose && !jsonOutput { + fmt.Fprintf(os.Stderr, "Listing workflow files\n") + if pattern != "" { + fmt.Fprintf(os.Stderr, "Filtering by pattern: %s\n", pattern) + } + } + + mdFiles, err := getMarkdownWorkflowFiles("") + if err != nil { + listWorkflowsLog.Printf("Failed to get markdown workflow files: %v", err) + fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) + return nil + } + + listWorkflowsLog.Printf("Found %d markdown workflow files", len(mdFiles)) + if len(mdFiles) == 0 { + if jsonOutput { + // Output empty array for JSON + output := []WorkflowListItem{} + jsonBytes, _ := json.MarshalIndent(output, "", " ") + fmt.Println(string(jsonBytes)) + return nil + } + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No workflow files found.")) + return nil + } + + if verbose && !jsonOutput { + fmt.Fprintf(os.Stderr, "Found %d markdown workflow files\n", len(mdFiles)) + } + + // Build workflow list + var workflows []WorkflowListItem + + for _, file := range mdFiles { + name := extractWorkflowNameFromPath(file) + + // Skip if pattern specified and doesn't match + if pattern != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(pattern)) { + continue + } + + // Extract engine ID from workflow file + agent := extractEngineIDFromFile(file) + + // Check if compiled (.lock.yml file is in .github/workflows) + lockFile := stringutil.MarkdownToLockFile(file) + compiled := "N/A" + + if _, err := os.Stat(lockFile); err == nil { + // Check if up to date + mdStat, _ := os.Stat(file) + lockStat, _ := os.Stat(lockFile) + if mdStat.ModTime().After(lockStat.ModTime()) { + compiled = "No" + } else { + compiled = "Yes" + } + } + + // Extract "on" field and labels from frontmatter + 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 + } + } + + // Build workflow list item + workflows = append(workflows, WorkflowListItem{ + Workflow: name, + EngineID: agent, + Compiled: compiled, + Labels: labels, + On: onField, + }) + } + + // Output results + if jsonOutput { + jsonBytes, err := json.MarshalIndent(workflows, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + // Render the table using struct-based rendering + fmt.Fprint(os.Stderr, console.RenderStruct(workflows)) + + return nil +} diff --git a/pkg/cli/list_workflows_command_test.go b/pkg/cli/list_workflows_command_test.go new file mode 100644 index 0000000000..110407b26b --- /dev/null +++ b/pkg/cli/list_workflows_command_test.go @@ -0,0 +1,120 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunListWorkflows_JSONOutput(t *testing.T) { + // Save current directory + originalDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + + // Change to repository root + repoRoot := filepath.Join(originalDir, "..", "..") + err = os.Chdir(repoRoot) + require.NoError(t, err, "Failed to change to repository root") + defer os.Chdir(originalDir) + + // Test JSON output without pattern + t.Run("JSON output without pattern", func(t *testing.T) { + err := RunListWorkflows("", false, true, "") + assert.NoError(t, err, "RunListWorkflows with JSON flag should not error") + }) + + // Test JSON output with pattern + t.Run("JSON output with pattern", func(t *testing.T) { + err := RunListWorkflows("smoke", false, true, "") + assert.NoError(t, err, "RunListWorkflows with JSON flag and pattern should not error") + }) + + // Test JSON output with label filter + t.Run("JSON output with label filter", func(t *testing.T) { + err := RunListWorkflows("", false, true, "test") + assert.NoError(t, err, "RunListWorkflows with JSON flag and label filter should not error") + }) +} + +func TestWorkflowListItem_JSONMarshaling(t *testing.T) { + // Test that WorkflowListItem can be marshaled to JSON + item := WorkflowListItem{ + Workflow: "test-workflow", + EngineID: "copilot", + Compiled: "Yes", + Labels: []string{"test", "automation"}, + On: map[string]any{ + "workflow_dispatch": nil, + }, + } + + jsonBytes, err := json.Marshal(item) + require.NoError(t, err, "Failed to marshal WorkflowListItem") + + // Verify JSON contains expected fields + var unmarshaled map[string]any + err = json.Unmarshal(jsonBytes, &unmarshaled) + require.NoError(t, err, "Failed to unmarshal JSON") + + assert.Equal(t, "test-workflow", unmarshaled["workflow"], "workflow field should match") + assert.Equal(t, "copilot", unmarshaled["engine_id"], "engine_id field should match") + assert.Equal(t, "Yes", unmarshaled["compiled"], "compiled field should match") + + // Verify labels array + labels, ok := unmarshaled["labels"].([]any) + require.True(t, ok, "labels should be an array") + assert.Len(t, labels, 2, "Should have 2 labels") + assert.Equal(t, "test", labels[0], "First label should be 'test'") + assert.Equal(t, "automation", labels[1], "Second label should be 'automation'") + + // Verify "on" field is included + onField, ok := unmarshaled["on"].(map[string]any) + require.True(t, ok, "on field should be a map") + _, exists := onField["workflow_dispatch"] + assert.True(t, exists, "on field should contain 'workflow_dispatch' key") +} + +func TestRunListWorkflows_TextOutput(t *testing.T) { + // Save current directory + originalDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + + // Change to repository root + repoRoot := filepath.Join(originalDir, "..", "..") + err = os.Chdir(repoRoot) + require.NoError(t, err, "Failed to change to repository root") + defer os.Chdir(originalDir) + + // Test text output + t.Run("Text output without pattern", func(t *testing.T) { + err := RunListWorkflows("", false, false, "") + assert.NoError(t, err, "RunListWorkflows without JSON flag should not error") + }) + + // Test text output with pattern + t.Run("Text output with pattern", func(t *testing.T) { + err := RunListWorkflows("ci-", false, false, "") + assert.NoError(t, err, "RunListWorkflows with pattern should not error") + }) +} + +func TestNewListCommand(t *testing.T) { + cmd := NewListCommand() + + // Verify command properties + assert.Equal(t, "list", cmd.Use[:4], "Command use should start with 'list'") + assert.NotEmpty(t, cmd.Short, "Command should have short description") + assert.NotEmpty(t, cmd.Long, "Command should have long description") + assert.NotNil(t, cmd.RunE, "Command should have RunE function") + + // Verify flags exist + jsonFlag := cmd.Flags().Lookup("json") + assert.NotNil(t, jsonFlag, "Command should have --json flag") + + labelFlag := cmd.Flags().Lookup("label") + assert.NotNil(t, labelFlag, "Command should have --label flag") +}