diff --git a/pkg/cli/inventory/README.md b/pkg/cli/inventory/README.md new file mode 100644 index 0000000000..0695e8ed5e --- /dev/null +++ b/pkg/cli/inventory/README.md @@ -0,0 +1,293 @@ +# Workflow Inventory Package + +The `pkg/cli/inventory` package provides unified helpers for extracting, normalizing, and managing workflow file names and paths. + +## Overview + +This package centralizes workflow name handling logic that was previously scattered across multiple files. It provides a single source of truth for: +- Extracting workflow names from file paths +- Normalizing user-provided workflow names +- Converting workflow names to file paths +- Discovering workflow files in directories + +## Functions + +### ExtractWorkflowName + +Extracts the normalized workflow name from any file path or filename. + +```go +import "github.com/githubnext/gh-aw/pkg/cli/inventory" + +// Regular workflows +name := inventory.ExtractWorkflowName("my-workflow.md") // "my-workflow" +name := inventory.ExtractWorkflowName(".github/workflows/deploy.md") // "deploy" + +// Lock files +name := inventory.ExtractWorkflowName("workflow.lock.yml") // "workflow" + +// Campaign workflows +name := inventory.ExtractWorkflowName("security.campaign.md") // "security" +name := inventory.ExtractWorkflowName("security.campaign.lock.yml") // "security" +name := inventory.ExtractWorkflowName("security.campaign.g.md") // "security" +``` + +**Supported file types:** +- Regular workflows: `.md` +- Lock files: `.lock.yml` +- Campaign workflows: `.campaign.md` +- Campaign lock files: `.campaign.lock.yml` +- Generated campaign orchestrators: `.campaign.g.md` + +### NormalizeWorkflowName + +Normalizes user input to a workflow name. This is semantically the same as `ExtractWorkflowName`, but indicates the input is from a user. + +```go +// User provides various formats +name := inventory.NormalizeWorkflowName("my-workflow") // "my-workflow" +name := inventory.NormalizeWorkflowName("my-workflow.md") // "my-workflow" +name := inventory.NormalizeWorkflowName(".github/workflows/deploy.md") // "deploy" +``` + +### GetWorkflowPath + +Converts a workflow name to its markdown file path. + +```go +// Default directory (.github/workflows) +path := inventory.GetWorkflowPath("my-workflow", "") +// ".github/workflows/my-workflow.md" + +// Custom directory +path := inventory.GetWorkflowPath("deploy", "/custom/path") +// "/custom/path/deploy.md" +``` + +### GetLockFilePath + +Returns the lock file path for a workflow. + +```go +// Regular workflow +lockPath := inventory.GetLockFilePath("workflow.md", "") +// ".github/workflows/workflow.lock.yml" + +// Campaign workflow +lockPath := inventory.GetLockFilePath("security.campaign.md", "") +// ".github/workflows/security.campaign.lock.yml" + +// Generated campaign orchestrator +lockPath := inventory.GetLockFilePath("security.campaign.g.md", "") +// ".github/workflows/security.campaign.lock.yml" +``` + +### ListWorkflowFiles + +Discovers all workflow files in a directory with filtering options. + +```go +// List only regular workflows (default) +workflows, err := inventory.ListWorkflowFiles("", false, false) + +// Include campaign workflows +workflows, err := inventory.ListWorkflowFiles("", true, false) + +// Include generated files +workflows, err := inventory.ListWorkflowFiles("", false, true) + +// Include everything +workflows, err := inventory.ListWorkflowFiles("", true, true) + +// Each workflow contains: +for _, wf := range workflows { + fmt.Printf("Name: %s\n", wf.Name) // Normalized name + fmt.Printf("Path: %s\n", wf.Path) // Full path to .md file + fmt.Printf("Type: %d\n", wf.Type) // WorkflowType enum + fmt.Printf("Lock: %s\n", wf.LockPath) // Path to lock file +} +``` + +**WorkflowType enum values:** +- `WorkflowTypeRegular` (0) - Standard workflow (.md) +- `WorkflowTypeCampaign` (1) - Campaign spec (.campaign.md) +- `WorkflowTypeCampaignGenerated` (2) - Generated orchestrator (.campaign.g.md) + +**Filtering:** +- `README.md` files are always excluded (case-insensitive) +- Files with README in the middle (e.g., `README-test.md`) are included +- By default, only regular `.md` workflows are returned +- Campaign files (`.campaign.md`) require `includeCampaigns=true` +- Generated files (`.campaign.g.md`) require `includeGenerated=true` + +## Usage Examples + +### Command-line workflow argument handling + +```go +import "github.com/githubnext/gh-aw/pkg/cli/inventory" + +func handleWorkflowCommand(userInput string) error { + // Normalize user input (strips .md, handles paths) + workflowName := inventory.NormalizeWorkflowName(userInput) + + // Get the actual workflow file path + workflowPath := inventory.GetWorkflowPath(workflowName, "") + + // Read and process the workflow + content, err := os.ReadFile(workflowPath) + // ... +} +``` + +### Listing workflows for status display + +```go +import "github.com/githubnext/gh-aw/pkg/cli/inventory" + +func listAllWorkflows() error { + // Get all workflows including campaigns + workflows, err := inventory.ListWorkflowFiles("", true, false) + if err != nil { + return err + } + + for _, wf := range workflows { + fmt.Printf("Workflow: %s (%s)\n", wf.Name, wf.Path) + + // Check if lock file exists + if fileExists(wf.LockPath) { + fmt.Printf(" ✓ Compiled: %s\n", wf.LockPath) + } else { + fmt.Printf(" ✗ Not compiled\n") + } + } + + return nil +} +``` + +### Extracting names from GitHub API responses + +```go +import "github.com/githubnext/gh-aw/pkg/cli/inventory" + +func processGitHubWorkflows(apiWorkflows []GitHubWorkflow) { + for _, apiWorkflow := range apiWorkflows { + // API returns paths like ".github/workflows/ci.lock.yml" + workflowName := inventory.ExtractWorkflowName(apiWorkflow.Path) + fmt.Printf("Workflow: %s\n", workflowName) + } +} +``` + +## Migration Guide + +### Before (scattered logic) + +```go +// In workflows.go +func extractWorkflowNameFromPath(path string) string { + base := filepath.Base(path) + name := strings.TrimSuffix(base, filepath.Ext(base)) + return strings.TrimSuffix(name, ".lock") +} + +// In status_command.go (duplicate) +func getMarkdownWorkflowFiles(dir string) ([]string, error) { + mdFiles, err := filepath.Glob(filepath.Join(dir, "*.md")) + // ... +} + +// In many files +workflowName := strings.TrimSuffix(filepath.Base(file), ".md") +``` + +### After (unified inventory package) + +```go +import "github.com/githubnext/gh-aw/pkg/cli/inventory" + +// Unified extraction +workflowName := inventory.ExtractWorkflowName(path) + +// Unified listing +workflows, err := inventory.ListWorkflowFiles("", false, false) + +// Normalized names +name := inventory.NormalizeWorkflowName(userInput) +``` + +## Benefits + +1. **Single Source of Truth**: All workflow name logic in one place +2. **Comprehensive**: Handles all workflow types (regular, campaign, generated) +3. **Well-Tested**: 56+ test cases covering edge cases +4. **Type-Safe**: Strongly-typed WorkflowFile struct +5. **Discoverable**: Clear function names and documentation +6. **Extensible**: Easy to add new workflow types or filters + +## Design Decisions + +### Why separate ExtractWorkflowName and NormalizeWorkflowName? + +While they do the same thing functionally, the semantic difference is important: +- `ExtractWorkflowName`: Used when processing known file paths (API responses, file listings) +- `NormalizeWorkflowName`: Used when handling user input (CLI arguments, interactive prompts) + +This makes code intent clearer and helps future developers understand the context. + +### Why filter README.md? + +README.md files in the workflows directory are documentation, not workflows. The filter: +- Is case-insensitive (matches README.md, readme.md, ReadMe.md) +- Only filters exact matches (allows README-test.md, test-README.md) +- Is applied automatically to protect against common mistakes + +### Why separate include flags for campaigns and generated files? + +Different use cases need different visibility: +- **User-facing commands** (status, list): Show regular and campaign workflows, hide generated +- **Internal operations** (compile): Need to see all files including generated +- **Cleanup operations**: Might need to target only generated files + +## Testing + +The package has comprehensive test coverage: + +```bash +# Run inventory package tests +go test ./pkg/cli/inventory/... + +# Run with verbose output +go test -v ./pkg/cli/inventory/... + +# Check test coverage +go test -cover ./pkg/cli/inventory/... +``` + +Test coverage includes: +- All file type variations (regular, campaign, lock, generated) +- Path handling (relative, absolute, with/without directories) +- Edge cases (empty input, no extension, multiple dots) +- README.md filtering (all case variations) +- Directory listing with filtering options +- Error conditions (non-existent directories) + +## Future Enhancements + +Possible future additions to this package: + +1. **Workflow validation**: Check if workflow files are valid +2. **Dependency tracking**: Find workflows that include/reference others +3. **Metadata extraction**: Parse frontmatter without full compilation +4. **Workflow templates**: Support for workflow templates/scaffolding +5. **Batch operations**: Rename, move, or organize multiple workflows +6. **Search/filtering**: Find workflows by name pattern, tags, or content + +## Related Packages + +- `pkg/parser`: Parses workflow markdown and frontmatter +- `pkg/workflow`: Compiles workflows to GitHub Actions YAML +- `pkg/campaign`: Campaign workflow orchestration +- `pkg/cli/fileutil`: General file utilities diff --git a/pkg/cli/inventory/inventory.go b/pkg/cli/inventory/inventory.go new file mode 100644 index 0000000000..fd977641da --- /dev/null +++ b/pkg/cli/inventory/inventory.go @@ -0,0 +1,225 @@ +package inventory + +import ( + "os" + "path/filepath" + "strings" + + "github.com/githubnext/gh-aw/pkg/logger" +) + +var inventoryLog = logger.New("cli:inventory") + +// ExtractWorkflowName extracts the normalized workflow name from a file path or filename. +// It handles various workflow file types: +// - Regular workflows: "workflow.md" -> "workflow" +// - Lock files: "workflow.lock.yml" -> "workflow" +// - Campaign workflows: "campaign.campaign.md" -> "campaign" +// - Campaign lock files: "campaign.campaign.lock.yml" -> "campaign" +// - Generated campaign orchestrators: "campaign.campaign.g.md" -> "campaign" +// - Paths: ".github/workflows/workflow.md" -> "workflow" +// +// The function returns the base workflow identifier without any file extensions. +func ExtractWorkflowName(path string) string { + // Get base filename + base := filepath.Base(path) + + // Handle special cases first before removing generic extensions + // Generated campaign orchestrators: .campaign.g.md -> base name + if strings.HasSuffix(base, ".campaign.g.md") { + return strings.TrimSuffix(base, ".campaign.g.md") + } + + // Campaign lock files: .campaign.lock.yml -> base name + if strings.HasSuffix(base, ".campaign.lock.yml") { + return strings.TrimSuffix(base, ".campaign.lock.yml") + } + + // Campaign workflows: .campaign.md -> base name + if strings.HasSuffix(base, ".campaign.md") { + return strings.TrimSuffix(base, ".campaign.md") + } + + // Lock files: .lock.yml -> base name + if strings.HasSuffix(base, ".lock.yml") { + return strings.TrimSuffix(base, ".lock.yml") + } + + // Regular workflows: .md -> base name + if strings.HasSuffix(base, ".md") { + return strings.TrimSuffix(base, ".md") + } + + // If no known extension, return as-is + inventoryLog.Printf("Extracted workflow name: %s -> %s", path, base) + return base +} + +// NormalizeWorkflowName normalizes a workflow name or path provided by user input. +// It strips file extensions and extracts the base workflow name. +// +// Examples: +// - "my-workflow" -> "my-workflow" +// - "my-workflow.md" -> "my-workflow" +// - ".github/workflows/my-workflow.md" -> "my-workflow" +// - "my-workflow.lock.yml" -> "my-workflow" +// +// This is the same as ExtractWorkflowName but semantically indicates +// it's being used to normalize user-provided input. +func NormalizeWorkflowName(input string) string { + return ExtractWorkflowName(input) +} + +// GetWorkflowPath returns the markdown file path for a workflow name. +// If workflowsDir is empty, it uses ".github/workflows" as the default. +// +// Examples: +// - GetWorkflowPath("my-workflow", "") -> ".github/workflows/my-workflow.md" +// - GetWorkflowPath("my-workflow", "/path/to/workflows") -> "/path/to/workflows/my-workflow.md" +func GetWorkflowPath(workflowName, workflowsDir string) string { + if workflowsDir == "" { + workflowsDir = ".github/workflows" + } + + // Ensure the workflow name doesn't have .md extension + workflowName = NormalizeWorkflowName(workflowName) + + return filepath.Join(workflowsDir, workflowName+".md") +} + +// GetLockFilePath returns the lock file path for a workflow. +// +// It handles different workflow types: +// - Regular workflow: "workflow.md" -> "workflow.lock.yml" +// - Campaign workflow: "campaign.campaign.md" -> "campaign.campaign.lock.yml" +// - Generated orchestrator: "campaign.campaign.g.md" -> "campaign.campaign.lock.yml" +// +// If workflowPath is just a name without a path, it uses workflowsDir. +// If workflowsDir is empty, it uses ".github/workflows" as the default. +func GetLockFilePath(workflowPath, workflowsDir string) string { + dir := filepath.Dir(workflowPath) + base := filepath.Base(workflowPath) + + // If workflowPath is just a filename, use workflowsDir + if dir == "." { + if workflowsDir == "" { + workflowsDir = ".github/workflows" + } + dir = workflowsDir + } + + // Handle different workflow types + if strings.HasSuffix(base, ".campaign.g.md") { + // Generated orchestrator: campaign.campaign.g.md -> campaign.campaign.lock.yml + lockName := strings.TrimSuffix(base, ".campaign.g.md") + ".campaign.lock.yml" + return filepath.Join(dir, lockName) + } else if strings.HasSuffix(base, ".campaign.md") { + // Campaign workflow: campaign.campaign.md -> campaign.campaign.lock.yml + lockName := strings.TrimSuffix(base, ".campaign.md") + ".campaign.lock.yml" + return filepath.Join(dir, lockName) + } else { + // Regular workflow: workflow.md -> workflow.lock.yml + lockName := strings.TrimSuffix(base, ".md") + ".lock.yml" + return filepath.Join(dir, lockName) + } +} + +// WorkflowType represents the type of workflow file +type WorkflowType int + +const ( + // WorkflowTypeRegular is a standard workflow (.md file) + WorkflowTypeRegular WorkflowType = iota + // WorkflowTypeCampaign is a campaign spec (.campaign.md file) + WorkflowTypeCampaign + // WorkflowTypeCampaignGenerated is a generated campaign orchestrator (.campaign.g.md file) + WorkflowTypeCampaignGenerated +) + +// WorkflowFile represents a discovered workflow file +type WorkflowFile struct { + Name string // Normalized workflow name (without extensions) + Path string // Full path to the markdown file + Type WorkflowType // Type of workflow + LockPath string // Path to corresponding lock file +} + +// isWorkflowFile returns true if the file should be treated as a workflow file. +// README.md files are excluded as they are documentation, not workflows. +func isWorkflowFile(filename string) bool { + base := strings.ToLower(filepath.Base(filename)) + return base != "readme.md" +} + +// ListWorkflowFiles discovers all workflow files in the specified directory. +// If workflowsDir is empty, it uses ".github/workflows" as the default. +// +// Options: +// - includeCampaigns: include campaign spec files (.campaign.md) +// - includeGenerated: include generated files (.campaign.g.md, .lock.yml) +// +// By default, only regular workflow .md files are returned, and README.md is excluded. +func ListWorkflowFiles(workflowsDir string, includeCampaigns, includeGenerated bool) ([]WorkflowFile, error) { + if workflowsDir == "" { + workflowsDir = ".github/workflows" + } + + inventoryLog.Printf("Listing workflow files in: %s (campaigns=%v, generated=%v)", workflowsDir, includeCampaigns, includeGenerated) + + // Check if directory exists + if _, err := os.Stat(workflowsDir); os.IsNotExist(err) { + inventoryLog.Printf("Workflows directory does not exist: %s", workflowsDir) + return nil, err + } + + // Find all markdown files + mdFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.md")) + if err != nil { + return nil, err + } + + var workflows []WorkflowFile + + for _, file := range mdFiles { + if !isWorkflowFile(file) { + inventoryLog.Printf("Skipping non-workflow file: %s", file) + continue + } + + base := filepath.Base(file) + + // Determine workflow type + var workflowType WorkflowType + if strings.HasSuffix(base, ".campaign.g.md") { + if !includeGenerated { + inventoryLog.Printf("Skipping generated file: %s", file) + continue + } + workflowType = WorkflowTypeCampaignGenerated + } else if strings.HasSuffix(base, ".campaign.md") { + if !includeCampaigns { + inventoryLog.Printf("Skipping campaign file: %s", file) + continue + } + workflowType = WorkflowTypeCampaign + } else { + workflowType = WorkflowTypeRegular + } + + name := ExtractWorkflowName(file) + lockPath := GetLockFilePath(file, workflowsDir) + + workflow := WorkflowFile{ + Name: name, + Path: file, + Type: workflowType, + LockPath: lockPath, + } + + workflows = append(workflows, workflow) + inventoryLog.Printf("Found workflow: %s (type=%d, lock=%s)", name, workflowType, lockPath) + } + + inventoryLog.Printf("Found %d workflow files", len(workflows)) + return workflows, nil +} diff --git a/pkg/cli/inventory/inventory_test.go b/pkg/cli/inventory/inventory_test.go new file mode 100644 index 0000000000..b8ca9f9574 --- /dev/null +++ b/pkg/cli/inventory/inventory_test.go @@ -0,0 +1,463 @@ +package inventory + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractWorkflowName(t *testing.T) { + tests := []struct { + name string + path string + expected string + }{ + { + name: "simple workflow file", + path: "my-workflow.md", + expected: "my-workflow", + }, + { + name: "workflow with path", + path: ".github/workflows/my-workflow.md", + expected: "my-workflow", + }, + { + name: "lock file", + path: "my-workflow.lock.yml", + expected: "my-workflow", + }, + { + name: "lock file with path", + path: ".github/workflows/my-workflow.lock.yml", + expected: "my-workflow", + }, + { + name: "campaign workflow", + path: "security.campaign.md", + expected: "security", + }, + { + name: "campaign lock file", + path: "security.campaign.lock.yml", + expected: "security", + }, + { + name: "generated campaign orchestrator", + path: "security.campaign.g.md", + expected: "security", + }, + { + name: "workflow with multiple dots", + path: "test.lock.yml", + expected: "test", + }, + { + name: "workflow with dashes", + path: "my-test-workflow.md", + expected: "my-test-workflow", + }, + { + name: "workflow with underscores", + path: "my_test_workflow.md", + expected: "my_test_workflow", + }, + { + name: "just filename no extension", + path: "workflow", + expected: "workflow", + }, + { + name: "absolute path", + path: "/home/user/.github/workflows/deploy.md", + expected: "deploy", + }, + { + name: "campaign with path", + path: ".github/workflows/update-deps.campaign.md", + expected: "update-deps", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractWorkflowName(tt.path) + assert.Equal(t, tt.expected, result, "ExtractWorkflowName(%q) should return %q", tt.path, tt.expected) + }) + } +} + +func TestNormalizeWorkflowName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "plain workflow name", + input: "my-workflow", + expected: "my-workflow", + }, + { + name: "workflow name with .md", + input: "my-workflow.md", + expected: "my-workflow", + }, + { + name: "path with workflow file", + input: ".github/workflows/my-workflow.md", + expected: "my-workflow", + }, + { + name: "lock file reference", + input: "my-workflow.lock.yml", + expected: "my-workflow", + }, + { + name: "campaign workflow", + input: "security.campaign.md", + expected: "security", + }, + { + name: "relative path", + input: "workflows/deploy.md", + expected: "deploy", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeWorkflowName(tt.input) + assert.Equal(t, tt.expected, result, "NormalizeWorkflowName(%q) should return %q", tt.input, tt.expected) + }) + } +} + +func TestGetWorkflowPath(t *testing.T) { + tests := []struct { + name string + workflowName string + workflowsDir string + expected string + }{ + { + name: "default directory", + workflowName: "my-workflow", + workflowsDir: "", + expected: ".github/workflows/my-workflow.md", + }, + { + name: "custom directory", + workflowName: "my-workflow", + workflowsDir: "/custom/path", + expected: "/custom/path/my-workflow.md", + }, + { + name: "workflow name with .md extension", + workflowName: "my-workflow.md", + workflowsDir: "", + expected: ".github/workflows/my-workflow.md", + }, + { + name: "workflow name with path", + workflowName: ".github/workflows/my-workflow.md", + workflowsDir: "", + expected: ".github/workflows/my-workflow.md", + }, + { + name: "relative custom directory", + workflowName: "deploy", + workflowsDir: "workflows", + expected: "workflows/deploy.md", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetWorkflowPath(tt.workflowName, tt.workflowsDir) + assert.Equal(t, tt.expected, result, "GetWorkflowPath(%q, %q) should return %q", tt.workflowName, tt.workflowsDir, tt.expected) + }) + } +} + +func TestGetLockFilePath(t *testing.T) { + tests := []struct { + name string + workflowPath string + workflowsDir string + expected string + }{ + { + name: "regular workflow", + workflowPath: "my-workflow.md", + workflowsDir: "", + expected: ".github/workflows/my-workflow.lock.yml", + }, + { + name: "regular workflow with path", + workflowPath: ".github/workflows/my-workflow.md", + workflowsDir: "", + expected: ".github/workflows/my-workflow.lock.yml", + }, + { + name: "campaign workflow", + workflowPath: "security.campaign.md", + workflowsDir: "", + expected: ".github/workflows/security.campaign.lock.yml", + }, + { + name: "campaign workflow with path", + workflowPath: ".github/workflows/security.campaign.md", + workflowsDir: "", + expected: ".github/workflows/security.campaign.lock.yml", + }, + { + name: "generated campaign orchestrator", + workflowPath: "security.campaign.g.md", + workflowsDir: "", + expected: ".github/workflows/security.campaign.lock.yml", + }, + { + name: "generated campaign orchestrator with path", + workflowPath: ".github/workflows/update-deps.campaign.g.md", + workflowsDir: "", + expected: ".github/workflows/update-deps.campaign.lock.yml", + }, + { + name: "custom workflows directory", + workflowPath: "deploy.md", + workflowsDir: "/custom/workflows", + expected: "/custom/workflows/deploy.lock.yml", + }, + { + name: "workflow name only", + workflowPath: "test-workflow.md", + workflowsDir: "workflows", + expected: "workflows/test-workflow.lock.yml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetLockFilePath(tt.workflowPath, tt.workflowsDir) + assert.Equal(t, tt.expected, result, "GetLockFilePath(%q, %q) should return %q", tt.workflowPath, tt.workflowsDir, tt.expected) + }) + } +} + +func TestIsWorkflowFile(t *testing.T) { + tests := []struct { + name string + filename string + expected bool + }{ + { + name: "regular workflow file", + filename: "my-workflow.md", + expected: true, + }, + { + name: "README.md uppercase", + filename: "README.md", + expected: false, + }, + { + name: "readme.md lowercase", + filename: "readme.md", + expected: false, + }, + { + name: "ReadMe.md mixed case", + filename: "ReadMe.md", + expected: false, + }, + { + name: "README with prefix", + filename: "README-workflow.md", + expected: true, + }, + { + name: "README with suffix", + filename: "workflow-README.md", + expected: true, + }, + { + name: "path with README.md", + filename: ".github/workflows/README.md", + expected: false, + }, + { + name: "path with workflow", + filename: ".github/workflows/deploy.md", + expected: true, + }, + { + name: "campaign file", + filename: "security.campaign.md", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isWorkflowFile(tt.filename) + assert.Equal(t, tt.expected, result, "isWorkflowFile(%q) should return %v", tt.filename, tt.expected) + }) + } +} + +func TestListWorkflowFiles(t *testing.T) { + // Create temporary test directory + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + err := os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Create test workflow files + testFiles := map[string]string{ + "workflow1.md": "---\nengine: copilot\n---\n# Workflow 1", + "workflow2.md": "---\nengine: claude\n---\n# Workflow 2", + "README.md": "# Workflows Documentation", + "security.campaign.md": "---\nengine: copilot\n---\n# Security Campaign", + "update-deps.campaign.md": "---\nengine: copilot\n---\n# Update Dependencies", + "security.campaign.g.md": "---\nengine: copilot\n---\n# Generated Security", + "deploy-prod.md": "---\nengine: codex\n---\n# Deploy Production", + "test-workflow-README.md": "---\nengine: copilot\n---\n# Test with README suffix", + "README-test-workflow.md": "---\nengine: copilot\n---\n# README prefix workflow", + } + + for filename, content := range testFiles { + path := filepath.Join(workflowsDir, filename) + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err, "Failed to write test file %s", filename) + } + + tests := []struct { + name string + includeCampaigns bool + includeGenerated bool + expectedCount int + expectedNames []string + excludedNames []string + }{ + { + name: "regular workflows only", + includeCampaigns: false, + includeGenerated: false, + expectedCount: 5, + expectedNames: []string{"workflow1", "workflow2", "deploy-prod", "test-workflow-README", "README-test-workflow"}, + excludedNames: []string{"security", "update-deps"}, + }, + { + name: "include campaigns", + includeCampaigns: true, + includeGenerated: false, + expectedCount: 7, + expectedNames: []string{"workflow1", "workflow2", "security", "update-deps", "deploy-prod"}, + excludedNames: []string{}, + }, + { + name: "include generated", + includeCampaigns: false, + includeGenerated: true, + expectedCount: 6, + expectedNames: []string{"workflow1", "workflow2", "security", "deploy-prod"}, + excludedNames: []string{"update-deps"}, + }, + { + name: "include everything", + includeCampaigns: true, + includeGenerated: true, + expectedCount: 8, + expectedNames: []string{"workflow1", "workflow2", "security", "update-deps", "deploy-prod"}, + excludedNames: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workflows, err := ListWorkflowFiles(workflowsDir, tt.includeCampaigns, tt.includeGenerated) + require.NoError(t, err, "ListWorkflowFiles should not return error") + + assert.Len(t, workflows, tt.expectedCount, "Should find %d workflows", tt.expectedCount) + + // Extract workflow names for easier checking + var foundNames []string + for _, wf := range workflows { + foundNames = append(foundNames, wf.Name) + } + + // Check expected names are present + for _, expectedName := range tt.expectedNames { + assert.Contains(t, foundNames, expectedName, "Should include workflow %s", expectedName) + } + + // Check excluded names are not present + for _, excludedName := range tt.excludedNames { + assert.NotContains(t, foundNames, excludedName, "Should not include workflow %s", excludedName) + } + + // Verify README.md is never included + assert.NotContains(t, foundNames, "README", "Should never include README.md") + }) + } +} + +func TestListWorkflowFiles_NonExistentDirectory(t *testing.T) { + tmpDir := t.TempDir() + nonExistentDir := filepath.Join(tmpDir, "nonexistent") + + workflows, err := ListWorkflowFiles(nonExistentDir, false, false) + require.Error(t, err, "Should return error for non-existent directory") + assert.Nil(t, workflows, "Should return nil workflows on error") +} + +func TestWorkflowFile_Fields(t *testing.T) { + // Create temporary test directory + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + err := os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Create test files + testFile := filepath.Join(workflowsDir, "test.md") + err = os.WriteFile(testFile, []byte("---\nengine: copilot\n---\n# Test"), 0644) + require.NoError(t, err, "Failed to write test file") + + campaignFile := filepath.Join(workflowsDir, "campaign.campaign.md") + err = os.WriteFile(campaignFile, []byte("---\nengine: copilot\n---\n# Campaign"), 0644) + require.NoError(t, err, "Failed to write campaign file") + + workflows, err := ListWorkflowFiles(workflowsDir, true, false) + require.NoError(t, err, "ListWorkflowFiles should not return error") + require.Len(t, workflows, 2, "Should find 2 workflows") + + // Check regular workflow + regularWorkflow := findWorkflowByName(workflows, "test") + require.NotNil(t, regularWorkflow, "Should find regular workflow") + assert.Equal(t, "test", regularWorkflow.Name) + assert.Equal(t, testFile, regularWorkflow.Path) + assert.Equal(t, WorkflowTypeRegular, regularWorkflow.Type) + assert.Equal(t, filepath.Join(workflowsDir, "test.lock.yml"), regularWorkflow.LockPath) + + // Check campaign workflow + campaignWorkflow := findWorkflowByName(workflows, "campaign") + require.NotNil(t, campaignWorkflow, "Should find campaign workflow") + assert.Equal(t, "campaign", campaignWorkflow.Name) + assert.Equal(t, campaignFile, campaignWorkflow.Path) + assert.Equal(t, WorkflowTypeCampaign, campaignWorkflow.Type) + assert.Equal(t, filepath.Join(workflowsDir, "campaign.campaign.lock.yml"), campaignWorkflow.LockPath) +} + +// Helper function to find workflow by name in slice +func findWorkflowByName(workflows []WorkflowFile, name string) *WorkflowFile { + for i := range workflows { + if workflows[i].Name == name { + return &workflows[i] + } + } + return nil +}