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:
7
.changeset/fork-aware-pr-head.md
Normal file
7
.changeset/fork-aware-pr-head.md
Normal 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.
|
||||
@@ -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)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,6 +1101,7 @@ export {
|
||||
ensureGhAuth,
|
||||
parseRepoFromRemote,
|
||||
getCurrentRepo,
|
||||
getPushRepo,
|
||||
type GhError,
|
||||
type GhErrorCode,
|
||||
type StructuredGhError,
|
||||
|
||||
@@ -1228,6 +1228,7 @@ export {
|
||||
ensureGhAuth,
|
||||
parseRepoFromRemote,
|
||||
getCurrentRepo,
|
||||
getPushRepo,
|
||||
type GhError,
|
||||
type GhErrorCode,
|
||||
type StructuredGhError,
|
||||
|
||||
Reference in New Issue
Block a user