diff --git a/docs/include-directives.md b/docs/include-directives.md index f0f43ae7..a0a154c0 100644 --- a/docs/include-directives.md +++ b/docs/include-directives.md @@ -10,6 +10,14 @@ Include directives allow you to modularize and reuse workflow components across Includes files relative to the current markdown file's location. +## Optional Include Syntax + +```markdown +@include? relative/path/to/file.md +``` + +Includes files optionally - if the file doesn't exist, no error occurs and a friendly informational comment is added to the workflow. The optional file will be watched for changes in `gh aw compile --watch` mode, so creating the file later will automatically include it. + ## Section-Specific Includes ```markdown @@ -80,8 +88,12 @@ When an issue is opened, analyze and respond appropriately. @include shared/common-tools.md +@include? perf-goals.md + ``` +The workflow above includes required shared files and optionally includes `perf-goals.md` if it exists. If `perf-goals.md` doesn't exist, the workflow will compile successfully with a friendly note that you can create the file to configure performance goals for the workflow. + ## Frontmatter Merging - **Only `tools:` frontmatter** is allowed in included files, other entries give a warning. diff --git a/go.mod b/go.mod index d7298cf4..63f81ddc 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/sourcegraph/conc v0.3.0 github.com/spf13/cobra v1.9.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -26,6 +27,7 @@ require ( github.com/fatih/color v1.7.0 // indirect github.com/henvic/httpretty v0.1.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect diff --git a/go.sum b/go.sum index 8bf62fe8..c8c4fcde 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,7 @@ github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5 github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,6 +39,10 @@ github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYm github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -58,6 +63,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= @@ -96,5 +103,7 @@ golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index dd126f8c..330914d0 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -603,6 +603,27 @@ func watchAndCompileWorkflows(markdownFile string, compiler *workflow.Compiler, return fmt.Errorf("failed to watch directory %s: %w", workflowsDir, err) } + // Also watch subdirectories for include files (recursive watching) + err = filepath.Walk(workflowsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors but continue walking + } + if info.IsDir() && path != workflowsDir { + // Add subdirectories to the watcher + if err := watcher.Add(path); err != nil { + if verbose { + fmt.Printf("Warning: Failed to watch subdirectory %s: %v\n", path, err) + } + } else if verbose { + fmt.Printf("Watching subdirectory: %s\n", path) + } + } + return nil + }) + if err != nil && verbose { + fmt.Printf("Warning: Failed to walk subdirectories: %v\n", err) + } + // Always emit the begin pattern for task integration if markdownFile != "" { fmt.Printf("Watching for file changes to %s...\n", markdownFile) @@ -1593,6 +1614,7 @@ func cancelWorkflowRuns(workflowID int64) error { type IncludeDependency struct { SourcePath string // Path in the source (local) TargetPath string // Relative path where it should be copied in .github/workflows + IsOptional bool // Whether this is an optional include (@include?) } // collectIncludeDependencies recursively collects all @include dependencies from a workflow file @@ -1609,13 +1631,14 @@ func collectIncludeDependencies(content, workflowPath, workflowsDir string) ([]I // collectIncludesRecursive recursively processes @include directives in content func collectIncludesRecursive(content, baseDir, workflowsDir string, dependencies *[]IncludeDependency, seen map[string]bool) error { - includePattern := regexp.MustCompile(`^@include\s+(.+)$`) + includePattern := regexp.MustCompile(`^@include(\?)?\s+(.+)$`) scanner := bufio.NewScanner(strings.NewReader(content)) for scanner.Scan() { line := scanner.Text() if matches := includePattern.FindStringSubmatch(line); matches != nil { - includePath := strings.TrimSpace(matches[1]) + isOptional := matches[1] == "?" + includePath := strings.TrimSpace(matches[2]) // Handle section references (file.md#Section) var filePath string @@ -1635,10 +1658,11 @@ func collectIncludesRecursive(content, baseDir, workflowsDir string, dependencie } seen[fullSourcePath] = true - // Add dependency + // Add dependency (even for optional includes that might not exist yet) dep := IncludeDependency{ SourcePath: fullSourcePath, TargetPath: filePath, // Keep relative path for target + IsOptional: isOptional, } *dependencies = append(*dependencies, dep) @@ -1688,6 +1712,11 @@ func copyIncludeDependenciesWithForce(dependencies []IncludeDependency, githubWo sourceContent, err = os.ReadFile(dep.SourcePath) if err != nil { + if dep.IsOptional { + // For optional includes, just show an informational message and skip + fmt.Printf("Optional include file not found: %s (you can create this file to configure the workflow)\n", dep.TargetPath) + continue + } fmt.Printf("Warning: Failed to read include file %s: %v\n", dep.SourcePath, err) continue } @@ -2383,13 +2412,14 @@ func collectPackageIncludeDependencies(content, packagePath string, verbose bool // collectPackageIncludesRecursive recursively processes @include directives in package content func collectPackageIncludesRecursive(content, baseDir string, dependencies *[]IncludeDependency, seen map[string]bool, verbose bool) error { - includePattern := regexp.MustCompile(`^@include\s+(.+)$`) + includePattern := regexp.MustCompile(`^@include(\?)?\s+(.+)$`) scanner := bufio.NewScanner(strings.NewReader(content)) for scanner.Scan() { line := scanner.Text() if matches := includePattern.FindStringSubmatch(line); matches != nil { - includePath := strings.TrimSpace(matches[1]) + isOptional := matches[1] == "?" + includePath := strings.TrimSpace(matches[2]) // Handle section references (file.md#Section) var filePath string @@ -2413,6 +2443,7 @@ func collectPackageIncludesRecursive(content, baseDir string, dependencies *[]In dep := IncludeDependency{ SourcePath: fullSourcePath, TargetPath: filePath, // Keep relative path for target + IsOptional: isOptional, } *dependencies = append(*dependencies, dep) @@ -2475,6 +2506,13 @@ func copyIncludeDependenciesFromPackageWithForce(dependencies []IncludeDependenc // Read source content from package sourceContent, err := os.ReadFile(dep.SourcePath) if err != nil { + if dep.IsOptional { + // For optional includes, just show an informational message and skip + if verbose { + fmt.Printf("Optional include file not found: %s (you can create this file to configure the workflow)\n", dep.TargetPath) + } + continue + } fmt.Printf("Warning: Failed to read include file %s: %v\n", dep.SourcePath, err) continue } @@ -2744,13 +2782,13 @@ func findIncludesInContent(content, baseDir string, verbose bool) ([]string, err _ = baseDir // unused parameter for now, keeping for potential future use _ = verbose // unused parameter for now, keeping for potential future use var includes []string - includePattern := regexp.MustCompile(`^@include\s+(.+)$`) + includePattern := regexp.MustCompile(`^@include(\?)?\s+(.+)$`) scanner := bufio.NewScanner(strings.NewReader(content)) for scanner.Scan() { line := scanner.Text() if matches := includePattern.FindStringSubmatch(line); matches != nil { - includePath := strings.TrimSpace(matches[1]) + includePath := strings.TrimSpace(matches[2]) // Handle section references (file.md#Section) var filePath string diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index 19d82068..9a584c07 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -723,6 +723,30 @@ More content here. expectError: false, description: "Include of nonexistent file should still add dependency but not recurse", }, + { + name: "optional_include_existing", + content: "# Optional Include Existing\n@include? shared/common.md\nMore content.", + workflowPath: workflowsDir + "/optional-existing.md", + expectedDepsCount: 1, + expectError: false, + description: "Optional include of existing file should work like regular include", + }, + { + name: "optional_include_missing", + content: "# Optional Include Missing\n@include? shared/optional.md\nMore content.", + workflowPath: workflowsDir + "/optional-missing.md", + expectedDepsCount: 1, + expectError: false, + description: "Optional include of missing file should still add dependency", + }, + { + name: "mixed_includes", + content: "# Mixed\n@include shared/common.md\n@include? shared/optional.md\n@include shared/recursive.md", + workflowPath: workflowsDir + "/mixed.md", + expectedDepsCount: 4, // common.md + optional.md + recursive.md + recursive.md->common.md + expectError: false, + description: "Mixed regular and optional includes should collect all dependencies", + }, } for _, tt := range tests { @@ -754,6 +778,31 @@ More content here. t.Errorf("Dependency %d has empty TargetPath", i) } } + + // Verify optional flag for specific test cases + if tt.name == "optional_include_existing" || tt.name == "optional_include_missing" { + if len(deps) > 0 && !deps[0].IsOptional { + t.Errorf("Optional include dependency should have IsOptional=true") + } + } + if tt.name == "mixed_includes" { + optionalFound := false + regularFound := false + for _, dep := range deps { + if strings.Contains(dep.TargetPath, "optional") && dep.IsOptional { + optionalFound = true + } + if (strings.Contains(dep.TargetPath, "common") || strings.Contains(dep.TargetPath, "recursive")) && !dep.IsOptional { + regularFound = true + } + } + if !optionalFound { + t.Errorf("Mixed includes should have at least one optional dependency") + } + if !regularFound { + t.Errorf("Mixed includes should have at least one regular dependency") + } + } }) } } diff --git a/pkg/parser/frontmatter.go b/pkg/parser/frontmatter.go index 70ef7818..c2d67f75 100644 --- a/pkg/parser/frontmatter.go +++ b/pkg/parser/frontmatter.go @@ -289,14 +289,15 @@ func ExtractMarkdown(filePath string) (string, error) { func ProcessIncludes(content, baseDir string, extractTools bool) (string, error) { scanner := bufio.NewScanner(strings.NewReader(content)) var result bytes.Buffer - includePattern := regexp.MustCompile(`^@include\s+(.+)$`) + includePattern := regexp.MustCompile(`^@include(\?)?\s+(.+)$`) for scanner.Scan() { line := scanner.Text() // Check if this line is an @include directive if matches := includePattern.FindStringSubmatch(line); matches != nil { - includePath := strings.TrimSpace(matches[1]) + isOptional := matches[1] == "?" + includePath := strings.TrimSpace(matches[2]) // Handle section references (file.md#Section) var filePath, sectionName string @@ -311,25 +312,22 @@ func ProcessIncludes(content, baseDir string, extractTools bool) (string, error) // Resolve file path fullPath, err := resolveIncludePath(filePath, baseDir) if err != nil { - if extractTools { - result.WriteString("{}\n") - } else { - strippedError := StripANSI(err.Error()) - result.WriteString(fmt.Sprintf("\n\n\n", strippedError)) + if isOptional { + // For optional includes, show a friendly informational message to stdout + if !extractTools { + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Optional include file not found: %s. You can create this file to configure the workflow.", filePath))) + } + continue } - continue + // For required includes, fail compilation with an error + return "", fmt.Errorf("failed to resolve required include '%s': %w", filePath, err) } // Process the included file includedContent, err := processIncludedFile(fullPath, sectionName, extractTools) if err != nil { - if extractTools { - result.WriteString("{}\n") - } else { - strippedError := StripANSI(err.Error()) - result.WriteString(fmt.Sprintf("\n\n\n", strippedError)) - } - continue + // For any processing errors, fail compilation + return "", fmt.Errorf("failed to process included file '%s': %w", fullPath, err) } if extractTools { diff --git a/pkg/parser/frontmatter_test.go b/pkg/parser/frontmatter_test.go index 2834485c..ddca058a 100644 --- a/pkg/parser/frontmatter_test.go +++ b/pkg/parser/frontmatter_test.go @@ -488,7 +488,7 @@ Some content here. content: "@include nonexistent.md", baseDir: tempDir, extractTools: false, - expected: "\n\n\n", + wantErr: true, // Now expects error instead of embedding comment }, { name: "include file with extra newlines", @@ -723,8 +723,7 @@ This is just plain markdown content with no frontmatter.` content: "@include .github/workflows/invalid.md", baseDir: tempDir, extractTools: false, - wantErr: false, - checkContent: "