From dd930c8d7d652e8c71154288a77af06973033f4e Mon Sep 17 00:00:00 2001 From: flexi767 <96955327+flexi767@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:50:58 +0200 Subject: [PATCH] fix(cli): qualify cross-fork PR heads (#2377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - resolve the repository receiving pushes through `git remote get-url --push origin` - qualify pull-request head branches with the fork owner when the push owner differs from upstream - preserve the existing unqualified head for same-repository workflows ## Root cause Fusion correctly resolved the PR target from origin's fetch URL, but assumed the pushed branch lived in that same repository. With an upstream fetch URL and a fork push URL, GitHub requires `fork-owner:branch`; the unqualified branch is rejected. ## Validation - CLI task lifecycle tests: 48 passed - `@fusion/core` typecheck - `@runfusion/fusion` typecheck - strict changeset validation ## Summary by CodeRabbit * **Bug Fixes** * Pull requests created from branches pushed to contributor forks now correctly qualify the PR head with the fork owner when the push remote differs from the upstream owner. * Improved PR head handling across both group/shared-branch and per-task pull request creation paths. * **Tests** * Updated and expanded lifecycle tests to cover “origin push to fork” scenarios using push URL–based repo resolution. * **Documentation** * Added a patch release note for the fork-aware PR head fix. --------- Co-authored-by: v Co-authored-by: gsxdsm --- .changeset/fork-aware-pr-head.md | 7 +++ .../commands/__tests__/task-lifecycle.test.ts | 60 ++++++++++++++++++- packages/cli/src/commands/task-lifecycle.ts | 37 ++++++++++-- packages/core/src/gh-cli.ts | 32 ++++++++++ packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + 6 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 .changeset/fork-aware-pr-head.md diff --git a/.changeset/fork-aware-pr-head.md b/.changeset/fork-aware-pr-head.md new file mode 100644 index 0000000000..ac22a6afd0 --- /dev/null +++ b/.changeset/fork-aware-pr-head.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Create upstream pull requests from task branches pushed to a contributor fork. +category: fix +dev: Qualifies PR heads with the owner from origin's push URL when it differs from the upstream fetch owner. diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index 4a24f81e04..84b364c083 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -35,10 +35,11 @@ vi.mock("@fusion/core", async () => { return { ...actual, getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })), + getPushRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })), }; }); -import { getCurrentRepo } from "@fusion/core"; +import { getCurrentRepo, getPushRepo } from "@fusion/core"; import { activeSessionRegistry } from "@fusion/engine"; import { cleanupMergedTaskArtifacts, @@ -158,6 +159,8 @@ describe("processPullRequestMergeTask", () => { execMock.mockReset(); execFileCalls.length = 0; vi.mocked(getCurrentRepo).mockReturnValue({ owner: "owner", repo: "repo" }); + // Same-repo default: push owner matches fetch owner so heads stay unqualified. + vi.mocked(getPushRepo).mockReturnValue({ owner: "owner", repo: "repo" }); }); describe("central-install repo threading (gh-4)", () => { @@ -169,6 +172,7 @@ describe("processPullRequestMergeTask", () => { beforeEach(() => { vi.mocked(getCurrentRepo).mockImplementation(((cwd?: string) => cwd ? { owner: "central-owner", repo: "central-repo" } : null) as never); + vi.mocked(getPushRepo).mockReturnValue({ owner: "central-owner", repo: "central-repo" }); }); it("threads explicit owner/repo into findPrForBranch, createPr, and mergePr on the per-task path", async () => { @@ -1839,6 +1843,7 @@ describe("createGroupPrCallback", () => { it("resolves the repo from the callback cwd and threads owner/repo into findPrForBranch/createPr (gh-4)", async () => { vi.mocked(getCurrentRepo).mockImplementation(((cwd?: string) => cwd ? { owner: "central-owner", repo: "central-repo" } : null) as never); + vi.mocked(getPushRepo).mockReturnValue({ owner: "central-owner", repo: "central-repo" }); execMock.mockReturnValue(""); const github = { @@ -1868,6 +1873,40 @@ describe("createGroupPrCallback", () => { ); expect(result.prNumber).toBe(5); }); + + it("qualifies the group PR head with the fork owner when origin pushes to a fork", async () => { + vi.mocked(getCurrentRepo).mockImplementation(((cwd?: string) => + cwd ? { owner: "central-owner", repo: "central-repo" } : null) as never); + vi.mocked(getPushRepo).mockReturnValue({ owner: "fork-owner", repo: "central-repo" }); + execMock.mockReturnValue(""); + + const github = { + findPrForBranch: vi.fn(async () => null), + createPr: vi.fn(async () => ({ + number: 7, + url: "https://github.com/central-owner/central-repo/pull/7", + status: "open" as const, + })), + }; + + const callback = createGroupPrCallback(github as never); + await callback({ + cwd: "/projects/repo-a", + group: { id: "BG-fork", branchName: "fusion/groups/fork" } as never, + members: [{ id: "FN-9602", title: "m1" }] as never, + headBranch: "fusion/groups/fork", + baseBranch: "main", + }); + + expect(vi.mocked(getPushRepo)).toHaveBeenCalledWith("/projects/repo-a"); + expect(github.createPr).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "central-owner", + repo: "central-repo", + head: "fork-owner:fusion/groups/fork", + }), + ); + }); }); describe("createPrNodeGithubOps repo resolution (gh-4)", () => { @@ -1876,6 +1915,7 @@ describe("createPrNodeGithubOps repo resolution (gh-4)", () => { execFileCalls.length = 0; vi.mocked(getCurrentRepo).mockImplementation(((cwd?: string) => cwd ? { owner: "central-owner", repo: "central-repo" } : null) as never); + vi.mocked(getPushRepo).mockReturnValue({ owner: "central-owner", repo: "central-repo" }); }); const githubStub = () => ({ @@ -1940,6 +1980,23 @@ describe("createPrNodeGithubOps repo resolution (gh-4)", () => { expect(result.prNumber).toBe(9); }); + it("qualifies the PR head with the fork owner when origin pushes to a fork", async () => { + execMock.mockReturnValue(""); + vi.mocked(getPushRepo).mockReturnValue({ owner: "fork-owner", repo: "central-repo" }); + const github = githubStub(); + const ops = createPrNodeGithubOps(github as never); + + await ops.createPr({ + task: { id: "FN-9601", title: "t", description: "d", worktree: "/projects/repo-a/.worktrees/fn-9601" }, + entity: { id: "e1", sourceId: "FN-9601", repo: "central-owner/central-repo", headBranch: "fusion/fn-9601", baseBranch: "main" }, + } as never); + + expect(vi.mocked(getPushRepo)).toHaveBeenCalledWith("/projects/repo-a/.worktrees/fn-9601"); + expect(github.createPr).toHaveBeenCalledWith( + expect.objectContaining({ owner: "central-owner", repo: "central-repo", head: "fork-owner:fusion/fn-9601" }), + ); + }); + it("mergePr passes owner/repo parsed from entity.repo", async () => { const github = githubStub(); const ops = createPrNodeGithubOps(github as never); @@ -1952,4 +2009,3 @@ describe("createPrNodeGithubOps repo resolution (gh-4)", () => { ); }); }); - diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index d7a21fa6df..0836ed9201 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -27,6 +27,7 @@ import type { TaskStore } from "@fusion/core"; import { resolveTaskMergeTarget, getCurrentRepo, + getPushRepo, isBranchGroupMemberLanded, resolveEffectiveSettings, isWorkspaceTask, @@ -134,6 +135,26 @@ export function getTaskBranchName(taskId: string): string { return `fusion/${taskId.toLowerCase()}`; } +/* +FNXC:ForkAwarePrHead 2026-07-26-07:18: +When origin's push URL targets a contributor fork while fetch points at upstream, +GitHub PR create requires head as `fork-owner:branch`. Same-repository workflows +keep the unqualified branch name. Centralize the rule so every createPr surface +(pr-create node, group PR callback, shared-branch and per-task processPullRequest +paths) qualifies the head the same way and cannot open against the wrong repo. +*/ +function qualifyForkAwarePrHead( + cwd: string, + upstreamOwner: string | undefined, + headBranch: string, +): string { + const pushRepo = getPushRepo(cwd); + if (pushRepo?.owner && upstreamOwner && pushRepo.owner !== upstreamOwner) { + return `${pushRepo.owner}:${headBranch}`; + } + return headBranch; +} + /** * Push the per-task branch to origin so `gh pr create --head ` * can find it. Idempotent: creates the remote branch on first push and @@ -313,12 +334,14 @@ export function createGroupPrCallback( title: member.title, branchName: getTaskBranchName(member.id), })); + // FNXC:ForkAwarePrHead 2026-07-26-07:18: group/shared-branch PRs also push via + // origin and must qualify head with the fork owner when push ≠ fetch repo. const created = await github.createPr({ owner: repo.owner, repo: repo.repo, title: buildGroupPullRequestTitle(group, members), body: buildGroupPullRequestBody(group, membersWithBranch), - head: headBranch, + head: qualifyForkAwarePrHead(cwd, repo.owner, headBranch), base: baseBranch, }); return { prNumber: created.number, prUrl: created.url, prState: toBranchGroupPrState(created) }; @@ -493,12 +516,14 @@ export function createPrNodeGithubOps( const headBranch = entity.headBranch || getTaskBranchName(task.id); await pushTaskBranchToOrigin(cwd, headBranch); const { owner, name } = splitRepoSlug(entity.repo); + // FNXC:ForkAwarePrHead 2026-07-26-07:18: qualify head as owner:branch when + // origin pushes to a fork while the PR targets upstream. const created = await github.createPr({ owner, repo: name, title: task.title ?? `Task ${task.id}`, body: task.description ?? "", - head: headBranch, + head: qualifyForkAwarePrHead(cwd, owner, headBranch), base: entity.baseBranch, }); const headOid = await resolveBranchHeadOid(cwd, headBranch); @@ -868,12 +893,14 @@ export async function processPullRequestMergeTask( if (!groupPrInfo) { await pushTaskBranchToOrigin(cwd, branchGroup.branchName); try { + // FNXC:ForkAwarePrHead 2026-07-26-07:18: shared-branch processPullRequest + // path must qualify head for fork push URLs (same as createGroupPrCallback). groupPrInfo = await github.createPr({ owner: prRepo.owner, repo: prRepo.repo, title: buildGroupPullRequestTitle(branchGroup, members), body: buildGroupPullRequestBody(branchGroup, membersWithCommits), - head: branchGroup.branchName, + head: qualifyForkAwarePrHead(cwd, prRepo.owner, branchGroup.branchName), base: projectDefaultBranch, }); } catch (err: unknown) { @@ -974,12 +1001,14 @@ export async function processPullRequestMergeTask( await pushTaskBranchToOrigin(cwd, branch); } try { + // FNXC:ForkAwarePrHead 2026-07-26-07:18: per-task processPullRequest path + // must qualify head for fork push URLs (same as createPrNodeGithubOps). prInfo = existingPr ?? await github.createPr({ owner: prRepo.owner, repo: prRepo.repo, title: buildPullRequestTitle(task), body: buildPullRequestBody(task), - head: branch, + head: qualifyForkAwarePrHead(cwd, prRepo.owner, branch), base: mergeTarget.branch, }); } catch (err: unknown) { diff --git a/packages/core/src/gh-cli.ts b/packages/core/src/gh-cli.ts index a07d28ae70..b0b71dcc49 100644 --- a/packages/core/src/gh-cli.ts +++ b/packages/core/src/gh-cli.ts @@ -502,3 +502,35 @@ export function getCurrentRepo(cwd?: string): { owner: string; repo: string } | return null; } } + +/* +FNXC:ForkAwarePrHead 2026-07-26-07:18: +Resolve the repository that receives pushes for a remote via +`git remote get-url --push`. Fork workflows commonly keep origin's fetch URL on +upstream while configuring a distinct push URL for the contributor fork. Callers +that open PRs must compare this owner to the upstream fetch owner and, when they +differ, qualify head as `push-owner:branch` so GitHub does not search upstream +for a branch that only exists on the fork. Returns null when the remote URL is +missing or unparseable; callers then keep an unqualified head (same-repo default). +*/ +/** + * Get the GitHub repository that receives pushes for a remote. + * + * A fork workflow commonly keeps `origin`'s fetch URL pointed at upstream while + * configuring a distinct push URL for the contributor fork. PR creation must + * qualify the head branch with that fork owner or GitHub looks for the branch + * in the upstream repository and rejects the request. + */ +export function getPushRepo(cwd?: string, remote = "origin"): { owner: string; repo: string } | null { + try { + const remoteUrl = execFileSync("git", ["remote", "get-url", "--push", remote], { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + }).trim(); + + return parseRepoFromRemote(remoteUrl); + } catch { + return null; + } +} diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 403beeea97..61de81b32c 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -1101,6 +1101,7 @@ export { ensureGhAuth, parseRepoFromRemote, getCurrentRepo, + getPushRepo, type GhError, type GhErrorCode, type StructuredGhError, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0933d6eb63..58757013e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1228,6 +1228,7 @@ export { ensureGhAuth, parseRepoFromRemote, getCurrentRepo, + getPushRepo, type GhError, type GhErrorCode, type StructuredGhError,