fix(cli): qualify cross-fork PR heads (#2377)

## 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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: v <v@v.speedport.ip>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
This commit is contained in:
flexi767
2026-07-31 06:50:58 +02:00
committed by GitHub
parent 4f929acc10
commit dd930c8d7d
6 changed files with 132 additions and 6 deletions

View File

@@ -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.

View File

@@ -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)", () => {
);
});
});

View File

@@ -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 <branch>`
* 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) {

View File

@@ -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;
}
}

View File

@@ -1101,6 +1101,7 @@ export {
ensureGhAuth,
parseRepoFromRemote,
getCurrentRepo,
getPushRepo,
type GhError,
type GhErrorCode,
type StructuredGhError,

View File

@@ -1228,6 +1228,7 @@ export {
ensureGhAuth,
parseRepoFromRemote,
getCurrentRepo,
getPushRepo,
type GhError,
type GhErrorCode,
type StructuredGhError,