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
138 changes: 112 additions & 26 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,56 @@ async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBran
}
}

/**
* Rewrites the current branch to a single non-merge commit relative to origin/<baseBranch>.
* 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<void>}
*/
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:
Expand Down Expand Up @@ -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}

---

Expand Down Expand Up @@ -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 {
Expand Down
68 changes: 68 additions & 0 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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");

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(
Expand Down