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
5 changes: 5 additions & 0 deletions .changeset/patch-apply-samples-cross-repo-subdir-staging.md

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

104 changes: 96 additions & 8 deletions actions/setup/js/apply_samples.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
// GH_AW_SAMPLES — JSON array of replay entries (required)
// GH_AW_AGENT_STDIO_LOG — path where the synthetic stdio log is written
// GH_AW_SAFE_OUTPUTS_CONFIG_PATH — path to the MCP server's config.json
// (also read to resolve the per-tool target-repo so
// cross-repo patches are staged in the right checkout)
// GH_AW_SAFE_OUTPUTS — path to the MCP server's outputs.jsonl
// GITHUB_WORKSPACE — git working directory for pre-staging (optional;
// falls back to cwd)
Expand All @@ -35,6 +37,7 @@ const path = require("path");
const os = require("os");
const { getErrorMessage } = require("./error_helpers.cjs");
const { ERR_VALIDATION, ERR_PARSE, ERR_SYSTEM, ERR_API, ERR_CONFIG } = require("./error_codes.cjs");
const { findRepoCheckout } = require("./find_repo_checkout.cjs");

const DEFAULT_BASE_BRANCH = process.env.GH_AW_CUSTOM_BASE_BRANCH || process.env.GITHUB_BASE_REF || process.env.GITHUB_REF_NAME || "main";
const PATCH_SIDECAR_TOOLS = new Set(["create_pull_request", "push_to_pull_request_branch"]);
Expand Down Expand Up @@ -243,6 +246,84 @@ async function derivePrHeadRef(entry) {
return null;
}

/**
* Read the configured `target-repo` for a given safe-output tool from the
* safe-outputs config file (GH_AW_SAFE_OUTPUTS_CONFIG_PATH). Returns an empty
* string when no config is available or no target-repo is configured.
* @param {string} tool
* @returns {string}
*/
function readConfiguredTargetRepo(tool) {
const configPath = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
if (!configPath || !configPath.trim()) {
return "";
}

const toolKey = typeof tool === "string" ? tool.replace(/-/g, "_") : "";

try {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw);

// Mirror safe_outputs_config.cjs behavior: normalize top-level keys by replacing '-' with '_'.
const config = parsed && typeof parsed === "object" ? Object.fromEntries(Object.entries(parsed).map(([k, v]) => [String(k).replace(/-/g, "_"), v])) : {};

const toolConfig = toolKey && config && typeof config === "object" ? config[toolKey] : null;
const target = toolConfig && typeof toolConfig === "object" ? toolConfig["target-repo"] || toolConfig["target_repo"] : null;
if (typeof target === "string") {
return target.trim();
}
} catch (err) {
core.debug(`apply_samples: could not read target-repo from ${configPath}: ${getErrorMessage(err)}`);
}
return "";
}
Comment thread
Copilot marked this conversation as resolved.

/**
* Resolve the on-disk working directory in which a sample's patch should be
* staged (branch created + patch committed).
*
* For cross-repo checkouts placed in a subdirectory (e.g. `path: github`), the
* branch and commit must be created inside that subdirectory so the safe-outputs
* MCP handler — which resolves the same checkout via the checkout manifest — can
* find the branch. Staging in the main workspace root instead leaves the branch
* invisible to the MCP server, producing "fatal: Needed a single revision" when
* it tries to pin the branch (issue #40086).
*
* Resolution mirrors the MCP handler: the target repo comes from the sample's
* `arguments.repo` override or the configured `target-repo`, and the checkout
* directory is resolved via the manifest-first `findRepoCheckout`. Falls back to
* the workspace root when no target repo is set or the checkout cannot be located.
*
* @param {SampleEntry} entry
* @param {string} workspace
* @returns {string}
*/
function resolvePatchWorkspace(entry, workspace) {
let targetRepo = "";
if (entry.arguments && typeof entry.arguments.repo === "string" && entry.arguments.repo.trim()) {
targetRepo = entry.arguments.repo.trim();
} else {
targetRepo = readConfiguredTargetRepo(entry.tool);
}
if (!targetRepo) {
return workspace;
}
try {
const result = findRepoCheckout(targetRepo, workspace);
if (result && result.success && result.path) {
if (path.resolve(result.path) !== path.resolve(workspace)) {
core.info(`apply_samples: staging patch for ${targetRepo} in checkout subdirectory ${result.path}`);
}
return result.path;
}
core.debug(`apply_samples: findRepoCheckout(${targetRepo}) did not locate a checkout; using workspace root`);
} catch (err) {
core.debug(`apply_samples: findRepoCheckout(${targetRepo}) failed: ${getErrorMessage(err)}; using workspace root`);
}
return workspace;
}

/**
* Pre-stage a branch + patch for samples whose tool reads the workspace diff.
*
Expand All @@ -264,6 +345,11 @@ async function preStagePatch(entry, index, workspace) {
return;
}

// Resolve the directory in which to create the branch and commit the patch.
// For cross-repo checkouts in a subdirectory this is the checkout subdir, not
// the main workspace root (issue #40086).
const repoCwd = resolvePatchWorkspace(entry, workspace);

let branch;
if (entry.tool === "push_to_pull_request_branch") {
// Source ref MUST match the PR's head ref so that
Expand All @@ -286,36 +372,36 @@ async function preStagePatch(entry, index, workspace) {
entry.arguments.branch = branch;
}

ensureGitIdentity(workspace);
ensureGitIdentity(repoCwd);

// Start from the base branch so the diff is meaningful. Tolerate the case
// where the base ref doesn't exist locally — fall back to HEAD.
try {
runGit(["checkout", DEFAULT_BASE_BRANCH], workspace);
runGit(["checkout", DEFAULT_BASE_BRANCH], repoCwd);
} catch (err) {
core.warning(`apply_samples: could not check out base branch ${DEFAULT_BASE_BRANCH}: ${getErrorMessage(err)}; staying on current HEAD`);
}

// Create the branch (or check it out if it already exists from a previous sample).
try {
runGit(["checkout", "-b", branch], workspace);
runGit(["checkout", "-b", branch], repoCwd);
} catch {
runGit(["checkout", branch], workspace);
runGit(["checkout", branch], repoCwd);
}

// Write patch to a temp file and apply it.
const tmpPatch = path.join(os.tmpdir(), `gh-aw-sample-${index + 1}.patch`);
fs.writeFileSync(tmpPatch, patch.endsWith("\n") ? patch : patch + "\n");
try {
runGit(["apply", "--whitespace=nowarn", tmpPatch], workspace);
runGit(["apply", "--whitespace=nowarn", tmpPatch], repoCwd);
} catch (err) {
// Fall back to --3way for patches that don't apply cleanly on top of an
// empty working tree (uncommon but possible for synthetic samples).
runGit(["apply", "--3way", "--whitespace=nowarn", tmpPatch], workspace);
runGit(["apply", "--3way", "--whitespace=nowarn", tmpPatch], repoCwd);
}

runGit(["add", "-A"], workspace);
runGit(["commit", "-m", `gh-aw sample ${index + 1}: ${entry.tool}`, "--allow-empty"], workspace);
runGit(["add", "-A"], repoCwd);
runGit(["commit", "-m", `gh-aw sample ${index + 1}: ${entry.tool}`, "--allow-empty"], repoCwd);
}

/**
Expand Down Expand Up @@ -561,6 +647,8 @@ module.exports = {
main,
loadSamples,
preStagePatch,
resolvePatchWorkspace,
readConfiguredTargetRepo,
resolveMcpServerPath,
selectTokenForRepo,
sendJsonRpc,
Expand Down
73 changes: 73 additions & 0 deletions actions/setup/js/apply_samples.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,79 @@ describe("apply_samples.cjs preStagePatch (create_pull_request / push_to_pull_re
expect(diff).toContain("+hello from a deterministic sample");
});

it("stages the patch in the cross-repo checkout subdirectory (path: github) (issue #40086)", async () => {
// Reproduce the failing layout: a main repo at the workspace root and a
// side-repo checked out into a `github/` subdirectory. The checkout manifest
// maps the target repo to path "github", so preStagePatch must create the
// branch + commit inside ${workspace}/github — not the main repo root —
// otherwise the safe-outputs MCP server cannot pin the branch.
const workspace = makeTempDir("gh-aw-prestage-subdir-");
initRepo(workspace, "main");

const subdir = path.join(workspace, "github");
fs.mkdirSync(subdir);
initRepo(subdir, "main");

const manifestPath = path.join(workspace, "checkout-manifest.json");
fs.writeFileSync(
manifestPath,
JSON.stringify({
"githubnext/gh-aw-side-repo": {
repository: "githubnext/gh-aw-side-repo",
path: "github",
default_branch: "main",
},
})
);

const configPath = path.join(workspace, "config.json");
fs.writeFileSync(
configPath,
JSON.stringify({
create_pull_request: { "target-repo": "githubnext/gh-aw-side-repo" },
})
);

const branchName = "gh-aw-sample-copilot-siderepo-subdir-pr";
const fileToAdd = "subdir-notes.md";
const entry = {
tool: "create_pull_request",
arguments: { title: "Subdir PR", body: "body", branch: branchName },
sidecars: { patch: newFileDiff(fileToAdd, "side repo content\n") },
};

// Reset the checkout-manifest module cache so our manifest is loaded fresh.
require("./checkout_manifest.cjs")._resetCache();

const prevBase = process.env.GH_AW_CUSTOM_BASE_BRANCH;
const prevManifest = process.env.GH_AW_CHECKOUT_MANIFEST;
const prevConfig = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
process.env.GH_AW_CUSTOM_BASE_BRANCH = "main";
process.env.GH_AW_CHECKOUT_MANIFEST = manifestPath;
process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH = configPath;
try {
await preStagePatch(entry, 0, workspace);
} finally {
require("./checkout_manifest.cjs")._resetCache();
if (prevBase === undefined) delete process.env.GH_AW_CUSTOM_BASE_BRANCH;
else process.env.GH_AW_CUSTOM_BASE_BRANCH = prevBase;
if (prevManifest === undefined) delete process.env.GH_AW_CHECKOUT_MANIFEST;
else process.env.GH_AW_CHECKOUT_MANIFEST = prevManifest;
if (prevConfig === undefined) delete process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
else process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH = prevConfig;
}

// The branch + commit + file land in the side-repo subdirectory...
expect(git(["rev-parse", "--abbrev-ref", "HEAD"], subdir).trim()).toBe(branchName);
expect(git(["branch", "--list", branchName], subdir)).toContain(branchName);
expect(fs.existsSync(path.join(subdir, fileToAdd))).toBe(true);

// ...and NOT in the main repo root (which stays on its seed branch).
expect(git(["rev-parse", "--abbrev-ref", "HEAD"], workspace).trim()).toBe("main");
expect(git(["branch", "--list", branchName], workspace).trim()).toBe("");
expect(fs.existsSync(path.join(workspace, fileToAdd))).toBe(false);
});

it("derives push_to_pull_request_branch branch from pull_request event payload", async () => {
const workspace = makeTempDir("gh-aw-prestage-push-pr-");
initRepo(workspace, "main");
Expand Down
Loading