Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
188 changes: 188 additions & 0 deletions pkg/cli/list_workflows_command.go
Original file line number Diff line number Diff line change
@@ -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
}
120 changes: 120 additions & 0 deletions pkg/cli/list_workflows_command_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading