diff --git a/pkg/cli/add_command.go b/pkg/cli/add_command.go index e7ea66e999..489a901689 100644 --- a/pkg/cli/add_command.go +++ b/pkg/cli/add_command.go @@ -3,19 +3,14 @@ package cli import ( "context" "fmt" - "math/rand" "os" "path/filepath" "strings" - "github.com/githubnext/gh-aw/pkg/stringutil" - "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/tty" - "github.com/githubnext/gh-aw/pkg/workflow" "github.com/spf13/cobra" ) @@ -31,32 +26,6 @@ type AddWorkflowsResult struct { HasWorkflowDispatch bool } -// ResolvedWorkflow contains metadata about a workflow that has been resolved and is ready to add -type ResolvedWorkflow struct { - // Spec is the parsed workflow specification - Spec *WorkflowSpec - // Content is the raw workflow content - Content []byte - // SourceInfo contains source metadata (package path, commit SHA) - SourceInfo *WorkflowSourceInfo - // Description is the workflow description extracted from frontmatter - Description string - // Engine is the preferred engine extracted from frontmatter (empty if not specified) - Engine string - // HasWorkflowDispatch indicates if the workflow has workflow_dispatch trigger - HasWorkflowDispatch bool -} - -// ResolvedWorkflows contains all resolved workflows ready to be added -type ResolvedWorkflows struct { - // Workflows is the list of resolved workflows - Workflows []*ResolvedWorkflow - // HasWildcard indicates if any of the original specs contained wildcards - HasWildcard bool - // HasWorkflowDispatch is true if any of the workflows has a workflow_dispatch trigger - HasWorkflowDispatch bool -} - // NewAddCommand creates the add command func NewAddCommand(validateEngine func(string) error) *cobra.Command { cmd := &cobra.Command{ @@ -202,136 +171,6 @@ Note: To create a new workflow from scratch, use the 'new' command instead.`, return cmd } -// ResolveWorkflows resolves workflow specifications by parsing specs, installing repositories, -// expanding wildcards, and fetching workflow content (including descriptions). -// This is useful for showing workflow information before actually adding them. -func ResolveWorkflows(workflows []string, verbose bool) (*ResolvedWorkflows, error) { - addLog.Printf("Resolving workflows: count=%d", len(workflows)) - - if len(workflows) == 0 { - return nil, fmt.Errorf("at least one workflow name is required") - } - - for i, workflow := range workflows { - if workflow == "" { - return nil, fmt.Errorf("workflow name cannot be empty (workflow %d)", i+1) - } - } - - // Parse workflow specifications and group by repository - repoVersions := make(map[string]string) // repo -> version - parsedSpecs := []*WorkflowSpec{} // List of parsed workflow specs - - for _, workflow := range workflows { - spec, err := parseWorkflowSpec(workflow) - if err != nil { - return nil, fmt.Errorf("invalid workflow specification '%s': %w", workflow, err) - } - - // Handle repository installation and workflow name extraction - if existing, exists := repoVersions[spec.RepoSlug]; exists && existing != spec.Version { - return nil, fmt.Errorf("conflicting versions for repository %s: %s vs %s", spec.RepoSlug, existing, spec.Version) - } - repoVersions[spec.RepoSlug] = spec.Version - - // Create qualified name for processing - parsedSpecs = append(parsedSpecs, spec) - } - - // Check if any workflow is from the current repository - // Skip this check if we can't determine the current repository (e.g., not in a git repo) - currentRepoSlug, repoErr := GetCurrentRepoSlug() - if repoErr == nil { - // We successfully determined the current repository, check all workflow specs - for _, spec := range parsedSpecs { - // Skip local workflow specs (starting with "./") - if strings.HasPrefix(spec.WorkflowPath, "./") { - continue - } - - if spec.RepoSlug == currentRepoSlug { - return nil, fmt.Errorf("cannot add workflows from the current repository (%s). The 'add' command is for installing workflows from other repositories", currentRepoSlug) - } - } - } - // If we can't determine the current repository, proceed without the check - - // Install required repositories - for repo, version := range repoVersions { - repoWithVersion := repo - if version != "" { - repoWithVersion = fmt.Sprintf("%s@%s", repo, version) - } - - addLog.Printf("Installing repository: %s", repoWithVersion) - - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Installing repository %s before adding workflows...", repoWithVersion))) - } - - // Install as global package (not local) to match the behavior expected - if err := InstallPackage(repoWithVersion, verbose); err != nil { - addLog.Printf("Failed to install repository %s: %v", repoWithVersion, err) - return nil, fmt.Errorf("failed to install repository %s: %w", repoWithVersion, err) - } - } - - // Check if any workflow specs contain wildcards before expansion - hasWildcard := false - for _, spec := range parsedSpecs { - if spec.IsWildcard { - hasWildcard = true - break - } - } - - // Expand wildcards after installation - var err error - parsedSpecs, err = expandWildcardWorkflows(parsedSpecs, verbose) - if err != nil { - return nil, err - } - - // Fetch workflow content and metadata for each workflow - resolvedWorkflows := make([]*ResolvedWorkflow, 0, len(parsedSpecs)) - hasWorkflowDispatch := false - - for _, spec := range parsedSpecs { - // Fetch workflow content - content, sourceInfo, err := findWorkflowInPackageForRepo(spec, verbose) - if err != nil { - return nil, fmt.Errorf("workflow '%s' not found: %w", spec.WorkflowPath, err) - } - - // Extract description from content - description := ExtractWorkflowDescription(string(content)) - - // Extract engine from content (if specified in frontmatter) - engine := ExtractWorkflowEngine(string(content)) - - // Check for workflow_dispatch trigger - workflowHasDispatch := checkWorkflowHasDispatch(spec, verbose) - if workflowHasDispatch { - hasWorkflowDispatch = true - } - - resolvedWorkflows = append(resolvedWorkflows, &ResolvedWorkflow{ - Spec: spec, - Content: content, - SourceInfo: sourceInfo, - Description: description, - Engine: engine, - HasWorkflowDispatch: workflowHasDispatch, - }) - } - - return &ResolvedWorkflows{ - Workflows: resolvedWorkflows, - HasWildcard: hasWildcard, - HasWorkflowDispatch: hasWorkflowDispatch, - }, nil -} - // AddWorkflows adds one or more workflows from components to .github/workflows // with optional repository installation and PR creation. // Returns AddWorkflowsResult containing PR number (if created) and other metadata. @@ -403,157 +242,6 @@ func AddResolvedWorkflows(workflowStrings []string, resolved *ResolvedWorkflows, return result, addWorkflowsNormal(processedWorkflows, number, verbose, quiet, engineOverride, name, force, appendText, push, noGitattributes, resolved.HasWildcard, workflowDir, noStopAfter, stopAfter) } -// handleRepoOnlySpec handles the case when user provides only owner/repo without workflow name -// It installs the package and lists available workflows with interactive selection -func handleRepoOnlySpec(repoSpec string, verbose bool) error { - addLog.Printf("Handling repo-only specification: %s", repoSpec) - - // Parse the repository specification to extract repo slug and version - spec, err := parseRepoSpec(repoSpec) - if err != nil { - return fmt.Errorf("invalid repository specification '%s': %w", repoSpec, err) - } - - // Install the repository - repoWithVersion := spec.RepoSlug - if spec.Version != "" { - repoWithVersion = fmt.Sprintf("%s@%s", spec.RepoSlug, spec.Version) - } - - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Installing repository %s...", repoWithVersion))) - } - - if err := InstallPackage(repoWithVersion, verbose); err != nil { - return fmt.Errorf("failed to install repository %s: %w", repoWithVersion, err) - } - - // List workflows in the installed package with metadata - workflows, err := listWorkflowsWithMetadata(spec.RepoSlug, verbose) - if err != nil { - return fmt.Errorf("failed to list workflows in %s: %w", spec.RepoSlug, err) - } - - // Display the list of available workflows - if len(workflows) == 0 { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("No workflows found in repository %s", spec.RepoSlug))) - return nil - } - - // Try interactive selection first - selected, err := showInteractiveWorkflowSelection(spec.RepoSlug, workflows, spec.Version, verbose) - if err == nil && selected != "" { - // User selected a workflow, proceed to add it - addLog.Printf("User selected workflow: %s", selected) - return nil // Successfully displayed and allowed selection - } - - // If interactive selection failed or was cancelled, fall back to table display - addLog.Printf("Interactive selection failed or cancelled, showing table: %v", err) - - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Available workflows in %s:", spec.RepoSlug))) - fmt.Fprintln(os.Stderr, "") - - // Render workflows as a table using console helpers - fmt.Fprint(os.Stderr, console.RenderStruct(workflows)) - - fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintln(os.Stderr, "") - - // Show example with first workflow - exampleSpec := fmt.Sprintf("%s/%s", spec.RepoSlug, workflows[0].ID) - if spec.Version != "" { - exampleSpec += "@" + spec.Version - } - - fmt.Fprintf(os.Stderr, " %s add %s\n", string(constants.CLIExtensionPrefix), exampleSpec) - fmt.Fprintln(os.Stderr, "") - - return nil -} - -// showInteractiveWorkflowSelection displays an interactive list of workflows -// and allows the user to select one -func showInteractiveWorkflowSelection(repoSlug string, workflows []WorkflowInfo, version string, verbose bool) (string, error) { - addLog.Printf("Showing interactive workflow selection: repo=%s, workflows=%d", repoSlug, len(workflows)) - - // Convert WorkflowInfo to ListItems - items := make([]console.ListItem, len(workflows)) - for i, wf := range workflows { - items[i] = console.NewListItem(wf.Name, wf.Description, wf.ID) - } - - // Show interactive list - title := fmt.Sprintf("Select a workflow from %s:", repoSlug) - selectedID, err := console.ShowInteractiveList(title, items) - if err != nil { - return "", err - } - - // Build the workflow spec - workflowSpec := fmt.Sprintf("%s/%s", repoSlug, selectedID) - if version != "" { - workflowSpec += "@" + version - } - - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("To add this workflow, run:")) - fmt.Fprintf(os.Stderr, " %s add %s\n", string(constants.CLIExtensionPrefix), workflowSpec) - fmt.Fprintln(os.Stderr, "") - - return selectedID, nil -} - -// displayAvailableWorkflows lists available workflows from an installed package -// with interactive selection when in TTY mode -func displayAvailableWorkflows(repoSlug, version string, verbose bool) error { - addLog.Printf("Displaying available workflows for repository: %s", repoSlug) - - // List workflows in the installed package with metadata - workflows, err := listWorkflowsWithMetadata(repoSlug, verbose) - if err != nil { - return fmt.Errorf("failed to list workflows in %s: %w", repoSlug, err) - } - - // Display the list of available workflows - if len(workflows) == 0 { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("No workflows found in repository %s", repoSlug))) - return nil - } - - // Try interactive selection first - _, err = showInteractiveWorkflowSelection(repoSlug, workflows, version, verbose) - if err == nil { - // Successfully displayed and allowed selection - return nil - } - - // If interactive selection failed or was cancelled, fall back to table display - addLog.Printf("Interactive selection failed or cancelled, showing table: %v", err) - - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Available workflows in %s:", repoSlug))) - fmt.Fprintln(os.Stderr, "") - - // Render workflows as a table using console helpers - fmt.Fprint(os.Stderr, console.RenderStruct(workflows)) - - fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintln(os.Stderr, "") - - // Show example with first workflow - exampleSpec := fmt.Sprintf("%s/%s", repoSlug, workflows[0].ID) - if version != "" { - exampleSpec += "@" + version - } - - fmt.Fprintf(os.Stderr, " %s add %s\n", string(constants.CLIExtensionPrefix), exampleSpec) - fmt.Fprintln(os.Stderr, "") - - return nil -} - // addWorkflowsNormal handles normal workflow addition without PR creation func addWorkflowsNormal(workflows []*WorkflowSpec, number int, verbose bool, quiet bool, engineOverride string, name string, force bool, appendText string, push bool, noGitattributes bool, fromWildcard bool, workflowDir string, noStopAfter bool, stopAfter string) error { // Create file tracker for all operations @@ -657,111 +345,6 @@ func addWorkflowsNormal(workflows []*WorkflowSpec, number int, verbose bool, qui return nil } -// addWorkflowsWithPR handles workflow addition with PR creation and returns the PR number and URL -func addWorkflowsWithPR(workflows []*WorkflowSpec, number int, verbose bool, quiet bool, engineOverride string, name string, force bool, appendText string, push bool, noGitattributes bool, fromWildcard bool, workflowDir string, noStopAfter bool, stopAfter string) (int, string, error) { - // Get current branch for restoration later - currentBranch, err := getCurrentBranch() - if err != nil { - return 0, "", fmt.Errorf("failed to get current branch: %w", err) - } - - // Create temporary branch with random 4-digit number - randomNum := rand.Intn(9000) + 1000 // Generate number between 1000-9999 - branchName := fmt.Sprintf("add-workflow-%s-%04d", strings.ReplaceAll(workflows[0].WorkflowPath, "/", "-"), randomNum) - - if err := createAndSwitchBranch(branchName, verbose); err != nil { - return 0, "", fmt.Errorf("failed to create branch %s: %w", branchName, err) - } - - // Create file tracker for rollback capability - tracker, err := NewFileTracker() - if err != nil { - return 0, "", fmt.Errorf("failed to create file tracker: %w", err) - } - - // Ensure we switch back to original branch on exit - defer func() { - if switchErr := switchBranch(currentBranch, verbose); switchErr != nil && verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to switch back to branch %s: %v", currentBranch, switchErr))) - } - }() - - // Add workflows using the normal function logic - if err := addWorkflowsNormal(workflows, number, verbose, quiet, engineOverride, name, force, appendText, push, noGitattributes, fromWildcard, workflowDir, noStopAfter, stopAfter); err != nil { - // Rollback on error - if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) - } - return 0, "", fmt.Errorf("failed to add workflows: %w", err) - } - - // Stage all files before creating PR - if err := tracker.StageAllFiles(verbose); err != nil { - if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) - } - return 0, "", fmt.Errorf("failed to stage workflow files: %w", err) - } - - // Update .gitattributes and stage it if modified - if err := stageGitAttributesIfChanged(); err != nil && verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to stage .gitattributes: %v", err))) - } - - // Commit changes - var commitMessage, prTitle, prBody, joinedNames string - if len(workflows) == 1 { - joinedNames = workflows[0].WorkflowName - commitMessage = fmt.Sprintf("Add agentic workflow %s", joinedNames) - prTitle = fmt.Sprintf("Add agentic workflow %s", joinedNames) - prBody = fmt.Sprintf("Add agentic workflow %s", joinedNames) - } else { - // Get workflow.Workflo - workflowNames := make([]string, len(workflows)) - for i, wf := range workflows { - workflowNames[i] = wf.WorkflowName - } - joinedNames = strings.Join(workflowNames, ", ") - commitMessage = fmt.Sprintf("Add agentic workflows: %s", joinedNames) - prTitle = fmt.Sprintf("Add agentic workflows: %s", joinedNames) - prBody = fmt.Sprintf("Add agentic workflows: %s", joinedNames) - } - - if err := commitChanges(commitMessage, verbose); err != nil { - if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) - } - return 0, "", fmt.Errorf("failed to commit files: %w", err) - } - - // Push branch - if err := pushBranch(branchName, verbose); err != nil { - if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) - } - return 0, "", fmt.Errorf("failed to push branch %s: %w", branchName, err) - } - - // Create PR - prNumber, prURL, err := createPR(branchName, prTitle, prBody, verbose) - if err != nil { - if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) - } - return 0, "", fmt.Errorf("failed to create PR: %w", err) - } - - // Success - no rollback needed - - // Switch back to original branch - if err := switchBranch(currentBranch, verbose); err != nil { - return prNumber, prURL, fmt.Errorf("failed to switch back to branch %s: %w", currentBranch, err) - } - - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Created pull request %s", prURL))) - return prNumber, prURL, nil -} - // addWorkflowWithTracking adds a workflow from components to .github/workflows with file tracking func addWorkflowWithTracking(workflow *WorkflowSpec, number int, verbose bool, quiet bool, engineOverride string, name string, force bool, appendText string, tracker *FileTracker, fromWildcard bool, workflowDir string, noStopAfter bool, stopAfter string) error { if verbose { @@ -1024,195 +607,3 @@ func addWorkflowWithTracking(workflow *WorkflowSpec, number int, verbose bool, q return nil } - -func updateWorkflowTitle(content string, number int) string { - // Find and update the first H1 header - lines := strings.Split(content, "\n") - for i, line := range lines { - if strings.HasPrefix(strings.TrimSpace(line), "# ") { - // Extract the title part and add number - title := strings.TrimSpace(line[2:]) - lines[i] = fmt.Sprintf("# %s %d", title, number) - break - } - } - return strings.Join(lines, "\n") -} - -func compileWorkflow(filePath string, verbose bool, quiet bool, engineOverride string) error { - return compileWorkflowWithRefresh(filePath, verbose, quiet, engineOverride, false) -} - -func compileWorkflowWithRefresh(filePath string, verbose bool, quiet bool, engineOverride string, refreshStopTime bool) error { - // Create compiler and compile the workflow - compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion()) - compiler.SetRefreshStopTime(refreshStopTime) - compiler.SetQuiet(quiet) - if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false, false, false, false); err != nil { - return err - } - - // Ensure .gitattributes marks .lock.yml files as generated - if err := ensureGitAttributes(); err != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update .gitattributes: %v", err))) - } - } - - // Note: Instructions are only written when explicitly requested via the compile command flag - // This helper function is used in contexts where instructions should not be automatically written - - return nil -} - -// compileWorkflowWithTracking compiles a workflow and tracks generated files -func compileWorkflowWithTracking(filePath string, verbose bool, quiet bool, engineOverride string, tracker *FileTracker) error { - return compileWorkflowWithTrackingAndRefresh(filePath, verbose, quiet, engineOverride, tracker, false) -} - -func compileWorkflowWithTrackingAndRefresh(filePath string, verbose bool, quiet bool, engineOverride string, tracker *FileTracker, refreshStopTime bool) error { - // Generate the expected lock file path - lockFile := stringutil.MarkdownToLockFile(filePath) - - // Check if lock file exists before compilation - lockFileExists := false - if _, err := os.Stat(lockFile); err == nil { - lockFileExists = true - } - - // Check if .gitattributes exists before ensuring it - gitRoot, err := findGitRoot() - if err != nil { - return err - } - gitAttributesPath := filepath.Join(gitRoot, ".gitattributes") - gitAttributesExists := false - if _, err := os.Stat(gitAttributesPath); err == nil { - gitAttributesExists = true - } - - // Track the lock file before compilation - if lockFileExists { - tracker.TrackModified(lockFile) - } else { - tracker.TrackCreated(lockFile) - } - - // Track .gitattributes file before modification - if gitAttributesExists { - tracker.TrackModified(gitAttributesPath) - } else { - tracker.TrackCreated(gitAttributesPath) - } - - // Create compiler and set the file tracker - compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion()) - compiler.SetFileTracker(tracker) - compiler.SetRefreshStopTime(refreshStopTime) - compiler.SetQuiet(quiet) - if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false, false, false, false); err != nil { - return err - } - - // Ensure .gitattributes marks .lock.yml files as generated - if err := ensureGitAttributes(); err != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update .gitattributes: %v", err))) - } - } - - return nil -} - -// addSourceToWorkflow adds the source field to the workflow's frontmatter -func addSourceToWorkflow(content, source string) (string, error) { - // Use shared frontmatter logic that preserves formatting - return addFieldToFrontmatter(content, "source", source) -} - -// expandWildcardWorkflows expands wildcard workflow specifications into individual workflow specs. -// For each wildcard spec, it discovers all workflows in the installed package and replaces -// the wildcard with the discovered workflows. Non-wildcard specs are passed through unchanged. -func expandWildcardWorkflows(specs []*WorkflowSpec, verbose bool) ([]*WorkflowSpec, error) { - expandedWorkflows := []*WorkflowSpec{} - - for _, spec := range specs { - if spec.IsWildcard { - addLog.Printf("Expanding wildcard for repository: %s", spec.RepoSlug) - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Discovering workflows in %s...", spec.RepoSlug))) - } - - discovered, err := discoverWorkflowsInPackage(spec.RepoSlug, spec.Version, verbose) - if err != nil { - return nil, fmt.Errorf("failed to discover workflows in %s: %w", spec.RepoSlug, err) - } - - if len(discovered) == 0 { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("No workflows found in %s", spec.RepoSlug))) - } else { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Found %d workflow(s) in %s", len(discovered), spec.RepoSlug))) - } - expandedWorkflows = append(expandedWorkflows, discovered...) - } - } else { - expandedWorkflows = append(expandedWorkflows, spec) - } - } - - if len(expandedWorkflows) == 0 { - return nil, fmt.Errorf("no workflows to add after expansion") - } - - return expandedWorkflows, nil -} - -// checkWorkflowHasDispatch checks if a single workflow has a workflow_dispatch trigger -func checkWorkflowHasDispatch(spec *WorkflowSpec, verbose bool) bool { - addLog.Printf("Checking if workflow %s has workflow_dispatch trigger", spec.WorkflowName) - - // Find and read the workflow content - sourceContent, _, err := findWorkflowInPackageForRepo(spec, verbose) - if err != nil { - addLog.Printf("Could not fetch workflow content: %v", err) - return false - } - - // Parse frontmatter to check on: triggers - result, err := parser.ExtractFrontmatterFromContent(string(sourceContent)) - if err != nil { - addLog.Printf("Could not parse workflow frontmatter: %v", err) - return false - } - - // Check if 'on' section exists and contains workflow_dispatch - onSection, exists := result.Frontmatter["on"] - if !exists { - addLog.Print("No 'on' section found in workflow") - return false - } - - // Handle different on: formats - switch on := onSection.(type) { - case map[string]any: - _, hasDispatch := on["workflow_dispatch"] - addLog.Printf("workflow_dispatch in on map: %v", hasDispatch) - return hasDispatch - case string: - hasDispatch := strings.Contains(strings.ToLower(on), "workflow_dispatch") - addLog.Printf("workflow_dispatch in on string: %v", hasDispatch) - return hasDispatch - case []any: - for _, item := range on { - if str, ok := item.(string); ok && strings.ToLower(str) == "workflow_dispatch" { - addLog.Print("workflow_dispatch found in on array") - return true - } - } - return false - default: - addLog.Printf("Unknown on: section type: %T", onSection) - return false - } -} diff --git a/pkg/cli/add_workflow_compilation.go b/pkg/cli/add_workflow_compilation.go new file mode 100644 index 0000000000..cedefe9a81 --- /dev/null +++ b/pkg/cli/add_workflow_compilation.go @@ -0,0 +1,127 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/stringutil" + "github.com/githubnext/gh-aw/pkg/workflow" +) + +// updateWorkflowTitle updates the H1 title in workflow content by appending a number. +// This is used when creating multiple numbered copies of a workflow. +func updateWorkflowTitle(content string, number int) string { + // Find and update the first H1 header + lines := strings.Split(content, "\n") + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "# ") { + // Extract the title part and add number + title := strings.TrimSpace(line[2:]) + lines[i] = fmt.Sprintf("# %s %d", title, number) + break + } + } + return strings.Join(lines, "\n") +} + +// compileWorkflow compiles a workflow file without refreshing stop time. +// This is a convenience wrapper around compileWorkflowWithRefresh. +func compileWorkflow(filePath string, verbose bool, quiet bool, engineOverride string) error { + return compileWorkflowWithRefresh(filePath, verbose, quiet, engineOverride, false) +} + +// compileWorkflowWithRefresh compiles a workflow file with optional stop time refresh. +// This function handles the compilation process and ensures .gitattributes is updated. +func compileWorkflowWithRefresh(filePath string, verbose bool, quiet bool, engineOverride string, refreshStopTime bool) error { + // Create compiler and compile the workflow + compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion()) + compiler.SetRefreshStopTime(refreshStopTime) + compiler.SetQuiet(quiet) + if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false, false, false, false); err != nil { + return err + } + + // Ensure .gitattributes marks .lock.yml files as generated + if err := ensureGitAttributes(); err != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update .gitattributes: %v", err))) + } + } + + // Note: Instructions are only written when explicitly requested via the compile command flag + // This helper function is used in contexts where instructions should not be automatically written + + return nil +} + +// compileWorkflowWithTracking compiles a workflow and tracks generated files. +// This is a convenience wrapper around compileWorkflowWithTrackingAndRefresh. +func compileWorkflowWithTracking(filePath string, verbose bool, quiet bool, engineOverride string, tracker *FileTracker) error { + return compileWorkflowWithTrackingAndRefresh(filePath, verbose, quiet, engineOverride, tracker, false) +} + +// compileWorkflowWithTrackingAndRefresh compiles a workflow, tracks generated files, and optionally refreshes stop time. +// This function ensures that the file tracker records all files created or modified during compilation. +func compileWorkflowWithTrackingAndRefresh(filePath string, verbose bool, quiet bool, engineOverride string, tracker *FileTracker, refreshStopTime bool) error { + // Generate the expected lock file path + lockFile := stringutil.MarkdownToLockFile(filePath) + + // Check if lock file exists before compilation + lockFileExists := false + if _, err := os.Stat(lockFile); err == nil { + lockFileExists = true + } + + // Check if .gitattributes exists before ensuring it + gitRoot, err := findGitRoot() + if err != nil { + return err + } + gitAttributesPath := filepath.Join(gitRoot, ".gitattributes") + gitAttributesExists := false + if _, err := os.Stat(gitAttributesPath); err == nil { + gitAttributesExists = true + } + + // Track the lock file before compilation + if lockFileExists { + tracker.TrackModified(lockFile) + } else { + tracker.TrackCreated(lockFile) + } + + // Track .gitattributes file before modification + if gitAttributesExists { + tracker.TrackModified(gitAttributesPath) + } else { + tracker.TrackCreated(gitAttributesPath) + } + + // Create compiler and set the file tracker + compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion()) + compiler.SetFileTracker(tracker) + compiler.SetRefreshStopTime(refreshStopTime) + compiler.SetQuiet(quiet) + if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false, false, false, false); err != nil { + return err + } + + // Ensure .gitattributes marks .lock.yml files as generated + if err := ensureGitAttributes(); err != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update .gitattributes: %v", err))) + } + } + + return nil +} + +// addSourceToWorkflow adds the source field to the workflow's frontmatter. +// This function preserves the existing frontmatter formatting while adding the source field. +func addSourceToWorkflow(content, source string) (string, error) { + // Use shared frontmatter logic that preserves formatting + return addFieldToFrontmatter(content, "source", source) +} diff --git a/pkg/cli/add_workflow_pr.go b/pkg/cli/add_workflow_pr.go new file mode 100644 index 0000000000..cd2bac0c14 --- /dev/null +++ b/pkg/cli/add_workflow_pr.go @@ -0,0 +1,118 @@ +package cli + +import ( + "fmt" + "math/rand" + "os" + "strings" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/logger" +) + +var addPRLog = logger.New("cli:add_workflow_pr") + +// addWorkflowsWithPR handles workflow addition with PR creation and returns the PR number and URL. +func addWorkflowsWithPR(workflows []*WorkflowSpec, number int, verbose bool, quiet bool, engineOverride string, name string, force bool, appendText string, push bool, noGitattributes bool, fromWildcard bool, workflowDir string, noStopAfter bool, stopAfter string) (int, string, error) { + // Get current branch for restoration later + currentBranch, err := getCurrentBranch() + if err != nil { + return 0, "", fmt.Errorf("failed to get current branch: %w", err) + } + + // Create temporary branch with random 4-digit number + randomNum := rand.Intn(9000) + 1000 // Generate number between 1000-9999 + branchName := fmt.Sprintf("add-workflow-%s-%04d", strings.ReplaceAll(workflows[0].WorkflowPath, "/", "-"), randomNum) + + if err := createAndSwitchBranch(branchName, verbose); err != nil { + return 0, "", fmt.Errorf("failed to create branch %s: %w", branchName, err) + } + + // Create file tracker for rollback capability + tracker, err := NewFileTracker() + if err != nil { + return 0, "", fmt.Errorf("failed to create file tracker: %w", err) + } + + // Ensure we switch back to original branch on exit + defer func() { + if switchErr := switchBranch(currentBranch, verbose); switchErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to switch back to branch %s: %v", currentBranch, switchErr))) + } + }() + + // Add workflows using the normal function logic + if err := addWorkflowsNormal(workflows, number, verbose, quiet, engineOverride, name, force, appendText, push, noGitattributes, fromWildcard, workflowDir, noStopAfter, stopAfter); err != nil { + // Rollback on error + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } + return 0, "", fmt.Errorf("failed to add workflows: %w", err) + } + + // Stage all files before creating PR + if err := tracker.StageAllFiles(verbose); err != nil { + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } + return 0, "", fmt.Errorf("failed to stage workflow files: %w", err) + } + + // Update .gitattributes and stage it if modified + if err := stageGitAttributesIfChanged(); err != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to stage .gitattributes: %v", err))) + } + + // Commit changes + var commitMessage, prTitle, prBody, joinedNames string + if len(workflows) == 1 { + joinedNames = workflows[0].WorkflowName + commitMessage = fmt.Sprintf("Add agentic workflow %s", joinedNames) + prTitle = fmt.Sprintf("Add agentic workflow %s", joinedNames) + prBody = fmt.Sprintf("Add agentic workflow %s", joinedNames) + } else { + // Get workflow.Workflo + workflowNames := make([]string, len(workflows)) + for i, wf := range workflows { + workflowNames[i] = wf.WorkflowName + } + joinedNames = strings.Join(workflowNames, ", ") + commitMessage = fmt.Sprintf("Add agentic workflows: %s", joinedNames) + prTitle = fmt.Sprintf("Add agentic workflows: %s", joinedNames) + prBody = fmt.Sprintf("Add agentic workflows: %s", joinedNames) + } + + if err := commitChanges(commitMessage, verbose); err != nil { + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } + return 0, "", fmt.Errorf("failed to commit files: %w", err) + } + + // Push branch + if err := pushBranch(branchName, verbose); err != nil { + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } + return 0, "", fmt.Errorf("failed to push branch %s: %w", branchName, err) + } + + // Create PR + prNumber, prURL, err := createPR(branchName, prTitle, prBody, verbose) + if err != nil { + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } + return 0, "", fmt.Errorf("failed to create PR: %w", err) + } + + // Success - no rollback needed + + // Switch back to original branch + if err := switchBranch(currentBranch, verbose); err != nil { + return prNumber, prURL, fmt.Errorf("failed to switch back to branch %s: %w", currentBranch, err) + } + + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Created pull request %s", prURL))) + return prNumber, prURL, nil +} diff --git a/pkg/cli/add_workflow_repository.go b/pkg/cli/add_workflow_repository.go new file mode 100644 index 0000000000..b238576da8 --- /dev/null +++ b/pkg/cli/add_workflow_repository.go @@ -0,0 +1,163 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/constants" + "github.com/githubnext/gh-aw/pkg/logger" +) + +var repositoryLog = logger.New("cli:add_workflow_repository") + +// handleRepoOnlySpec handles the case when user provides only owner/repo without workflow name. +// It installs the package and lists available workflows with interactive selection. +func handleRepoOnlySpec(repoSpec string, verbose bool) error { + repositoryLog.Printf("Handling repo-only specification: %s", repoSpec) + + // Parse the repository specification to extract repo slug and version + spec, err := parseRepoSpec(repoSpec) + if err != nil { + return fmt.Errorf("invalid repository specification '%s': %w", repoSpec, err) + } + + // Install the repository + repoWithVersion := spec.RepoSlug + if spec.Version != "" { + repoWithVersion = fmt.Sprintf("%s@%s", spec.RepoSlug, spec.Version) + } + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Installing repository %s...", repoWithVersion))) + } + + if err := InstallPackage(repoWithVersion, verbose); err != nil { + return fmt.Errorf("failed to install repository %s: %w", repoWithVersion, err) + } + + // List workflows in the installed package with metadata + workflows, err := listWorkflowsWithMetadata(spec.RepoSlug, verbose) + if err != nil { + return fmt.Errorf("failed to list workflows in %s: %w", spec.RepoSlug, err) + } + + // Display the list of available workflows + if len(workflows) == 0 { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("No workflows found in repository %s", spec.RepoSlug))) + return nil + } + + // Try interactive selection first + selected, err := showInteractiveWorkflowSelection(spec.RepoSlug, workflows, spec.Version, verbose) + if err == nil && selected != "" { + // User selected a workflow, proceed to add it + repositoryLog.Printf("User selected workflow: %s", selected) + return nil // Successfully displayed and allowed selection + } + + // If interactive selection failed or was cancelled, fall back to table display + repositoryLog.Printf("Interactive selection failed or cancelled, showing table: %v", err) + + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Available workflows in %s:", spec.RepoSlug))) + fmt.Fprintln(os.Stderr, "") + + // Render workflows as a table using console helpers + fmt.Fprint(os.Stderr, console.RenderStruct(workflows)) + + fmt.Fprintln(os.Stderr, "Example:") + fmt.Fprintln(os.Stderr, "") + + // Show example with first workflow + exampleSpec := fmt.Sprintf("%s/%s", spec.RepoSlug, workflows[0].ID) + if spec.Version != "" { + exampleSpec += "@" + spec.Version + } + + fmt.Fprintf(os.Stderr, " %s add %s\n", string(constants.CLIExtensionPrefix), exampleSpec) + fmt.Fprintln(os.Stderr, "") + + return nil +} + +// showInteractiveWorkflowSelection displays an interactive list of workflows +// and allows the user to select one. +func showInteractiveWorkflowSelection(repoSlug string, workflows []WorkflowInfo, version string, verbose bool) (string, error) { + repositoryLog.Printf("Showing interactive workflow selection: repo=%s, workflows=%d", repoSlug, len(workflows)) + + // Convert WorkflowInfo to ListItems + items := make([]console.ListItem, len(workflows)) + for i, wf := range workflows { + items[i] = console.NewListItem(wf.Name, wf.Description, wf.ID) + } + + // Show interactive list + title := fmt.Sprintf("Select a workflow from %s:", repoSlug) + selectedID, err := console.ShowInteractiveList(title, items) + if err != nil { + return "", err + } + + // Build the workflow spec + workflowSpec := fmt.Sprintf("%s/%s", repoSlug, selectedID) + if version != "" { + workflowSpec += "@" + version + } + + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("To add this workflow, run:")) + fmt.Fprintf(os.Stderr, " %s add %s\n", string(constants.CLIExtensionPrefix), workflowSpec) + fmt.Fprintln(os.Stderr, "") + + return selectedID, nil +} + +// displayAvailableWorkflows lists available workflows from an installed package +// with interactive selection when in TTY mode. +func displayAvailableWorkflows(repoSlug, version string, verbose bool) error { + repositoryLog.Printf("Displaying available workflows for repository: %s", repoSlug) + + // List workflows in the installed package with metadata + workflows, err := listWorkflowsWithMetadata(repoSlug, verbose) + if err != nil { + return fmt.Errorf("failed to list workflows in %s: %w", repoSlug, err) + } + + // Display the list of available workflows + if len(workflows) == 0 { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("No workflows found in repository %s", repoSlug))) + return nil + } + + // Try interactive selection first + _, err = showInteractiveWorkflowSelection(repoSlug, workflows, version, verbose) + if err == nil { + // Successfully displayed and allowed selection + return nil + } + + // If interactive selection failed or was cancelled, fall back to table display + repositoryLog.Printf("Interactive selection failed or cancelled, showing table: %v", err) + + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Available workflows in %s:", repoSlug))) + fmt.Fprintln(os.Stderr, "") + + // Render workflows as a table using console helpers + fmt.Fprint(os.Stderr, console.RenderStruct(workflows)) + + fmt.Fprintln(os.Stderr, "Example:") + fmt.Fprintln(os.Stderr, "") + + // Show example with first workflow + exampleSpec := fmt.Sprintf("%s/%s", repoSlug, workflows[0].ID) + if version != "" { + exampleSpec += "@" + version + } + + fmt.Fprintf(os.Stderr, " %s add %s\n", string(constants.CLIExtensionPrefix), exampleSpec) + fmt.Fprintln(os.Stderr, "") + + return nil +} diff --git a/pkg/cli/add_workflow_resolution.go b/pkg/cli/add_workflow_resolution.go new file mode 100644 index 0000000000..4d7a26daab --- /dev/null +++ b/pkg/cli/add_workflow_resolution.go @@ -0,0 +1,256 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/logger" + "github.com/githubnext/gh-aw/pkg/parser" +) + +var resolutionLog = logger.New("cli:add_workflow_resolution") + +// ResolvedWorkflow contains metadata about a workflow that has been resolved and is ready to add +type ResolvedWorkflow struct { + // Spec is the parsed workflow specification + Spec *WorkflowSpec + // Content is the raw workflow content + Content []byte + // SourceInfo contains source metadata (package path, commit SHA) + SourceInfo *WorkflowSourceInfo + // Description is the workflow description extracted from frontmatter + Description string + // Engine is the preferred engine extracted from frontmatter (empty if not specified) + Engine string + // HasWorkflowDispatch indicates if the workflow has workflow_dispatch trigger + HasWorkflowDispatch bool +} + +// ResolvedWorkflows contains all resolved workflows ready to be added +type ResolvedWorkflows struct { + // Workflows is the list of resolved workflows + Workflows []*ResolvedWorkflow + // HasWildcard indicates if any of the original specs contained wildcards + HasWildcard bool + // HasWorkflowDispatch is true if any of the workflows has a workflow_dispatch trigger + HasWorkflowDispatch bool +} + +// ResolveWorkflows resolves workflow specifications by parsing specs, installing repositories, +// expanding wildcards, and fetching workflow content (including descriptions). +// This is useful for showing workflow information before actually adding them. +func ResolveWorkflows(workflows []string, verbose bool) (*ResolvedWorkflows, error) { + resolutionLog.Printf("Resolving workflows: count=%d", len(workflows)) + + if len(workflows) == 0 { + return nil, fmt.Errorf("at least one workflow name is required") + } + + for i, workflow := range workflows { + if workflow == "" { + return nil, fmt.Errorf("workflow name cannot be empty (workflow %d)", i+1) + } + } + + // Parse workflow specifications and group by repository + repoVersions := make(map[string]string) // repo -> version + parsedSpecs := []*WorkflowSpec{} // List of parsed workflow specs + + for _, workflow := range workflows { + spec, err := parseWorkflowSpec(workflow) + if err != nil { + return nil, fmt.Errorf("invalid workflow specification '%s': %w", workflow, err) + } + + // Handle repository installation and workflow name extraction + if existing, exists := repoVersions[spec.RepoSlug]; exists && existing != spec.Version { + return nil, fmt.Errorf("conflicting versions for repository %s: %s vs %s", spec.RepoSlug, existing, spec.Version) + } + repoVersions[spec.RepoSlug] = spec.Version + + // Create qualified name for processing + parsedSpecs = append(parsedSpecs, spec) + } + + // Check if any workflow is from the current repository + // Skip this check if we can't determine the current repository (e.g., not in a git repo) + currentRepoSlug, repoErr := GetCurrentRepoSlug() + if repoErr == nil { + // We successfully determined the current repository, check all workflow specs + for _, spec := range parsedSpecs { + // Skip local workflow specs (starting with "./") + if strings.HasPrefix(spec.WorkflowPath, "./") { + continue + } + + if spec.RepoSlug == currentRepoSlug { + return nil, fmt.Errorf("cannot add workflows from the current repository (%s). The 'add' command is for installing workflows from other repositories", currentRepoSlug) + } + } + } + // If we can't determine the current repository, proceed without the check + + // Install required repositories + for repo, version := range repoVersions { + repoWithVersion := repo + if version != "" { + repoWithVersion = fmt.Sprintf("%s@%s", repo, version) + } + + resolutionLog.Printf("Installing repository: %s", repoWithVersion) + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Installing repository %s before adding workflows...", repoWithVersion))) + } + + // Install as global package (not local) to match the behavior expected + if err := InstallPackage(repoWithVersion, verbose); err != nil { + resolutionLog.Printf("Failed to install repository %s: %v", repoWithVersion, err) + return nil, fmt.Errorf("failed to install repository %s: %w", repoWithVersion, err) + } + } + + // Check if any workflow specs contain wildcards before expansion + hasWildcard := false + for _, spec := range parsedSpecs { + if spec.IsWildcard { + hasWildcard = true + break + } + } + + // Expand wildcards after installation + var err error + parsedSpecs, err = expandWildcardWorkflows(parsedSpecs, verbose) + if err != nil { + return nil, err + } + + // Fetch workflow content and metadata for each workflow + resolvedWorkflows := make([]*ResolvedWorkflow, 0, len(parsedSpecs)) + hasWorkflowDispatch := false + + for _, spec := range parsedSpecs { + // Fetch workflow content + content, sourceInfo, err := findWorkflowInPackageForRepo(spec, verbose) + if err != nil { + return nil, fmt.Errorf("workflow '%s' not found: %w", spec.WorkflowPath, err) + } + + // Extract description from content + description := ExtractWorkflowDescription(string(content)) + + // Extract engine from content (if specified in frontmatter) + engine := ExtractWorkflowEngine(string(content)) + + // Check for workflow_dispatch trigger + workflowHasDispatch := checkWorkflowHasDispatch(spec, verbose) + if workflowHasDispatch { + hasWorkflowDispatch = true + } + + resolvedWorkflows = append(resolvedWorkflows, &ResolvedWorkflow{ + Spec: spec, + Content: content, + SourceInfo: sourceInfo, + Description: description, + Engine: engine, + HasWorkflowDispatch: workflowHasDispatch, + }) + } + + return &ResolvedWorkflows{ + Workflows: resolvedWorkflows, + HasWildcard: hasWildcard, + HasWorkflowDispatch: hasWorkflowDispatch, + }, nil +} + +// expandWildcardWorkflows expands wildcard workflow specifications into individual workflow specs. +// For each wildcard spec, it discovers all workflows in the installed package and replaces +// the wildcard with the discovered workflows. Non-wildcard specs are passed through unchanged. +func expandWildcardWorkflows(specs []*WorkflowSpec, verbose bool) ([]*WorkflowSpec, error) { + expandedWorkflows := []*WorkflowSpec{} + + for _, spec := range specs { + if spec.IsWildcard { + resolutionLog.Printf("Expanding wildcard for repository: %s", spec.RepoSlug) + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Discovering workflows in %s...", spec.RepoSlug))) + } + + discovered, err := discoverWorkflowsInPackage(spec.RepoSlug, spec.Version, verbose) + if err != nil { + return nil, fmt.Errorf("failed to discover workflows in %s: %w", spec.RepoSlug, err) + } + + if len(discovered) == 0 { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("No workflows found in %s", spec.RepoSlug))) + } else { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Found %d workflow(s) in %s", len(discovered), spec.RepoSlug))) + } + expandedWorkflows = append(expandedWorkflows, discovered...) + } + } else { + expandedWorkflows = append(expandedWorkflows, spec) + } + } + + if len(expandedWorkflows) == 0 { + return nil, fmt.Errorf("no workflows to add after expansion") + } + + return expandedWorkflows, nil +} + +// checkWorkflowHasDispatch checks if a single workflow has a workflow_dispatch trigger +func checkWorkflowHasDispatch(spec *WorkflowSpec, verbose bool) bool { + resolutionLog.Printf("Checking if workflow %s has workflow_dispatch trigger", spec.WorkflowName) + + // Find and read the workflow content + sourceContent, _, err := findWorkflowInPackageForRepo(spec, verbose) + if err != nil { + resolutionLog.Printf("Could not fetch workflow content: %v", err) + return false + } + + // Parse frontmatter to check on: triggers + result, err := parser.ExtractFrontmatterFromContent(string(sourceContent)) + if err != nil { + resolutionLog.Printf("Could not parse workflow frontmatter: %v", err) + return false + } + + // Check if 'on' section exists and contains workflow_dispatch + onSection, exists := result.Frontmatter["on"] + if !exists { + resolutionLog.Print("No 'on' section found in workflow") + return false + } + + // Handle different on: formats + switch on := onSection.(type) { + case map[string]any: + _, hasDispatch := on["workflow_dispatch"] + resolutionLog.Printf("workflow_dispatch in on map: %v", hasDispatch) + return hasDispatch + case string: + hasDispatch := strings.Contains(strings.ToLower(on), "workflow_dispatch") + resolutionLog.Printf("workflow_dispatch in on string: %v", hasDispatch) + return hasDispatch + case []any: + for _, item := range on { + if str, ok := item.(string); ok && strings.ToLower(str) == "workflow_dispatch" { + resolutionLog.Print("workflow_dispatch found in on array") + return true + } + } + return false + default: + resolutionLog.Printf("Unknown on: section type: %T", onSection) + return false + } +}