From 5188cf83ac0fc34cac8e00d212082171b90e4ec7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 02:31:42 +0000 Subject: [PATCH 1/4] Initial plan From e19322ed3208789e132838e413cb15aff1163bdc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 02:39:37 +0000 Subject: [PATCH 2/4] Add new module files for compile orchestrator refactoring - compile_output_formatter.go: Output formatting wrappers - compile_config_validator.go: Configuration validation - compile_compiler_setup.go: Compiler setup and configuration - compile_workflow_processor.go: Workflow file processing - compile_batch_operations.go: Batch operations (linting, purging) - compile_post_processing.go: Post-compilation processing All files compile successfully and follow the repository's patterns. Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- pkg/cli/compile_batch_operations.go | 274 ++++++++++++++++++++++++++ pkg/cli/compile_compiler_setup.go | 140 +++++++++++++ pkg/cli/compile_config_validator.go | 77 ++++++++ pkg/cli/compile_output_formatter.go | 65 ++++++ pkg/cli/compile_post_processing.go | 194 ++++++++++++++++++ pkg/cli/compile_workflow_processor.go | 239 ++++++++++++++++++++++ 6 files changed, 989 insertions(+) create mode 100644 pkg/cli/compile_batch_operations.go create mode 100644 pkg/cli/compile_compiler_setup.go create mode 100644 pkg/cli/compile_config_validator.go create mode 100644 pkg/cli/compile_output_formatter.go create mode 100644 pkg/cli/compile_post_processing.go create mode 100644 pkg/cli/compile_workflow_processor.go diff --git a/pkg/cli/compile_batch_operations.go b/pkg/cli/compile_batch_operations.go new file mode 100644 index 0000000000..bbcb5a24d7 --- /dev/null +++ b/pkg/cli/compile_batch_operations.go @@ -0,0 +1,274 @@ +// Package cli provides batch operations for workflow compilation. +// +// This file contains functions that perform batch operations on compiled workflows, +// such as running linters, security scanners, and cleaning up orphaned files. +// +// # Organization Rationale +// +// These batch operation functions are grouped here because they: +// - Operate on multiple files at once +// - Run external tools (actionlint, zizmor, poutine) +// - Have a clear domain focus (batch operations) +// - Keep the main orchestrator focused on coordination +// +// # Key Functions +// +// Batch Linting: +// - runBatchActionlint() - Run actionlint on multiple lock files +// +// File Cleanup: +// - purgeOrphanedLockFiles() - Remove orphaned .lock.yml files +// - purgeInvalidFiles() - Remove .invalid.yml files +// - purgeOrphanedCampaignOrchestrators() - Remove orphaned .campaign.g.md files +// - purgeOrphanedCampaignOrchestratorLockFiles() - Remove orphaned .campaign.g.lock.yml files +// +// These functions abstract batch operations, allowing the main compile +// orchestrator to focus on coordination while these handle batch processing. +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/logger" +) + +var compileBatchOperationsLog = logger.New("cli:compile_batch_operations") + +// runBatchActionlint runs actionlint on all lock files in batch +func runBatchActionlint(lockFiles []string, verbose bool, strict bool) error { + if len(lockFiles) == 0 { + compileBatchOperationsLog.Print("No lock files to lint with actionlint") + return nil + } + + compileBatchOperationsLog.Printf("Running batch actionlint on %d lock files", len(lockFiles)) + + if err := RunActionlintOnFiles(lockFiles, verbose, strict); err != nil { + if strict { + return fmt.Errorf("actionlint linter failed: %w", err) + } + // In non-strict mode, actionlint errors are warnings + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("actionlint warnings: %v", err))) + } + } + + return nil +} + +// purgeOrphanedLockFiles removes orphaned .lock.yml files +// These are lock files that exist but don't have a corresponding .md file +func purgeOrphanedLockFiles(workflowsDir string, expectedLockFiles []string, verbose bool) error { + compileBatchOperationsLog.Printf("Purging orphaned lock files in %s", workflowsDir) + + // Find all existing .lock.yml files + existingLockFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.lock.yml")) + if err != nil { + return fmt.Errorf("failed to find existing lock files: %w", err) + } + + if len(existingLockFiles) == 0 { + compileBatchOperationsLog.Print("No lock files found") + return nil + } + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .lock.yml files", len(existingLockFiles)))) + } + + // Build a set of expected lock files + expectedLockFileSet := make(map[string]bool) + for _, expected := range expectedLockFiles { + expectedLockFileSet[expected] = true + } + + // Find lock files that should be deleted (exist but aren't expected) + var orphanedFiles []string + for _, existing := range existingLockFiles { + if !expectedLockFileSet[existing] { + orphanedFiles = append(orphanedFiles, existing) + } + } + + // Delete orphaned lock files + if len(orphanedFiles) > 0 { + for _, orphanedFile := range orphanedFiles { + if err := os.Remove(orphanedFile); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove orphaned lock file %s: %v", filepath.Base(orphanedFile), err))) + } else { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Removed orphaned lock file: %s", filepath.Base(orphanedFile)))) + } + } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d orphaned .lock.yml files", len(orphanedFiles)))) + } + } else if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No orphaned .lock.yml files found to purge")) + } + + compileBatchOperationsLog.Printf("Purged %d orphaned lock files", len(orphanedFiles)) + return nil +} + +// purgeInvalidFiles removes all .invalid.yml files +// These are temporary debugging artifacts that should not persist +func purgeInvalidFiles(workflowsDir string, verbose bool) error { + compileBatchOperationsLog.Printf("Purging invalid files in %s", workflowsDir) + + // Find all existing .invalid.yml files + existingInvalidFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.invalid.yml")) + if err != nil { + return fmt.Errorf("failed to find existing invalid files: %w", err) + } + + if len(existingInvalidFiles) == 0 { + compileBatchOperationsLog.Print("No invalid files found") + return nil + } + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .invalid.yml files", len(existingInvalidFiles)))) + } + + // Delete all .invalid.yml files + for _, invalidFile := range existingInvalidFiles { + if err := os.Remove(invalidFile); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove invalid file %s: %v", filepath.Base(invalidFile), err))) + } else { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Removed invalid file: %s", filepath.Base(invalidFile)))) + } + } + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d .invalid.yml files", len(existingInvalidFiles)))) + } + + compileBatchOperationsLog.Printf("Purged %d invalid files", len(existingInvalidFiles)) + return nil +} + +// purgeOrphanedCampaignOrchestrators removes orphaned .campaign.g.md files +// These are generated from .campaign.md source files, so remove them if source no longer exists +func purgeOrphanedCampaignOrchestrators(workflowsDir string, expectedCampaignDefinitions []string, verbose bool) error { + compileBatchOperationsLog.Printf("Purging orphaned campaign orchestrators in %s", workflowsDir) + + // Find all existing campaign orchestrator files (.campaign.g.md) + existingCampaignOrchestratorFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.campaign.g.md")) + if err != nil { + return fmt.Errorf("failed to find existing campaign orchestrator files: %w", err) + } + + if len(existingCampaignOrchestratorFiles) == 0 { + compileBatchOperationsLog.Print("No campaign orchestrator files found") + return nil + } + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .campaign.g.md files", len(existingCampaignOrchestratorFiles)))) + } + + // Build a set of expected campaign definition files + expectedCampaignSet := make(map[string]bool) + for _, campaignDef := range expectedCampaignDefinitions { + expectedCampaignSet[campaignDef] = true + } + + // Find orphaned orchestrator files + var orphanedOrchestrators []string + for _, orchestratorFile := range existingCampaignOrchestratorFiles { + // Derive the expected source campaign definition file name + // e.g., "example.campaign.g.md" -> "example.campaign.md" + baseName := filepath.Base(orchestratorFile) + sourceName := strings.TrimSuffix(baseName, ".g.md") + ".md" + sourcePath := filepath.Join(workflowsDir, sourceName) + + // Check if the source campaign definition exists + if !expectedCampaignSet[sourcePath] { + orphanedOrchestrators = append(orphanedOrchestrators, orchestratorFile) + } + } + + // Delete orphaned campaign orchestrator files + if len(orphanedOrchestrators) > 0 { + for _, orphanedFile := range orphanedOrchestrators { + if err := os.Remove(orphanedFile); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove orphaned campaign orchestrator %s: %v", filepath.Base(orphanedFile), err))) + } else { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Removed orphaned campaign orchestrator: %s", filepath.Base(orphanedFile)))) + } + } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d orphaned .campaign.g.md files", len(orphanedOrchestrators)))) + } + } else if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No orphaned .campaign.g.md files found to purge")) + } + + compileBatchOperationsLog.Printf("Purged %d orphaned campaign orchestrators", len(orphanedOrchestrators)) + return nil +} + +// purgeOrphanedCampaignOrchestratorLockFiles removes orphaned .campaign.g.lock.yml files +// These are compiled from .campaign.g.md files, which are generated from .campaign.md source files +func purgeOrphanedCampaignOrchestratorLockFiles(workflowsDir string, expectedCampaignDefinitions []string, verbose bool) error { + compileBatchOperationsLog.Printf("Purging orphaned campaign orchestrator lock files in %s", workflowsDir) + + // Find all existing campaign orchestrator lock files (.campaign.g.lock.yml) + existingCampaignOrchestratorLockFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.campaign.g.lock.yml")) + if err != nil { + return fmt.Errorf("failed to find existing campaign orchestrator lock files: %w", err) + } + + if len(existingCampaignOrchestratorLockFiles) == 0 { + compileBatchOperationsLog.Print("No campaign orchestrator lock files found") + return nil + } + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .campaign.g.lock.yml files", len(existingCampaignOrchestratorLockFiles)))) + } + + // Build a set of expected campaign definition files + expectedCampaignSet := make(map[string]bool) + for _, campaignDef := range expectedCampaignDefinitions { + expectedCampaignSet[campaignDef] = true + } + + // Find orphaned lock files + var orphanedLockFiles []string + for _, lockFile := range existingCampaignOrchestratorLockFiles { + // Derive the expected source campaign definition file name + // e.g., "example.campaign.g.lock.yml" -> "example.campaign.md" + baseName := filepath.Base(lockFile) + sourceName := strings.TrimSuffix(strings.TrimSuffix(baseName, ".g.lock.yml"), ".campaign") + ".campaign.md" + sourcePath := filepath.Join(workflowsDir, sourceName) + + // Check if the source campaign definition exists + if !expectedCampaignSet[sourcePath] { + orphanedLockFiles = append(orphanedLockFiles, lockFile) + } + } + + // Delete orphaned campaign orchestrator lock files + if len(orphanedLockFiles) > 0 { + for _, orphanedFile := range orphanedLockFiles { + if err := os.Remove(orphanedFile); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove orphaned campaign orchestrator lock file %s: %v", filepath.Base(orphanedFile), err))) + } else { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Removed orphaned campaign orchestrator lock file: %s", filepath.Base(orphanedFile)))) + } + } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d orphaned .campaign.g.lock.yml files", len(orphanedLockFiles)))) + } + } else if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No orphaned .campaign.g.lock.yml files found to purge")) + } + + compileBatchOperationsLog.Printf("Purged %d orphaned campaign orchestrator lock files", len(orphanedLockFiles)) + return nil +} diff --git a/pkg/cli/compile_compiler_setup.go b/pkg/cli/compile_compiler_setup.go new file mode 100644 index 0000000000..a9d1dd2ecc --- /dev/null +++ b/pkg/cli/compile_compiler_setup.go @@ -0,0 +1,140 @@ +// Package cli provides compiler initialization and configuration for workflow compilation. +// +// This file contains functions that create and configure the workflow compiler +// instance with various settings like validation, strict mode, trial mode, and +// action mode. +// +// # Organization Rationale +// +// These compiler setup functions are grouped here because they: +// - Handle compiler instance creation and configuration +// - Set up compilation flags and modes +// - Have a clear domain focus (compiler configuration) +// - Keep the main orchestrator focused on workflow processing +// +// # Key Functions +// +// Compiler Creation: +// - createAndConfigureCompiler() - Creates compiler with full configuration +// +// Configuration: +// - configureCompilerFlags() - Sets validation, strict mode, trial mode flags +// - setupActionMode() - Configures action script inlining mode +// - setupRepositoryContext() - Sets repository slug for schedule scattering +// +// These functions abstract compiler setup, allowing the main compile +// orchestrator to focus on coordination while these handle configuration. +package cli + +import ( + "fmt" + + "github.com/githubnext/gh-aw/pkg/logger" + "github.com/githubnext/gh-aw/pkg/workflow" +) + +var compileCompilerSetupLog = logger.New("cli:compile_compiler_setup") + +// createAndConfigureCompiler creates a new compiler instance and configures it +// based on the provided configuration +func createAndConfigureCompiler(config CompileConfig) *workflow.Compiler { + compileCompilerSetupLog.Printf("Creating compiler with config: verbose=%v, validate=%v, strict=%v, trialMode=%v", + config.Verbose, config.Validate, config.Strict, config.TrialMode) + + // Create compiler with verbose flag and AI engine override + compiler := workflow.NewCompiler(config.Verbose, config.EngineOverride, GetVersion()) + compileCompilerSetupLog.Print("Created compiler instance") + + // Configure compiler flags + configureCompilerFlags(compiler, config) + + // Set up action mode + setupActionMode(compiler, config.ActionMode) + + // Set up repository context + setupRepositoryContext(compiler) + + return compiler +} + +// configureCompilerFlags sets various compilation flags on the compiler +func configureCompilerFlags(compiler *workflow.Compiler, config CompileConfig) { + compileCompilerSetupLog.Print("Configuring compiler flags") + + // Set validation based on the validate flag (false by default for compatibility) + compiler.SetSkipValidation(!config.Validate) + compileCompilerSetupLog.Printf("Validation enabled: %v", config.Validate) + + // Set noEmit flag to validate without generating lock files + compiler.SetNoEmit(config.NoEmit) + if config.NoEmit { + compileCompilerSetupLog.Print("No-emit mode enabled: validating without generating lock files") + } + + // Set strict mode if specified + compiler.SetStrictMode(config.Strict) + + // Set trial mode if specified + if config.TrialMode { + compileCompilerSetupLog.Printf("Enabling trial mode: repoSlug=%s", config.TrialLogicalRepoSlug) + compiler.SetTrialMode(true) + if config.TrialLogicalRepoSlug != "" { + compiler.SetTrialLogicalRepoSlug(config.TrialLogicalRepoSlug) + } + } + + // Set refresh stop time flag + compiler.SetRefreshStopTime(config.RefreshStopTime) + if config.RefreshStopTime { + compileCompilerSetupLog.Print("Stop time refresh enabled: will regenerate stop-after times") + } +} + +// setupActionMode configures the action script inlining mode +func setupActionMode(compiler *workflow.Compiler, actionMode string) { + compileCompilerSetupLog.Printf("Setting up action mode: %s", actionMode) + + if actionMode != "" { + mode := workflow.ActionMode(actionMode) + if !mode.IsValid() { + // This should be caught by validation earlier, but log it + compileCompilerSetupLog.Printf("Invalid action mode '%s', using auto-detection", actionMode) + mode = workflow.DetectActionMode(GetVersion()) + } + compiler.SetActionMode(mode) + compileCompilerSetupLog.Printf("Action mode set to: %s", mode) + } else { + // Use auto-detection with version from binary + mode := workflow.DetectActionMode(GetVersion()) + compiler.SetActionMode(mode) + compileCompilerSetupLog.Printf("Action mode auto-detected: %s (version: %s)", mode, GetVersion()) + } +} + +// setupRepositoryContext sets the repository slug for schedule scattering +func setupRepositoryContext(compiler *workflow.Compiler) { + compileCompilerSetupLog.Print("Setting up repository context") + + // Set repository slug for schedule scattering + repoSlug := getRepositorySlugFromRemote() + if repoSlug != "" { + compiler.SetRepositorySlug(repoSlug) + compileCompilerSetupLog.Printf("Repository slug set: %s", repoSlug) + } else { + compileCompilerSetupLog.Print("No repository slug found") + } +} + +// validateActionModeConfig validates the action mode configuration +func validateActionModeConfig(actionMode string) error { + if actionMode == "" { + return nil + } + + mode := workflow.ActionMode(actionMode) + if !mode.IsValid() { + return fmt.Errorf("invalid action mode '%s'. Must be 'inline', 'dev', or 'release'", actionMode) + } + + return nil +} diff --git a/pkg/cli/compile_config_validator.go b/pkg/cli/compile_config_validator.go new file mode 100644 index 0000000000..e61d95267f --- /dev/null +++ b/pkg/cli/compile_config_validator.go @@ -0,0 +1,77 @@ +// Package cli provides configuration validation for workflow compilation. +// +// This file contains functions that validate compilation configuration before +// the compilation process begins, ensuring that all flags and parameters are +// valid and compatible. +// +// # Organization Rationale +// +// These configuration validation functions are grouped here because they: +// - Validate pre-compilation configuration +// - Are independent of compilation logic +// - Have a clear domain focus (configuration validation) +// - Enable early error detection before expensive operations +// +// # Key Functions +// +// Configuration Validation: +// - validateWorkflowDirectory() - Validates workflow directory path +// - setupWorkflowDirectory() - Sets up and validates workflow directory +// +// These functions abstract configuration validation, allowing the main compile +// orchestrator to focus on coordination while these handle validation logic. +package cli + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/githubnext/gh-aw/pkg/logger" +) + +var compileConfigValidatorLog = logger.New("cli:compile_config_validator") + +// validateWorkflowDirectory validates that the workflow directory exists +func validateWorkflowDirectory(workflowDir string) error { + compileConfigValidatorLog.Printf("Validating workflow directory: %s", workflowDir) + + if _, err := os.Stat(workflowDir); os.IsNotExist(err) { + // Get git root for better error message + gitRoot, gitErr := findGitRoot() + if gitErr != nil { + return fmt.Errorf("workflow directory %s does not exist", workflowDir) + } + return fmt.Errorf("the %s directory does not exist in git root (%s)", filepath.Base(workflowDir), gitRoot) + } + + compileConfigValidatorLog.Print("Workflow directory exists") + return nil +} + +// setupWorkflowDirectory sets up the workflow directory path, using defaults if needed +// Returns the absolute path to the workflows directory +func setupWorkflowDirectory(workflowDir string, gitRoot string) (string, error) { + compileConfigValidatorLog.Printf("Setting up workflow directory: dir=%s, gitRoot=%s", workflowDir, gitRoot) + + // Use default if not specified + if workflowDir == "" { + workflowDir = ".github/workflows" + compileConfigValidatorLog.Printf("Using default workflow directory: %s", workflowDir) + } else { + // Clean the path to avoid issues with ".." or other problematic elements + workflowDir = filepath.Clean(workflowDir) + compileConfigValidatorLog.Printf("Using custom workflow directory: %s", workflowDir) + } + + // Build absolute path + absWorkflowDir := filepath.Join(gitRoot, workflowDir) + + // Validate it exists + if err := validateWorkflowDirectory(absWorkflowDir); err != nil { + return "", err + } + + compileConfigValidatorLog.Printf("Workflow directory setup complete: %s", absWorkflowDir) + return absWorkflowDir, nil +} diff --git a/pkg/cli/compile_output_formatter.go b/pkg/cli/compile_output_formatter.go new file mode 100644 index 0000000000..7833cadd65 --- /dev/null +++ b/pkg/cli/compile_output_formatter.go @@ -0,0 +1,65 @@ +// Package cli provides output formatting functions for workflow compilation. +// +// This file contains functions that format and display compilation results, +// including summaries, statistics tables, and validation outputs. +// +// # Organization Rationale +// +// These output formatting functions are grouped here because they: +// - Handle presentation layer concerns (what users see) +// - Are used at the end of compilation operations +// - Have a clear domain focus (output formatting and display) +// - Keep the main orchestrator focused on orchestration logic +// +// # Key Functions +// +// Summary Output: +// - formatCompilationSummary() - Format compilation statistics +// - formatValidationOutput() - Format validation results as JSON +// +// These functions abstract output formatting, allowing the main compile +// orchestrator to focus on coordination while these handle presentation. +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/githubnext/gh-aw/pkg/logger" +) + +var compileOutputFormatterLog = logger.New("cli:compile_output_formatter") + +// formatCompilationSummary formats compilation statistics for display +// This is a wrapper around printCompilationSummary for consistency +func formatCompilationSummary(stats *CompilationStats) { + printCompilationSummary(stats) +} + +// formatValidationOutput formats validation results as JSON +func formatValidationOutput(results []ValidationResult) (string, error) { + compileOutputFormatterLog.Printf("Formatting validation output for %d workflow(s)", len(results)) + + // Sanitize validation results before JSON marshaling to prevent logging of sensitive information + // This removes potential secret key names from error messages at the output boundary + sanitizedResults := sanitizeValidationResults(results) + + jsonBytes, err := json.MarshalIndent(sanitizedResults, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + + return string(jsonBytes), nil +} + +// formatActionlintOutput displays the actionlint summary +// This is a wrapper around displayActionlintSummary for consistency +func formatActionlintOutput() { + displayActionlintSummary() +} + +// formatStatsTable displays the workflow statistics table +// This is a wrapper around displayStatsTable for consistency +func formatStatsTable(statsList []*WorkflowStats) { + displayStatsTable(statsList) +} diff --git a/pkg/cli/compile_post_processing.go b/pkg/cli/compile_post_processing.go new file mode 100644 index 0000000000..56f41a4caf --- /dev/null +++ b/pkg/cli/compile_post_processing.go @@ -0,0 +1,194 @@ +// Package cli provides post-processing operations for workflow compilation. +// +// This file contains functions that perform post-compilation operations such as +// generating Dependabot manifests, maintenance workflows, and validating campaigns. +// +// # Organization Rationale +// +// These post-processing functions are grouped here because they: +// - Run after workflow compilation completes +// - Generate auxiliary files and manifests +// - Have a clear domain focus (post-compilation processing) +// - Keep the main orchestrator focused on coordination +// +// # Key Functions +// +// Generation: +// - generateDependabotManifestsWrapper() - Generate Dependabot manifests +// - generateMaintenanceWorkflowWrapper() - Generate maintenance workflow +// +// Validation: +// - validateCampaignsWrapper() - Validate campaign specs +// +// Statistics: +// - collectWorkflowStatisticsWrapper() - Collect workflow statistics +// +// These functions abstract post-processing operations, allowing the main compile +// orchestrator to focus on coordination while these handle generation and validation. +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/logger" + "github.com/githubnext/gh-aw/pkg/workflow" +) + +var compilePostProcessingLog = logger.New("cli:compile_post_processing") + +// generateDependabotManifestsWrapper generates Dependabot manifests for compiled workflows +func generateDependabotManifestsWrapper( + compiler *workflow.Compiler, + workflowDataList []*workflow.WorkflowData, + workflowsDir string, + forceOverwrite bool, + strict bool, +) error { + compilePostProcessingLog.Print("Generating Dependabot manifests for compiled workflows") + + if err := compiler.GenerateDependabotManifests(workflowDataList, workflowsDir, forceOverwrite); err != nil { + if strict { + return fmt.Errorf("failed to generate Dependabot manifests: %w", err) + } + // Non-strict mode: just report as warning + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate Dependabot manifests: %v", err))) + } + + return nil +} + +// generateMaintenanceWorkflowWrapper generates maintenance workflow if any workflow uses expires field +func generateMaintenanceWorkflowWrapper( + compiler *workflow.Compiler, + workflowDataList []*workflow.WorkflowData, + workflowsDir string, + verbose bool, + strict bool, +) error { + compilePostProcessingLog.Print("Generating maintenance workflow") + + if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, workflowsDir, compiler.GetVersion(), compiler.GetActionMode(), verbose); err != nil { + if strict { + return fmt.Errorf("failed to generate maintenance workflow: %w", err) + } + // Non-strict mode: just report as warning + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate maintenance workflow: %v", err))) + } + + return nil +} + +// validateCampaignsWrapper validates campaign specs if they exist +func validateCampaignsWrapper(workflowDir string, verbose bool, strict bool) error { + compilePostProcessingLog.Print("Validating campaign specs") + + if err := validateCampaigns(workflowDir, verbose); err != nil { + if strict { + return fmt.Errorf("campaign validation failed: %w", err) + } + // Non-strict mode: just report as warning + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Campaign validation: %v", err))) + } + + return nil +} + +// collectWorkflowStatisticsWrapper collects and returns workflow statistics +func collectWorkflowStatisticsWrapper(markdownFiles []string) []*WorkflowStats { + compilePostProcessingLog.Printf("Collecting workflow statistics for %d files", len(markdownFiles)) + + var statsList []*WorkflowStats + for _, file := range markdownFiles { + resolvedFile, err := resolveWorkflowFile(file, false) + if err != nil { + continue // Skip files that couldn't be resolved + } + lockFile := strings.TrimSuffix(resolvedFile, ".md") + ".lock.yml" + if workflowStats, err := collectWorkflowStats(lockFile); err == nil { + statsList = append(statsList, workflowStats) + } + } + + compilePostProcessingLog.Printf("Collected statistics for %d workflows", len(statsList)) + return statsList +} + +// collectWorkflowStatisticsFromDir collects workflow statistics from all files in a directory +func collectWorkflowStatisticsFromDir(mdFiles []string) []*WorkflowStats { + compilePostProcessingLog.Printf("Collecting workflow statistics for %d files", len(mdFiles)) + + var statsList []*WorkflowStats + for _, file := range mdFiles { + lockFile := strings.TrimSuffix(file, ".md") + ".lock.yml" + if workflowStats, err := collectWorkflowStats(lockFile); err == nil { + statsList = append(statsList, workflowStats) + } + } + + compilePostProcessingLog.Printf("Collected statistics for %d workflows", len(statsList)) + return statsList +} + +// updateGitAttributes ensures .gitattributes marks .lock.yml files as generated +func updateGitAttributes(successCount int, actionCache *workflow.ActionCache, verbose bool) error { + compilePostProcessingLog.Printf("Updating .gitattributes (compiled=%d, actionCache=%v)", successCount, actionCache != nil) + + hasActionCacheEntries := actionCache != nil && len(actionCache.Entries) > 0 + + // Only update if we successfully compiled workflows or have action cache entries + if successCount > 0 || hasActionCacheEntries { + compilePostProcessingLog.Printf("Updating .gitattributes (compiled=%d, actionCache=%v)", successCount, hasActionCacheEntries) + if err := ensureGitAttributes(); err != nil { + compilePostProcessingLog.Printf("Failed to update .gitattributes: %v", err) + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update .gitattributes: %v", err))) + } + return err + } + compilePostProcessingLog.Printf("Successfully updated .gitattributes") + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Updated .gitattributes to mark .lock.yml files as generated")) + } + } else { + compilePostProcessingLog.Print("Skipping .gitattributes update (no compiled workflows and no action cache entries)") + } + + return nil +} + +// saveActionCache saves the action cache after all compilations +func saveActionCache(actionCache *workflow.ActionCache, verbose bool) error { + if actionCache == nil { + return nil + } + + compilePostProcessingLog.Print("Saving action cache") + + if err := actionCache.Save(); err != nil { + compilePostProcessingLog.Printf("Failed to save action cache: %v", err) + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save action cache: %v", err))) + } + return err + } + + compilePostProcessingLog.Print("Action cache saved successfully") + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Action cache saved to %s", actionCache.GetCachePath()))) + } + + return nil +} + +// getAbsoluteWorkflowDir converts a relative workflow dir to absolute path +func getAbsoluteWorkflowDir(workflowDir string, gitRoot string) string { + absWorkflowDir := workflowDir + if !filepath.IsAbs(absWorkflowDir) { + absWorkflowDir = filepath.Join(gitRoot, workflowDir) + } + return absWorkflowDir +} diff --git a/pkg/cli/compile_workflow_processor.go b/pkg/cli/compile_workflow_processor.go new file mode 100644 index 0000000000..199ed93900 --- /dev/null +++ b/pkg/cli/compile_workflow_processor.go @@ -0,0 +1,239 @@ +// Package cli provides workflow file processing functions for compilation. +// +// This file contains functions that process individual workflow files and +// campaign specs, handling both regular workflows and campaign orchestrators. +// +// # Organization Rationale +// +// These workflow processing functions are grouped here because they: +// - Handle per-file processing logic +// - Process both regular workflows and campaign specs +// - Have a clear domain focus (workflow file processing) +// - Keep the main orchestrator focused on batch operations +// +// # Key Functions +// +// Workflow Processing: +// - processWorkflowFile() - Process a single workflow markdown file +// - processCampaignSpec() - Process a campaign spec file +// - collectLockFilesForLinting() - Collect lock files for batch linting +// +// These functions abstract per-file processing, allowing the main compile +// orchestrator to focus on coordination while these handle file processing. +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/githubnext/gh-aw/pkg/campaign" + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/logger" + "github.com/githubnext/gh-aw/pkg/workflow" +) + +var compileWorkflowProcessorLog = logger.New("cli:compile_workflow_processor") + +// compileWorkflowFileResult represents the result of compiling a single workflow file +type compileWorkflowFileResult struct { + workflowData *workflow.WorkflowData + lockFile string + validationResult ValidationResult + success bool +} + +// compileWorkflowFile compiles a single workflow file (not a campaign spec) +// Returns the workflow data, lock file path, validation result, and success status +func compileWorkflowFile( + compiler *workflow.Compiler, + resolvedFile string, + verbose bool, + jsonOutput bool, + noEmit bool, + zizmor bool, + poutine bool, + actionlint bool, + strict bool, + validate bool, +) compileWorkflowFileResult { + compileWorkflowProcessorLog.Printf("Processing workflow file: %s", resolvedFile) + + result := compileWorkflowFileResult{ + validationResult: ValidationResult{ + Workflow: filepath.Base(resolvedFile), + Valid: true, + Errors: []ValidationError{}, + Warnings: []ValidationError{}, + }, + success: false, + } + + lockFile := strings.TrimSuffix(resolvedFile, ".md") + ".lock.yml" + result.lockFile = lockFile + if !noEmit { + result.validationResult.CompiledFile = lockFile + } + + // Parse workflow file to get data + compileWorkflowProcessorLog.Printf("Parsing workflow file: %s", resolvedFile) + + // Set workflow identifier for schedule scattering (use repository-relative path for stability) + relPath, err := getRepositoryRelativePath(resolvedFile) + if err != nil { + compileWorkflowProcessorLog.Printf("Warning: failed to get repository-relative path for %s: %v", resolvedFile, err) + // Fallback to basename if we can't get relative path + relPath = filepath.Base(resolvedFile) + } + compiler.SetWorkflowIdentifier(relPath) + + // Set repository slug for this specific file (may differ from CWD's repo) + fileRepoSlug := getRepositorySlugFromRemoteForPath(resolvedFile) + if fileRepoSlug != "" { + compiler.SetRepositorySlug(fileRepoSlug) + compileWorkflowProcessorLog.Printf("Repository slug for file set: %s", fileRepoSlug) + } + + // Parse the workflow + workflowData, err := compiler.ParseWorkflowFile(resolvedFile) + if err != nil { + errMsg := fmt.Sprintf("failed to parse workflow file %s: %v", resolvedFile, err) + if !jsonOutput { + fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg)) + } + result.validationResult.Valid = false + result.validationResult.Errors = append(result.validationResult.Errors, ValidationError{ + Type: "parse_error", + Message: err.Error(), + }) + return result + } + result.workflowData = workflowData + + compileWorkflowProcessorLog.Printf("Starting compilation of %s", resolvedFile) + + // Compile the workflow + // Disable per-file actionlint run (false instead of actionlint && !noEmit) - we'll batch them + if err := CompileWorkflowDataWithValidation(compiler, workflowData, resolvedFile, verbose && !jsonOutput, zizmor && !noEmit, poutine && !noEmit, false, strict, validate && !noEmit); err != nil { + // Always put error on a new line and don't wrap with "failed to compile workflow" + if !jsonOutput { + fmt.Fprintln(os.Stderr, err.Error()) + } + result.validationResult.Valid = false + result.validationResult.Errors = append(result.validationResult.Errors, ValidationError{ + Type: "compilation_error", + Message: err.Error(), + }) + return result + } + + result.success = true + compileWorkflowProcessorLog.Printf("Successfully processed workflow file: %s", resolvedFile) + return result +} + +// processCampaignSpec processes a campaign spec file +// Returns the validation result and success status +func processCampaignSpec( + compiler *workflow.Compiler, + resolvedFile string, + verbose bool, + jsonOutput bool, + noEmit bool, + zizmor bool, + poutine bool, + actionlint bool, + strict bool, + validate bool, +) (ValidationResult, bool) { + compileWorkflowProcessorLog.Printf("Processing campaign spec file: %s", resolvedFile) + + result := ValidationResult{ + Workflow: filepath.Base(resolvedFile), + Valid: true, + Errors: []ValidationError{}, + Warnings: []ValidationError{}, + } + + // Validate the campaign spec file and referenced workflows + spec, problems, vErr := campaign.ValidateSpecFromFile(resolvedFile) + if vErr != nil { + errMsg := fmt.Sprintf("failed to validate campaign spec %s: %v", resolvedFile, vErr) + if !jsonOutput { + fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg)) + } + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Type: "campaign_validation_error", + Message: vErr.Error(), + }) + return result, false + } + + // Also ensure that workflows referenced by the campaign spec exist + workflowsDir := filepath.Dir(resolvedFile) + workflowProblems := campaign.ValidateWorkflowsExist(spec, workflowsDir) + problems = append(problems, workflowProblems...) + + if len(problems) > 0 { + for _, p := range problems { + if !jsonOutput { + fmt.Fprintln(os.Stderr, console.FormatErrorMessage(p)) + } + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Type: "campaign_validation_error", + Message: p, + }) + } + return result, false + } + + if verbose && !jsonOutput { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Validated campaign spec %s", filepath.Base(resolvedFile)))) + } + + // Generate and compile the campaign orchestrator + if _, genErr := generateAndCompileCampaignOrchestrator( + compiler, + spec, + resolvedFile, + verbose && !jsonOutput, + noEmit, + zizmor && !noEmit, + poutine && !noEmit, + actionlint && !noEmit, + strict, + validate && !noEmit, + ); genErr != nil { + errMsg := fmt.Sprintf("failed to compile campaign orchestrator for %s: %v", filepath.Base(resolvedFile), genErr) + if !jsonOutput { + fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg)) + } + result.Valid = false + result.Errors = append(result.Errors, ValidationError{Type: "campaign_orchestrator_error", Message: errMsg}) + return result, false + } + + compileWorkflowProcessorLog.Printf("Successfully processed campaign spec: %s", resolvedFile) + return result, true +} + +// collectLockFilesForLinting collects lock files that exist for batch linting +func collectLockFilesForLinting(resolvedFiles []string, noEmit bool) []string { + if noEmit { + return nil + } + + var lockFiles []string + for _, resolvedFile := range resolvedFiles { + lockFile := strings.TrimSuffix(resolvedFile, ".md") + ".lock.yml" + if _, err := os.Stat(lockFile); err == nil { + lockFiles = append(lockFiles, lockFile) + } + } + + compileWorkflowProcessorLog.Printf("Collected %d lock files for linting", len(lockFiles)) + return lockFiles +} From 441c914881fa47ad35c259b7cb5f0be3d60e0d90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 02:47:26 +0000 Subject: [PATCH 3/4] Refactor CompileWorkflows to use extracted helper functions - Reduced compile_orchestrator.go from 1200 to 262 lines (78% reduction) - Created compile_orchestration.go with main compilation logic (488 lines) - CompileWorkflows now delegates to compileSpecificFiles and compileAllFilesInDirectory - All unit tests pass successfully - Maintains exact same behavior and public API Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- pkg/cli/compile_orchestration.go | 492 +++++++++++++++ pkg/cli/compile_orchestrator.go | 994 +------------------------------ 2 files changed, 520 insertions(+), 966 deletions(-) create mode 100644 pkg/cli/compile_orchestration.go diff --git a/pkg/cli/compile_orchestration.go b/pkg/cli/compile_orchestration.go new file mode 100644 index 0000000000..970d63f114 --- /dev/null +++ b/pkg/cli/compile_orchestration.go @@ -0,0 +1,492 @@ +// Package cli provides main orchestration logic for workflow compilation. +// +// This file contains the primary compilation orchestration functions that coordinate +// the compilation of specific files or all files in a directory. +// +// # Organization Rationale +// +// These orchestration functions are grouped here because they: +// - Coordinate the overall compilation process +// - Handle both specific file and directory-wide compilation +// - Integrate all compilation phases (processing, validation, linting, post-processing) +// - Keep the main CompileWorkflows function small and focused +// +// # Key Functions +// +// Compilation Orchestration: +// - compileSpecificFiles() - Compile a list of specific workflow files +// - compileAllFilesInDirectory() - Compile all workflows in a directory +// +// These functions handle the complete compilation pipeline for their respective scenarios. +package cli + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/logger" + "github.com/githubnext/gh-aw/pkg/workflow" +) + +var compileOrchestrationLog = logger.New("cli:compile_orchestration") + +// compileSpecificFiles compiles a specific list of workflow files +func compileSpecificFiles( + compiler *workflow.Compiler, + config CompileConfig, + stats *CompilationStats, + validationResults *[]ValidationResult, +) ([]*workflow.WorkflowData, error) { + compileOrchestrationLog.Printf("Compiling %d specific workflow files", len(config.MarkdownFiles)) + + var workflowDataList []*workflow.WorkflowData + var compiledCount int + var errorCount int + var errorMessages []string + var lockFilesForActionlint []string + + // Compile each specified file + for _, markdownFile := range config.MarkdownFiles { + stats.Total++ + + // Initialize validation result + result := ValidationResult{ + Workflow: markdownFile, + Valid: true, + Errors: []ValidationError{}, + Warnings: []ValidationError{}, + } + + // Resolve workflow ID or file path to actual file path + compileOrchestrationLog.Printf("Resolving workflow file: %s", markdownFile) + resolvedFile, err := resolveWorkflowFile(markdownFile, config.Verbose) + if err != nil { + if !config.JSONOutput { + fmt.Fprintln(os.Stderr, err.Error()) + } + errorMessages = append(errorMessages, err.Error()) + errorCount++ + stats.Errors++ + trackWorkflowFailure(stats, markdownFile, 1) + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Type: "resolution_error", + Message: err.Error(), + }) + *validationResults = append(*validationResults, result) + continue + } + compileOrchestrationLog.Printf("Resolved to: %s", resolvedFile) + + // Update result with resolved file name + result.Workflow = filepath.Base(resolvedFile) + + // Handle campaign spec files separately + if strings.HasSuffix(resolvedFile, ".campaign.md") { + campaignResult, success := processCampaignSpec( + compiler, resolvedFile, config.Verbose, config.JSONOutput, + config.NoEmit, config.Zizmor, config.Poutine, config.Actionlint, + config.Strict, config.Validate, + ) + if !success { + errorCount++ + stats.Errors++ + trackWorkflowFailure(stats, resolvedFile, len(campaignResult.Errors)) + errorMessages = append(errorMessages, campaignResult.Errors[0].Message) + } + *validationResults = append(*validationResults, campaignResult) + continue + } + + // Compile regular workflow file + fileResult := compileWorkflowFile( + compiler, resolvedFile, config.Verbose, config.JSONOutput, + config.NoEmit, config.Zizmor, config.Poutine, config.Actionlint, + config.Strict, config.Validate, + ) + + if !fileResult.success { + errorCount++ + stats.Errors++ + trackWorkflowFailure(stats, resolvedFile, 1) + errorMessages = append(errorMessages, fileResult.validationResult.Errors[0].Message) + } else { + compiledCount++ + workflowDataList = append(workflowDataList, fileResult.workflowData) + + // Collect lock file for batch actionlint + if config.Actionlint && !config.NoEmit && fileResult.lockFile != "" { + if _, err := os.Stat(fileResult.lockFile); err == nil { + lockFilesForActionlint = append(lockFilesForActionlint, fileResult.lockFile) + } + } + } + + *validationResults = append(*validationResults, fileResult.validationResult) + } + + // Run batch actionlint on all collected lock files + if config.Actionlint && !config.NoEmit && len(lockFilesForActionlint) > 0 { + if err := runBatchActionlint(lockFilesForActionlint, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + return workflowDataList, err + } + } + } + + // Get warning count from compiler + stats.Warnings = compiler.GetWarningCount() + + // Display schedule warnings + displayScheduleWarnings(compiler, config.JSONOutput) + + // Post-processing + if err := runPostProcessing(compiler, workflowDataList, config, compiledCount); err != nil { + return workflowDataList, err + } + + // Output results + if err := outputResults(stats, validationResults, config); err != nil { + return workflowDataList, err + } + + // Return error if any compilations failed + if errorCount > 0 { + if len(errorMessages) > 0 { + return workflowDataList, errors.New(errorMessages[0]) + } + return workflowDataList, fmt.Errorf("compilation failed") + } + + return workflowDataList, nil +} + +// compileAllFilesInDirectory compiles all workflow files in a directory +func compileAllFilesInDirectory( + compiler *workflow.Compiler, + config CompileConfig, + workflowDir string, + stats *CompilationStats, + validationResults *[]ValidationResult, +) ([]*workflow.WorkflowData, error) { + // Find git root for consistent behavior + gitRoot, err := findGitRoot() + if err != nil { + return nil, fmt.Errorf("compile without arguments requires being in a git repository: %w", err) + } + compileOrchestrationLog.Printf("Found git root: %s", gitRoot) + + // Compile all markdown files in the specified workflow directory + workflowsDir := filepath.Join(gitRoot, workflowDir) + if _, err := os.Stat(workflowsDir); os.IsNotExist(err) { + return nil, fmt.Errorf("the %s directory does not exist in git root (%s)", workflowDir, gitRoot) + } + + compileOrchestrationLog.Printf("Scanning for markdown files in %s", workflowsDir) + if config.Verbose { + fmt.Printf("Scanning for markdown files in %s\n", workflowsDir) + } + + // Find all markdown files + mdFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.md")) + if err != nil { + return nil, fmt.Errorf("failed to find markdown files: %w", err) + } + + if len(mdFiles) == 0 { + return nil, fmt.Errorf("no markdown files found in %s", workflowsDir) + } + + compileOrchestrationLog.Printf("Found %d markdown files to compile", len(mdFiles)) + if config.Verbose { + fmt.Printf("Found %d markdown files to compile\n", len(mdFiles)) + } + + // Handle purge logic: collect existing files before compilation + var purgeData *purgeTrackingData + if config.Purge { + purgeData = collectPurgeData(workflowsDir, mdFiles, config.Verbose) + } + + // Compile each file + var workflowDataList []*workflow.WorkflowData + var successCount int + var errorCount int + var lockFilesForActionlint []string + + for _, file := range mdFiles { + stats.Total++ + + // Handle campaign spec files + if strings.HasSuffix(file, ".campaign.md") { + campaignResult, success := processCampaignSpec( + compiler, file, config.Verbose, config.JSONOutput, + config.NoEmit, config.Zizmor, config.Poutine, config.Actionlint, + config.Strict, config.Validate, + ) + if !success { + errorCount++ + stats.Errors++ + trackWorkflowFailure(stats, file, len(campaignResult.Errors)) + } + *validationResults = append(*validationResults, campaignResult) + continue + } + + // Compile regular workflow file + fileResult := compileWorkflowFile( + compiler, file, config.Verbose, config.JSONOutput, + config.NoEmit, config.Zizmor, config.Poutine, config.Actionlint, + config.Strict, config.Validate, + ) + + if !fileResult.success { + errorCount++ + stats.Errors++ + trackWorkflowFailure(stats, file, 1) + } else { + successCount++ + workflowDataList = append(workflowDataList, fileResult.workflowData) + + // Collect lock file for batch actionlint + if config.Actionlint && !config.NoEmit && fileResult.lockFile != "" { + if _, err := os.Stat(fileResult.lockFile); err == nil { + lockFilesForActionlint = append(lockFilesForActionlint, fileResult.lockFile) + } + } + } + + *validationResults = append(*validationResults, fileResult.validationResult) + } + + // Run batch actionlint + if config.Actionlint && !config.NoEmit && len(lockFilesForActionlint) > 0 { + if err := runBatchActionlint(lockFilesForActionlint, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + return workflowDataList, err + } + } + } + + // Get warning count from compiler + stats.Warnings = compiler.GetWarningCount() + + // Display schedule warnings + displayScheduleWarnings(compiler, config.JSONOutput) + + if config.Verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully compiled %d out of %d workflow files", successCount, len(mdFiles)))) + } + + // Handle purge logic if requested + if config.Purge && purgeData != nil { + runPurgeOperations(workflowsDir, purgeData, config.Verbose) + } + + // Post-processing + if err := runPostProcessingForDirectory(compiler, workflowDataList, config, workflowsDir, gitRoot, successCount); err != nil { + return workflowDataList, err + } + + // Output results + if err := outputResults(stats, validationResults, config); err != nil { + return workflowDataList, err + } + + // Return error if any compilations failed + if errorCount > 0 { + return workflowDataList, fmt.Errorf("compilation failed") + } + + return workflowDataList, nil +} + +// purgeTrackingData holds data needed for purge operations +type purgeTrackingData struct { + existingLockFiles []string + existingInvalidFiles []string + existingCampaignOrchestratorFiles []string + existingCampaignOrchestratorLockFiles []string + expectedLockFiles []string + expectedCampaignDefinitions []string +} + +// collectPurgeData collects existing files for purge operations +func collectPurgeData(workflowsDir string, mdFiles []string, verbose bool) *purgeTrackingData { + data := &purgeTrackingData{} + + // Find all existing files + data.existingLockFiles, _ = filepath.Glob(filepath.Join(workflowsDir, "*.lock.yml")) + data.existingInvalidFiles, _ = filepath.Glob(filepath.Join(workflowsDir, "*.invalid.yml")) + data.existingCampaignOrchestratorFiles, _ = filepath.Glob(filepath.Join(workflowsDir, "*.campaign.g.md")) + data.existingCampaignOrchestratorLockFiles, _ = filepath.Glob(filepath.Join(workflowsDir, "*.campaign.g.lock.yml")) + + // Create expected files list + for _, mdFile := range mdFiles { + lockFile := strings.TrimSuffix(mdFile, ".md") + ".lock.yml" + data.expectedLockFiles = append(data.expectedLockFiles, lockFile) + + if strings.HasSuffix(mdFile, ".campaign.md") { + data.expectedCampaignDefinitions = append(data.expectedCampaignDefinitions, mdFile) + } + } + + if verbose { + if len(data.existingLockFiles) > 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .lock.yml files", len(data.existingLockFiles)))) + } + if len(data.existingInvalidFiles) > 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .invalid.yml files", len(data.existingInvalidFiles)))) + } + } + + return data +} + +// runPurgeOperations runs all purge operations +func runPurgeOperations(workflowsDir string, data *purgeTrackingData, verbose bool) { + purgeOrphanedLockFiles(workflowsDir, data.expectedLockFiles, verbose) + purgeInvalidFiles(workflowsDir, verbose) + purgeOrphanedCampaignOrchestrators(workflowsDir, data.expectedCampaignDefinitions, verbose) + purgeOrphanedCampaignOrchestratorLockFiles(workflowsDir, data.expectedCampaignDefinitions, verbose) +} + +// displayScheduleWarnings displays any schedule warnings from the compiler +func displayScheduleWarnings(compiler *workflow.Compiler, jsonOutput bool) { + scheduleWarnings := compiler.GetScheduleWarnings() + if len(scheduleWarnings) > 0 && !jsonOutput { + for _, warning := range scheduleWarnings { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warning)) + } + } +} + +// runPostProcessing runs post-processing for specific files compilation +func runPostProcessing( + compiler *workflow.Compiler, + workflowDataList []*workflow.WorkflowData, + config CompileConfig, + successCount int, +) error { + // Get action cache + actionCache := compiler.GetSharedActionCache() + + // Update .gitattributes + if err := updateGitAttributes(successCount, actionCache, config.Verbose); err != nil { + // Non-fatal, continue + } + + // Generate Dependabot manifests if requested + if config.Dependabot && !config.NoEmit { + gitRoot, err := findGitRoot() + if err == nil { + absWorkflowDir := filepath.Join(gitRoot, config.WorkflowDir) + if err := generateDependabotManifestsWrapper(compiler, workflowDataList, absWorkflowDir, config.ForceOverwrite, config.Strict); err != nil { + if config.Strict { + return err + } + } + } + } + + // Validate campaigns + if err := validateCampaignsWrapper(config.WorkflowDir, config.Verbose, config.Strict); err != nil { + if config.Strict { + return err + } + } + + // Save action cache + saveActionCache(actionCache, config.Verbose) + + return nil +} + +// runPostProcessingForDirectory runs post-processing for directory compilation +func runPostProcessingForDirectory( + compiler *workflow.Compiler, + workflowDataList []*workflow.WorkflowData, + config CompileConfig, + workflowsDir string, + gitRoot string, + successCount int, +) error { + // Get action cache + actionCache := compiler.GetSharedActionCache() + + // Update .gitattributes + if err := updateGitAttributes(successCount, actionCache, config.Verbose); err != nil { + // Non-fatal, continue + } + + // Generate Dependabot manifests if requested + if config.Dependabot && !config.NoEmit { + absWorkflowDir := getAbsoluteWorkflowDir(workflowsDir, gitRoot) + if err := generateDependabotManifestsWrapper(compiler, workflowDataList, absWorkflowDir, config.ForceOverwrite, config.Strict); err != nil { + if config.Strict { + return err + } + } + } + + // Generate maintenance workflow if needed + if !config.NoEmit { + absWorkflowDir := getAbsoluteWorkflowDir(workflowsDir, gitRoot) + if err := generateMaintenanceWorkflowWrapper(compiler, workflowDataList, absWorkflowDir, config.Verbose, config.Strict); err != nil { + if config.Strict { + return err + } + } + } + + // Validate campaigns + if err := validateCampaignsWrapper(config.WorkflowDir, config.Verbose, config.Strict); err != nil { + if config.Strict { + return err + } + } + + // Save action cache + saveActionCache(actionCache, config.Verbose) + + return nil +} + +// outputResults outputs compilation results in the requested format +func outputResults( + stats *CompilationStats, + validationResults *[]ValidationResult, + config CompileConfig, +) error { + // Collect and display stats if requested + if config.Stats && !config.NoEmit && !config.JSONOutput { + var statsList []*WorkflowStats + if len(config.MarkdownFiles) > 0 { + statsList = collectWorkflowStatisticsWrapper(config.MarkdownFiles) + } + formatStatsTable(statsList) + } + + // Output JSON if requested + if config.JSONOutput { + jsonStr, err := formatValidationOutput(*validationResults) + if err != nil { + return err + } + fmt.Println(jsonStr) + } else if !config.Stats { + // Print summary for text output (skip if stats mode) + formatCompilationSummary(stats) + } + + // Display actionlint summary if enabled + if config.Actionlint && !config.NoEmit && !config.JSONOutput { + formatActionlintOutput() + } + + return nil +} diff --git a/pkg/cli/compile_orchestrator.go b/pkg/cli/compile_orchestrator.go index 84f68f14d0..01c2b1d52b 100644 --- a/pkg/cli/compile_orchestrator.go +++ b/pkg/cli/compile_orchestrator.go @@ -1,8 +1,6 @@ package cli import ( - "encoding/json" - "errors" "fmt" "os" "path/filepath" @@ -197,28 +195,21 @@ func generateAndCompileCampaignOrchestrator( // CompileWorkflows compiles workflows based on the provided configuration func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) { - markdownFiles := config.MarkdownFiles - verbose := config.Verbose - engineOverride := config.EngineOverride - validate := config.Validate - watch := config.Watch - workflowDir := config.WorkflowDir - noEmit := config.NoEmit - purge := config.Purge - trialMode := config.TrialMode - trialLogicalRepoSlug := config.TrialLogicalRepoSlug - strict := config.Strict - dependabot := config.Dependabot - forceOverwrite := config.ForceOverwrite - zizmor := config.Zizmor - poutine := config.Poutine - actionlint := config.Actionlint - jsonOutput := config.JSONOutput + compileOrchestratorLog.Printf("Starting workflow compilation: files=%d, validate=%v, watch=%v, noEmit=%v", + len(config.MarkdownFiles), config.Validate, config.Watch, config.NoEmit) - compileOrchestratorLog.Printf("Starting workflow compilation: files=%d, validate=%v, watch=%v, noEmit=%v, dependabot=%v, zizmor=%v, poutine=%v, actionlint=%v, jsonOutput=%v", len(markdownFiles), validate, watch, noEmit, dependabot, zizmor, poutine, actionlint, jsonOutput) + // Validate configuration + if err := validateCompileConfig(config); err != nil { + return nil, err + } + + // Validate action mode if specified + if err := validateActionModeConfig(config.ActionMode); err != nil { + return nil, err + } // Initialize actionlint statistics if actionlint is enabled - if actionlint && !noEmit { + if config.Actionlint && !config.NoEmit { initActionlintStats() } @@ -228,973 +219,44 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) { // Track validation results for JSON output var validationResults []ValidationResult - // Validate configuration - if err := validateCompileConfig(config); err != nil { - return nil, err - } - - // Validate and set default for workflow directory + // Set up workflow directory (using default if not specified) + workflowDir := config.WorkflowDir if workflowDir == "" { workflowDir = ".github/workflows" compileOrchestratorLog.Printf("Using default workflow directory: %s", workflowDir) } else { - // Clean the path to avoid issues with ".." or other problematic elements workflowDir = filepath.Clean(workflowDir) compileOrchestratorLog.Printf("Using custom workflow directory: %s", workflowDir) } - // Create compiler with verbose flag and AI engine override - compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion()) - compileOrchestratorLog.Print("Created compiler instance") + // Create and configure compiler + compiler := createAndConfigureCompiler(config) - // Set repository slug for schedule scattering - repoSlug := getRepositorySlugFromRemote() - if repoSlug != "" { - compiler.SetRepositorySlug(repoSlug) - compileOrchestratorLog.Printf("Repository slug set: %s", repoSlug) - } - - // Set validation based on the validate flag (false by default for compatibility) - compiler.SetSkipValidation(!validate) - compileOrchestratorLog.Printf("Validation enabled: %v", validate) - - // Set noEmit flag to validate without generating lock files - compiler.SetNoEmit(noEmit) - if noEmit { - compileOrchestratorLog.Print("No-emit mode enabled: validating without generating lock files") - } - - // Set strict mode if specified - compiler.SetStrictMode(strict) - - // Set trial mode if specified - if trialMode { - compileOrchestratorLog.Printf("Enabling trial mode: repoSlug=%s", trialLogicalRepoSlug) - compiler.SetTrialMode(true) - if trialLogicalRepoSlug != "" { - compiler.SetTrialLogicalRepoSlug(trialLogicalRepoSlug) - } - } - - // Set refresh stop time flag - compiler.SetRefreshStopTime(config.RefreshStopTime) - if config.RefreshStopTime { - compileOrchestratorLog.Print("Stop time refresh enabled: will regenerate stop-after times") - } - - // Set action mode if specified - if config.ActionMode != "" { - mode := workflow.ActionMode(config.ActionMode) - if !mode.IsValid() { - return nil, fmt.Errorf("invalid action mode '%s'. Must be 'inline', 'dev', or 'release'", config.ActionMode) - } - compiler.SetActionMode(mode) - compileOrchestratorLog.Printf("Action mode set to: %s", mode) - } else { - // Use auto-detection with version from binary - mode := workflow.DetectActionMode(GetVersion()) - compiler.SetActionMode(mode) - compileOrchestratorLog.Printf("Action mode auto-detected: %s (version: %s)", mode, GetVersion()) - } - - if watch { + // Handle watch mode (early return) + if config.Watch { // Watch mode: watch for file changes and recompile automatically // For watch mode, we only support a single file for now var markdownFile string - if len(markdownFiles) > 0 { - if len(markdownFiles) > 1 { + if len(config.MarkdownFiles) > 0 { + if len(config.MarkdownFiles) > 1 { fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Watch mode only supports a single file, using the first one")) } // Resolve the workflow file to get the full path - resolvedFile, err := resolveWorkflowFile(markdownFiles[0], verbose) + resolvedFile, err := resolveWorkflowFile(config.MarkdownFiles[0], config.Verbose) if err != nil { - return nil, fmt.Errorf("failed to resolve workflow '%s': %w", markdownFiles[0], err) + return nil, fmt.Errorf("failed to resolve workflow '%s': %w", config.MarkdownFiles[0], err) } markdownFile = resolvedFile } - return nil, watchAndCompileWorkflows(markdownFile, compiler, verbose) + return nil, watchAndCompileWorkflows(markdownFile, compiler, config.Verbose) } - var workflowDataList []*workflow.WorkflowData - - if len(markdownFiles) > 0 { - compileOrchestratorLog.Printf("Compiling %d specific workflow files", len(markdownFiles)) + // Compile specific files or all files in directory + if len(config.MarkdownFiles) > 0 { // Compile specific workflow files - var compiledCount int - var errorCount int - var errorMessages []string - var lockFilesForActionlint []string // Collect lock files for batch actionlint run - for _, markdownFile := range markdownFiles { - stats.Total++ - - // Initialize validation result for this workflow - result := ValidationResult{ - Workflow: markdownFile, - Valid: true, - Errors: []ValidationError{}, - Warnings: []ValidationError{}, - } - - // Resolve workflow ID or file path to actual file path - compileOrchestratorLog.Printf("Resolving workflow file: %s", markdownFile) - resolvedFile, err := resolveWorkflowFile(markdownFile, verbose) - if err != nil { - if !jsonOutput { - // Print the error directly - it already contains suggestions and formatting - fmt.Fprintln(os.Stderr, err.Error()) - } - errorMessages = append(errorMessages, err.Error()) - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, markdownFile, 1) - - // Add to validation results - result.Valid = false - result.Errors = append(result.Errors, ValidationError{ - Type: "resolution_error", - Message: err.Error(), - }) - validationResults = append(validationResults, result) - continue - } - compileOrchestratorLog.Printf("Resolved to: %s", resolvedFile) - - // Update result with resolved file name - result.Workflow = filepath.Base(resolvedFile) - - // Handle campaign spec files separately from regular workflows - if strings.HasSuffix(resolvedFile, ".campaign.md") { - // Validate the campaign spec file and referenced workflows instead of - // compiling it as a regular workflow YAML. - spec, problems, vErr := campaign.ValidateSpecFromFile(resolvedFile) - if vErr != nil { - errMsg := fmt.Sprintf("failed to validate campaign spec %s: %v", resolvedFile, vErr) - if !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg)) - } - errorMessages = append(errorMessages, vErr.Error()) - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, resolvedFile, 1) - - result.Valid = false - result.Errors = append(result.Errors, ValidationError{ - Type: "campaign_validation_error", - Message: vErr.Error(), - }) - validationResults = append(validationResults, result) - continue - } - - // Also ensure that workflows referenced by the campaign spec exist - workflowsDir := filepath.Dir(resolvedFile) - workflowProblems := campaign.ValidateWorkflowsExist(spec, workflowsDir) - problems = append(problems, workflowProblems...) - - if len(problems) > 0 { - for _, p := range problems { - if !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(p)) - } - result.Valid = false - result.Errors = append(result.Errors, ValidationError{ - Type: "campaign_validation_error", - Message: p, - }) - } - errorMessages = append(errorMessages, problems[0]) - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, resolvedFile, len(problems)) - } else { - if verbose && !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Validated campaign spec %s", filepath.Base(resolvedFile)))) - } - - if _, genErr := generateAndCompileCampaignOrchestrator( - compiler, - spec, - resolvedFile, - verbose && !jsonOutput, - noEmit, - zizmor && !noEmit, - poutine && !noEmit, - actionlint && !noEmit, - strict, - validate && !noEmit, - ); genErr != nil { - errMsg := fmt.Sprintf("failed to compile campaign orchestrator for %s: %v", filepath.Base(resolvedFile), genErr) - if !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg)) - } - errorMessages = append(errorMessages, errMsg) - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, resolvedFile, 1) - result.Valid = false - result.Errors = append(result.Errors, ValidationError{Type: "campaign_orchestrator_error", Message: errMsg}) - } - } - - validationResults = append(validationResults, result) - continue - } - - lockFile := strings.TrimSuffix(resolvedFile, ".md") + ".lock.yml" - if !noEmit { - result.CompiledFile = lockFile - } - - // Parse workflow file to get data - compileOrchestratorLog.Printf("Parsing workflow file: %s", resolvedFile) - // Set workflow identifier for schedule scattering (use repository-relative path for stability) - relPath, err := getRepositoryRelativePath(resolvedFile) - if err != nil { - compileOrchestratorLog.Printf("Warning: failed to get repository-relative path for %s: %v", resolvedFile, err) - // Fallback to basename if we can't get relative path - relPath = filepath.Base(resolvedFile) - } - compiler.SetWorkflowIdentifier(relPath) - - // Set repository slug for this specific file (may differ from CWD's repo) - fileRepoSlug := getRepositorySlugFromRemoteForPath(resolvedFile) - if fileRepoSlug != "" { - compiler.SetRepositorySlug(fileRepoSlug) - compileOrchestratorLog.Printf("Repository slug for file set: %s", fileRepoSlug) - } - - workflowData, err := compiler.ParseWorkflowFile(resolvedFile) - if err != nil { - errMsg := fmt.Sprintf("failed to parse workflow file %s: %v", resolvedFile, err) - if !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg)) - } - errorMessages = append(errorMessages, err.Error()) - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, resolvedFile, 1) - - // Add to validation results - result.Valid = false - result.Errors = append(result.Errors, ValidationError{ - Type: "parse_error", - Message: err.Error(), - }) - validationResults = append(validationResults, result) - continue - } - workflowDataList = append(workflowDataList, workflowData) - - compileOrchestratorLog.Printf("Starting compilation of %s", resolvedFile) - // Disable per-file actionlint run (false instead of actionlint && !noEmit) - we'll batch them - if err := CompileWorkflowDataWithValidation(compiler, workflowData, resolvedFile, verbose && !jsonOutput, zizmor && !noEmit, poutine && !noEmit, false, strict, validate && !noEmit); err != nil { - // Always put error on a new line and don't wrap with "failed to compile workflow" - if !jsonOutput { - fmt.Fprintln(os.Stderr, err.Error()) - } - errorMessages = append(errorMessages, err.Error()) - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, resolvedFile, 1) - - // Add to validation results - result.Valid = false - result.Errors = append(result.Errors, ValidationError{ - Type: "compilation_error", - Message: err.Error(), - }) - validationResults = append(validationResults, result) - continue - } - compiledCount++ - - // Collect lock file for batch actionlint run - if actionlint && !noEmit { - lockFile := strings.TrimSuffix(resolvedFile, ".md") + ".lock.yml" - if _, err := os.Stat(lockFile); err == nil { - lockFilesForActionlint = append(lockFilesForActionlint, lockFile) - } - } - - // Add successful validation result - validationResults = append(validationResults, result) - } - - // Run actionlint on all lock files in batch for better performance - if actionlint && !noEmit && len(lockFilesForActionlint) > 0 { - compileOrchestratorLog.Printf("Running batch actionlint on %d lock files", len(lockFilesForActionlint)) - if err := RunActionlintOnFiles(lockFilesForActionlint, verbose && !jsonOutput, strict); err != nil { - if strict { - return workflowDataList, fmt.Errorf("actionlint linter failed: %w", err) - } - // In non-strict mode, actionlint errors are warnings - if verbose && !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("actionlint warnings: %v", err))) - } - } - } - - // Get warning count from compiler - stats.Warnings = compiler.GetWarningCount() - - // Display any schedule warnings from this compiler instance - scheduleWarnings := compiler.GetScheduleWarnings() - if len(scheduleWarnings) > 0 && !jsonOutput { - for _, warning := range scheduleWarnings { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warning)) - } - } - - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully compiled %d workflow file(s)", compiledCount))) - } - - // Get the action cache once for use in multiple places - actionCache := compiler.GetSharedActionCache() - hasActionCacheEntries := actionCache != nil && len(actionCache.Entries) > 0 - - // Ensure .gitattributes marks .lock.yml files as generated - // Only update if we successfully compiled workflows or have action cache entries - if compiledCount > 0 || hasActionCacheEntries { - compileOrchestratorLog.Printf("Updating .gitattributes (compiled=%d, actionCache=%v)", compiledCount, hasActionCacheEntries) - if err := ensureGitAttributes(); err != nil { - compileOrchestratorLog.Printf("Failed to update .gitattributes: %v", err) - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update .gitattributes: %v", err))) - } - } else { - compileOrchestratorLog.Printf("Successfully updated .gitattributes") - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Updated .gitattributes to mark .lock.yml files as generated")) - } - } - } else { - compileOrchestratorLog.Print("Skipping .gitattributes update (no compiled workflows and no action cache entries)") - } - - // Generate Dependabot manifests if requested - if dependabot && !noEmit { - compileOrchestratorLog.Print("Generating Dependabot manifests for compiled workflows") - // Resolve workflow directory path - absWorkflowDir := workflowDir - if !filepath.IsAbs(absWorkflowDir) { - gitRoot, err := findGitRoot() - if err == nil { - absWorkflowDir = filepath.Join(gitRoot, workflowDir) - } - } - - if err := compiler.GenerateDependabotManifests(workflowDataList, absWorkflowDir, forceOverwrite); err != nil { - if strict { - return workflowDataList, fmt.Errorf("failed to generate Dependabot manifests: %w", err) - } - // Non-strict mode: just report as warning - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate Dependabot manifests: %v", err))) - } - } - - // Note: Instructions are only written by the init command - // The compile command should not write instruction files - - // Validate campaign specs if they exist - if err := validateCampaigns(workflowDir, verbose); err != nil { - if strict { - return workflowDataList, fmt.Errorf("campaign validation failed: %w", err) - } - // Non-strict mode: just report as warning - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Campaign validation: %v", err))) - } - - // Collect and display workflow statistics if requested - if config.Stats && !noEmit && !jsonOutput { - var statsList []*WorkflowStats - for _, file := range markdownFiles { - resolvedFile, err := resolveWorkflowFile(file, false) - if err != nil { - continue // Skip files that couldn't be resolved - } - lockFile := strings.TrimSuffix(resolvedFile, ".md") + ".lock.yml" - if workflowStats, err := collectWorkflowStats(lockFile); err == nil { - statsList = append(statsList, workflowStats) - } - } - displayStatsTable(statsList) - } - - // Output JSON if requested - if jsonOutput { - // Sanitize validation results before JSON marshaling to prevent logging of sensitive information - // This removes potential secret key names from error messages at the output boundary - sanitizedResults := sanitizeValidationResults(validationResults) - jsonBytes, err := json.MarshalIndent(sanitizedResults, "", " ") - if err != nil { - return workflowDataList, fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(jsonBytes)) - } else if !config.Stats { - // Print summary for text output (skip if stats mode) - printCompilationSummary(stats) - } - - // Display actionlint summary if actionlint was enabled and we're not in JSON output mode - if actionlint && !noEmit && !jsonOutput { - displayActionlintSummary() - } - - // Save the action cache after all compilations - if actionCache != nil { - if err := actionCache.Save(); err != nil { - compileOrchestratorLog.Printf("Failed to save action cache: %v", err) - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save action cache: %v", err))) - } - } else { - compileOrchestratorLog.Print("Action cache saved successfully") - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Action cache saved to %s", actionCache.GetCachePath()))) - } - } - } - - // Return error if any compilations failed - if errorCount > 0 { - // Return the first error message for backward compatibility with tests - if len(errorMessages) > 0 { - return workflowDataList, errors.New(errorMessages[0]) - } - return workflowDataList, fmt.Errorf("compilation failed") - } - - return workflowDataList, nil - } - - // Find git root for consistent behavior - gitRoot, err := findGitRoot() - if err != nil { - return nil, fmt.Errorf("compile without arguments requires being in a git repository: %w", err) - } - compileOrchestratorLog.Printf("Found git root: %s", gitRoot) - - // Compile all markdown files in the specified workflow directory relative to git root - workflowsDir := filepath.Join(gitRoot, workflowDir) - if _, err := os.Stat(workflowsDir); os.IsNotExist(err) { - return nil, fmt.Errorf("the %s directory does not exist in git root (%s)", workflowDir, gitRoot) - } - - compileOrchestratorLog.Printf("Scanning for markdown files in %s", workflowsDir) - if verbose { - fmt.Printf("Scanning for markdown files in %s\n", workflowsDir) - } - - // Find all markdown files - mdFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.md")) - if err != nil { - return nil, fmt.Errorf("failed to find markdown files: %w", err) - } - - if len(mdFiles) == 0 { - return nil, fmt.Errorf("no markdown files found in %s", workflowsDir) - } - - compileOrchestratorLog.Printf("Found %d markdown files to compile", len(mdFiles)) - if verbose { - fmt.Printf("Found %d markdown files to compile\n", len(mdFiles)) - } - - // Handle purge logic: collect existing .lock.yml and .invalid.yml files before compilation - var existingLockFiles []string - var existingInvalidFiles []string - var existingCampaignOrchestratorFiles []string - var existingCampaignOrchestratorLockFiles []string - var expectedLockFiles []string - var expectedCampaignDefinitions []string - if purge { - // Find all existing .lock.yml files - existingLockFiles, err = filepath.Glob(filepath.Join(workflowsDir, "*.lock.yml")) - if err != nil { - return nil, fmt.Errorf("failed to find existing lock files: %w", err) - } - - // Find all existing .invalid.yml files - existingInvalidFiles, err = filepath.Glob(filepath.Join(workflowsDir, "*.invalid.yml")) - if err != nil { - return nil, fmt.Errorf("failed to find existing invalid files: %w", err) - } - - // Find all existing campaign orchestrator files (.campaign.g.md) - existingCampaignOrchestratorFiles, err = filepath.Glob(filepath.Join(workflowsDir, "*.campaign.g.md")) - if err != nil { - return nil, fmt.Errorf("failed to find existing campaign orchestrator files: %w", err) - } - - // Find all existing campaign orchestrator lock files (.campaign.g.lock.yml) - existingCampaignOrchestratorLockFiles, err = filepath.Glob(filepath.Join(workflowsDir, "*.campaign.g.lock.yml")) - if err != nil { - return nil, fmt.Errorf("failed to find existing campaign orchestrator lock files: %w", err) - } - - // Create expected lock files list based on markdown files - for _, mdFile := range mdFiles { - lockFile := strings.TrimSuffix(mdFile, ".md") + ".lock.yml" - expectedLockFiles = append(expectedLockFiles, lockFile) - - // Track campaign definition files to identify orphaned orchestrators - if strings.HasSuffix(mdFile, ".campaign.md") { - expectedCampaignDefinitions = append(expectedCampaignDefinitions, mdFile) - } - } - - if verbose && len(existingLockFiles) > 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .lock.yml files", len(existingLockFiles)))) - } - if verbose && len(existingInvalidFiles) > 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .invalid.yml files", len(existingInvalidFiles)))) - } - if verbose && len(existingCampaignOrchestratorFiles) > 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .campaign.g.md files", len(existingCampaignOrchestratorFiles)))) - } - if verbose && len(existingCampaignOrchestratorLockFiles) > 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .campaign.g.lock.yml files", len(existingCampaignOrchestratorLockFiles)))) - } - } - - // Compile each file (including .campaign.md files) - var errorCount int - var successCount int - var lockFilesForActionlint []string // Collect lock files for batch actionlint run - for _, file := range mdFiles { - stats.Total++ - - // Initialize validation result for this workflow - result := ValidationResult{ - Workflow: filepath.Base(file), - Valid: true, - Errors: []ValidationError{}, - Warnings: []ValidationError{}, - } - - // Handle campaign spec files separately from regular workflows - if strings.HasSuffix(file, ".campaign.md") { - // Validate the campaign spec file and referenced workflows instead of - // compiling it as a regular workflow YAML. - spec, problems, vErr := campaign.ValidateSpecFromFile(file) - if vErr != nil { - if !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(fmt.Sprintf("failed to validate campaign spec %s: %v", file, vErr))) - } - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, file, 1) - - result.Valid = false - result.Errors = append(result.Errors, ValidationError{ - Type: "campaign_validation_error", - Message: vErr.Error(), - }) - validationResults = append(validationResults, result) - continue - } - - workflowsDir := filepath.Dir(file) - workflowProblems := campaign.ValidateWorkflowsExist(spec, workflowsDir) - problems = append(problems, workflowProblems...) - - if len(problems) > 0 { - for _, p := range problems { - if !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(p)) - } - result.Valid = false - result.Errors = append(result.Errors, ValidationError{ - Type: "campaign_validation_error", - Message: p, - }) - } - // Treat campaign spec problems as compilation errors for this file - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, file, len(problems)) - } else { - if verbose && !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Validated campaign spec %s", filepath.Base(file)))) - } - - if _, genErr := generateAndCompileCampaignOrchestrator( - compiler, - spec, - file, - verbose && !jsonOutput, - noEmit, - zizmor && !noEmit, - poutine && !noEmit, - actionlint && !noEmit, - strict, - validate && !noEmit, - ); genErr != nil { - if !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(genErr.Error())) - } - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, file, 1) - result.Valid = false - result.Errors = append(result.Errors, ValidationError{Type: "campaign_orchestrator_error", Message: genErr.Error()}) - } - } - - validationResults = append(validationResults, result) - continue - } - - lockFile := strings.TrimSuffix(file, ".md") + ".lock.yml" - if !noEmit { - result.CompiledFile = lockFile - } - - // Parse workflow file to get data - // Set workflow identifier for schedule scattering (use repository-relative path for stability) - relPath, err := getRepositoryRelativePath(file) - if err != nil { - compileOrchestratorLog.Printf("Warning: failed to get repository-relative path for %s: %v", file, err) - // Fallback to basename if we can't get relative path - relPath = filepath.Base(file) - } - compiler.SetWorkflowIdentifier(relPath) - - // Set repository slug for this specific file (may differ from CWD's repo) - fileRepoSlug := getRepositorySlugFromRemoteForPath(file) - if fileRepoSlug != "" { - compiler.SetRepositorySlug(fileRepoSlug) - compileOrchestratorLog.Printf("Repository slug for file set: %s", fileRepoSlug) - } - - workflowData, err := compiler.ParseWorkflowFile(file) - if err != nil { - if !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(fmt.Sprintf("failed to parse workflow file %s: %v", file, err))) - } - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, file, 1) - - // Add to validation results - result.Valid = false - result.Errors = append(result.Errors, ValidationError{ - Type: "parse_error", - Message: err.Error(), - }) - validationResults = append(validationResults, result) - continue - } - workflowDataList = append(workflowDataList, workflowData) - - // Disable per-file actionlint run (false instead of actionlint && !noEmit) - we'll batch them - if err := CompileWorkflowDataWithValidation(compiler, workflowData, file, verbose && !jsonOutput, zizmor && !noEmit, poutine && !noEmit, false, strict, validate && !noEmit); err != nil { - // Print the error to stderr (errors from CompileWorkflow are already formatted) - if !jsonOutput { - fmt.Fprintln(os.Stderr, err.Error()) - } - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, file, 1) - - // Add to validation results - result.Valid = false - result.Errors = append(result.Errors, ValidationError{ - Type: "compilation_error", - Message: err.Error(), - }) - validationResults = append(validationResults, result) - continue - } - successCount++ - - // Collect lock file for batch actionlint run - if actionlint && !noEmit { - lockFile := strings.TrimSuffix(file, ".md") + ".lock.yml" - if _, err := os.Stat(lockFile); err == nil { - lockFilesForActionlint = append(lockFilesForActionlint, lockFile) - } - } - - // Add successful validation result - validationResults = append(validationResults, result) - } - - // Run actionlint on all lock files in batch for better performance - if actionlint && !noEmit && len(lockFilesForActionlint) > 0 { - compileOrchestratorLog.Printf("Running batch actionlint on %d lock files", len(lockFilesForActionlint)) - if err := RunActionlintOnFiles(lockFilesForActionlint, verbose && !jsonOutput, strict); err != nil { - if strict { - return workflowDataList, fmt.Errorf("actionlint linter failed: %w", err) - } - // In non-strict mode, actionlint errors are warnings - if verbose && !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("actionlint warnings: %v", err))) - } - } - } - - // Get warning count from compiler - stats.Warnings = compiler.GetWarningCount() - - // Display any schedule warnings from this compiler instance - scheduleWarnings := compiler.GetScheduleWarnings() - if len(scheduleWarnings) > 0 && !jsonOutput { - for _, warning := range scheduleWarnings { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warning)) - } - } - - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully compiled %d out of %d workflow files", successCount, len(mdFiles)))) - } - - // Handle purge logic: delete orphaned .lock.yml files - if purge && len(existingLockFiles) > 0 { - // Find lock files that should be deleted (exist but aren't expected) - expectedLockFileSet := make(map[string]bool) - for _, expected := range expectedLockFiles { - expectedLockFileSet[expected] = true - } - - var orphanedFiles []string - for _, existing := range existingLockFiles { - if !expectedLockFileSet[existing] { - orphanedFiles = append(orphanedFiles, existing) - } - } - - // Delete orphaned lock files - if len(orphanedFiles) > 0 { - for _, orphanedFile := range orphanedFiles { - if err := os.Remove(orphanedFile); err != nil { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove orphaned lock file %s: %v", filepath.Base(orphanedFile), err))) - } else { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Removed orphaned lock file: %s", filepath.Base(orphanedFile)))) - } - } - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d orphaned .lock.yml files", len(orphanedFiles)))) - } - } else if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No orphaned .lock.yml files found to purge")) - } - } - - // Handle purge logic: delete all .invalid.yml files (these should always be cleaned up) - if purge && len(existingInvalidFiles) > 0 { - // Delete all .invalid.yml files since these are temporary debugging artifacts - // that should not persist after compilation - for _, invalidFile := range existingInvalidFiles { - if err := os.Remove(invalidFile); err != nil { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove invalid file %s: %v", filepath.Base(invalidFile), err))) - } else { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Removed invalid file: %s", filepath.Base(invalidFile)))) - } - } - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d .invalid.yml files", len(existingInvalidFiles)))) - } - } - - // Handle purge logic: delete orphaned campaign orchestrator files (.campaign.g.md) - // These are generated from .campaign.md source files, so we should remove them - // if their source no longer exists. - if purge && len(existingCampaignOrchestratorFiles) > 0 { - // Build a set of expected campaign definition files - expectedCampaignSet := make(map[string]bool) - for _, campaignDef := range expectedCampaignDefinitions { - expectedCampaignSet[campaignDef] = true - } - - var orphanedOrchestrators []string - for _, orchestratorFile := range existingCampaignOrchestratorFiles { - // Derive the expected source campaign definition file name - // e.g., "example.campaign.g.md" -> "example.campaign.md" - baseName := filepath.Base(orchestratorFile) - sourceName := strings.TrimSuffix(baseName, ".g.md") + ".md" - sourcePath := filepath.Join(workflowsDir, sourceName) - - // Check if the source campaign definition exists - if !expectedCampaignSet[sourcePath] { - orphanedOrchestrators = append(orphanedOrchestrators, orchestratorFile) - } - } - - // Delete orphaned campaign orchestrator files - if len(orphanedOrchestrators) > 0 { - for _, orphanedFile := range orphanedOrchestrators { - if err := os.Remove(orphanedFile); err != nil { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove orphaned campaign orchestrator %s: %v", filepath.Base(orphanedFile), err))) - } else { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Removed orphaned campaign orchestrator: %s", filepath.Base(orphanedFile)))) - } - } - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d orphaned .campaign.g.md files", len(orphanedOrchestrators)))) - } - } else if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No orphaned .campaign.g.md files found to purge")) - } - } - - // Handle purge logic: delete orphaned campaign orchestrator lock files (.campaign.g.lock.yml) - // These are compiled from .campaign.g.md files, which are generated from .campaign.md source files. - // We should remove them if their source .campaign.md no longer exists. - if purge && len(existingCampaignOrchestratorLockFiles) > 0 { - // Build a set of expected campaign definition files - expectedCampaignSet := make(map[string]bool) - for _, campaignDef := range expectedCampaignDefinitions { - expectedCampaignSet[campaignDef] = true - } - - var orphanedLockFiles []string - for _, lockFile := range existingCampaignOrchestratorLockFiles { - // Derive the expected source campaign definition file name - // e.g., "example.campaign.g.lock.yml" -> "example.campaign.md" - baseName := filepath.Base(lockFile) - sourceName := strings.TrimSuffix(strings.TrimSuffix(baseName, ".g.lock.yml"), ".campaign") + ".campaign.md" - sourcePath := filepath.Join(workflowsDir, sourceName) - - // Check if the source campaign definition exists - if !expectedCampaignSet[sourcePath] { - orphanedLockFiles = append(orphanedLockFiles, lockFile) - } - } - - // Delete orphaned campaign orchestrator lock files - if len(orphanedLockFiles) > 0 { - for _, orphanedFile := range orphanedLockFiles { - if err := os.Remove(orphanedFile); err != nil { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove orphaned campaign orchestrator lock file %s: %v", filepath.Base(orphanedFile), err))) - } else { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Removed orphaned campaign orchestrator lock file: %s", filepath.Base(orphanedFile)))) - } - } - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d orphaned .campaign.g.lock.yml files", len(orphanedLockFiles)))) - } - } else if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No orphaned .campaign.g.lock.yml files found to purge")) - } - } - - // Get the action cache once for use in multiple places - actionCache := compiler.GetSharedActionCache() - hasActionCacheEntries := actionCache != nil && len(actionCache.Entries) > 0 - - // Ensure .gitattributes marks .lock.yml files as generated - // Only update if we successfully compiled workflows or have action cache entries - if successCount > 0 || hasActionCacheEntries { - compileOrchestratorLog.Printf("Updating .gitattributes (compiled=%d, actionCache=%v)", successCount, hasActionCacheEntries) - if err := ensureGitAttributes(); err != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update .gitattributes: %v", err))) - } - } else if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Updated .gitattributes to mark .lock.yml files as generated")) - } - } else { - compileOrchestratorLog.Print("Skipping .gitattributes update (no compiled workflows and no action cache entries)") - } - - // Generate Dependabot manifests if requested - if dependabot && !noEmit { - // Use absolute path for workflow directory - absWorkflowDir := workflowsDir - if !filepath.IsAbs(absWorkflowDir) { - absWorkflowDir = filepath.Join(gitRoot, workflowDir) - } - - if err := compiler.GenerateDependabotManifests(workflowDataList, absWorkflowDir, forceOverwrite); err != nil { - if strict { - return workflowDataList, fmt.Errorf("failed to generate Dependabot manifests: %w", err) - } - // Non-strict mode: just report as warning - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate Dependabot manifests: %v", err))) - } - } - - // Generate maintenance workflow if any workflow uses expires field - if !noEmit { - absWorkflowDir := workflowsDir - if !filepath.IsAbs(absWorkflowDir) { - absWorkflowDir = filepath.Join(gitRoot, workflowDir) - } - - if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, absWorkflowDir, compiler.GetVersion(), compiler.GetActionMode(), verbose); err != nil { - if strict { - return workflowDataList, fmt.Errorf("failed to generate maintenance workflow: %w", err) - } - // Non-strict mode: just report as warning - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate maintenance workflow: %v", err))) - } - } - - // Note: Instructions are only written by the init command - // The compile command should not write instruction files - - // Validate campaign specs if they exist - if err := validateCampaigns(workflowDir, verbose); err != nil { - if strict { - return workflowDataList, fmt.Errorf("campaign validation failed: %w", err) - } - // Non-strict mode: just report as warning - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Campaign validation: %v", err))) - } - - // Collect and display workflow statistics if requested - if config.Stats && !noEmit && !jsonOutput { - var statsList []*WorkflowStats - for _, file := range mdFiles { - lockFile := strings.TrimSuffix(file, ".md") + ".lock.yml" - if workflowStats, err := collectWorkflowStats(lockFile); err == nil { - statsList = append(statsList, workflowStats) - } - } - displayStatsTable(statsList) - } - - // Output JSON if requested - if jsonOutput { - // Sanitize validation results before JSON marshaling to prevent logging of sensitive information - // This removes potential secret key names from error messages at the output boundary - sanitizedResults := sanitizeValidationResults(validationResults) - jsonBytes, err := json.MarshalIndent(sanitizedResults, "", " ") - if err != nil { - return workflowDataList, fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(jsonBytes)) - } else if !config.Stats { - // Print summary for text output (skip if stats mode) - printCompilationSummary(stats) - } - - // Display actionlint summary if actionlint was enabled and we're not in JSON output mode - if actionlint && !noEmit && !jsonOutput { - displayActionlintSummary() - } - - // Save the action cache after all compilations - if actionCache != nil { - if err := actionCache.Save(); err != nil { - compileOrchestratorLog.Printf("Failed to save action cache: %v", err) - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save action cache: %v", err))) - } - } else { - compileOrchestratorLog.Print("Action cache saved successfully") - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Action cache saved to %s", actionCache.GetCachePath()))) - } - } - } - - // Return error if any compilations failed - if errorCount > 0 { - return workflowDataList, fmt.Errorf("compilation failed") + return compileSpecificFiles(compiler, config, stats, &validationResults) } - return workflowDataList, nil + // Compile all workflow files in directory + return compileAllFilesInDirectory(compiler, config, workflowDir, stats, &validationResults) } From adfb4f1bcf79477e12f60770320bfc962b232712 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 03:22:41 +0000 Subject: [PATCH 4/4] Fix linting issues found by golangci-lint - Fix errcheck: Add blank identifier for non-fatal errors in purge operations - Fix errcheck: Add blank identifier for non-fatal updateGitAttributes calls - Fix errcheck: Add blank identifier for non-fatal saveActionCache calls - Fix revive: Remove empty error handling blocks - Fix unused: Remove unused compile_config_validator.go file - Fix unused: Remove unused collectWorkflowStatisticsFromDir function - Fix unused: Remove unused collectLockFilesForLinting function All linting issues resolved. All tests pass. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/compile_batch_operations.go | 64 +++++++++++----------- pkg/cli/compile_compiler_setup.go | 28 +++++----- pkg/cli/compile_config_validator.go | 77 --------------------------- pkg/cli/compile_orchestration.go | 41 +++++++------- pkg/cli/compile_orchestrator.go | 4 +- pkg/cli/compile_output_formatter.go | 6 +-- pkg/cli/compile_post_processing.go | 44 +++++---------- pkg/cli/compile_workflow_processor.go | 56 +++++++------------ 8 files changed, 103 insertions(+), 217 deletions(-) delete mode 100644 pkg/cli/compile_config_validator.go diff --git a/pkg/cli/compile_batch_operations.go b/pkg/cli/compile_batch_operations.go index bbcb5a24d7..dbcc10114f 100644 --- a/pkg/cli/compile_batch_operations.go +++ b/pkg/cli/compile_batch_operations.go @@ -44,9 +44,9 @@ func runBatchActionlint(lockFiles []string, verbose bool, strict bool) error { compileBatchOperationsLog.Print("No lock files to lint with actionlint") return nil } - + compileBatchOperationsLog.Printf("Running batch actionlint on %d lock files", len(lockFiles)) - + if err := RunActionlintOnFiles(lockFiles, verbose, strict); err != nil { if strict { return fmt.Errorf("actionlint linter failed: %w", err) @@ -56,7 +56,7 @@ func runBatchActionlint(lockFiles []string, verbose bool, strict bool) error { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("actionlint warnings: %v", err))) } } - + return nil } @@ -64,28 +64,28 @@ func runBatchActionlint(lockFiles []string, verbose bool, strict bool) error { // These are lock files that exist but don't have a corresponding .md file func purgeOrphanedLockFiles(workflowsDir string, expectedLockFiles []string, verbose bool) error { compileBatchOperationsLog.Printf("Purging orphaned lock files in %s", workflowsDir) - + // Find all existing .lock.yml files existingLockFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.lock.yml")) if err != nil { return fmt.Errorf("failed to find existing lock files: %w", err) } - + if len(existingLockFiles) == 0 { compileBatchOperationsLog.Print("No lock files found") return nil } - + if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .lock.yml files", len(existingLockFiles)))) } - + // Build a set of expected lock files expectedLockFileSet := make(map[string]bool) for _, expected := range expectedLockFiles { expectedLockFileSet[expected] = true } - + // Find lock files that should be deleted (exist but aren't expected) var orphanedFiles []string for _, existing := range existingLockFiles { @@ -93,7 +93,7 @@ func purgeOrphanedLockFiles(workflowsDir string, expectedLockFiles []string, ver orphanedFiles = append(orphanedFiles, existing) } } - + // Delete orphaned lock files if len(orphanedFiles) > 0 { for _, orphanedFile := range orphanedFiles { @@ -109,7 +109,7 @@ func purgeOrphanedLockFiles(workflowsDir string, expectedLockFiles []string, ver } else if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No orphaned .lock.yml files found to purge")) } - + compileBatchOperationsLog.Printf("Purged %d orphaned lock files", len(orphanedFiles)) return nil } @@ -118,22 +118,22 @@ func purgeOrphanedLockFiles(workflowsDir string, expectedLockFiles []string, ver // These are temporary debugging artifacts that should not persist func purgeInvalidFiles(workflowsDir string, verbose bool) error { compileBatchOperationsLog.Printf("Purging invalid files in %s", workflowsDir) - + // Find all existing .invalid.yml files existingInvalidFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.invalid.yml")) if err != nil { return fmt.Errorf("failed to find existing invalid files: %w", err) } - + if len(existingInvalidFiles) == 0 { compileBatchOperationsLog.Print("No invalid files found") return nil } - + if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .invalid.yml files", len(existingInvalidFiles)))) } - + // Delete all .invalid.yml files for _, invalidFile := range existingInvalidFiles { if err := os.Remove(invalidFile); err != nil { @@ -142,11 +142,11 @@ func purgeInvalidFiles(workflowsDir string, verbose bool) error { fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Removed invalid file: %s", filepath.Base(invalidFile)))) } } - + if verbose { fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d .invalid.yml files", len(existingInvalidFiles)))) } - + compileBatchOperationsLog.Printf("Purged %d invalid files", len(existingInvalidFiles)) return nil } @@ -155,28 +155,28 @@ func purgeInvalidFiles(workflowsDir string, verbose bool) error { // These are generated from .campaign.md source files, so remove them if source no longer exists func purgeOrphanedCampaignOrchestrators(workflowsDir string, expectedCampaignDefinitions []string, verbose bool) error { compileBatchOperationsLog.Printf("Purging orphaned campaign orchestrators in %s", workflowsDir) - + // Find all existing campaign orchestrator files (.campaign.g.md) existingCampaignOrchestratorFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.campaign.g.md")) if err != nil { return fmt.Errorf("failed to find existing campaign orchestrator files: %w", err) } - + if len(existingCampaignOrchestratorFiles) == 0 { compileBatchOperationsLog.Print("No campaign orchestrator files found") return nil } - + if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .campaign.g.md files", len(existingCampaignOrchestratorFiles)))) } - + // Build a set of expected campaign definition files expectedCampaignSet := make(map[string]bool) for _, campaignDef := range expectedCampaignDefinitions { expectedCampaignSet[campaignDef] = true } - + // Find orphaned orchestrator files var orphanedOrchestrators []string for _, orchestratorFile := range existingCampaignOrchestratorFiles { @@ -185,13 +185,13 @@ func purgeOrphanedCampaignOrchestrators(workflowsDir string, expectedCampaignDef baseName := filepath.Base(orchestratorFile) sourceName := strings.TrimSuffix(baseName, ".g.md") + ".md" sourcePath := filepath.Join(workflowsDir, sourceName) - + // Check if the source campaign definition exists if !expectedCampaignSet[sourcePath] { orphanedOrchestrators = append(orphanedOrchestrators, orchestratorFile) } } - + // Delete orphaned campaign orchestrator files if len(orphanedOrchestrators) > 0 { for _, orphanedFile := range orphanedOrchestrators { @@ -207,7 +207,7 @@ func purgeOrphanedCampaignOrchestrators(workflowsDir string, expectedCampaignDef } else if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No orphaned .campaign.g.md files found to purge")) } - + compileBatchOperationsLog.Printf("Purged %d orphaned campaign orchestrators", len(orphanedOrchestrators)) return nil } @@ -216,28 +216,28 @@ func purgeOrphanedCampaignOrchestrators(workflowsDir string, expectedCampaignDef // These are compiled from .campaign.g.md files, which are generated from .campaign.md source files func purgeOrphanedCampaignOrchestratorLockFiles(workflowsDir string, expectedCampaignDefinitions []string, verbose bool) error { compileBatchOperationsLog.Printf("Purging orphaned campaign orchestrator lock files in %s", workflowsDir) - + // Find all existing campaign orchestrator lock files (.campaign.g.lock.yml) existingCampaignOrchestratorLockFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.campaign.g.lock.yml")) if err != nil { return fmt.Errorf("failed to find existing campaign orchestrator lock files: %w", err) } - + if len(existingCampaignOrchestratorLockFiles) == 0 { compileBatchOperationsLog.Print("No campaign orchestrator lock files found") return nil } - + if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .campaign.g.lock.yml files", len(existingCampaignOrchestratorLockFiles)))) } - + // Build a set of expected campaign definition files expectedCampaignSet := make(map[string]bool) for _, campaignDef := range expectedCampaignDefinitions { expectedCampaignSet[campaignDef] = true } - + // Find orphaned lock files var orphanedLockFiles []string for _, lockFile := range existingCampaignOrchestratorLockFiles { @@ -246,13 +246,13 @@ func purgeOrphanedCampaignOrchestratorLockFiles(workflowsDir string, expectedCam baseName := filepath.Base(lockFile) sourceName := strings.TrimSuffix(strings.TrimSuffix(baseName, ".g.lock.yml"), ".campaign") + ".campaign.md" sourcePath := filepath.Join(workflowsDir, sourceName) - + // Check if the source campaign definition exists if !expectedCampaignSet[sourcePath] { orphanedLockFiles = append(orphanedLockFiles, lockFile) } } - + // Delete orphaned campaign orchestrator lock files if len(orphanedLockFiles) > 0 { for _, orphanedFile := range orphanedLockFiles { @@ -268,7 +268,7 @@ func purgeOrphanedCampaignOrchestratorLockFiles(workflowsDir string, expectedCam } else if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No orphaned .campaign.g.lock.yml files found to purge")) } - + compileBatchOperationsLog.Printf("Purged %d orphaned campaign orchestrator lock files", len(orphanedLockFiles)) return nil } diff --git a/pkg/cli/compile_compiler_setup.go b/pkg/cli/compile_compiler_setup.go index a9d1dd2ecc..c203d3b098 100644 --- a/pkg/cli/compile_compiler_setup.go +++ b/pkg/cli/compile_compiler_setup.go @@ -40,40 +40,40 @@ var compileCompilerSetupLog = logger.New("cli:compile_compiler_setup") func createAndConfigureCompiler(config CompileConfig) *workflow.Compiler { compileCompilerSetupLog.Printf("Creating compiler with config: verbose=%v, validate=%v, strict=%v, trialMode=%v", config.Verbose, config.Validate, config.Strict, config.TrialMode) - + // Create compiler with verbose flag and AI engine override compiler := workflow.NewCompiler(config.Verbose, config.EngineOverride, GetVersion()) compileCompilerSetupLog.Print("Created compiler instance") - + // Configure compiler flags configureCompilerFlags(compiler, config) - + // Set up action mode setupActionMode(compiler, config.ActionMode) - + // Set up repository context setupRepositoryContext(compiler) - + return compiler } // configureCompilerFlags sets various compilation flags on the compiler func configureCompilerFlags(compiler *workflow.Compiler, config CompileConfig) { compileCompilerSetupLog.Print("Configuring compiler flags") - + // Set validation based on the validate flag (false by default for compatibility) compiler.SetSkipValidation(!config.Validate) compileCompilerSetupLog.Printf("Validation enabled: %v", config.Validate) - + // Set noEmit flag to validate without generating lock files compiler.SetNoEmit(config.NoEmit) if config.NoEmit { compileCompilerSetupLog.Print("No-emit mode enabled: validating without generating lock files") } - + // Set strict mode if specified compiler.SetStrictMode(config.Strict) - + // Set trial mode if specified if config.TrialMode { compileCompilerSetupLog.Printf("Enabling trial mode: repoSlug=%s", config.TrialLogicalRepoSlug) @@ -82,7 +82,7 @@ func configureCompilerFlags(compiler *workflow.Compiler, config CompileConfig) { compiler.SetTrialLogicalRepoSlug(config.TrialLogicalRepoSlug) } } - + // Set refresh stop time flag compiler.SetRefreshStopTime(config.RefreshStopTime) if config.RefreshStopTime { @@ -93,7 +93,7 @@ func configureCompilerFlags(compiler *workflow.Compiler, config CompileConfig) { // setupActionMode configures the action script inlining mode func setupActionMode(compiler *workflow.Compiler, actionMode string) { compileCompilerSetupLog.Printf("Setting up action mode: %s", actionMode) - + if actionMode != "" { mode := workflow.ActionMode(actionMode) if !mode.IsValid() { @@ -114,7 +114,7 @@ func setupActionMode(compiler *workflow.Compiler, actionMode string) { // setupRepositoryContext sets the repository slug for schedule scattering func setupRepositoryContext(compiler *workflow.Compiler) { compileCompilerSetupLog.Print("Setting up repository context") - + // Set repository slug for schedule scattering repoSlug := getRepositorySlugFromRemote() if repoSlug != "" { @@ -130,11 +130,11 @@ func validateActionModeConfig(actionMode string) error { if actionMode == "" { return nil } - + mode := workflow.ActionMode(actionMode) if !mode.IsValid() { return fmt.Errorf("invalid action mode '%s'. Must be 'inline', 'dev', or 'release'", actionMode) } - + return nil } diff --git a/pkg/cli/compile_config_validator.go b/pkg/cli/compile_config_validator.go deleted file mode 100644 index e61d95267f..0000000000 --- a/pkg/cli/compile_config_validator.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package cli provides configuration validation for workflow compilation. -// -// This file contains functions that validate compilation configuration before -// the compilation process begins, ensuring that all flags and parameters are -// valid and compatible. -// -// # Organization Rationale -// -// These configuration validation functions are grouped here because they: -// - Validate pre-compilation configuration -// - Are independent of compilation logic -// - Have a clear domain focus (configuration validation) -// - Enable early error detection before expensive operations -// -// # Key Functions -// -// Configuration Validation: -// - validateWorkflowDirectory() - Validates workflow directory path -// - setupWorkflowDirectory() - Sets up and validates workflow directory -// -// These functions abstract configuration validation, allowing the main compile -// orchestrator to focus on coordination while these handle validation logic. -package cli - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/githubnext/gh-aw/pkg/logger" -) - -var compileConfigValidatorLog = logger.New("cli:compile_config_validator") - -// validateWorkflowDirectory validates that the workflow directory exists -func validateWorkflowDirectory(workflowDir string) error { - compileConfigValidatorLog.Printf("Validating workflow directory: %s", workflowDir) - - if _, err := os.Stat(workflowDir); os.IsNotExist(err) { - // Get git root for better error message - gitRoot, gitErr := findGitRoot() - if gitErr != nil { - return fmt.Errorf("workflow directory %s does not exist", workflowDir) - } - return fmt.Errorf("the %s directory does not exist in git root (%s)", filepath.Base(workflowDir), gitRoot) - } - - compileConfigValidatorLog.Print("Workflow directory exists") - return nil -} - -// setupWorkflowDirectory sets up the workflow directory path, using defaults if needed -// Returns the absolute path to the workflows directory -func setupWorkflowDirectory(workflowDir string, gitRoot string) (string, error) { - compileConfigValidatorLog.Printf("Setting up workflow directory: dir=%s, gitRoot=%s", workflowDir, gitRoot) - - // Use default if not specified - if workflowDir == "" { - workflowDir = ".github/workflows" - compileConfigValidatorLog.Printf("Using default workflow directory: %s", workflowDir) - } else { - // Clean the path to avoid issues with ".." or other problematic elements - workflowDir = filepath.Clean(workflowDir) - compileConfigValidatorLog.Printf("Using custom workflow directory: %s", workflowDir) - } - - // Build absolute path - absWorkflowDir := filepath.Join(gitRoot, workflowDir) - - // Validate it exists - if err := validateWorkflowDirectory(absWorkflowDir); err != nil { - return "", err - } - - compileConfigValidatorLog.Printf("Workflow directory setup complete: %s", absWorkflowDir) - return absWorkflowDir, nil -} diff --git a/pkg/cli/compile_orchestration.go b/pkg/cli/compile_orchestration.go index 970d63f114..43d7f1d549 100644 --- a/pkg/cli/compile_orchestration.go +++ b/pkg/cli/compile_orchestration.go @@ -307,12 +307,12 @@ func compileAllFilesInDirectory( // purgeTrackingData holds data needed for purge operations type purgeTrackingData struct { - existingLockFiles []string - existingInvalidFiles []string - existingCampaignOrchestratorFiles []string - existingCampaignOrchestratorLockFiles []string - expectedLockFiles []string - expectedCampaignDefinitions []string + existingLockFiles []string + existingInvalidFiles []string + existingCampaignOrchestratorFiles []string + existingCampaignOrchestratorLockFiles []string + expectedLockFiles []string + expectedCampaignDefinitions []string } // collectPurgeData collects existing files for purge operations @@ -349,10 +349,11 @@ func collectPurgeData(workflowsDir string, mdFiles []string, verbose bool) *purg // runPurgeOperations runs all purge operations func runPurgeOperations(workflowsDir string, data *purgeTrackingData, verbose bool) { - purgeOrphanedLockFiles(workflowsDir, data.expectedLockFiles, verbose) - purgeInvalidFiles(workflowsDir, verbose) - purgeOrphanedCampaignOrchestrators(workflowsDir, data.expectedCampaignDefinitions, verbose) - purgeOrphanedCampaignOrchestratorLockFiles(workflowsDir, data.expectedCampaignDefinitions, verbose) + // Errors from purge operations are logged but don't stop compilation + _ = purgeOrphanedLockFiles(workflowsDir, data.expectedLockFiles, verbose) + _ = purgeInvalidFiles(workflowsDir, verbose) + _ = purgeOrphanedCampaignOrchestrators(workflowsDir, data.expectedCampaignDefinitions, verbose) + _ = purgeOrphanedCampaignOrchestratorLockFiles(workflowsDir, data.expectedCampaignDefinitions, verbose) } // displayScheduleWarnings displays any schedule warnings from the compiler @@ -375,10 +376,8 @@ func runPostProcessing( // Get action cache actionCache := compiler.GetSharedActionCache() - // Update .gitattributes - if err := updateGitAttributes(successCount, actionCache, config.Verbose); err != nil { - // Non-fatal, continue - } + // Update .gitattributes (errors are non-fatal) + _ = updateGitAttributes(successCount, actionCache, config.Verbose) // Generate Dependabot manifests if requested if config.Dependabot && !config.NoEmit { @@ -400,8 +399,8 @@ func runPostProcessing( } } - // Save action cache - saveActionCache(actionCache, config.Verbose) + // Save action cache (errors are logged but non-fatal) + _ = saveActionCache(actionCache, config.Verbose) return nil } @@ -418,10 +417,8 @@ func runPostProcessingForDirectory( // Get action cache actionCache := compiler.GetSharedActionCache() - // Update .gitattributes - if err := updateGitAttributes(successCount, actionCache, config.Verbose); err != nil { - // Non-fatal, continue - } + // Update .gitattributes (errors are non-fatal) + _ = updateGitAttributes(successCount, actionCache, config.Verbose) // Generate Dependabot manifests if requested if config.Dependabot && !config.NoEmit { @@ -450,8 +447,8 @@ func runPostProcessingForDirectory( } } - // Save action cache - saveActionCache(actionCache, config.Verbose) + // Save action cache (errors are logged but non-fatal) + _ = saveActionCache(actionCache, config.Verbose) return nil } diff --git a/pkg/cli/compile_orchestrator.go b/pkg/cli/compile_orchestrator.go index 01c2b1d52b..58f27bff12 100644 --- a/pkg/cli/compile_orchestrator.go +++ b/pkg/cli/compile_orchestrator.go @@ -195,14 +195,14 @@ func generateAndCompileCampaignOrchestrator( // CompileWorkflows compiles workflows based on the provided configuration func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) { - compileOrchestratorLog.Printf("Starting workflow compilation: files=%d, validate=%v, watch=%v, noEmit=%v", + compileOrchestratorLog.Printf("Starting workflow compilation: files=%d, validate=%v, watch=%v, noEmit=%v", len(config.MarkdownFiles), config.Validate, config.Watch, config.NoEmit) // Validate configuration if err := validateCompileConfig(config); err != nil { return nil, err } - + // Validate action mode if specified if err := validateActionModeConfig(config.ActionMode); err != nil { return nil, err diff --git a/pkg/cli/compile_output_formatter.go b/pkg/cli/compile_output_formatter.go index 7833cadd65..15559914db 100644 --- a/pkg/cli/compile_output_formatter.go +++ b/pkg/cli/compile_output_formatter.go @@ -39,16 +39,16 @@ func formatCompilationSummary(stats *CompilationStats) { // formatValidationOutput formats validation results as JSON func formatValidationOutput(results []ValidationResult) (string, error) { compileOutputFormatterLog.Printf("Formatting validation output for %d workflow(s)", len(results)) - + // Sanitize validation results before JSON marshaling to prevent logging of sensitive information // This removes potential secret key names from error messages at the output boundary sanitizedResults := sanitizeValidationResults(results) - + jsonBytes, err := json.MarshalIndent(sanitizedResults, "", " ") if err != nil { return "", fmt.Errorf("failed to marshal JSON: %w", err) } - + return string(jsonBytes), nil } diff --git a/pkg/cli/compile_post_processing.go b/pkg/cli/compile_post_processing.go index 56f41a4caf..1ddda6b44a 100644 --- a/pkg/cli/compile_post_processing.go +++ b/pkg/cli/compile_post_processing.go @@ -49,7 +49,7 @@ func generateDependabotManifestsWrapper( strict bool, ) error { compilePostProcessingLog.Print("Generating Dependabot manifests for compiled workflows") - + if err := compiler.GenerateDependabotManifests(workflowDataList, workflowsDir, forceOverwrite); err != nil { if strict { return fmt.Errorf("failed to generate Dependabot manifests: %w", err) @@ -57,7 +57,7 @@ func generateDependabotManifestsWrapper( // Non-strict mode: just report as warning fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate Dependabot manifests: %v", err))) } - + return nil } @@ -70,7 +70,7 @@ func generateMaintenanceWorkflowWrapper( strict bool, ) error { compilePostProcessingLog.Print("Generating maintenance workflow") - + if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, workflowsDir, compiler.GetVersion(), compiler.GetActionMode(), verbose); err != nil { if strict { return fmt.Errorf("failed to generate maintenance workflow: %w", err) @@ -78,14 +78,14 @@ func generateMaintenanceWorkflowWrapper( // Non-strict mode: just report as warning fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate maintenance workflow: %v", err))) } - + return nil } // validateCampaignsWrapper validates campaign specs if they exist func validateCampaignsWrapper(workflowDir string, verbose bool, strict bool) error { compilePostProcessingLog.Print("Validating campaign specs") - + if err := validateCampaigns(workflowDir, verbose); err != nil { if strict { return fmt.Errorf("campaign validation failed: %w", err) @@ -93,14 +93,14 @@ func validateCampaignsWrapper(workflowDir string, verbose bool, strict bool) err // Non-strict mode: just report as warning fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Campaign validation: %v", err))) } - + return nil } // collectWorkflowStatisticsWrapper collects and returns workflow statistics func collectWorkflowStatisticsWrapper(markdownFiles []string) []*WorkflowStats { compilePostProcessingLog.Printf("Collecting workflow statistics for %d files", len(markdownFiles)) - + var statsList []*WorkflowStats for _, file := range markdownFiles { resolvedFile, err := resolveWorkflowFile(file, false) @@ -112,23 +112,7 @@ func collectWorkflowStatisticsWrapper(markdownFiles []string) []*WorkflowStats { statsList = append(statsList, workflowStats) } } - - compilePostProcessingLog.Printf("Collected statistics for %d workflows", len(statsList)) - return statsList -} -// collectWorkflowStatisticsFromDir collects workflow statistics from all files in a directory -func collectWorkflowStatisticsFromDir(mdFiles []string) []*WorkflowStats { - compilePostProcessingLog.Printf("Collecting workflow statistics for %d files", len(mdFiles)) - - var statsList []*WorkflowStats - for _, file := range mdFiles { - lockFile := strings.TrimSuffix(file, ".md") + ".lock.yml" - if workflowStats, err := collectWorkflowStats(lockFile); err == nil { - statsList = append(statsList, workflowStats) - } - } - compilePostProcessingLog.Printf("Collected statistics for %d workflows", len(statsList)) return statsList } @@ -136,9 +120,9 @@ func collectWorkflowStatisticsFromDir(mdFiles []string) []*WorkflowStats { // updateGitAttributes ensures .gitattributes marks .lock.yml files as generated func updateGitAttributes(successCount int, actionCache *workflow.ActionCache, verbose bool) error { compilePostProcessingLog.Printf("Updating .gitattributes (compiled=%d, actionCache=%v)", successCount, actionCache != nil) - + hasActionCacheEntries := actionCache != nil && len(actionCache.Entries) > 0 - + // Only update if we successfully compiled workflows or have action cache entries if successCount > 0 || hasActionCacheEntries { compilePostProcessingLog.Printf("Updating .gitattributes (compiled=%d, actionCache=%v)", successCount, hasActionCacheEntries) @@ -156,7 +140,7 @@ func updateGitAttributes(successCount int, actionCache *workflow.ActionCache, ve } else { compilePostProcessingLog.Print("Skipping .gitattributes update (no compiled workflows and no action cache entries)") } - + return nil } @@ -165,9 +149,9 @@ func saveActionCache(actionCache *workflow.ActionCache, verbose bool) error { if actionCache == nil { return nil } - + compilePostProcessingLog.Print("Saving action cache") - + if err := actionCache.Save(); err != nil { compilePostProcessingLog.Printf("Failed to save action cache: %v", err) if verbose { @@ -175,12 +159,12 @@ func saveActionCache(actionCache *workflow.ActionCache, verbose bool) error { } return err } - + compilePostProcessingLog.Print("Action cache saved successfully") if verbose { fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Action cache saved to %s", actionCache.GetCachePath()))) } - + return nil } diff --git a/pkg/cli/compile_workflow_processor.go b/pkg/cli/compile_workflow_processor.go index 199ed93900..44395cc98f 100644 --- a/pkg/cli/compile_workflow_processor.go +++ b/pkg/cli/compile_workflow_processor.go @@ -38,10 +38,10 @@ var compileWorkflowProcessorLog = logger.New("cli:compile_workflow_processor") // compileWorkflowFileResult represents the result of compiling a single workflow file type compileWorkflowFileResult struct { - workflowData *workflow.WorkflowData - lockFile string + workflowData *workflow.WorkflowData + lockFile string validationResult ValidationResult - success bool + success bool } // compileWorkflowFile compiles a single workflow file (not a campaign spec) @@ -59,7 +59,7 @@ func compileWorkflowFile( validate bool, ) compileWorkflowFileResult { compileWorkflowProcessorLog.Printf("Processing workflow file: %s", resolvedFile) - + result := compileWorkflowFileResult{ validationResult: ValidationResult{ Workflow: filepath.Base(resolvedFile), @@ -69,16 +69,16 @@ func compileWorkflowFile( }, success: false, } - + lockFile := strings.TrimSuffix(resolvedFile, ".md") + ".lock.yml" result.lockFile = lockFile if !noEmit { result.validationResult.CompiledFile = lockFile } - + // Parse workflow file to get data compileWorkflowProcessorLog.Printf("Parsing workflow file: %s", resolvedFile) - + // Set workflow identifier for schedule scattering (use repository-relative path for stability) relPath, err := getRepositoryRelativePath(resolvedFile) if err != nil { @@ -87,14 +87,14 @@ func compileWorkflowFile( relPath = filepath.Base(resolvedFile) } compiler.SetWorkflowIdentifier(relPath) - + // Set repository slug for this specific file (may differ from CWD's repo) fileRepoSlug := getRepositorySlugFromRemoteForPath(resolvedFile) if fileRepoSlug != "" { compiler.SetRepositorySlug(fileRepoSlug) compileWorkflowProcessorLog.Printf("Repository slug for file set: %s", fileRepoSlug) } - + // Parse the workflow workflowData, err := compiler.ParseWorkflowFile(resolvedFile) if err != nil { @@ -110,9 +110,9 @@ func compileWorkflowFile( return result } result.workflowData = workflowData - + compileWorkflowProcessorLog.Printf("Starting compilation of %s", resolvedFile) - + // Compile the workflow // Disable per-file actionlint run (false instead of actionlint && !noEmit) - we'll batch them if err := CompileWorkflowDataWithValidation(compiler, workflowData, resolvedFile, verbose && !jsonOutput, zizmor && !noEmit, poutine && !noEmit, false, strict, validate && !noEmit); err != nil { @@ -127,7 +127,7 @@ func compileWorkflowFile( }) return result } - + result.success = true compileWorkflowProcessorLog.Printf("Successfully processed workflow file: %s", resolvedFile) return result @@ -148,14 +148,14 @@ func processCampaignSpec( validate bool, ) (ValidationResult, bool) { compileWorkflowProcessorLog.Printf("Processing campaign spec file: %s", resolvedFile) - + result := ValidationResult{ Workflow: filepath.Base(resolvedFile), Valid: true, Errors: []ValidationError{}, Warnings: []ValidationError{}, } - + // Validate the campaign spec file and referenced workflows spec, problems, vErr := campaign.ValidateSpecFromFile(resolvedFile) if vErr != nil { @@ -170,12 +170,12 @@ func processCampaignSpec( }) return result, false } - + // Also ensure that workflows referenced by the campaign spec exist workflowsDir := filepath.Dir(resolvedFile) workflowProblems := campaign.ValidateWorkflowsExist(spec, workflowsDir) problems = append(problems, workflowProblems...) - + if len(problems) > 0 { for _, p := range problems { if !jsonOutput { @@ -189,11 +189,11 @@ func processCampaignSpec( } return result, false } - + if verbose && !jsonOutput { fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Validated campaign spec %s", filepath.Base(resolvedFile)))) } - + // Generate and compile the campaign orchestrator if _, genErr := generateAndCompileCampaignOrchestrator( compiler, @@ -215,25 +215,7 @@ func processCampaignSpec( result.Errors = append(result.Errors, ValidationError{Type: "campaign_orchestrator_error", Message: errMsg}) return result, false } - + compileWorkflowProcessorLog.Printf("Successfully processed campaign spec: %s", resolvedFile) return result, true } - -// collectLockFilesForLinting collects lock files that exist for batch linting -func collectLockFilesForLinting(resolvedFiles []string, noEmit bool) []string { - if noEmit { - return nil - } - - var lockFiles []string - for _, resolvedFile := range resolvedFiles { - lockFile := strings.TrimSuffix(resolvedFile, ".md") + ".lock.yml" - if _, err := os.Stat(lockFile); err == nil { - lockFiles = append(lockFiles, lockFile) - } - } - - compileWorkflowProcessorLog.Printf("Collected %d lock files for linting", len(lockFiles)) - return lockFiles -}