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
63 changes: 49 additions & 14 deletions pkg/parser/import_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,10 @@ func ProcessImportsFromFrontmatter(frontmatter map[string]any, baseDir string) (
// When a file is fetched from a remote GitHub repository via workflowspec,
// its nested relative imports must be resolved against the same remote repo.
type remoteImportOrigin struct {
Owner string // Repository owner (e.g., "elastic")
Repo string // Repository name (e.g., "ai-github-actions")
Ref string // Git ref - branch, tag, or SHA (e.g., "main", "v1.0.0", "abc123...")
Owner string // Repository owner (e.g., "elastic")
Repo string // Repository name (e.g., "ai-github-actions")
Ref string // Git ref - branch, tag, or SHA (e.g., "main", "v1.0.0", "abc123...")
BasePath string // Base directory path within the repo (e.g., "gh-agent-workflows" for gh-agent-workflows/gh-aw-workflows/file.md)
}

// importQueueItem represents a file to be imported with its context
Expand All @@ -104,9 +105,12 @@ type importQueueItem struct {
remoteOrigin *remoteImportOrigin // Remote origin context (non-nil when imported from a remote repo)
}

// parseRemoteOrigin extracts the remote origin (owner, repo, ref) from a workflowspec path.
// parseRemoteOrigin extracts the remote origin (owner, repo, ref, basePath) from a workflowspec path.
// Returns nil if the path is not a valid workflowspec.
// Format: owner/repo/path[@ref] where ref defaults to "main" if not specified.
// BasePath is derived from the parent workflowspec path and used for resolving nested relative imports.
// For example, "elastic/ai-github-actions/gh-agent-workflows/gh-aw-workflows/file.md@main"
// produces BasePath="gh-agent-workflows" so nested imports resolve relative to that directory.
func parseRemoteOrigin(spec string) *remoteImportOrigin {
// Remove section reference if present
cleanSpec := spec
Expand All @@ -128,10 +132,33 @@ func parseRemoteOrigin(spec string) *remoteImportOrigin {
return nil
}

// Derive BasePath: everything between owner/repo and the last component (filename)
// Since imports are always 2-level (dir/file.md), the base is everything before the filename
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "Since imports are always 2-level (dir/file.md)" is inaccurate. The test cases demonstrate that imports can have arbitrary depth (e.g., "owner/repo/a/b/c/d/e/file.md" has BasePath "a/b/c/d/e" which is 5 levels deep). The comment should be updated to reflect that the BasePath is the directory path containing the file, which can have any number of levels.

Suggested change
// Since imports are always 2-level (dir/file.md), the base is everything before the filename
// Imports can have arbitrary directory depth; BasePath is the directory path containing the file

Copilot uses AI. Check for mistakes.
// Examples:
// - "owner/repo/.github/workflows/file.md" -> BasePath = ".github/workflows"
// - "owner/repo/gh-agent-workflows/gh-aw-workflows/file.md" -> BasePath = "gh-agent-workflows/gh-aw-workflows"
// - "owner/repo/a/b/c/d/file.md" -> BasePath = "a/b/c/d"
var basePath string
repoRelativeParts := slashParts[2:] // Everything after owner/repo
if len(repoRelativeParts) >= 2 {
// Take everything except the last component (the file itself)
// For nested imports, we want the directory containing the file
baseDirParts := repoRelativeParts[:len(repoRelativeParts)-1]
if len(baseDirParts) > 0 {
// Clean the path to normalize it (remove ./ and resolve ..)
basePath = path.Clean(strings.Join(baseDirParts, "/"))
importLog.Printf("Derived BasePath=%q from spec=%q (owner=%s, repo=%s, ref=%s)",
basePath, spec, slashParts[0], slashParts[1], ref)
}
} else {
importLog.Printf("No BasePath derived from spec=%q (file at repo root)", spec)
}

return &remoteImportOrigin{
Owner: slashParts[0],
Repo: slashParts[1],
Ref: ref,
Owner: slashParts[0],
Repo: slashParts[1],
Ref: ref,
BasePath: basePath,
}
}

Expand Down Expand Up @@ -492,19 +519,27 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a

if item.remoteOrigin != nil && !isWorkflowSpec(nestedFilePath) {
// Parent was fetched from a remote repo and nested path is relative.
// Convert to a workflowspec that resolves against the remote repo's
// .github/workflows/ directory (mirrors local compilation behavior).
// Convert to a workflowspec that resolves against the parent workflowspec's
// base directory (e.g., gh-agent-workflows for gh-agent-workflows/gh-aw-workflows/file.md).
cleanPath := path.Clean(strings.TrimPrefix(nestedFilePath, "./"))

// Reject paths that escape .github/workflows/ (e.g., ../../../etc/passwd)
// Reject paths that escape the base directory (e.g., ../../../etc/passwd)
if cleanPath == ".." || strings.HasPrefix(cleanPath, "../") || path.IsAbs(cleanPath) {
return nil, fmt.Errorf("nested import '%s' from remote file '%s' escapes .github/workflows/ base directory", nestedFilePath, item.importPath)
return nil, fmt.Errorf("nested import '%s' from remote file '%s' escapes base directory", nestedFilePath, item.importPath)
}

// Use the parent's BasePath if available, otherwise default to .github/workflows
basePath := item.remoteOrigin.BasePath
if basePath == "" {
basePath = ".github/workflows"
}
// Clean the basePath to ensure it's normalized
basePath = path.Clean(basePath)

resolvedPath = fmt.Sprintf("%s/%s/.github/workflows/%s@%s",
item.remoteOrigin.Owner, item.remoteOrigin.Repo, cleanPath, item.remoteOrigin.Ref)
resolvedPath = fmt.Sprintf("%s/%s/%s/%s@%s",
item.remoteOrigin.Owner, item.remoteOrigin.Repo, basePath, cleanPath, item.remoteOrigin.Ref)
nestedRemoteOrigin = item.remoteOrigin
importLog.Printf("Resolving nested import as remote workflowspec: %s -> %s", nestedFilePath, resolvedPath)
importLog.Printf("Resolving nested import as remote workflowspec: %s -> %s (basePath=%s)", nestedFilePath, resolvedPath, basePath)
} else if isWorkflowSpec(nestedFilePath) {
// Nested import is itself a workflowspec - parse its remote origin
nestedRemoteOrigin = parseRemoteOrigin(nestedFilePath)
Expand Down
Loading
Loading