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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/patch-aggregate-validation-errors.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ Examples:
jsonOutput, _ := cmd.Flags().GetBool("json")
fix, _ := cmd.Flags().GetBool("fix")
stats, _ := cmd.Flags().GetBool("stats")
failFast, _ := cmd.Flags().GetBool("fail-fast")
noCheckUpdate, _ := cmd.Flags().GetBool("no-check-update")
verbose, _ := cmd.Flags().GetBool("verbose")
if err := validateEngine(engineOverride); err != nil {
Expand Down Expand Up @@ -272,6 +273,7 @@ Examples:
Actionlint: actionlint,
JSONOutput: jsonOutput,
Stats: stats,
FailFast: failFast,
}
if _, err := cli.CompileWorkflows(cmd.Context(), config); err != nil {
errMsg := err.Error()
Expand Down Expand Up @@ -502,6 +504,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all
compileCmd.Flags().Bool("fix", false, "Apply automatic codemod fixes to workflows before compiling")
compileCmd.Flags().BoolP("json", "j", false, "Output results in JSON format")
compileCmd.Flags().Bool("stats", false, "Display statistics table sorted by file size (shows jobs, steps, scripts, and shells)")
compileCmd.Flags().Bool("fail-fast", false, "Stop at the first validation error instead of collecting all errors")
compileCmd.Flags().Bool("no-check-update", false, "Skip checking for gh-aw updates")
compileCmd.MarkFlagsMutuallyExclusive("dir", "workflows-dir")

Expand Down
1 change: 1 addition & 0 deletions pkg/cli/compile_compiler_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ func createAndConfigureCompiler(config CompileConfig) *workflow.Compiler {
compiler := workflow.NewCompiler(
workflow.WithVerbose(config.Verbose),
workflow.WithEngineOverride(config.EngineOverride),
workflow.WithFailFast(config.FailFast),
)
compileCompilerSetupLog.Print("Created compiler instance")

Expand Down
1 change: 1 addition & 0 deletions pkg/cli/compile_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type CompileConfig struct {
ActionMode string // Action script inlining mode: inline, dev, or release
ActionTag string // Override action SHA or tag for actions/setup (overrides action-mode to release)
Stats bool // Display statistics table sorted by file size
FailFast bool // Stop at first error instead of collecting all errors
}

// WorkflowFailure represents a failed workflow with its error count
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,13 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath

// Validate safe-outputs allowed-domains configuration
log.Printf("Validating safe-outputs allowed-domains")
if err := validateSafeOutputsAllowedDomains(workflowData.SafeOutputs); err != nil {
if err := c.validateSafeOutputsAllowedDomains(workflowData.SafeOutputs); err != nil {
return formatCompilerError(markdownPath, "error", err.Error())
}

// Validate network allowed domains configuration
log.Printf("Validating network allowed domains")
if err := validateNetworkAllowedDomains(workflowData.NetworkPermissions); err != nil {
if err := c.validateNetworkAllowedDomains(workflowData.NetworkPermissions); err != nil {
return formatCompilerError(markdownPath, "error", err.Error())
}

Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ func WithStrictMode(strict bool) CompilerOption {
return func(c *Compiler) { c.strictMode = strict }
}

// WithFailFast configures whether to stop at first validation error
func WithFailFast(failFast bool) CompilerOption {
return func(c *Compiler) { c.failFast = failFast }
}

// WithForceRefreshActionPins configures whether to force refresh of action pins
func WithForceRefreshActionPins(force bool) CompilerOption {
return func(c *Compiler) { c.forceRefreshActionPins = force }
Expand Down Expand Up @@ -101,6 +106,7 @@ type Compiler struct {
trialLogicalRepoSlug string // If set in trial mode, the logical repository to checkout
refreshStopTime bool // If true, regenerate stop-after times instead of preserving existing ones
forceRefreshActionPins bool // If true, clear action cache and resolve all actions from GitHub API
failFast bool // If true, stop at first validation error instead of collecting all errors
actionCacheCleared bool // Tracks if action cache has already been cleared (for forceRefreshActionPins)
markdownPath string // Path to the markdown file being compiled (for context in dynamic tool generation)
actionMode ActionMode // Mode for generating JavaScript steps (inline vs custom actions)
Expand Down
195 changes: 195 additions & 0 deletions pkg/workflow/error_aggregation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// This file provides error aggregation utilities for validation.
//
// # Error Aggregation
//
// This file implements error collection and aggregation for validation
// functions, allowing users to see all validation errors in a single run
// instead of discovering them one at a time.
//
// # Error Aggregation Functions
//
// - NewErrorCollector() - Creates a new error collector
// - ErrorCollector.Add() - Adds an error to the collection
// - ErrorCollector.HasErrors() - Checks if any errors were collected
// - ErrorCollector.Error() - Returns aggregated error using errors.Join
// - ErrorCollector.Count() - Returns the number of collected errors
//
// # Usage Pattern
//
// Use error collectors in validation functions to collect multiple errors:
//
// func validateMultipleThings(config Config, failFast bool) error {
// collector := NewErrorCollector(failFast)
//
// if err := validateThing1(config); err != nil {
// if returnErr := collector.Add(err); returnErr != nil {
// return returnErr // Fail-fast mode
// }
// }
//
// if err := validateThing2(config); err != nil {
// if returnErr := collector.Add(err); returnErr != nil {
// return returnErr // Fail-fast mode
// }
// }
//
// return collector.Error()
// }
//
// # Fail-Fast Mode
//
// When failFast is true, the collector returns immediately on the first error.
// When false, it collects all errors and returns them joined with errors.Join.

package workflow

import (
"errors"
"fmt"
"strings"

"github.com/githubnext/gh-aw/pkg/logger"
)

var errorAggregationLog = logger.New("workflow:error_aggregation")

// ErrorCollector collects multiple validation errors
type ErrorCollector struct {
errors []error
failFast bool
}

// NewErrorCollector creates a new error collector
// If failFast is true, the collector will stop at the first error
func NewErrorCollector(failFast bool) *ErrorCollector {
errorAggregationLog.Printf("Creating error collector: fail_fast=%v", failFast)
return &ErrorCollector{
errors: make([]error, 0),
failFast: failFast,
}
}

// Add adds an error to the collector
// If failFast is enabled, returns the error immediately
// Otherwise, adds it to the collection and returns nil
func (c *ErrorCollector) Add(err error) error {
if err == nil {
return nil
}

errorAggregationLog.Printf("Adding error to collector: %v", err)

if c.failFast {
errorAggregationLog.Print("Fail-fast enabled, returning error immediately")
return err
}

c.errors = append(c.errors, err)
return nil
}

// HasErrors returns true if any errors have been collected
func (c *ErrorCollector) HasErrors() bool {
return len(c.errors) > 0
}

// Count returns the number of errors collected
func (c *ErrorCollector) Count() int {
return len(c.errors)
}

// Error returns the aggregated error using errors.Join
// Returns nil if no errors were collected
func (c *ErrorCollector) Error() error {
if len(c.errors) == 0 {
return nil
}

errorAggregationLog.Printf("Aggregating %d errors", len(c.errors))

if len(c.errors) == 1 {
return c.errors[0]
}

return errors.Join(c.errors...)
}

// FormattedError returns the aggregated error with a formatted header showing the count
// Returns nil if no errors were collected
// This method is preferred over Error() + FormatAggregatedError for better accuracy
func (c *ErrorCollector) FormattedError(category string) error {
if len(c.errors) == 0 {
return nil
}

errorAggregationLog.Printf("Formatting %d errors for category: %s", len(c.errors), category)

if len(c.errors) == 1 {
return c.errors[0]
}

// Build formatted error with count header
var sb strings.Builder
fmt.Fprintf(&sb, "Found %d %s errors:", len(c.errors), category)
for _, err := range c.errors {
sb.WriteString("\n • ")
sb.WriteString(err.Error())
}

return fmt.Errorf("%s", sb.String())
}

// FormatAggregatedError formats aggregated errors with a summary header
// Returns a formatted error with count and categorization if multiple errors exist
func FormatAggregatedError(err error, category string) error {
if err == nil {
return nil
}

// Check if this is a joined error by looking for newlines
errStr := err.Error()
lines := strings.Split(errStr, "\n")

if len(lines) <= 1 {
return err
}

// Format with count and category
header := fmt.Sprintf("Found %d %s errors:", len(lines), category)

// Reconstruct with header
var sb strings.Builder
sb.WriteString(header)
for _, line := range lines {
if line != "" {
sb.WriteString("\n • ")
sb.WriteString(line)
}
}

return fmt.Errorf("%s", sb.String())
}

// SplitJoinedErrors splits a joined error into individual error strings
func SplitJoinedErrors(err error) []error {
if err == nil {
return nil
}

// errors.Join formats errors separated by newlines
errStr := err.Error()
lines := strings.Split(errStr, "\n")

result := make([]error, 0, len(lines))
for _, line := range lines {
if line != "" {
result = append(result, fmt.Errorf("%s", line))
}
}

if len(result) == 0 {
return []error{err}
}

return result
}
Loading
Loading