From 0cf63a4df8b5eace83affbfc48193fa5eada9b5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:44:29 +0000 Subject: [PATCH 1/7] Initial plan From c2ab3f0e258d0b466fc921b4ebff08ba54fa729c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:00:50 +0000 Subject: [PATCH 2/7] Fix PR creation failing when merge conflicts with intervening updates When git am --3way fails due to merge conflicts (intervening commits to the target branch), fall back to creating the PR branch at the original base commit. The patch is then applied cleanly at that earlier commit, and the resulting PR will show merge conflicts in GitHub for manual resolution. This implements Option 2 (try --3way first) with fallback to Option 1 (apply at original base commit) as discussed in the issue. The original base commit is found by: 1. Parsing the "From " header of the first patch in the series 2. Resolving the parent commit with git rev-parse ^ 3. Verifying it exists locally (fails gracefully for cross-repo cases) Agent-Logs-Url: https://github.com/github/gh-aw/sessions/0f83490d-837a-47a6-bd2c-e11e6563085e Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 59 +++++- actions/setup/js/create_pull_request.test.cjs | 183 ++++++++++++++++++ 2 files changed, 240 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 6ddede69153..c0995bd7ee1 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -704,11 +704,13 @@ async function main(config = {}) { // Patches are created with git format-patch, so use git am to apply them // Use --3way to handle cross-repo patches where the patch base may differ from target repo // This allows git to resolve create-vs-modify mismatches when a file exists in target but not source + let patchApplied = false; try { await exec.exec(`git am --3way ${patchFilePath}`); core.info("Patch applied successfully"); + patchApplied = true; } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); + core.error(`Failed to apply patch with --3way: ${patchError instanceof Error ? patchError.message : String(patchError)}`); // Investigate why the patch failed by logging git status and the failed patch try { @@ -727,7 +729,60 @@ async function main(config = {}) { core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); } - return { success: false, error: "Failed to apply patch" }; + // Abort the failed git am before attempting any fallback + try { + await exec.exec("git am --abort"); + core.info("Aborted failed git am"); + } catch (abortError) { + core.warning(`Failed to abort git am: ${abortError instanceof Error ? abortError.message : String(abortError)}`); + } + + // Fallback (Option 1): create the PR branch at the original base commit so the PR + // can still be created. GitHub will show the merge conflicts, allowing manual resolution. + // This handles the case where the target branch received intervening commits after + // the patch was generated, making --3way unable to resolve the conflicts automatically. + core.info("Attempting fallback: create PR branch at original base commit..."); + try { + // Extract the first patch commit SHA from the "From ..." header line + const firstCommitMatch = patchContent.match(/^From ([0-9a-f]{40}) /m); + if (!firstCommitMatch) { + core.warning("Could not extract first commit SHA from patch - fallback not possible"); + } else { + const firstPatchCommitSha = firstCommitMatch[1]; + core.info(`First patch commit SHA: ${firstPatchCommitSha}`); + + // Resolve the parent commit (= original base the patch was created from) + const { stdout: parentOutput } = await exec.getExecOutput("git", ["rev-parse", `${firstPatchCommitSha}^`]); + const originalBaseCommit = parentOutput.trim(); + core.info(`Original base commit: ${originalBaseCommit}`); + + // Verify the parent commit is available in this repo (may not exist cross-repo) + await exec.exec("git", ["cat-file", "-e", originalBaseCommit]); + core.info("Original base commit exists locally - proceeding with fallback"); + + // Re-create the PR branch at the original base commit + await exec.exec(`git checkout ${baseBranch}`); + try { + await exec.exec(`git branch -D ${branchName}`); + } catch { + // Branch may not exist yet, ignore + } + await exec.exec(`git checkout -b ${branchName} ${originalBaseCommit}`); + core.info(`Created branch ${branchName} at original base commit ${originalBaseCommit}`); + + // Apply the patch without --3way; we are on the correct base so it should apply cleanly + await exec.exec(`git am ${patchFilePath}`); + core.info("Patch applied successfully at original base commit"); + core.warning(`PR branch ${branchName} is based on an earlier commit than the current ${baseBranch} HEAD. ` + `The pull request will show merge conflicts that require manual resolution.`); + patchApplied = true; + } + } catch (fallbackError) { + core.warning(`Fallback to original base commit failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`); + } + + if (!patchApplied) { + return { success: false, error: "Failed to apply patch" }; + } } // Push the applied commits to the branch (with fallback to issue creation on failure) diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 0028dee4911..51d28568ee0 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -1004,3 +1004,186 @@ describe("create_pull_request - wildcard target-repo", () => { expect(result.success).toBe(false); }); }); + +describe("create_pull_request - patch apply fallback to original base commit", () => { + let tempDir; + let originalEnv; + let patchFilePath; + + const PATCH_COMMIT_SHA = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + const PARENT_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + // Minimal valid format-patch output with a 40-char SHA in the "From" header + const PATCH_CONTENT = + `From ${PATCH_COMMIT_SHA} Mon Sep 17 00:00:00 2001\n` + + `From: Test Author \n` + + `Date: Wed, 26 Mar 2026 12:00:00 +0000\n` + + `Subject: [PATCH] Test change\n\n` + + `---\n` + + ` file.txt | 1 +\n\n` + + `diff --git a/file.txt b/file.txt\n` + + `index 1234567..abcdefg 100644\n` + + `--- a/file.txt\n` + + `+++ b/file.txt\n` + + `@@ -1 +1,2 @@\n` + + ` existing content\n` + + `+new content\n` + + `--\n` + + `2.39.0\n`; + + beforeEach(() => { + originalEnv = { ...process.env }; + process.env.GH_AW_WORKFLOW_ID = "test-workflow"; + process.env.GITHUB_REPOSITORY = "test-owner/test-repo"; + process.env.GITHUB_BASE_REF = "main"; + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-pr-fallback-test-")); + patchFilePath = path.join(tempDir, "test.patch"); + fs.writeFileSync(patchFilePath, PATCH_CONTENT, "utf8"); + + global.core = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + startGroup: vi.fn(), + endGroup: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(undefined), + }, + }; + global.github = { + rest: { + pulls: { + create: vi.fn().mockResolvedValue({ data: { number: 42, html_url: "https://github.com/test/pull/42", node_id: "PR_42" } }), + requestReviewers: vi.fn().mockResolvedValue({}), + }, + repos: { + get: vi.fn().mockResolvedValue({ data: { default_branch: "main" } }), + }, + issues: { + addLabels: vi.fn().mockResolvedValue({}), + }, + }, + graphql: vi.fn(), + }; + global.context = { + eventName: "workflow_dispatch", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: {}, + }; + + delete require.cache[require.resolve("./create_pull_request.cjs")]; + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + Object.assign(process.env, originalEnv); + + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + + delete global.core; + delete global.github; + delete global.context; + delete global.exec; + vi.clearAllMocks(); + }); + + it("should fall back to original base commit when git am --3way fails with merge conflicts", async () => { + let primaryAmAttempted = false; + global.exec = { + exec: vi.fn().mockImplementation((cmd, args) => { + // Fail the first "git am --3way" call to simulate a merge conflict + if (typeof cmd === "string" && cmd.startsWith("git am --3way") && !primaryAmAttempted) { + primaryAmAttempted = true; + throw new Error("CONFLICT (content): Merge conflict in file.txt"); + } + return Promise.resolve(0); + }), + getExecOutput: vi.fn().mockImplementation((cmd, args) => { + // Return the parent SHA for git rev-parse calls + if (cmd === "git" && Array.isArray(args) && args[0] === "rev-parse") { + return Promise.resolve({ exitCode: 0, stdout: `${PARENT_SHA}\n`, stderr: "" }); + } + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }), + }; + + const { main } = require("./create_pull_request.cjs"); + const handler = await main({}); + + const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "test-branch" }, {}); + + expect(result.success).toBe(true); + // Should warn that the PR will show merge conflicts + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("merge conflicts")); + // git am --abort should have been called to clean up the failed attempt + expect(global.exec.exec).toHaveBeenCalledWith("git am --abort"); + // Fallback git am (without --3way) should have been called + expect(global.exec.exec).toHaveBeenCalledWith(expect.stringMatching(/^git am (?!--3way)/)); + }); + + it("should return error when both git am --3way and the fallback git am fail", async () => { + global.exec = { + exec: vi.fn().mockImplementation(cmd => { + // Fail all git am calls except git am --abort + if (typeof cmd === "string" && cmd.startsWith("git am") && !cmd.includes("--abort")) { + throw new Error("CONFLICT (content): Merge conflict in file.txt"); + } + return Promise.resolve(0); + }), + getExecOutput: vi.fn().mockImplementation((cmd, args) => { + if (cmd === "git" && Array.isArray(args) && args[0] === "rev-parse") { + return Promise.resolve({ exitCode: 0, stdout: `${PARENT_SHA}\n`, stderr: "" }); + } + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }), + }; + + const { main } = require("./create_pull_request.cjs"); + const handler = await main({}); + + const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "test-branch" }, {}); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to apply patch"); + }); + + it("should return error when original base commit is not available (cross-repo scenario)", async () => { + global.exec = { + exec: vi.fn().mockImplementation((cmd, args) => { + // Fail git am --3way + if (typeof cmd === "string" && cmd.startsWith("git am --3way")) { + throw new Error("CONFLICT (content): Merge conflict in file.txt"); + } + // Fail git cat-file to simulate commit not present in local repo + if (cmd === "git" && Array.isArray(args) && args[0] === "cat-file") { + throw new Error("Not a valid object name"); + } + return Promise.resolve(0); + }), + getExecOutput: vi.fn().mockImplementation((cmd, args) => { + if (cmd === "git" && Array.isArray(args) && args[0] === "rev-parse") { + return Promise.resolve({ exitCode: 0, stdout: `${PARENT_SHA}\n`, stderr: "" }); + } + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }), + }; + + const { main } = require("./create_pull_request.cjs"); + const handler = await main({}); + + const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "test-branch" }, {}); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to apply patch"); + }); +}); From 179a025d1256380c3b1b877e41bc8c450ffd4a12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:03:17 +0000 Subject: [PATCH 3/7] Address code review feedback: improve constant names and regex - Rename PATCH_COMMIT_SHA/PARENT_SHA to MOCK_PATCH_COMMIT_SHA/MOCK_PARENT_COMMIT_SHA - Use single template literal instead of string concatenation in warning - Replace fragile negative lookahead regex with explicit /^git am [^-]/ pattern Agent-Logs-Url: https://github.com/github/gh-aw/sessions/0f83490d-837a-47a6-bd2c-e11e6563085e Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 2 +- actions/setup/js/create_pull_request.test.cjs | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index c0995bd7ee1..d05df33287f 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -773,7 +773,7 @@ async function main(config = {}) { // Apply the patch without --3way; we are on the correct base so it should apply cleanly await exec.exec(`git am ${patchFilePath}`); core.info("Patch applied successfully at original base commit"); - core.warning(`PR branch ${branchName} is based on an earlier commit than the current ${baseBranch} HEAD. ` + `The pull request will show merge conflicts that require manual resolution.`); + core.warning(`PR branch ${branchName} is based on an earlier commit than the current ${baseBranch} HEAD. The pull request will show merge conflicts that require manual resolution.`); patchApplied = true; } } catch (fallbackError) { diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 51d28568ee0..9cb6f005570 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -1010,11 +1010,11 @@ describe("create_pull_request - patch apply fallback to original base commit", ( let originalEnv; let patchFilePath; - const PATCH_COMMIT_SHA = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; - const PARENT_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + const MOCK_PATCH_COMMIT_SHA = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + const MOCK_PARENT_COMMIT_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; // Minimal valid format-patch output with a 40-char SHA in the "From" header const PATCH_CONTENT = - `From ${PATCH_COMMIT_SHA} Mon Sep 17 00:00:00 2001\n` + + `From ${MOCK_PATCH_COMMIT_SHA} Mon Sep 17 00:00:00 2001\n` + `From: Test Author \n` + `Date: Wed, 26 Mar 2026 12:00:00 +0000\n` + `Subject: [PATCH] Test change\n\n` + @@ -1111,7 +1111,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( getExecOutput: vi.fn().mockImplementation((cmd, args) => { // Return the parent SHA for git rev-parse calls if (cmd === "git" && Array.isArray(args) && args[0] === "rev-parse") { - return Promise.resolve({ exitCode: 0, stdout: `${PARENT_SHA}\n`, stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: `${MOCK_PARENT_COMMIT_SHA}\n`, stderr: "" }); } return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); }), @@ -1128,7 +1128,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( // git am --abort should have been called to clean up the failed attempt expect(global.exec.exec).toHaveBeenCalledWith("git am --abort"); // Fallback git am (without --3way) should have been called - expect(global.exec.exec).toHaveBeenCalledWith(expect.stringMatching(/^git am (?!--3way)/)); + expect(global.exec.exec).toHaveBeenCalledWith(expect.stringMatching(/^git am [^-]/)); }); it("should return error when both git am --3way and the fallback git am fail", async () => { @@ -1142,7 +1142,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( }), getExecOutput: vi.fn().mockImplementation((cmd, args) => { if (cmd === "git" && Array.isArray(args) && args[0] === "rev-parse") { - return Promise.resolve({ exitCode: 0, stdout: `${PARENT_SHA}\n`, stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: `${MOCK_PARENT_COMMIT_SHA}\n`, stderr: "" }); } return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); }), @@ -1172,7 +1172,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( }), getExecOutput: vi.fn().mockImplementation((cmd, args) => { if (cmd === "git" && Array.isArray(args) && args[0] === "rev-parse") { - return Promise.resolve({ exitCode: 0, stdout: `${PARENT_SHA}\n`, stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: `${MOCK_PARENT_COMMIT_SHA}\n`, stderr: "" }); } return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); }), From dfece519374e0c00d94020e430a319b94d127535 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:19:34 +0000 Subject: [PATCH 4/7] docs: add "How PR creation works" section to safe-outputs-pull-requests Explains the git patch mechanism, what happens when the target branch has changed (no conflicts vs merge conflicts with fallback), and what happens when .github/workflows files are modified (protected files). Agent-Logs-Url: https://github.com/github/gh-aw/sessions/5a1f5ac0-7653-4770-8fb5-92b991bccb60 Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com> --- .../reference/safe-outputs-pull-requests.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/src/content/docs/reference/safe-outputs-pull-requests.md b/docs/src/content/docs/reference/safe-outputs-pull-requests.md index 65041c063c2..e448a03d8fa 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -9,6 +9,29 @@ This page covers the two safe-output types that write code to a repository: [`cr For all other safe-output types see [Safe Outputs](/gh-aw/reference/safe-outputs/). +## How PR creation works + +When the coding agent finishes its task, it records the requested changes in a structured output file (JSONL). A separate, permission-controlled job then reads that output and applies the changes: + +1. The agent's commits are exported as a `git format-patch` file covering everything since the original checkout commit. +2. The safe-output job checks out the target repository and fetches the latest state of the base branch. +3. The patch is applied to a new branch using `git am --3way`. The `--3way` flag allows the patch to succeed even when the agent's source repository differs from the target (for example, in cross-repository workflows). +4. The branch is pushed and the GitHub API creates the pull request. + +### If the target branch has changed + +If commits have been pushed to the base branch after the agent started, two outcomes are possible: + +- **No conflicts** — `git am --3way` resolves the patch cleanly against the updated base. The PR is created normally and targets the current head of the base branch. +- **Conflicts** — if `--3way` cannot resolve the conflicts automatically, the safe-output job falls back to applying the patch at the commit the agent originally branched from. The PR is created with the branch based on that earlier commit, and GitHub's pull request UI shows the conflicts for manual resolution. + +> [!NOTE] +> The fallback to the original base commit requires that commit to be present in the target repository. In cross-repository scenarios where the agent repository's history is unrelated, only the `--3way` attempt is made and a hard failure is returned if that also fails. + +### If `.github/workflows` files are changed + +Files under `.github/` are [protected](#protected-files) by default. A patch that modifies `.github/workflows/*.yml` (or any other path under `.github/`) is refused unless the workflow explicitly configures `protected-files: fallback-to-issue` or `protected-files: allowed`. With `fallback-to-issue`, the branch is still pushed but a review issue is created instead of a PR, asking a human to inspect the workflow changes before creating the PR manually. + ## Pull Request Creation (`create-pull-request:`) Creates PRs with code changes. By default, falls back to creating an issue if PR creation fails (e.g., org settings block it). Set `fallback-as-issue: false` to disable this fallback and avoid requiring `issues: write` permission. `expires` field (same-repo only) auto-closes after period: integers (days) or `2h`, `7d`, `2w`, `1m`, `1y` (hours < 24 treated as 1 day). From 937b87b911a367d6e8942d85fc10596f4dec4ea8 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Thu, 26 Mar 2026 17:22:28 +0000 Subject: [PATCH 5/7] Update safe-outputs-pull-requests.md --- .../reference/safe-outputs-pull-requests.md | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/docs/src/content/docs/reference/safe-outputs-pull-requests.md b/docs/src/content/docs/reference/safe-outputs-pull-requests.md index e448a03d8fa..4c44a74f4dd 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -9,29 +9,6 @@ This page covers the two safe-output types that write code to a repository: [`cr For all other safe-output types see [Safe Outputs](/gh-aw/reference/safe-outputs/). -## How PR creation works - -When the coding agent finishes its task, it records the requested changes in a structured output file (JSONL). A separate, permission-controlled job then reads that output and applies the changes: - -1. The agent's commits are exported as a `git format-patch` file covering everything since the original checkout commit. -2. The safe-output job checks out the target repository and fetches the latest state of the base branch. -3. The patch is applied to a new branch using `git am --3way`. The `--3way` flag allows the patch to succeed even when the agent's source repository differs from the target (for example, in cross-repository workflows). -4. The branch is pushed and the GitHub API creates the pull request. - -### If the target branch has changed - -If commits have been pushed to the base branch after the agent started, two outcomes are possible: - -- **No conflicts** — `git am --3way` resolves the patch cleanly against the updated base. The PR is created normally and targets the current head of the base branch. -- **Conflicts** — if `--3way` cannot resolve the conflicts automatically, the safe-output job falls back to applying the patch at the commit the agent originally branched from. The PR is created with the branch based on that earlier commit, and GitHub's pull request UI shows the conflicts for manual resolution. - -> [!NOTE] -> The fallback to the original base commit requires that commit to be present in the target repository. In cross-repository scenarios where the agent repository's history is unrelated, only the `--3way` attempt is made and a hard failure is returned if that also fails. - -### If `.github/workflows` files are changed - -Files under `.github/` are [protected](#protected-files) by default. A patch that modifies `.github/workflows/*.yml` (or any other path under `.github/`) is refused unless the workflow explicitly configures `protected-files: fallback-to-issue` or `protected-files: allowed`. With `fallback-to-issue`, the branch is still pushed but a review issue is created instead of a PR, asking a human to inspect the workflow changes before creating the PR manually. - ## Pull Request Creation (`create-pull-request:`) Creates PRs with code changes. By default, falls back to creating an issue if PR creation fails (e.g., org settings block it). Set `fallback-as-issue: false` to disable this fallback and avoid requiring `issues: write` permission. `expires` field (same-repo only) auto-closes after period: integers (days) or `2h`, `7d`, `2w`, `1m`, `1y` (hours < 24 treated as 1 day). @@ -86,6 +63,25 @@ When `create-pull-request` is configured, git commands (`checkout`, `branch`, `s By default, PRs created with GitHub Agentic Workflows do not trigger CI. See [Triggering CI](/gh-aw/reference/triggering-ci/) for how to configure CI triggers. +### How PR creation works + +When the coding agent finishes its task, it records the requested changes in a structured output file. A separate, permission-controlled job then reads that output and applies the changes: + +1. The agent's commits are exported as a `git format-patch` file covering everything since the original checkout commit. +2. The safe-output job checks out the target repository and fetches the latest state of the base branch. +3. The patch is applied to a new branch using `git am --3way`. The `--3way` flag allows the patch to succeed even when the agent's source repository differs from the target (for example, in cross-repository workflows). +4. The branch is pushed and the GitHub API creates the pull request. + +### If the target branch has changed + +If commits have been pushed to the base branch after the agent started, two outcomes are possible: + +- **No conflicts** — `git am --3way` resolves the patch cleanly against the updated base. The PR is created normally and targets the current head of the base branch. +- **Conflicts** — if `--3way` cannot resolve the conflicts automatically, the safe-output job falls back to applying the patch at the commit the agent originally branched from. The PR is created with the branch based on that earlier commit, and GitHub's pull request UI shows the conflicts for manual resolution. + +> [!NOTE] +> The fallback to the original base commit requires that commit to be present in the target repository. In cross-repository scenarios where the agent repository's history is unrelated, only the `--3way` attempt is made and a hard failure is returned if that also fails. + ## Push to PR Branch (`push-to-pull-request-branch:`) Pushes changes to a PR's branch. Validates via `title-prefix` and `labels` to ensure only approved PRs receive changes. Multiple pushes per run are supported by setting `max` higher than 1. From 25d856e07f9065efe8d3cdbfe8d8d534dca8e323 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Thu, 26 Mar 2026 17:27:53 +0000 Subject: [PATCH 6/7] Update actions/setup/js/create_pull_request.cjs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index d05df33287f..02422e10562 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -706,7 +706,7 @@ async function main(config = {}) { // This allows git to resolve create-vs-modify mismatches when a file exists in target but not source let patchApplied = false; try { - await exec.exec(`git am --3way ${patchFilePath}`); + await exec.exec("git", ["am", "--3way", patchFilePath]); core.info("Patch applied successfully"); patchApplied = true; } catch (patchError) { From 250fc7459b5f9eb6c12c9b4730320017ef1736b8 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Thu, 26 Mar 2026 17:48:16 +0000 Subject: [PATCH 7/7] fix: record base commit at patch generation time for fallback The fallback in create_pull_request.cjs tried to derive the base commit by parsing the From header from git format-patch output and running git rev-parse ^. That SHA is the agent's new commit which doesn't exist in the clean target checkout, so rev-parse always fails and the fallback never activates. Fix: record the resolved base commit SHA explicitly in generate_git_patch.cjs (where it's known), pass it through the safe outputs entry as base_commit, and use it directly in the create_pull_request fallback path. --- actions/setup/js/create_pull_request.cjs | 20 ++--- actions/setup/js/create_pull_request.test.cjs | 78 +++++++++++++------ actions/setup/js/generate_git_patch.cjs | 15 +++- actions/setup/js/safe_outputs_handlers.cjs | 12 +++ 4 files changed, 89 insertions(+), 36 deletions(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 02422e10562..dc6a64abca3 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -743,20 +743,16 @@ async function main(config = {}) { // the patch was generated, making --3way unable to resolve the conflicts automatically. core.info("Attempting fallback: create PR branch at original base commit..."); try { - // Extract the first patch commit SHA from the "From ..." header line - const firstCommitMatch = patchContent.match(/^From ([0-9a-f]{40}) /m); - if (!firstCommitMatch) { - core.warning("Could not extract first commit SHA from patch - fallback not possible"); + // Use the base commit recorded at patch generation time. + // The From header in format-patch output contains the agent's new commit SHA + // which does not exist in this checkout, so we cannot derive the base from it. + const originalBaseCommit = pullRequestItem.base_commit; + if (!originalBaseCommit) { + core.warning("No base_commit recorded in safe output entry - fallback not possible"); } else { - const firstPatchCommitSha = firstCommitMatch[1]; - core.info(`First patch commit SHA: ${firstPatchCommitSha}`); + core.info(`Original base commit from patch generation: ${originalBaseCommit}`); - // Resolve the parent commit (= original base the patch was created from) - const { stdout: parentOutput } = await exec.getExecOutput("git", ["rev-parse", `${firstPatchCommitSha}^`]); - const originalBaseCommit = parentOutput.trim(); - core.info(`Original base commit: ${originalBaseCommit}`); - - // Verify the parent commit is available in this repo (may not exist cross-repo) + // Verify the base commit is available in this repo (may not exist cross-repo) await exec.exec("git", ["cat-file", "-e", originalBaseCommit]); core.info("Original base commit exists locally - proceeding with fallback"); diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 9cb6f005570..e30f5aee7c4 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -1010,11 +1010,10 @@ describe("create_pull_request - patch apply fallback to original base commit", ( let originalEnv; let patchFilePath; - const MOCK_PATCH_COMMIT_SHA = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; - const MOCK_PARENT_COMMIT_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; - // Minimal valid format-patch output with a 40-char SHA in the "From" header + const MOCK_BASE_COMMIT_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + // Minimal valid format-patch output const PATCH_CONTENT = - `From ${MOCK_PATCH_COMMIT_SHA} Mon Sep 17 00:00:00 2001\n` + + `From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001\n` + `From: Test Author \n` + `Date: Wed, 26 Mar 2026 12:00:00 +0000\n` + `Subject: [PATCH] Test change\n\n` + @@ -1097,22 +1096,41 @@ describe("create_pull_request - patch apply fallback to original base commit", ( vi.clearAllMocks(); }); + /** + * Helper to detect git am calls in both formats: + * - exec("git", ["am", "--3way", path]) (array form) + * - exec("git am --3way /path") (string form) + */ + function isGitAmCall(cmd, args) { + if (cmd === "git" && Array.isArray(args) && args[0] === "am") return true; + if (typeof cmd === "string" && cmd.startsWith("git am")) return true; + return false; + } + + function isGitAmAbort(cmd, args) { + if (cmd === "git" && Array.isArray(args) && args[0] === "am" && args.includes("--abort")) return true; + if (typeof cmd === "string" && cmd.includes("am --abort")) return true; + return false; + } + + function isGitAm3Way(cmd, args) { + if (cmd === "git" && Array.isArray(args) && args[0] === "am" && args.includes("--3way")) return true; + if (typeof cmd === "string" && cmd.startsWith("git am --3way")) return true; + return false; + } + it("should fall back to original base commit when git am --3way fails with merge conflicts", async () => { let primaryAmAttempted = false; global.exec = { exec: vi.fn().mockImplementation((cmd, args) => { // Fail the first "git am --3way" call to simulate a merge conflict - if (typeof cmd === "string" && cmd.startsWith("git am --3way") && !primaryAmAttempted) { + if (isGitAm3Way(cmd, args) && !primaryAmAttempted) { primaryAmAttempted = true; throw new Error("CONFLICT (content): Merge conflict in file.txt"); } return Promise.resolve(0); }), getExecOutput: vi.fn().mockImplementation((cmd, args) => { - // Return the parent SHA for git rev-parse calls - if (cmd === "git" && Array.isArray(args) && args[0] === "rev-parse") { - return Promise.resolve({ exitCode: 0, stdout: `${MOCK_PARENT_COMMIT_SHA}\n`, stderr: "" }); - } return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); }), }; @@ -1120,30 +1138,23 @@ describe("create_pull_request - patch apply fallback to original base commit", ( const { main } = require("./create_pull_request.cjs"); const handler = await main({}); - const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "test-branch" }, {}); + const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "test-branch", base_commit: MOCK_BASE_COMMIT_SHA }, {}); expect(result.success).toBe(true); // Should warn that the PR will show merge conflicts expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("merge conflicts")); - // git am --abort should have been called to clean up the failed attempt - expect(global.exec.exec).toHaveBeenCalledWith("git am --abort"); - // Fallback git am (without --3way) should have been called - expect(global.exec.exec).toHaveBeenCalledWith(expect.stringMatching(/^git am [^-]/)); }); it("should return error when both git am --3way and the fallback git am fail", async () => { global.exec = { - exec: vi.fn().mockImplementation(cmd => { + exec: vi.fn().mockImplementation((cmd, args) => { // Fail all git am calls except git am --abort - if (typeof cmd === "string" && cmd.startsWith("git am") && !cmd.includes("--abort")) { + if (isGitAmCall(cmd, args) && !isGitAmAbort(cmd, args)) { throw new Error("CONFLICT (content): Merge conflict in file.txt"); } return Promise.resolve(0); }), getExecOutput: vi.fn().mockImplementation((cmd, args) => { - if (cmd === "git" && Array.isArray(args) && args[0] === "rev-parse") { - return Promise.resolve({ exitCode: 0, stdout: `${MOCK_PARENT_COMMIT_SHA}\n`, stderr: "" }); - } return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); }), }; @@ -1151,7 +1162,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( const { main } = require("./create_pull_request.cjs"); const handler = await main({}); - const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "test-branch" }, {}); + const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "test-branch", base_commit: MOCK_BASE_COMMIT_SHA }, {}); expect(result.success).toBe(false); expect(result.error).toBe("Failed to apply patch"); @@ -1161,7 +1172,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( global.exec = { exec: vi.fn().mockImplementation((cmd, args) => { // Fail git am --3way - if (typeof cmd === "string" && cmd.startsWith("git am --3way")) { + if (isGitAm3Way(cmd, args)) { throw new Error("CONFLICT (content): Merge conflict in file.txt"); } // Fail git cat-file to simulate commit not present in local repo @@ -1171,9 +1182,28 @@ describe("create_pull_request - patch apply fallback to original base commit", ( return Promise.resolve(0); }), getExecOutput: vi.fn().mockImplementation((cmd, args) => { - if (cmd === "git" && Array.isArray(args) && args[0] === "rev-parse") { - return Promise.resolve({ exitCode: 0, stdout: `${MOCK_PARENT_COMMIT_SHA}\n`, stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }), + }; + + const { main } = require("./create_pull_request.cjs"); + const handler = await main({}); + + const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "test-branch", base_commit: MOCK_BASE_COMMIT_SHA }, {}); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to apply patch"); + }); + + it("should return error when no base_commit is provided and git am --3way fails", async () => { + global.exec = { + exec: vi.fn().mockImplementation((cmd, args) => { + if (isGitAm3Way(cmd, args)) { + throw new Error("CONFLICT (content): Merge conflict in file.txt"); } + return Promise.resolve(0); + }), + getExecOutput: vi.fn().mockImplementation(() => { return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); }), }; @@ -1181,9 +1211,11 @@ describe("create_pull_request - patch apply fallback to original base commit", ( const { main } = require("./create_pull_request.cjs"); const handler = await main({}); + // No base_commit provided - fallback should not be possible const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "test-branch" }, {}); expect(result.success).toBe(false); expect(result.error).toBe("Failed to apply patch"); + expect(global.core.warning).toHaveBeenCalledWith("No base_commit recorded in safe output entry - fallback not possible"); }); }); diff --git a/actions/setup/js/generate_git_patch.cjs b/actions/setup/js/generate_git_patch.cjs index c71f00c3a1c..449c6894f56 100644 --- a/actions/setup/js/generate_git_patch.cjs +++ b/actions/setup/js/generate_git_patch.cjs @@ -148,6 +148,10 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { let patchGenerated = false; let errorMessage = null; + // Track the resolved base commit SHA so consumers (e.g. create_pull_request fallback) + // can use it directly. The From header in format-patch output contains the + // *new* commit SHA which won't exist in the target checkout. + let baseCommitSha = null; try { // Strategy 1: If we have a branch name, check if that branch exists and get its diff @@ -263,6 +267,10 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { } } + // Resolve baseRef to a SHA so we can record it for consumers + baseCommitSha = execGitSync(["rev-parse", baseRef], { cwd }).trim(); + debugLog(`Strategy 1: Resolved baseRef ${baseRef} to SHA ${baseCommitSha}`); + // Count commits to be included const commitCount = parseInt(execGitSync(["rev-list", "--count", `${baseRef}..${branchName}`], { cwd }).trim(), 10); debugLog(`Strategy 1: Found ${commitCount} commits between ${baseRef} and ${branchName}`); @@ -332,6 +340,9 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { execGitSync(["merge-base", "--is-ancestor", githubSha, "HEAD"], { cwd }); debugLog(`Strategy 2: GITHUB_SHA is an ancestor of HEAD`); + // Record GITHUB_SHA as the base commit + baseCommitSha = githubSha; + // Count commits between GITHUB_SHA and HEAD const commitCount = parseInt(execGitSync(["rev-list", "--count", `${githubSha}..HEAD`], { cwd }).trim(), 10); debugLog(`Strategy 2: Found ${commitCount} commits between GITHUB_SHA and HEAD`); @@ -396,6 +407,7 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { } if (baseCommit) { + baseCommitSha = baseCommit; const patchContent = execGitSync(["format-patch", `${baseCommit}..${branchName}`, "--stdout", ...excludeArgs()], { cwd }); if (patchContent && patchContent.trim()) { @@ -438,12 +450,13 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { }; } - debugLog(`Final: SUCCESS - patchSize=${patchSize} bytes, patchLines=${patchLines}`); + debugLog(`Final: SUCCESS - patchSize=${patchSize} bytes, patchLines=${patchLines}, baseCommit=${baseCommitSha || "(unknown)"}`); return { success: true, patchPath: patchPath, patchSize: patchSize, patchLines: patchLines, + baseCommit: baseCommitSha, }; } diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index 6632b9626e9..440bb15958a 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -358,6 +358,13 @@ function createHandlers(server, appendSafeOutput, config = {}) { // Store the patch path in the entry so consumers know which file to use entry.patch_path = patchResult.patchPath; + // Store the base commit SHA so the create_pull_request handler can use it + // directly in the fallback path (the From header in format-patch output + // contains the agent's commit SHA which won't exist in the target checkout) + if (patchResult.baseCommit) { + entry.base_commit = patchResult.baseCommit; + } + appendSafeOutput(entry); return { content: [ @@ -472,6 +479,11 @@ function createHandlers(server, appendSafeOutput, config = {}) { // Store the patch path in the entry so consumers know which file to use entry.patch_path = patchResult.patchPath; + // Store the base commit SHA so the push handler can use it directly + if (patchResult.baseCommit) { + entry.base_commit = patchResult.baseCommit; + } + appendSafeOutput(entry); return { content: [