Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
54 changes: 43 additions & 11 deletions pkg/cli/includes.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ type frontmatterImportsOpts struct {
force bool
tracker *FileTracker
seen map[string]struct{}
// downloadFn is the function used to fetch file content from the source repository.
// When nil, parser.DownloadFileFromGitHub is used. Tests may inject a stub to avoid
// network calls and observe which paths were requested.
downloadFn func(ctx context.Context, owner, repo, path, ref string) ([]byte, error)
}

// fetchFrontmatterImportsRecursive is the internal worker for fetchAndSaveRemoteFrontmatterImports.
Expand Down Expand Up @@ -275,15 +279,28 @@ func fetchFrontmatterImportsRecursive(ctx context.Context, content, currentBaseD
remoteFilePath = filePath
}
} else {
// Non-explicit relative path (e.g. "shared/foo.md"): resolve relative to the
// original base directory (the top-level workflow's directory). Workflows in
// this repository write shared import paths relative to the workflow root
// (e.g. ".github/workflows"), not relative to the importing file's own
// directory. Resolving against originalBaseDir instead of currentBaseDir
// ensures that a file at ".github/workflows/shared/base.md" can import
// "shared/helper.md" and have it resolve to ".github/workflows/shared/helper.md"
// rather than the incorrect ".github/workflows/shared/shared/helper.md".
baseDir := opts.originalBaseDir
// Non-explicit relative path: resolution strategy mirrors the compiler's
// determineNestedBaseDir logic — it depends on whether the path contains a
// "/" separator.
//
// Paths WITHOUT "/" (e.g. "helper.md"): the importing file treats them as
// siblings in its own directory, so resolve relative to currentBaseDir.
// Example: "control-precompute.md" from ".github/workflows/shared/control.md"
// → ".github/workflows/shared/control-precompute.md"
//
// Paths WITH "/" (e.g. "shared/foo.md"): workflows in this repository write
// these paths relative to the workflow root (.github/workflows), not relative to
// the importing file's directory, so resolve against originalBaseDir.
// Example: "shared/reporting.md" from ".github/workflows/shared/daily-audit-base.md"
// → ".github/workflows/shared/reporting.md"
var baseDir string
if !strings.Contains(filePath, "/") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnosing-bugs] The strings.Contains(filePath, "/") heuristic mirrors determineNestedBaseDir in the compiler, but no code comment links to that function. If the compiler logic diverges, this silent coupling will be hard to spot.

💡 Suggestion

Add a reference comment above the if block:

// Resolution mirrors pkg/parser.determineNestedBaseDir:
// bare filenames (no "/") → sibling of the importing file (currentBaseDir)
// slash-paths              → repo-root-relative (originalBaseDir)
if !strings.Contains(filePath, "/") {

This makes it easy to grep for the coupling and keep both sides in sync.

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Forward-slash heuristic breaks on Windows paths: strings.Contains(filePath, "/") only detects POSIX separators; on Windows, import paths using backslashes will be misclassified as bare filenames and resolved against currentBaseDir instead of originalBaseDir.

💡 Suggested fix

Normalise filePath to forward-slashes before the check, consistent with the filepath.FromSlash usage elsewhere in this function:

normalisedFilePath := filepath.ToSlash(filePath)
if !strings.Contains(normalisedFilePath, "/") {
    baseDir = currentBaseDir
} else {
    baseDir = opts.originalBaseDir
}

Or use path.Base(filePath) == filePath as a cross-platform equivalent (true when there is no directory component).

baseDir = currentBaseDir
} else {
baseDir = opts.originalBaseDir
}
// Fallback: if the selected baseDir is empty (e.g. workflow at repo root
// with no originalBaseDir set), use currentBaseDir as a last resort.
if baseDir == "" {
baseDir = currentBaseDir
}
Expand Down Expand Up @@ -349,20 +366,35 @@ func fetchFrontmatterImportsRecursive(ctx context.Context, content, currentBaseD
}

// Check existence before downloading: if the file already exists and force=false,
// skip the download entirely (no unnecessary network round-trip).
// skip the download entirely (no unnecessary network round-trip). However, still
// recurse into the existing file's imports so that any transitive dependencies
// it references are fetched even when the parent file was already present.
fileExists := false
if fileutil.FileExists(targetPath) {
fileExists = true
if !opts.force {
if opts.verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Import file already exists, skipping: "+targetPath))
}
// Read the existing file so we can recurse into its own imports.
// If the read fails, log it and skip — the compiler will report
// any missing transitive dependencies.
if existingContent, readErr := os.ReadFile(targetPath); readErr == nil {
importedBaseDir := path.Dir(remoteFilePath)
fetchFrontmatterImportsRecursive(ctx, string(existingContent), importedBaseDir, opts)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnosing-bugs] When recursing into an existing file, importedBaseDir is set to path.Dir(remoteFilePath), but remoteFilePath may equal originalBaseDir + "/" + localRelPath — not the local disk path. This is correct for remote-path recursion, but if the existing file's local content references paths that were written relative to a different layout, the recursion will use the wrong base dir.

Consider adding an assertion or comment that remoteFilePath is always the canonical remote path at this point, so future readers know this is intentional and not a bug.

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silent failure on transitive dependency recursion: errors inside the recursive call are fully swallowed — if any transitive download or write fails, the caller has no indication that the local copy is incomplete.

💡 Detail

At line 384, fetchFrontmatterImportsRecursive is called for an existing file's imports. The function has no return value, so download failures, disk-write failures, or permission errors inside recursion are silently logged at best. A broken transitive dep leaves an incomplete install with no observable error surfaced to the user.

The same void-return pattern exists at line 438 (newly downloaded files), so partial-failure state cannot propagate at any recursion depth.

Consider returning an error or accumulating failures (e.g., a []error slice) so the top-level caller can report incomplete installs clearly.

} else {
remoteWorkflowLog.Printf("Failed to read existing import %s for recursion: %v", targetPath, readErr)
}
continue
}
}

// Download from the source repository
importContent, err := parser.DownloadFileFromGitHub(ctx, opts.owner, opts.repo, remoteFilePath, opts.ref)
downloadFn := opts.downloadFn
if downloadFn == nil {
downloadFn = parser.DownloadFileFromGitHub
}
importContent, err := downloadFn(ctx, opts.owner, opts.repo, remoteFilePath, opts.ref)
if err != nil {
remoteWorkflowLog.Printf("Failed to download import %s from %s/%s@%s: %v", remoteFilePath, opts.owner, opts.repo, opts.ref, err)
if opts.verbose {
Expand Down
120 changes: 120 additions & 0 deletions pkg/cli/remote_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,126 @@ imports:
assert.Empty(t, entries, "no files should be created for an invalid RepoSlug")
}

// TestFetchFrontmatterImportsRecursive_SiblingPathResolution verifies that when a
// shared file imports a sibling using a bare filename (no "/" character), the path
// is resolved relative to the importing file's directory rather than the original
// workflow base directory. This mirrors the compiler's determineNestedBaseDir logic.
func TestFetchFrontmatterImportsRecursive_SiblingPathResolution(t *testing.T) {
tmpDir := t.TempDir()

// Track which remote paths the downloader is asked for, and return minimal content.
var downloadedPaths []string
mockDownload := func(_ context.Context, _, _, remoteFilePath, _ string) ([]byte, error) {
downloadedPaths = append(downloadedPaths, remoteFilePath)
return []byte("# stub\n"), nil
}

// shared/control.md imports a sibling via a bare filename (no "/").
controlContent := `---
imports:
- control-aux.md
---
# Control
`

seen := make(map[string]struct{})
opts := frontmatterImportsOpts{
owner: "github",
repo: "gh-aw",
ref: "main",
originalBaseDir: ".github/workflows",
targetDir: tmpDir,
verbose: false,
force: true, // force=true so the download step is always reached
tracker: &FileTracker{OriginalContent: make(map[string][]byte)},
seen: seen,
downloadFn: mockDownload,
}

// Process shared/control.md; currentBaseDir mirrors its location in the source repo.
fetchFrontmatterImportsRecursive(t.Context(), controlContent, ".github/workflows/shared", opts)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The test asserts require.Len(t, downloadedPaths, 1) but does not verify the content or location of the written file for the stub ([]byte("# stub\n")). The file-existence check later catches gross path errors, but a test that also asserts the written bytes would make the regression test more complete and protect against silent overwrites.

💡 Example addition
content, err := os.ReadFile(filepath.Join(tmpDir, "shared", "control-aux.md"))
require.NoError(t, err)
assert.Equal(t, "# stub\n", string(content))

@copilot please address this.


// "control-aux.md" (bare filename, no "/") must resolve relative to the importing
// file's directory → ".github/workflows/shared/control-aux.md".
require.Len(t, downloadedPaths, 1, "downloader must be called exactly once")
assert.Equal(t, ".github/workflows/shared/control-aux.md", downloadedPaths[0],
"sibling import must resolve to the shared/ subdir, not the workflow root")

// The file must be saved at the correct local path inside targetDir.
assert.FileExists(t, filepath.Join(tmpDir, "shared", "control-aux.md"),
"file must be written to shared/control-aux.md")
assert.NoFileExists(t, filepath.Join(tmpDir, "control-aux.md"),
"file must not be written at the root level (wrong path resolution)")
}

// TestFetchFrontmatterImportsRecursive_RecurseIntoExistingFile verifies that when a
// top-level import already exists on disk (force=false), the function still recurses
// into that file's imports so that transitive dependencies are fetched. This test
// also covers sibling-path resolution: the transitive dep uses a bare filename, so it
// should resolve to the shared file's directory, not the workflow root.
func TestFetchFrontmatterImportsRecursive_RecurseIntoExistingFile(t *testing.T) {
tmpDir := t.TempDir()
sharedDir := filepath.Join(tmpDir, "shared")
require.NoError(t, os.MkdirAll(sharedDir, 0755))

// shared/control.md already exists locally; it imports control-aux.md (a sibling).
controlContent := `---
imports:
- control-aux.md
---
# Control
`
require.NoError(t, os.WriteFile(filepath.Join(sharedDir, "control.md"), []byte(controlContent), 0600))

// Track which remote paths are downloaded.
var downloadedPaths []string
mockDownload := func(_ context.Context, _, _, remoteFilePath, _ string) ([]byte, error) {
downloadedPaths = append(downloadedPaths, remoteFilePath)
return []byte("# stub\n"), nil
}

// Top-level content imports shared/control.md (which already exists on disk).
topContent := `---
engine: copilot
imports:
- shared/control.md
---
# Orchestrator
`

seen := make(map[string]struct{})
opts := frontmatterImportsOpts{
owner: "github",
repo: "gh-aw",
ref: "v1.0.0",
originalBaseDir: ".github/workflows",
targetDir: tmpDir,
verbose: false,
force: false, // force=false so pre-existing files trigger the "skip + recurse" path
tracker: &FileTracker{OriginalContent: make(map[string][]byte)},
seen: seen,
downloadFn: mockDownload,
}

fetchFrontmatterImportsRecursive(t.Context(), topContent, ".github/workflows", opts)

// shared/control.md existed on disk → must NOT be downloaded.
assert.NotContains(t, downloadedPaths, ".github/workflows/shared/control.md",
"pre-existing shared/control.md must not be re-downloaded")

// The function must recurse into the existing file and discover its sibling dep.
// control-aux.md (bare filename, no "/") must resolve to shared/control-aux.md.
require.Len(t, downloadedPaths, 1, "transitive dep control-aux.md must be fetched exactly once")
assert.Equal(t, ".github/workflows/shared/control-aux.md", downloadedPaths[0],
"transitive dep must be fetched from the shared/ subdir")

// The transitive dep must be saved at the correct local path.
assert.FileExists(t, filepath.Join(tmpDir, "shared", "control-aux.md"),
"transitive dep must be written to shared/control-aux.md")
assert.NoFileExists(t, filepath.Join(tmpDir, "control-aux.md"),
"transitive dep must not be written at the root level")
}

// --- extractDispatchWorkflowNames tests ---

// TestExtractDispatchWorkflowNames_ArrayFormat verifies that workflow names are extracted
Expand Down
Loading