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
9 changes: 9 additions & 0 deletions .github/workflows/scout.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

78 changes: 38 additions & 40 deletions actions/setup/js/merge_remote_agent_github_folder.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ function getAllFiles(dir, baseDir = dir) {

/**
* Sparse checkout the .github folder from a remote repository
* @deprecated This function is no longer used. The compiler now generates actions/checkout steps
* that checkout repositories into /tmp/gh-aw/repo-imports/, and mergeRepositoryGithubFolder uses those.
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} ref - Git reference (branch, tag, or SHA)
Expand Down Expand Up @@ -213,7 +215,7 @@ function mergeGithubFolder(sourcePath, destPath) {
}

/**
* Merge a repository's .github folder into the workspace
* Merge a repository's .github folder into the workspace using pre-checked-out folder
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} ref - Git reference
Expand All @@ -222,53 +224,49 @@ function mergeGithubFolder(sourcePath, destPath) {
async function mergeRepositoryGithubFolder(owner, repo, ref, workspace) {
coreObj.info(`Merging .github folder from ${owner}/${repo}@${ref} into workspace`);

// Create temporary directory for sparse checkout
const tempDir = path.join("/tmp", `gh-aw-repo-merge-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
coreObj.info(`Created temporary directory: ${tempDir}`);
// Calculate the pre-checked-out folder path
// This matches the format generated by the compiler: /tmp/gh-aw/repo-imports/<owner>-<repo>-<sanitized-ref>
const sanitizedRef = ref.replace(/\//g, "-").replace(/:/g, "-").replace(/\\/g, "-");
const checkoutPath = `/tmp/gh-aw/repo-imports/${owner}-${repo}-${sanitizedRef}`;

try {
// Sparse checkout .github folder from remote repository
sparseCheckoutGithubFolder(owner, repo, ref, tempDir);
coreObj.info(`Looking for pre-checked-out repository at: ${checkoutPath}`);

// Check if .github folder exists in remote repository
const sourceGithubFolder = path.join(tempDir, ".github");
if (!pathExists(sourceGithubFolder)) {
coreObj.warning(`Remote repository ${owner}/${repo}@${ref} does not contain a .github folder`);
return;
}
// Check if the pre-checked-out folder exists
if (!pathExists(checkoutPath)) {
throw new Error(`Pre-checked-out repository not found at ${checkoutPath}. The actions/checkout step may have failed.`);
}

// Merge .github folder into current repository
const destGithubFolder = path.join(workspace, ".github");
// Check if .github folder exists in the checked-out repository
const sourceGithubFolder = path.join(checkoutPath, ".github");
if (!pathExists(sourceGithubFolder)) {
coreObj.warning(`Remote repository ${owner}/${repo}@${ref} does not contain a .github folder`);
return;
}

// Ensure destination .github folder exists
if (!pathExists(destGithubFolder)) {
fs.mkdirSync(destGithubFolder, { recursive: true });
coreObj.info("Created .github folder in workspace");
}
// Merge .github folder into current repository
const destGithubFolder = path.join(workspace, ".github");

const { merged, conflicts } = mergeGithubFolder(sourceGithubFolder, destGithubFolder);
// Ensure destination .github folder exists
if (!pathExists(destGithubFolder)) {
fs.mkdirSync(destGithubFolder, { recursive: true });
coreObj.info("Created .github folder in workspace");
}

// Report results
if (conflicts.length > 0) {
coreObj.error(`Found ${conflicts.length} file conflicts:`);
for (const conflict of conflicts) {
coreObj.error(` - ${conflict}`);
}
throw new Error(`Cannot merge .github folder from ${owner}/${repo}@${ref}: ${conflicts.length} file(s) conflict with existing files`);
}
const { merged, conflicts } = mergeGithubFolder(sourceGithubFolder, destGithubFolder);

if (merged > 0) {
coreObj.info(`Successfully merged ${merged} file(s) from ${owner}/${repo}@${ref}`);
} else {
coreObj.info("No new files to merge");
}
} finally {
// Clean up temporary directory
if (pathExists(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
coreObj.info("Cleaned up temporary directory");
// Report results
if (conflicts.length > 0) {
coreObj.error(`Found ${conflicts.length} file conflicts:`);
for (const conflict of conflicts) {
coreObj.error(` - ${conflict}`);
}
throw new Error(`Cannot merge .github folder from ${owner}/${repo}@${ref}: ${conflicts.length} file(s) conflict with existing files`);
}

if (merged > 0) {
coreObj.info(`Successfully merged ${merged} file(s) from ${owner}/${repo}@${ref}`);
} else {
coreObj.info("No new files to merge");
}
}

Expand Down
122 changes: 122 additions & 0 deletions pkg/workflow/compiler_yaml_main_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
}
}

// Add checkout steps for repository imports
// Each repository import needs to be checked out into a temporary folder
// so the merge script can copy files from it
if len(data.RepositoryImports) > 0 {
compilerYamlLog.Printf("Adding checkout steps for %d repository imports", len(data.RepositoryImports))
c.generateRepositoryImportCheckouts(yaml, data.RepositoryImports)
}

// Add checkout step for legacy agent import (if present)
// This handles the older import format where a specific agent file is imported
if data.AgentFile != "" && data.AgentImportSpec != "" {
compilerYamlLog.Printf("Adding checkout step for legacy agent import: %s", data.AgentImportSpec)
c.generateLegacyAgentImportCheckout(yaml, data.AgentImportSpec)
}

// Add merge remote .github folder step for repository imports or agent imports
needsGithubMerge := (len(data.RepositoryImports) > 0) || (data.AgentFile != "" && data.AgentImportSpec != "")
if needsGithubMerge {
Expand Down Expand Up @@ -488,3 +503,110 @@ func (c *Compiler) addCustomStepsWithRuntimeInsertion(yaml *strings.Builder, cus
i++
}
}

// generateRepositoryImportCheckouts generates checkout steps for repository imports
// Each repository is checked out into a temporary folder at /tmp/gh-aw/repo-imports/<owner>-<repo>-<sanitized-ref>
// This allows the merge script to copy files from pre-checked-out folders instead of doing git operations
func (c *Compiler) generateRepositoryImportCheckouts(yaml *strings.Builder, repositoryImports []string) {
for _, repoImport := range repositoryImports {
compilerYamlLog.Printf("Generating checkout step for repository import: %s", repoImport)

// Parse the import spec to extract owner, repo, and ref
// Format: owner/repo@ref or owner/repo
owner, repo, ref := parseRepositoryImportSpec(repoImport)
if owner == "" || repo == "" {
compilerYamlLog.Printf("Warning: failed to parse repository import: %s", repoImport)
continue
}

// Generate a sanitized directory name for the checkout
// Use a consistent format: owner-repo-ref
sanitizedRef := sanitizeRefForPath(ref)
checkoutPath := fmt.Sprintf("/tmp/gh-aw/repo-imports/%s-%s-%s", owner, repo, sanitizedRef)

// Generate the checkout step
fmt.Fprintf(yaml, " - name: Checkout repository import %s/%s@%s\n", owner, repo, ref)
fmt.Fprintf(yaml, " uses: %s\n", GetActionPin("actions/checkout"))
yaml.WriteString(" with:\n")
fmt.Fprintf(yaml, " repository: %s/%s\n", owner, repo)
fmt.Fprintf(yaml, " ref: %s\n", ref)
fmt.Fprintf(yaml, " path: %s\n", checkoutPath)
yaml.WriteString(" sparse-checkout: |\n")
yaml.WriteString(" .github/\n")
yaml.WriteString(" persist-credentials: false\n")

compilerYamlLog.Printf("Added checkout step: %s/%s@%s -> %s", owner, repo, ref, checkoutPath)
}
}

// parseRepositoryImportSpec parses a repository import specification
// Format: owner/repo@ref or owner/repo (defaults to "main" if no ref)
// Returns: owner, repo, ref
func parseRepositoryImportSpec(importSpec string) (owner, repo, ref string) {
// Remove section reference if present (file.md#Section)
cleanSpec := importSpec
if idx := strings.Index(importSpec, "#"); idx != -1 {
cleanSpec = importSpec[:idx]
}

// Split on @ to get path and ref
parts := strings.Split(cleanSpec, "@")
pathPart := parts[0]
ref = "main" // default ref
if len(parts) > 1 {
ref = parts[1]
}

// Parse path: owner/repo
slashParts := strings.Split(pathPart, "/")
if len(slashParts) != 2 {
return "", "", ""
}

owner = slashParts[0]
repo = slashParts[1]

return owner, repo, ref
}

// generateLegacyAgentImportCheckout generates a checkout step for legacy agent imports
// Legacy format: owner/repo/path/to/file.md@ref
// This checks out the entire repository (not just .github folder) since the file could be anywhere
func (c *Compiler) generateLegacyAgentImportCheckout(yaml *strings.Builder, agentImportSpec string) {
compilerYamlLog.Printf("Generating checkout step for legacy agent import: %s", agentImportSpec)

// Parse the import spec to extract owner, repo, and ref
owner, repo, ref := parseRepositoryImportSpec(agentImportSpec)
if owner == "" || repo == "" {
compilerYamlLog.Printf("Warning: failed to parse legacy agent import spec: %s", agentImportSpec)
return
}

// Generate a sanitized directory name for the checkout
sanitizedRef := sanitizeRefForPath(ref)
checkoutPath := fmt.Sprintf("/tmp/gh-aw/repo-imports/%s-%s-%s", owner, repo, sanitizedRef)

// Generate the checkout step
fmt.Fprintf(yaml, " - name: Checkout agent import %s/%s@%s\n", owner, repo, ref)
fmt.Fprintf(yaml, " uses: %s\n", GetActionPin("actions/checkout"))
yaml.WriteString(" with:\n")
fmt.Fprintf(yaml, " repository: %s/%s\n", owner, repo)
fmt.Fprintf(yaml, " ref: %s\n", ref)
fmt.Fprintf(yaml, " path: %s\n", checkoutPath)
yaml.WriteString(" sparse-checkout: |\n")
yaml.WriteString(" .github/\n")
yaml.WriteString(" persist-credentials: false\n")

compilerYamlLog.Printf("Added legacy agent checkout step: %s/%s@%s -> %s", owner, repo, ref, checkoutPath)
}

// sanitizeRefForPath sanitizes a git ref for use in a file path
// Replaces characters that are problematic in file paths with safe alternatives
func sanitizeRefForPath(ref string) string {
// Replace slashes with dashes (for refs like "feature/my-branch")
sanitized := strings.ReplaceAll(ref, "/", "-")
// Replace other problematic characters
sanitized = strings.ReplaceAll(sanitized, ":", "-")
sanitized = strings.ReplaceAll(sanitized, "\\", "-")
return sanitized
}
Loading
Loading