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
113 changes: 81 additions & 32 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,76 @@ const HANDLER_TYPE = "create_pull_request";
/** @type {string} Label always added to fallback issues so the triage system can find them */
const MANAGED_FALLBACK_ISSUE_LABEL = "agentic-workflows";

/**
* Creates a temporary refs/bundles ref for applying create_pull_request bundles.
* Branch names are sanitized for ref compatibility, and a short crypto-random
* suffix avoids collisions between branches that sanitize to the same value.
*
* @param {string} branchName - Target branch name
* @returns {string} Temporary bundle ref name
*/
function createBundleTempRef(branchName) {
const suffix = crypto.randomBytes(4).toString("hex");
return `refs/bundles/create-pr-${branchName.replace(/[^a-zA-Z0-9-]/g, "-")}-${suffix}`;
}

/**
* Apply a git bundle to a local branch without fetching directly into the branch ref.
* Fetching directly into refs/heads/<branch> fails when that branch is currently checked out.
*
* @param {string} bundleFilePath - Path to the bundle file
* @param {string} branchName - Target branch name
* @param {string} originalAgentBranch - Original source branch name from the agent, if different
* @param {{ exec: Function, getExecOutput: Function }} execApi - GitHub Actions exec API
* @returns {Promise<void>}
*/
async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, execApi) {
let bundleBranchRef = `refs/heads/${originalAgentBranch || branchName}`;
const bundleTargetRef = `refs/heads/${branchName}`;
const bundleTempRef = createBundleTempRef(branchName);

try {
await ensureFullHistoryForBundle(execApi);

// Fetch from bundle into a temporary ref, then update the target branch.
// bundleBranchRef is the source ref inside the bundle (typically refs/heads/<agent-branch>).
try {
await execApi.exec("git", ["fetch", bundleFilePath, `${bundleBranchRef}:${bundleTempRef}`]);
} catch (initialFetchError) {
// Fallback: resolve the source ref directly from the bundle contents.
// Some agents may emit a JSONL branch name that differs from the ref embedded in the bundle.
const initialFetchErrorMessage = initialFetchError instanceof Error ? initialFetchError.message : String(initialFetchError);
core.warning(`Bundle fetch with ${bundleBranchRef} failed: ${initialFetchErrorMessage}; resolving branch ref from bundle heads`);
const { stdout: bundleHeadsOutput } = await execApi.getExecOutput("git", ["bundle", "list-heads", bundleFilePath]);
const branchRefs = bundleHeadsOutput
.split("\n")
.map(line => line.trim().split(/\s+/)[1] || "")
.filter(ref => /^refs\/heads\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/.test(ref));

if (branchRefs.length === 1) {
bundleBranchRef = branchRefs[0];
core.info(`Resolved bundle source ref from list-heads: ${bundleBranchRef}`);
await execApi.exec("git", ["fetch", bundleFilePath, `${bundleBranchRef}:${bundleTempRef}`]);
} else {
throw new Error(`Failed to resolve bundle branch ref from list-heads: expected exactly 1 refs/heads entry, found ${branchRefs.length}`, { cause: initialFetchError });
}
}
core.info(`Fetched bundle to ${bundleTempRef}`);
await execApi.exec("git", ["update-ref", bundleTargetRef, bundleTempRef]);
core.info(`Created local branch ${branchName} from bundle`);
await execApi.exec("git", ["checkout", branchName]);
// Ensure the working tree matches the new HEAD in case checkout left any index/working tree drift.
await execApi.exec("git", ["reset", "--hard"]);
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.

[/diagnose] git reset --hard (without a ref) discards any staged/unstaged changes on the currently checked-out branch. After git checkout <branch> has already updated the working tree to the new HEAD, the reset is a no-op in the clean CI case — but if for some reason the branch had local uncommitted changes before applyBundleToBranch was called, this silently destroys them.

A comment explaining the intent would help:

// Ensure the working tree matches the new HEAD in case checkout left any index/worktree drift
await execApi.exec("git", ["reset", "--hard"]);

If the goal is strictly to update the working tree to match the new ref, consider git reset --hard HEAD (explicit) or git read-tree -u -m HEAD so the intent is unambiguous.

core.info(`Checked out branch ${branchName} from bundle`);
} finally {
try {
await execApi.exec("git", ["update-ref", "-d", bundleTempRef]);
} catch {
// Non-fatal cleanup
}
}
}

/**
* Determines if a label API error is transient and worth retrying.
* Returns true for:
Expand Down Expand Up @@ -1265,36 +1335,8 @@ async function main(config = {}) {
// This preserves merge commit topology and per-commit metadata (messages, authorship)
// unlike git format-patch which flattens history and drops merge resolution content.
core.info(`Applying changes from bundle: ${bundleFilePath}`);
let bundleBranchRef = `refs/heads/${originalAgentBranch || branchName}`;
try {
await ensureFullHistoryForBundle(exec);

// Fetch from bundle: creates a local branch pointing to the bundle's tip commit.
// bundleBranchRef is the source ref inside the bundle (typically refs/heads/<agent-branch>).
try {
await exec.exec("git", ["fetch", bundleFilePath, `${bundleBranchRef}:refs/heads/${branchName}`]);
} catch (initialFetchError) {
// Fallback: resolve the source ref directly from the bundle contents.
// Some agents may emit a JSONL branch name that differs from the ref embedded in the bundle.
const initialFetchErrorMessage = initialFetchError instanceof Error ? initialFetchError.message : String(initialFetchError);
core.warning(`Bundle fetch with ${bundleBranchRef} failed: ${initialFetchErrorMessage}; resolving branch ref from bundle heads`);
const { stdout: bundleHeadsOutput } = await exec.getExecOutput("git", ["bundle", "list-heads", bundleFilePath]);
const branchRefs = bundleHeadsOutput
.split("\n")
.map(line => line.trim().split(/\s+/)[1] || "")
.filter(ref => /^refs\/heads\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/.test(ref));

if (branchRefs.length === 1) {
bundleBranchRef = branchRefs[0];
core.info(`Resolved bundle source ref from list-heads: ${bundleBranchRef}`);
await exec.exec("git", ["fetch", bundleFilePath, `${bundleBranchRef}:refs/heads/${branchName}`]);
} else {
throw new Error(`Failed to resolve bundle branch ref from list-heads: expected exactly 1 refs/heads entry, found ${branchRefs.length}`, { cause: initialFetchError });
}
}
core.info(`Created local branch ${branchName} from bundle`);
await exec.exec("git", ["checkout", branchName]);
core.info(`Checked out branch ${branchName} from bundle`);
await applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, exec);
} catch (bundleError) {
core.error(`Failed to apply bundle: ${bundleError instanceof Error ? bundleError.message : String(bundleError)}`);
return { success: false, error: "Failed to apply bundle" };
Expand Down Expand Up @@ -1336,6 +1378,8 @@ async function main(config = {}) {
const runId = context.runId;

const artifactFileName = bundleFilePath ? bundleFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.bundle";
const fallbackBundleSourceRef = `refs/heads/${originalAgentBranch || branchName}`;
const fallbackBundleTempRef = createBundleTempRef(branchName);
const fallbackBody = `${body}

---
Expand All @@ -1353,9 +1397,14 @@ To create a pull request with the changes:
# Download the artifact from the workflow run
gh run download ${runId} -n agent -D /tmp/agent-${runId}

# Fetch the bundle into a local branch
git fetch /tmp/agent-${runId}/${artifactFileName} ${bundleBranchRef}:refs/heads/${branchName}
# Fetch the bundle into a temporary ref, then update the local branch
git fetch /tmp/agent-${runId}/${artifactFileName} ${fallbackBundleSourceRef}:${fallbackBundleTempRef}
git update-ref refs/heads/${branchName} ${fallbackBundleTempRef}
git checkout ${branchName}
# Ensure the working tree matches the updated branch
git reset --hard
# Remove the temporary bundle ref
git update-ref -d ${fallbackBundleTempRef}

# Push the branch to origin
git push origin ${branchName}
Expand Down Expand Up @@ -2059,4 +2108,4 @@ ${patchPreview}`;
}; // End of handleCreatePullRequest
} // End of main

module.exports = { main, enforcePullRequestLimits, countUniquePatchFiles, parseDiffGitHeader };
module.exports = { main, enforcePullRequestLimits, countUniquePatchFiles, parseDiffGitHeader, applyBundleToBranch };
99 changes: 96 additions & 3 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ describe("create_pull_request - bundle transport shallow checkout", () => {
get: vi.fn().mockResolvedValue({ data: { default_branch: "main" } }),
},
issues: {
create: vi.fn().mockResolvedValue({ data: { number: 99, html_url: "https://github.com/test-owner/test-repo/issues/99" } }),
addLabels: vi.fn().mockResolvedValue({}),
},
},
Expand Down Expand Up @@ -251,7 +252,14 @@ index 0000000..abc1234

expect(result.success).toBe(true);
expect(global.exec.exec).toHaveBeenCalledWith("git", ["fetch", "--unshallow", "origin"], expect.any(Object));
expect(global.exec.exec).toHaveBeenCalledWith("git", ["fetch", bundlePath, "refs/heads/feature/test:refs/heads/feature/test"]);
const bundleFetchCall = global.exec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && args[1] === bundlePath);
if (!bundleFetchCall) {
throw new Error("expected bundle fetch call");
}
expect(bundleFetchCall[1][2]).toMatch(/^refs\/heads\/feature\/test:refs\/bundles\/create-pr-feature-test-[a-f0-9]{8}$/);
const bundleTempRef = bundleFetchCall[1][2].split(":")[1];
expect(global.exec.exec).toHaveBeenCalledWith("git", ["update-ref", "refs/heads/feature/test", bundleTempRef]);
expect(global.exec.exec).toHaveBeenCalledWith("git", ["reset", "--hard"]);
const unshallowCallIndex = global.exec.exec.mock.calls.findIndex(([, args]) => Array.isArray(args) && args[0] === "fetch" && args[1] === "--unshallow");
const bundleFetchCallIndex = global.exec.exec.mock.calls.findIndex(([, args]) => Array.isArray(args) && args[0] === "fetch" && args[1] === bundlePath);
expect(unshallowCallIndex).toBeGreaterThanOrEqual(0);
Expand Down Expand Up @@ -282,7 +290,7 @@ index 0000000..abc1234
fs.writeFileSync(bundlePath, "bundle content");

global.exec.exec.mockImplementation((cmd, args) => {
if (cmd === "git" && Array.isArray(args) && args[0] === "fetch" && args[1] === bundlePath && args[2] === "refs/heads/ops-review-may09-2026:refs/heads/ops-review-may09-2026") {
if (cmd === "git" && Array.isArray(args) && args[0] === "fetch" && args[1] === bundlePath && /^refs\/heads\/ops-review-may09-2026:refs\/bundles\/create-pr-ops-review-may09-2026-[a-f0-9]{8}$/.test(args[2])) {
throw new Error("fatal: couldn't find remote ref refs/heads/ops-review-may09-2026");
}
return Promise.resolve(0);
Expand Down Expand Up @@ -314,7 +322,92 @@ index 0000000..abc1234

expect(result.success).toBe(true);
expect(global.exec.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "list-heads", bundlePath]);
expect(global.exec.exec).toHaveBeenCalledWith("git", ["fetch", bundlePath, "refs/heads/main:refs/heads/ops-review-may09-2026"]);
const resolvedFetchCall = global.exec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && args[1] === bundlePath && args[2].startsWith("refs/heads/main:"));
if (!resolvedFetchCall) {
throw new Error("expected resolved bundle fetch call");
}
expect(resolvedFetchCall[1][2]).toMatch(/^refs\/heads\/main:refs\/bundles\/create-pr-ops-review-may09-2026-[a-f0-9]{8}$/);
});

it("should not fetch a bundle directly into the target branch", async () => {
const patchPath = path.join(tempDir, "test.patch");
fs.writeFileSync(
patchPath,
`From abc123 Mon Sep 17 00:00:00 2001
From: Test Author <test@example.com>
Date: Mon, 1 Jan 2024 00:00:00 +0000
Subject: [PATCH] Test commit

diff --git a/test.txt b/test.txt
new file mode 100644
index 0000000..abc1234
--- /dev/null
+++ b/test.txt
@@ -0,0 +1 @@
+Hello World
--
2.34.1
`
);
const bundlePath = path.join(tempDir, "test.bundle");
fs.writeFileSync(bundlePath, "bundle content");

const { main } = require("./create_pull_request.cjs");
const handler = await main({ base_branch: "main", preserve_branch_name: true });
const result = await handler({ title: "Test PR", body: "Test body", branch: "autoloop/perf-comparison", patch_path: patchPath, bundle_path: bundlePath }, {});

expect(result.success).toBe(true);
expect(global.exec.exec).not.toHaveBeenCalledWith("git", ["fetch", bundlePath, "refs/heads/autoloop/perf-comparison:refs/heads/autoloop/perf-comparison"]);
const bundleFetchCall = global.exec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && args[1] === bundlePath);
if (!bundleFetchCall) {
throw new Error("expected bundle fetch call");
}
expect(bundleFetchCall[1][2]).toMatch(/^refs\/heads\/autoloop\/perf-comparison:refs\/bundles\/create-pr-autoloop-perf-comparison-[a-f0-9]{8}$/);
const bundleTempRef = bundleFetchCall[1][2].split(":")[1];
expect(global.exec.exec).toHaveBeenCalledWith("git", ["update-ref", "refs/heads/autoloop/perf-comparison", bundleTempRef]);
});

it("should give fallback issue bundle instructions that avoid direct branch fetches", async () => {
const patchPath = path.join(tempDir, "test.patch");
fs.writeFileSync(
patchPath,
`From abc123 Mon Sep 17 00:00:00 2001
From: Test Author <test@example.com>
Date: Mon, 1 Jan 2024 00:00:00 +0000
Subject: [PATCH] Test commit

diff --git a/test.txt b/test.txt
new file mode 100644
index 0000000..abc1234
--- /dev/null
+++ b/test.txt
@@ -0,0 +1 @@
+Hello World
--
2.34.1
`
);
const bundlePath = path.join(tempDir, "aw-test.bundle");
fs.writeFileSync(bundlePath, "bundle content");
pushSignedSpy.mockRejectedValueOnce(new Error("push rejected"));

const { main } = require("./create_pull_request.cjs");
const handler = await main({ base_branch: "main", preserve_branch_name: true });
const result = await handler({ title: "Test PR", body: "Test body", branch: "autoloop/perf-comparison", patch_path: patchPath, bundle_path: bundlePath }, {});

expect(result.success).toBe(true);
expect(result.fallback_used).toBe(true);

const fallbackIssueBody = global.github.rest.issues.create.mock.calls[0][0].body;
const tempRefMatch = fallbackIssueBody.match(/refs\/heads\/autoloop\/perf-comparison:(refs\/bundles\/create-pr-autoloop-perf-comparison-[a-f0-9]{8})/);
if (!tempRefMatch?.[1]) {
throw new Error("expected fallback bundle temp ref");
}
const fallbackBundleTempRef = tempRefMatch[1];
expect(fallbackIssueBody).toContain(`git update-ref refs/heads/autoloop/perf-comparison ${fallbackBundleTempRef}`);
expect(fallbackIssueBody).toContain("git reset --hard");
expect(fallbackIssueBody).toContain(`git update-ref -d ${fallbackBundleTempRef}`);
expect(fallbackIssueBody).not.toContain("refs/heads/autoloop/perf-comparison:refs/heads/autoloop/perf-comparison");
});
});

Expand Down
Loading