diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index d8acf98de4a..5dc2d97a1dc 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -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/ 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} + */ +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/). + 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"]); + 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: @@ -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/). - 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" }; @@ -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} --- @@ -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} @@ -2059,4 +2108,4 @@ ${patchPreview}`; }; // End of handleCreatePullRequest } // End of main -module.exports = { main, enforcePullRequestLimits, countUniquePatchFiles, parseDiffGitHeader }; +module.exports = { main, enforcePullRequestLimits, countUniquePatchFiles, parseDiffGitHeader, applyBundleToBranch }; diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index ca3ce36bdca..43bf942063a 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -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({}), }, }, @@ -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); @@ -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); @@ -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 +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 +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"); }); }); diff --git a/actions/setup/js/create_pull_request_bundle_integration.test.cjs b/actions/setup/js/create_pull_request_bundle_integration.test.cjs new file mode 100644 index 00000000000..9c37f784c2f --- /dev/null +++ b/actions/setup/js/create_pull_request_bundle_integration.test.cjs @@ -0,0 +1,177 @@ +/** + * Integration tests for create_pull_request bundle application. + * + * These tests run real git commands against temporary repositories to verify + * bundle handling for checked-out target branches. + */ + +import { describe, it, expect, afterEach, vi } from "vitest"; +import { createRequire } from "module"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { spawnSync } from "child_process"; + +const require = createRequire(import.meta.url); + +global.core = { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +}; + +function execGit(args, options = {}) { + const result = spawnSync("git", args, { + encoding: "utf8", + ...options, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0 && !options.allowFailure) { + throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); + } + return result; +} + +function createRepo(prefix) { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + execGit(["init"], { cwd: repoDir }); + execGit(["config", "user.name", "Test User"], { cwd: repoDir }); + execGit(["config", "user.email", "test@example.com"], { cwd: repoDir }); + return repoDir; +} + +function createExecApi(cwd, onExec) { + return { + async exec(command, args = []) { + if (command !== "git") { + throw new Error(`unexpected command: ${command}`); + } + const result = execGit(args, { cwd, allowFailure: true }); + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout); + } + if (onExec) { + onExec(args); + } + return result.status; + }, + async getExecOutput(command, args = []) { + if (command !== "git") { + throw new Error(`unexpected command: ${command}`); + } + const result = execGit(args, { cwd, allowFailure: true }); + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout); + } + return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr }; + }, + }; +} + +describe("create_pull_request bundle integration", () => { + const tempDirs = []; + + afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + vi.clearAllMocks(); + }); + + it("applies a bundle when the target branch is currently checked out", async () => { + const branchName = "autoloop/perf-comparison"; + const sourceRepo = createRepo("create-pr-bundle-source-"); + const targetRepo = createRepo("create-pr-bundle-target-"); + tempDirs.push(sourceRepo, targetRepo); + + fs.writeFileSync(path.join(sourceRepo, "file.txt"), "base\n"); + execGit(["add", "file.txt"], { cwd: sourceRepo }); + execGit(["commit", "-m", "base"], { cwd: sourceRepo }); + execGit(["branch", "-M", "main"], { cwd: sourceRepo }); + execGit(["checkout", "-b", branchName], { cwd: sourceRepo }); + fs.writeFileSync(path.join(sourceRepo, "file.txt"), "bundle tip\n"); + execGit(["commit", "-am", "bundle tip"], { cwd: sourceRepo }); + const expectedHead = execGit(["rev-parse", "HEAD"], { cwd: sourceRepo }).stdout.trim(); + const bundlePath = path.join(sourceRepo, "change.bundle"); + execGit(["bundle", "create", bundlePath, `refs/heads/${branchName}`], { cwd: sourceRepo }); + + fs.writeFileSync(path.join(targetRepo, "file.txt"), "checked out branch before bundle\n"); + execGit(["add", "file.txt"], { cwd: targetRepo }); + execGit(["commit", "-m", "old branch state"], { cwd: targetRepo }); + execGit(["checkout", "-b", branchName], { cwd: targetRepo }); + + const checkedOutBranchFetchResult = execGit(["fetch", bundlePath, `refs/heads/${branchName}:refs/heads/${branchName}`], { cwd: targetRepo, allowFailure: true }); + expect(checkedOutBranchFetchResult.status).not.toBe(0); + expect(checkedOutBranchFetchResult.stderr).toContain("refusing to fetch into branch"); + + let bundleTempRef = ""; + const { applyBundleToBranch } = require("./create_pull_request.cjs"); + await applyBundleToBranch( + bundlePath, + branchName, + "", + createExecApi(targetRepo, args => { + if (args[0] === "fetch" && args[1] === bundlePath) { + bundleTempRef = args[2].split(":")[1]; + expect(execGit(["show-ref", "--verify", bundleTempRef], { cwd: targetRepo }).status).toBe(0); + } + }) + ); + + const actualHead = execGit(["rev-parse", "HEAD"], { cwd: targetRepo }).stdout.trim(); + expect(actualHead).toBe(expectedHead); + expect(fs.readFileSync(path.join(targetRepo, "file.txt"), "utf8")).toBe("bundle tip\n"); + expect(bundleTempRef).toMatch(/^refs\/bundles\/create-pr-autoloop-perf-comparison-[a-f0-9]{8}$/); + expect(execGit(["show-ref", "--verify", bundleTempRef], { cwd: targetRepo, allowFailure: true }).status).not.toBe(0); + }); + + it("cleans up the temp ref when updating the target branch fails", async () => { + const branchName = "autoloop/perf-comparison"; + const sourceRepo = createRepo("create-pr-bundle-source-"); + const targetRepo = createRepo("create-pr-bundle-target-"); + tempDirs.push(sourceRepo, targetRepo); + + fs.writeFileSync(path.join(sourceRepo, "file.txt"), "base\n"); + execGit(["add", "file.txt"], { cwd: sourceRepo }); + execGit(["commit", "-m", "base"], { cwd: sourceRepo }); + execGit(["branch", "-M", "main"], { cwd: sourceRepo }); + execGit(["checkout", "-b", branchName], { cwd: sourceRepo }); + fs.writeFileSync(path.join(sourceRepo, "file.txt"), "bundle tip\n"); + execGit(["commit", "-am", "bundle tip"], { cwd: sourceRepo }); + const bundlePath = path.join(sourceRepo, "change.bundle"); + execGit(["bundle", "create", bundlePath, `refs/heads/${branchName}`], { cwd: sourceRepo }); + + fs.writeFileSync(path.join(targetRepo, "file.txt"), "old branch state\n"); + execGit(["add", "file.txt"], { cwd: targetRepo }); + execGit(["commit", "-m", "old branch state"], { cwd: targetRepo }); + execGit(["checkout", "-b", branchName], { cwd: targetRepo }); + const originalHead = execGit(["rev-parse", `refs/heads/${branchName}`], { cwd: targetRepo }).stdout.trim(); + + let bundleTempRef = ""; + const execApi = createExecApi(targetRepo, args => { + if (args[0] === "fetch" && args[1] === bundlePath) { + bundleTempRef = args[2].split(":")[1]; + } + }); + const { applyBundleToBranch } = require("./create_pull_request.cjs"); + + await expect( + applyBundleToBranch(bundlePath, branchName, "", { + ...execApi, + async exec(command, args = []) { + if (command === "git" && args[0] === "update-ref" && args[1] === `refs/heads/${branchName}`) { + throw new Error("simulated update-ref failure"); + } + return execApi.exec(command, args); + }, + }) + ).rejects.toThrow("simulated update-ref failure"); + + expect(bundleTempRef).toMatch(/^refs\/bundles\/create-pr-autoloop-perf-comparison-[a-f0-9]{8}$/); + expect(execGit(["show-ref", "--verify", bundleTempRef], { cwd: targetRepo, allowFailure: true }).status).not.toBe(0); + expect(execGit(["rev-parse", `refs/heads/${branchName}`], { cwd: targetRepo }).stdout.trim()).toBe(originalHead); + }); +});