diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 466908e325a..601d978bd98 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -183,6 +183,56 @@ async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBran } } +/** + * Rewrites the current branch to a single non-merge commit relative to origin/. + * This is used as a recovery path when signed commit replay rejects merge commit topology. + * + * @param {string} baseBranch + * @param {{ exec: Function, getExecOutput: Function }} execApi + * @returns {Promise} + */ +async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi) { + const baseRef = `origin/${baseBranch}`; + const { stdout: originalHeadOut } = await execApi.getExecOutput("git", ["rev-parse", "HEAD"]); + const originalHead = originalHeadOut.trim(); + if (!originalHead) { + throw new Error("Could not resolve current HEAD before bundle rewrite"); + } + + let commitHeadline = "Apply bundled create_pull_request changes"; + try { + const { stdout: headlineOut } = await execApi.getExecOutput("git", ["log", "-1", "--format=%s", "HEAD"]); + if (headlineOut.trim()) { + commitHeadline = headlineOut.trim(); + } + } catch { + // Non-fatal: use default commit headline. + } + + core.warning(`Rewriting bundled commits to a single linear commit for signed push compatibility (base: ${baseRef})`); + try { + await execApi.exec("git", ["reset", "--soft", baseRef]); + const { stdout: stagedFilesOut } = await execApi.getExecOutput("git", ["diff", "--cached", "--name-only"]); + if (!stagedFilesOut.trim()) { + throw new Error(`No staged changes found after soft reset to ${baseRef}`); + } + await execApi.exec("git", ["commit", "-m", commitHeadline]); + const { stdout: rewrittenHeadOut } = await execApi.getExecOutput("git", ["rev-parse", "HEAD"]); + const rewrittenHead = rewrittenHeadOut.trim(); + core.info(`Bundle rewrite completed (old HEAD: ${originalHead}, new HEAD: ${rewrittenHead})`); + } catch (rewriteError) { + try { + await execApi.exec("git", ["reset", "--hard", originalHead]); + core.warning(`Bundle rewrite failed; restored original HEAD ${originalHead}`); + } catch (restoreError) { + core.warning(`Bundle rewrite rollback failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`); + } + throw new Error(`Failed to rewrite bundled commits for signed push retry: ${rewriteError instanceof Error ? rewriteError.message : String(rewriteError)}`, { + cause: rewriteError, + }); + } +} + /** * Determines if a label API error is transient and worth retrying. * Returns true for: @@ -1428,23 +1478,58 @@ async function main(config = {}) { } catch { core.info("Could not count new commits - extra empty commit will be skipped"); } - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); + } catch (initialPushError) { + /** @type {unknown} */ + let pushError = initialPushError; + let pushRecovered = false; + const pushErrorMessage = pushError instanceof Error ? pushError.message : String(pushError); + const isSignedMergeReplayRefusal = signedCommits && /pushSignedCommits: refusing unsigned push/.test(pushErrorMessage) && /merge commit/i.test(pushErrorMessage); + + if (isSignedMergeReplayRefusal) { + core.warning("Signed push rejected merge commit topology from bundle; rewriting branch and retrying signed push"); + try { + await rewriteBundleBranchAsSingleCommit(baseBranch, exec); + await pushSignedCommits({ + githubClient, + owner: repoParts.owner, + repo: repoParts.repo, + branch: branchName, + baseRef: `origin/${baseBranch}`, + cwd: process.cwd(), + signedCommits, + }); + core.info("Changes pushed to branch after bundle rewrite retry"); - if (!fallbackAsIssue) { - const error = `Failed to push changes: ${pushError instanceof Error ? pushError.message : String(pushError)}`; - return { success: false, error, error_type: "push_failed" }; + try { + const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]); + newCommitCount = parseInt(countStr.trim(), 10); + core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`); + } catch { + core.info("Could not count new commits - extra empty commit will be skipped"); + } + pushRecovered = true; + } catch (retryPushError) { + pushError = retryPushError; + } } - core.warning("Git push operation failed - creating fallback issue instead of pull request"); + if (!pushRecovered) { + core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - const runUrl = buildWorkflowRunUrl(context, context.repo); - const runId = context.runId; + if (!fallbackAsIssue) { + const error = `Failed to push changes: ${pushError instanceof Error ? pushError.message : String(pushError)}`; + return { success: false, error, error_type: "push_failed" }; + } + + core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const artifactFileName = bundleFilePath ? bundleFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.bundle"; - const fallbackBundleSourceRef = `refs/heads/${originalAgentBranch || branchName}`; - const fallbackBundleTempRef = createBundleTempRef(branchName); - const fallbackBody = `${body} + const runUrl = buildWorkflowRunUrl(context, context.repo); + 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} --- @@ -1477,22 +1562,23 @@ git push origin ${branchName} gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo ${repoParts.owner}/${repoParts.repo} \`\`\``; - try { - const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees); + try { + const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); + core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); + await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number); + await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - return { - success: true, - fallback_used: true, - issue_number: issue.number, - issue_url: issue.html_url, - }; - } catch (issueError) { - const error = `Failed to push changes and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`; - return { success: false, error }; + return { + success: true, + fallback_used: true, + issue_number: issue.number, + issue_url: issue.html_url, + }; + } catch (issueError) { + const error = `Failed to push changes and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`; + return { success: false, error }; + } } } } else { diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 71b7b7d3e23..47f35b16a6d 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -299,6 +299,74 @@ index 0000000..abc1234 expect(pushSignedSpy).toHaveBeenCalledWith(expect.objectContaining({ signedCommits: false })); }); + it("should rewrite bundle history to a single commit and retry when signed push rejects merge commits", 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"); + + let revParseHeadCallCount = 0; + global.exec.getExecOutput.mockImplementation((cmd, args) => { + if (cmd === "git" && args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + return Promise.resolve({ exitCode: 0, stdout: "true\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "rev-list") { + return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "ls-remote") { + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + } + if (cmd === "git" && args[0] === "rev-parse" && args[1] === "HEAD") { + revParseHeadCallCount += 1; + return Promise.resolve({ exitCode: 0, stdout: revParseHeadCallCount === 1 ? "old-head-sha\n" : "new-head-sha\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "log" && args[1] === "-1" && args[2] === "--format=%s" && args[3] === "HEAD") { + return Promise.resolve({ exitCode: 0, stdout: "bundle merge headline\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "diff" && args[1] === "--cached" && args[2] === "--name-only") { + return Promise.resolve({ exitCode: 0, stdout: "test.txt\n", stderr: "" }); + } + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }); + + pushSignedSpy + .mockRejectedValueOnce( + new Error( + "pushSignedCommits: refusing unsigned push for branch 'feature/test': merge commit detected. " + + "GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits." + ) + ) + .mockResolvedValueOnce("bundle-tip"); + + 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: "feature/test", patch_path: patchPath, bundle_path: bundlePath }, {}); + + expect(result.success).toBe(true); + expect(result.fallback_used).not.toBe(true); + expect(pushSignedSpy).toHaveBeenCalledTimes(2); + expect(global.exec.exec).toHaveBeenCalledWith("git", ["reset", "--soft", "origin/main"]); + expect(global.exec.exec).toHaveBeenCalledWith("git", ["commit", "-m", "bundle merge headline"]); + expect(global.github.rest.issues.create).not.toHaveBeenCalled(); + }); + it("should resolve bundle source ref from list-heads when JSONL branch ref is missing in bundle", async () => { const patchPath = path.join(tempDir, "test.patch"); fs.writeFileSync(