diff --git a/pkg/cli/actions_build_command.go b/pkg/cli/actions_build_command.go index fed4a96bab..a3e4edbfce 100644 --- a/pkg/cli/actions_build_command.go +++ b/pkg/cli/actions_build_command.go @@ -147,52 +147,6 @@ func getActionDirectories(actionsDir string) ([]string, error) { return dirs, nil } -// validateActionYml validates that an action.yml file exists and contains required fields. -// -// This validation function is co-located with the actions build command because: -// - It's specific to GitHub Actions custom action structure -// - It's only called during the actions build process -// - It validates action metadata before bundling JavaScript -// -// The function validates: -// - action.yml file exists in the action directory -// - Required fields are present (name, description, runs) -// - Basic action metadata structure is valid -// -// This follows the principle that domain-specific validation belongs in domain files. -func validateActionYml(actionPath string) error { - ymlPath := filepath.Join(actionPath, "action.yml") - - if _, err := os.Stat(ymlPath); os.IsNotExist(err) { - return fmt.Errorf("action.yml not found") - } - - content, err := os.ReadFile(ymlPath) - if err != nil { - return fmt.Errorf("failed to read action.yml: %w", err) - } - - contentStr := string(content) - - // Check required fields - requiredFields := []string{"name:", "description:", "runs:"} - for _, field := range requiredFields { - if !strings.Contains(contentStr, field) { - return fmt.Errorf("missing required field '%s'", strings.TrimSuffix(field, ":")) - } - } - - // Check that it's either a node20 or composite action - isNode20 := strings.Contains(contentStr, "using: 'node20'") || strings.Contains(contentStr, "using: \"node20\"") - isComposite := strings.Contains(contentStr, "using: 'composite'") || strings.Contains(contentStr, "using: \"composite\"") - - if !isNode20 && !isComposite { - return fmt.Errorf("action must use either 'node20' or 'composite' runtime") - } - - return nil -} - // buildAction builds a single action by bundling its dependencies func buildAction(actionsDir, actionName string) error { actionsBuildLog.Printf("Building action: %s", actionName) diff --git a/pkg/cli/compile_config.go b/pkg/cli/compile_config.go index e689f4d589..3d41ce8fc0 100644 --- a/pkg/cli/compile_config.go +++ b/pkg/cli/compile_config.go @@ -1,6 +1,9 @@ package cli import ( + "fmt" + "path/filepath" + "github.com/githubnext/gh-aw/pkg/logger" "github.com/githubnext/gh-aw/pkg/stringutil" ) @@ -108,3 +111,36 @@ func sanitizeValidationResults(results []ValidationResult) []ValidationResult { return sanitized } + +// validateCompileConfig validates the configuration flags before compilation +// This is extracted for faster testing without full compilation +func validateCompileConfig(config CompileConfig) error { + compileConfigLog.Printf("Validating compile config: files=%d, dependabot=%v, purge=%v, workflowDir=%s", len(config.MarkdownFiles), config.Dependabot, config.Purge, config.WorkflowDir) + + // Validate dependabot flag usage + if config.Dependabot { + if len(config.MarkdownFiles) > 0 { + compileConfigLog.Print("Config validation failed: dependabot flag with specific files") + return fmt.Errorf("--dependabot flag cannot be used with specific workflow files") + } + if config.WorkflowDir != "" && config.WorkflowDir != ".github/workflows" { + compileConfigLog.Printf("Config validation failed: dependabot with custom dir: %s", config.WorkflowDir) + return fmt.Errorf("--dependabot flag cannot be used with custom --dir") + } + } + + // Validate purge flag usage + if config.Purge && len(config.MarkdownFiles) > 0 { + compileConfigLog.Print("Config validation failed: purge flag with specific files") + return fmt.Errorf("--purge flag can only be used when compiling all markdown files (no specific files specified)") + } + + // Validate workflow directory path + if config.WorkflowDir != "" && filepath.IsAbs(config.WorkflowDir) { + compileConfigLog.Printf("Config validation failed: absolute path in workflowDir: %s", config.WorkflowDir) + return fmt.Errorf("--dir must be a relative path, got: %s", config.WorkflowDir) + } + + compileConfigLog.Print("Config validation successful") + return nil +} diff --git a/pkg/cli/compile_post_processing.go b/pkg/cli/compile_post_processing.go index dd6f53df5c..7e61f6c8ee 100644 --- a/pkg/cli/compile_post_processing.go +++ b/pkg/cli/compile_post_processing.go @@ -31,7 +31,9 @@ package cli import ( "fmt" "os" + "os/exec" "path/filepath" + "strings" "github.com/githubnext/gh-aw/pkg/console" "github.com/githubnext/gh-aw/pkg/logger" @@ -145,6 +147,92 @@ func updateGitAttributes(successCount int, actionCache *workflow.ActionCache, ve return nil } +// ensureGitAttributes ensures that .gitattributes contains the entry to mark .lock.yml files as generated +func ensureGitAttributes() error { + compilePostProcessingLog.Print("Ensuring .gitattributes is updated") + gitRoot, err := findGitRoot() + if err != nil { + return err // Not in a git repository, skip + } + + gitAttributesPath := filepath.Join(gitRoot, ".gitattributes") + lockYmlEntry := ".github/workflows/*.lock.yml linguist-generated=true merge=ours" + requiredEntries := []string{lockYmlEntry} + + // Read existing .gitattributes file if it exists + var lines []string + if content, err := os.ReadFile(gitAttributesPath); err == nil { + lines = strings.Split(string(content), "\n") + compilePostProcessingLog.Printf("Read existing .gitattributes with %d lines", len(lines)) + } else { + compilePostProcessingLog.Print("No existing .gitattributes file found") + } + + modified := false + for _, required := range requiredEntries { + found := false + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + if trimmedLine == required { + found = true + break + } + // Check for old format entries that need updating + if strings.HasPrefix(trimmedLine, ".github/workflows/*.lock.yml") && required == lockYmlEntry { + compilePostProcessingLog.Print("Updating old .gitattributes entry format") + lines[i] = lockYmlEntry + found = true + modified = true + break + } + } + + if !found { + compilePostProcessingLog.Printf("Adding new .gitattributes entry: %s", required) + if len(lines) > 0 && lines[len(lines)-1] != "" { + lines = append(lines, "") + } + lines = append(lines, required) + modified = true + } + } + + // Remove old campaign.g.md entries if they exist (they're now in .gitignore) + for i := len(lines) - 1; i >= 0; i-- { + trimmedLine := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmedLine, ".github/workflows/*.campaign.g.md") { + compilePostProcessingLog.Print("Removing obsolete .campaign.g.md .gitattributes entry") + lines = append(lines[:i], lines[i+1:]...) + modified = true + } + } + + if !modified { + compilePostProcessingLog.Print(".gitattributes already contains required entries") + return nil + } + + // Write back to file with owner-only read/write permissions (0600) for security best practices + content := strings.Join(lines, "\n") + if err := os.WriteFile(gitAttributesPath, []byte(content), 0600); err != nil { + compilePostProcessingLog.Printf("Failed to write .gitattributes: %v", err) + return fmt.Errorf("failed to write .gitattributes: %w", err) + } + + compilePostProcessingLog.Print("Successfully updated .gitattributes") + return nil +} + +// stageGitAttributesIfChanged stages .gitattributes if it was modified +func stageGitAttributesIfChanged() error { + gitRoot, err := findGitRoot() + if err != nil { + return err + } + gitAttributesPath := filepath.Join(gitRoot, ".gitattributes") + return exec.Command("git", "-C", gitRoot, "add", gitAttributesPath).Run() +} + // saveActionCache saves the action cache after all compilations func saveActionCache(actionCache *workflow.ActionCache, verbose bool) error { if actionCache == nil { diff --git a/pkg/cli/compile_validation.go b/pkg/cli/compile_validation.go index 16c6c5ecf4..4da8c65de6 100644 --- a/pkg/cli/compile_validation.go +++ b/pkg/cli/compile_validation.go @@ -187,36 +187,3 @@ func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData return nil } - -// validateCompileConfig validates the configuration flags before compilation -// This is extracted for faster testing without full compilation -func validateCompileConfig(config CompileConfig) error { - compileValidationLog.Printf("Validating compile config: files=%d, dependabot=%v, purge=%v, workflowDir=%s", len(config.MarkdownFiles), config.Dependabot, config.Purge, config.WorkflowDir) - - // Validate dependabot flag usage - if config.Dependabot { - if len(config.MarkdownFiles) > 0 { - compileValidationLog.Print("Config validation failed: dependabot flag with specific files") - return fmt.Errorf("--dependabot flag cannot be used with specific workflow files") - } - if config.WorkflowDir != "" && config.WorkflowDir != ".github/workflows" { - compileValidationLog.Printf("Config validation failed: dependabot with custom dir: %s", config.WorkflowDir) - return fmt.Errorf("--dependabot flag cannot be used with custom --dir") - } - } - - // Validate purge flag usage - if config.Purge && len(config.MarkdownFiles) > 0 { - compileValidationLog.Print("Config validation failed: purge flag with specific files") - return fmt.Errorf("--purge flag can only be used when compiling all markdown files (no specific files specified)") - } - - // Validate workflow directory path - if config.WorkflowDir != "" && filepath.IsAbs(config.WorkflowDir) { - compileValidationLog.Printf("Config validation failed: absolute path in workflowDir: %s", config.WorkflowDir) - return fmt.Errorf("--dir must be a relative path, got: %s", config.WorkflowDir) - } - - compileValidationLog.Print("Config validation successful") - return nil -} diff --git a/pkg/cli/git.go b/pkg/cli/git.go index ae2375f289..53a230ec75 100644 --- a/pkg/cli/git.go +++ b/pkg/cli/git.go @@ -7,7 +7,6 @@ import ( "path/filepath" "strings" - "github.com/charmbracelet/huh" "github.com/githubnext/gh-aw/pkg/console" "github.com/githubnext/gh-aw/pkg/logger" ) @@ -153,92 +152,6 @@ func stageWorkflowChanges() { } } -// ensureGitAttributes ensures that .gitattributes contains the entry to mark .lock.yml files as generated -func ensureGitAttributes() error { - gitLog.Print("Ensuring .gitattributes is updated") - gitRoot, err := findGitRoot() - if err != nil { - return err // Not in a git repository, skip - } - - gitAttributesPath := filepath.Join(gitRoot, ".gitattributes") - lockYmlEntry := ".github/workflows/*.lock.yml linguist-generated=true merge=ours" - requiredEntries := []string{lockYmlEntry} - - // Read existing .gitattributes file if it exists - var lines []string - if content, err := os.ReadFile(gitAttributesPath); err == nil { - lines = strings.Split(string(content), "\n") - gitLog.Printf("Read existing .gitattributes with %d lines", len(lines)) - } else { - gitLog.Print("No existing .gitattributes file found") - } - - modified := false - for _, required := range requiredEntries { - found := false - for i, line := range lines { - trimmedLine := strings.TrimSpace(line) - if trimmedLine == required { - found = true - break - } - // Check for old format entries that need updating - if strings.HasPrefix(trimmedLine, ".github/workflows/*.lock.yml") && required == lockYmlEntry { - gitLog.Print("Updating old .gitattributes entry format") - lines[i] = lockYmlEntry - found = true - modified = true - break - } - } - - if !found { - gitLog.Printf("Adding new .gitattributes entry: %s", required) - if len(lines) > 0 && lines[len(lines)-1] != "" { - lines = append(lines, "") - } - lines = append(lines, required) - modified = true - } - } - - // Remove old campaign.g.md entries if they exist (they're now in .gitignore) - for i := len(lines) - 1; i >= 0; i-- { - trimmedLine := strings.TrimSpace(lines[i]) - if strings.HasPrefix(trimmedLine, ".github/workflows/*.campaign.g.md") { - gitLog.Print("Removing obsolete .campaign.g.md .gitattributes entry") - lines = append(lines[:i], lines[i+1:]...) - modified = true - } - } - - if !modified { - gitLog.Print(".gitattributes already contains required entries") - return nil - } - - // Write back to file with owner-only read/write permissions (0600) for security best practices - content := strings.Join(lines, "\n") - if err := os.WriteFile(gitAttributesPath, []byte(content), 0600); err != nil { - gitLog.Printf("Failed to write .gitattributes: %v", err) - return fmt.Errorf("failed to write .gitattributes: %w", err) - } - - gitLog.Print("Successfully updated .gitattributes") - return nil -} - -// stageGitAttributesIfChanged stages .gitattributes if it was modified -func stageGitAttributesIfChanged() error { - gitRoot, err := findGitRoot() - if err != nil { - return err - } - gitAttributesPath := filepath.Join(gitRoot, ".gitattributes") - return exec.Command("git", "-C", gitRoot, "add", gitAttributesPath).Run() -} - // ensureLogsGitignore ensures that .github/aw/logs/.gitignore exists to ignore log files func ensureLogsGitignore() error { gitLog.Print("Ensuring .github/aw/logs/.gitignore exists") @@ -710,47 +623,3 @@ func checkOnDefaultBranch(verbose bool) error { return nil } -// confirmPushOperation prompts the user to confirm push operation (skips in CI) -func confirmPushOperation(verbose bool) error { - gitLog.Print("Checking if user confirmation is needed for push operation") - - // Skip confirmation in CI environments - if IsRunningInCI() { - gitLog.Print("Running in CI, skipping user confirmation") - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Running in CI - skipping confirmation prompt")) - } - return nil - } - - // Prompt user for confirmation - gitLog.Print("Prompting user for push confirmation") - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, console.FormatWarningMessage("This will commit and push changes to the remote repository.")) - - var confirmed bool - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Do you want to proceed with commit and push?"). - Description("This will stage all changes, commit them, and push to the remote repository"). - Value(&confirmed), - ), - ).WithAccessible(console.IsAccessibleMode()) - - if err := form.Run(); err != nil { - gitLog.Printf("Confirmation prompt failed: %v", err) - return fmt.Errorf("confirmation prompt failed: %w", err) - } - - if !confirmed { - gitLog.Print("User declined push operation") - return fmt.Errorf("push operation cancelled by user") - } - - gitLog.Print("User confirmed push operation") - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Push operation confirmed")) - } - return nil -} diff --git a/pkg/cli/interactive.go b/pkg/cli/interactive.go index 77d4e98251..171d9157f2 100644 --- a/pkg/cli/interactive.go +++ b/pkg/cli/interactive.go @@ -508,3 +508,48 @@ func (b *InteractiveWorkflowBuilder) compileWorkflow(verbose bool) error { return nil } + +// confirmPushOperation prompts the user to confirm push operation (skips in CI) +func confirmPushOperation(verbose bool) error { + interactiveLog.Print("Checking if user confirmation is needed for push operation") + + // Skip confirmation in CI environments + if IsRunningInCI() { + interactiveLog.Print("Running in CI, skipping user confirmation") + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Running in CI - skipping confirmation prompt")) + } + return nil + } + + // Prompt user for confirmation + interactiveLog.Print("Prompting user for push confirmation") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("This will commit and push changes to the remote repository.")) + + var confirmed bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Do you want to proceed with commit and push?"). + Description("This will stage all changes, commit them, and push to the remote repository"). + Value(&confirmed), + ), + ).WithAccessible(console.IsAccessibleMode()) + + if err := form.Run(); err != nil { + interactiveLog.Printf("Confirmation prompt failed: %v", err) + return fmt.Errorf("confirmation prompt failed: %w", err) + } + + if !confirmed { + interactiveLog.Print("User declined push operation") + return fmt.Errorf("push operation cancelled by user") + } + + interactiveLog.Print("User confirmed push operation") + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Push operation confirmed")) + } + return nil +} diff --git a/pkg/cli/validators.go b/pkg/cli/validators.go index 8363269f53..7223e67e3f 100644 --- a/pkg/cli/validators.go +++ b/pkg/cli/validators.go @@ -2,6 +2,9 @@ package cli import ( "errors" + "fmt" + "os" + "path/filepath" "regexp" "strings" @@ -46,3 +49,42 @@ func ValidateWorkflowIntent(s string) error { validatorsLog.Printf("Workflow intent validated successfully: %d chars", len(trimmed)) return nil } + +// validateActionYml validates that an action.yml file exists and contains required fields. +// +// This validates GitHub Actions custom action structure: +// - action.yml file exists in the action directory +// - Required fields are present (name, description, runs) +// - Runtime is either 'node20' or 'composite' +func validateActionYml(actionPath string) error { + ymlPath := filepath.Join(actionPath, "action.yml") + + if _, err := os.Stat(ymlPath); os.IsNotExist(err) { + return fmt.Errorf("action.yml not found") + } + + content, err := os.ReadFile(ymlPath) + if err != nil { + return fmt.Errorf("failed to read action.yml: %w", err) + } + + contentStr := string(content) + + // Check required fields + requiredFields := []string{"name:", "description:", "runs:"} + for _, field := range requiredFields { + if !strings.Contains(contentStr, field) { + return fmt.Errorf("missing required field '%s'", strings.TrimSuffix(field, ":")) + } + } + + // Check that it's either a node20 or composite action + isNode20 := strings.Contains(contentStr, "using: 'node20'") || strings.Contains(contentStr, "using: \"node20\"") + isComposite := strings.Contains(contentStr, "using: 'composite'") || strings.Contains(contentStr, "using: \"composite\"") + + if !isNode20 && !isComposite { + return fmt.Errorf("action must use either 'node20' or 'composite' runtime") + } + + return nil +}