diff --git a/pkg/parser/import_bfs.go b/pkg/parser/import_bfs.go index 9bb09aebabc..b2dc7373870 100644 --- a/pkg/parser/import_bfs.go +++ b/pkg/parser/import_bfs.go @@ -293,7 +293,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a return nil, fmt.Errorf("failed to read imported file '%s': %w", item.fullPath, err) } - // Extract frontmatter from imported file to discover nested imports. + // Extract frontmatter from the imported file's original content. // Use the process-level cache for builtin virtual files to avoid repeated YAML parsing. var result *FrontmatterResult if strings.HasPrefix(item.fullPath, BuiltinPathPrefix) { @@ -301,6 +301,22 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a } else { result, err = ExtractFrontmatterFromContent(string(content)) } + + // When the import provides 'with' inputs, apply expression substitution before + // discovering nested imports. This resolves ${{ github.aw.import-inputs.* }} + // expressions that appear in the 'with' values of nested imports, enabling + // multi-level workflow composition. + // We reuse the already-parsed frontmatter to extract import-schema defaults, + // avoiding a second YAML parse inside applyImportSchemaDefaults. + if err == nil && result != nil && len(item.inputs) > 0 { + inputsWithDefaults := applyImportSchemaDefaultsFromFrontmatter(result.Frontmatter, item.inputs) + substituted := substituteImportInputsInContent(string(content), inputsWithDefaults) + // Re-parse the substituted content so that nested-import discovery sees + // the resolved 'with' values instead of literal expression strings. + if reparse, rerr := ExtractFrontmatterFromContent(substituted); rerr == nil { + result = reparse + } + } if err != nil { // If frontmatter extraction fails, continue with other processing log.Printf("Failed to extract frontmatter from %s: %v", item.fullPath, err) diff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go index 98b5d7fdaf3..515266370e6 100644 --- a/pkg/parser/import_field_extractor.go +++ b/pkg/parser/import_field_extractor.go @@ -661,7 +661,16 @@ func applyImportSchemaDefaults(rawContent string, inputs map[string]any) map[str if err != nil { return inputs } - rawSchema, ok := parsed.Frontmatter["import-schema"] + return applyImportSchemaDefaultsFromFrontmatter(parsed.Frontmatter, inputs) +} + +// applyImportSchemaDefaultsFromFrontmatter applies import-schema defaults from an +// already-parsed frontmatter map, avoiding a redundant YAML parse when the caller +// has already extracted the frontmatter. Returns a copy of inputs augmented with +// default values for any schema parameters declared with a "default" field but not +// present in the provided inputs map. Parameters already in inputs are left unchanged. +func applyImportSchemaDefaultsFromFrontmatter(frontmatter map[string]any, inputs map[string]any) map[string]any { + rawSchema, ok := frontmatter["import-schema"] if !ok { return inputs } diff --git a/pkg/parser/import_inputs_integration_test.go b/pkg/parser/import_inputs_integration_test.go new file mode 100644 index 00000000000..2dea92d19ad --- /dev/null +++ b/pkg/parser/import_inputs_integration_test.go @@ -0,0 +1,224 @@ +//go:build integration + +package parser_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/parser" + "github.com/github/gh-aw/pkg/testutil" +) + +// TestImportInputsForwardedToNestedImports_Integration verifies via the parser package +// API that ${{ github.aw.import-inputs.* }} expressions inside an imported workflow's +// imports: frontmatter section are resolved before nested import discovery, enabling +// multi-level shared-workflow composition. +func TestImportInputsForwardedToNestedImports_Integration(t *testing.T) { + tempDir := testutil.TempDir(t, "import-inputs-forwarding-*") + + sharedDir := filepath.Join(tempDir, "shared") + if err := os.MkdirAll(sharedDir, 0755); err != nil { + t.Fatalf("Failed to create shared directory: %v", err) + } + + // Leaf shared workflow — accepts branch-name via import-schema + leafPath := filepath.Join(sharedDir, "repo-memory.md") + leafContent := `--- +import-schema: + branch-name: + type: string + required: true + description: "Branch name for storage" +tools: + bash: + - "git *" +--- + +Store data in branch ${{ github.aw.import-inputs.branch-name }}. +` + if err := os.WriteFile(leafPath, []byte(leafContent), 0644); err != nil { + t.Fatalf("Failed to write leaf workflow: %v", err) + } + + // Intermediate shared workflow — accepts branch-name and forwards it to the leaf + // via an expression in its own imports: section + intermediateContent := `--- +import-schema: + branch-name: + type: string + required: true + description: "Branch name for repo-memory storage" + +imports: + - uses: shared/repo-memory.md + with: + branch-name: ${{ github.aw.import-inputs.branch-name }} +--- + +Daily report workflow. +` + intermediatePath := filepath.Join(sharedDir, "daily-report.md") + if err := os.WriteFile(intermediatePath, []byte(intermediateContent), 0644); err != nil { + t.Fatalf("Failed to write intermediate workflow: %v", err) + } + + // Consumer workflow — imports the intermediate with a concrete value + consumerContent := `--- +on: issues +permissions: + contents: read + issues: read +engine: copilot +imports: + - uses: shared/daily-report.md + with: + branch-name: "memory/my-workflow" +--- + +Consumer workflow. +` + consumerPath := filepath.Join(tempDir, "consumer.md") + if err := os.WriteFile(consumerPath, []byte(consumerContent), 0644); err != nil { + t.Fatalf("Failed to write consumer workflow: %v", err) + } + + // Parse the consumer workflow's frontmatter and process its imports + result, err := parser.ExtractFrontmatterFromContent(consumerContent) + if err != nil { + t.Fatalf("Failed to extract frontmatter: %v", err) + } + + importsResult, err := parser.ProcessImportsFromFrontmatterWithSource( + result.Frontmatter, + tempDir, + nil, + consumerPath, + consumerContent, + ) + if err != nil { + t.Fatalf("ProcessImportsFromFrontmatterWithSource failed: %v", err) + } + + // The leaf workflow's bash tool (git *) should be present in merged tools + if !strings.Contains(importsResult.MergedTools, "git *") { + t.Errorf("MergedTools should contain 'git *' from leaf workflow; got:\n%s", importsResult.MergedTools) + } + + // No unresolved import-inputs expressions should remain anywhere + mergedContent := importsResult.MergedTools + importsResult.MergedMarkdown + if strings.Contains(mergedContent, "github.aw.import-inputs") { + t.Errorf("Merged content should not contain unsubstituted github.aw.import-inputs expressions;\ngot:\n%s", mergedContent) + } +} + +// TestImportInputsMultipleForwardedToNestedImports_Integration verifies that multiple +// ${{ github.aw.import-inputs.* }} expressions in an intermediate workflow's imports: +// section are all resolved before nested import discovery. +func TestImportInputsMultipleForwardedToNestedImports_Integration(t *testing.T) { + tempDir := testutil.TempDir(t, "import-inputs-multi-forwarding-*") + + sharedDir := filepath.Join(tempDir, "shared") + if err := os.MkdirAll(sharedDir, 0755); err != nil { + t.Fatalf("Failed to create shared directory: %v", err) + } + + // Leaf shared workflow accepting two inputs + leafPath := filepath.Join(sharedDir, "publisher.md") + leafContent := `--- +import-schema: + target-repo: + type: string + required: true + description: "Target repository" + title-prefix: + type: string + required: true + description: "Title prefix" +tools: + bash: + - "curl *" +--- + +Publish to ${{ github.aw.import-inputs.target-repo }} with prefix ${{ github.aw.import-inputs.title-prefix }}. +` + if err := os.WriteFile(leafPath, []byte(leafContent), 0644); err != nil { + t.Fatalf("Failed to write leaf workflow: %v", err) + } + + // Intermediate workflow — accepts both inputs and forwards them to the leaf + intermediateContent := `--- +import-schema: + target-repo: + type: string + required: true + description: "Target repository for publishing" + title-prefix: + type: string + required: true + description: "Title prefix for created items" + +imports: + - uses: shared/publisher.md + with: + target-repo: ${{ github.aw.import-inputs.target-repo }} + title-prefix: ${{ github.aw.import-inputs.title-prefix }} +--- + +Intermediate reporter. +` + intermediatePath := filepath.Join(sharedDir, "reporter.md") + if err := os.WriteFile(intermediatePath, []byte(intermediateContent), 0644); err != nil { + t.Fatalf("Failed to write intermediate workflow: %v", err) + } + + // Consumer that provides concrete values for both inputs + consumerContent := `--- +on: issues +permissions: + contents: read +engine: copilot +imports: + - uses: shared/reporter.md + with: + target-repo: "myorg/myrepo" + title-prefix: "daily-" +--- + +Consumer. +` + consumerPath := filepath.Join(tempDir, "consumer.md") + if err := os.WriteFile(consumerPath, []byte(consumerContent), 0644); err != nil { + t.Fatalf("Failed to write consumer workflow: %v", err) + } + + result, err := parser.ExtractFrontmatterFromContent(consumerContent) + if err != nil { + t.Fatalf("Failed to extract frontmatter: %v", err) + } + + importsResult, err := parser.ProcessImportsFromFrontmatterWithSource( + result.Frontmatter, + tempDir, + nil, + consumerPath, + consumerContent, + ) + if err != nil { + t.Fatalf("ProcessImportsFromFrontmatterWithSource failed: %v", err) + } + + // The leaf workflow's bash tool (curl *) must be present — proving the leaf + // was discovered and merged after both inputs were forwarded correctly. + if !strings.Contains(importsResult.MergedTools, "curl *") { + t.Errorf("MergedTools should contain 'curl *' from leaf workflow; got:\n%s", importsResult.MergedTools) + } + + // No unresolved import-inputs expressions should remain anywhere + mergedContent := importsResult.MergedTools + importsResult.MergedMarkdown + if strings.Contains(mergedContent, "github.aw.import-inputs") { + t.Errorf("Merged content should not contain unsubstituted github.aw.import-inputs expressions;\ngot:\n%s", mergedContent) + } +} diff --git a/pkg/workflow/imports_inputs_test.go b/pkg/workflow/imports_inputs_test.go index 7520fcbde82..90907f49467 100644 --- a/pkg/workflow/imports_inputs_test.go +++ b/pkg/workflow/imports_inputs_test.go @@ -104,6 +104,107 @@ This workflow tests import with inputs. } } +// TestImportInputsForwardedToNestedImports tests that ${{ github.aw.import-inputs.* }} +// expressions in the imports: section of a shared workflow are resolved before nested +// imports are processed. This enables multi-level workflow composition where a shared +// workflow can forward its own inputs to the workflows it depends on. +func TestImportInputsForwardedToNestedImports(t *testing.T) { + tempDir := testutil.TempDir(t, "test-import-inputs-forwarding-*") + + sharedDir := filepath.Join(tempDir, "shared") + if err := os.MkdirAll(sharedDir, 0755); err != nil { + t.Fatalf("Failed to create shared directory: %v", err) + } + + // Create the leaf shared workflow that accepts a branch-name input + leafPath := filepath.Join(sharedDir, "repo-memory.md") + leafContent := `--- +import-schema: + branch-name: + type: string + required: true + description: "Branch name for storage" +tools: + bash: + - "git *" +--- + +Store data in branch ${{ github.aw.import-inputs.branch-name }}. +` + if err := os.WriteFile(leafPath, []byte(leafContent), 0644); err != nil { + t.Fatalf("Failed to write leaf file: %v", err) + } + + // Create the intermediate shared workflow that accepts branch-name and forwards it + // to the leaf workflow via an expression in its own imports: section + intermediatePath := filepath.Join(sharedDir, "daily-report.md") + intermediateContent := `--- +import-schema: + branch-name: + type: string + required: true + description: "Branch name for repo-memory storage" + +imports: + - uses: shared/repo-memory.md + with: + branch-name: ${{ github.aw.import-inputs.branch-name }} +--- + +Daily report workflow. +` + if err := os.WriteFile(intermediatePath, []byte(intermediateContent), 0644); err != nil { + t.Fatalf("Failed to write intermediate file: %v", err) + } + + // Create the consuming workflow that imports the intermediate workflow with a concrete value + workflowPath := filepath.Join(tempDir, "consumer.md") + workflowContent := `--- +on: issues +permissions: + contents: read + issues: read +engine: copilot +imports: + - uses: shared/daily-report.md + with: + branch-name: "memory/my-workflow" +--- + +Consumer workflow. +` + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write consumer workflow: %v", err) + } + + compiler := workflow.NewCompiler() + if err := compiler.CompileWorkflow(workflowPath); err != nil { + t.Fatalf("CompileWorkflow failed: %v", err) + } + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockFileContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContent := string(lockFileContent) + + // The leaf workflow's tools (bash) should be merged + if !strings.Contains(lockContent, "git *") { + t.Error("Expected lock file to contain bash tool from leaf workflow (git *)") + } + + // The substituted branch-name value should appear in the compiled output + if !strings.Contains(lockContent, "memory/my-workflow") { + t.Error("Expected compiled workflow to contain forwarded branch-name value 'memory/my-workflow'") + } + + // No unresolved import-inputs expressions should remain + if strings.Contains(lockContent, "github.aw.import-inputs.branch-name") { + t.Error("Generated workflow should not contain unsubstituted github.aw.import-inputs.branch-name expression") + } +} + // TestImportWithInputsStringFormat tests that string import format still works func TestImportWithInputsStringFormat(t *testing.T) { // Create a temporary directory for test files diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden index ad95d2ef530..298f1b50644 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden @@ -51,8 +51,8 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.20" - GH_AW_INFO_AGENT_VERSION: "1.0.20" + GH_AW_INFO_VERSION: "1.0.21" + GH_AW_INFO_AGENT_VERSION: "1.0.21" GH_AW_INFO_WORKFLOW_NAME: "basic-copilot-test" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -289,7 +289,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh 1.0.20 + run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh 1.0.21 env: GH_HOST: github.com - name: Install AWF binary diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden index c531714871c..b6a9a5e3aae 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden @@ -51,8 +51,8 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.20" - GH_AW_INFO_AGENT_VERSION: "1.0.20" + GH_AW_INFO_VERSION: "1.0.21" + GH_AW_INFO_AGENT_VERSION: "1.0.21" GH_AW_INFO_WORKFLOW_NAME: "with-imports-test" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -290,7 +290,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh 1.0.20 + run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh 1.0.21 env: GH_HOST: github.com - name: Install AWF binary