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
18 changes: 17 additions & 1 deletion pkg/parser/import_bfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,14 +293,30 @@ 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) {
result, err = ExtractFrontmatterFromBuiltinFile(item.fullPath, content)
} 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)
Expand Down
11 changes: 10 additions & 1 deletion pkg/parser/import_field_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
224 changes: 224 additions & 0 deletions pkg/parser/import_inputs_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
101 changes: 101 additions & 0 deletions pkg/workflow/imports_inputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading