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 {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })),
|
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 { activeSessionRegistry } from "@fusion/engine";
|
||||||
import {
|
import {
|
||||||
cleanupMergedTaskArtifacts,
|
cleanupMergedTaskArtifacts,
|
||||||
@@ -158,6 +159,8 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
execMock.mockReset();
|
execMock.mockReset();
|
||||||
execFileCalls.length = 0;
|
execFileCalls.length = 0;
|
||||||
vi.mocked(getCurrentRepo).mockReturnValue({ owner: "owner", repo: "repo" });
|
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)", () => {
|
describe("central-install repo threading (gh-4)", () => {
|
||||||
@@ -169,6 +172,7 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getCurrentRepo).mockImplementation(((cwd?: string) =>
|
vi.mocked(getCurrentRepo).mockImplementation(((cwd?: string) =>
|
||||||
cwd ? { owner: "central-owner", repo: "central-repo" } : null) as never);
|
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 () => {
|
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 () => {
|
it("resolves the repo from the callback cwd and threads owner/repo into findPrForBranch/createPr (gh-4)", async () => {
|
||||||
vi.mocked(getCurrentRepo).mockImplementation(((cwd?: string) =>
|
vi.mocked(getCurrentRepo).mockImplementation(((cwd?: string) =>
|
||||||
cwd ? { owner: "central-owner", repo: "central-repo" } : null) as never);
|
cwd ? { owner: "central-owner", repo: "central-repo" } : null) as never);
|
||||||
|
vi.mocked(getPushRepo).mockReturnValue({ owner: "central-owner", repo: "central-repo" });
|
||||||
execMock.mockReturnValue("");
|
execMock.mockReturnValue("");
|
||||||
|
|
||||||
const github = {
|
const github = {
|
||||||
@@ -1868,6 +1873,40 @@ describe("createGroupPrCallback", () => {
|
|||||||
);
|
);
|
||||||
expect(result.prNumber).toBe(5);
|
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)", () => {
|
describe("createPrNodeGithubOps repo resolution (gh-4)", () => {
|
||||||
@@ -1876,6 +1915,7 @@ describe("createPrNodeGithubOps repo resolution (gh-4)", () => {
|
|||||||
execFileCalls.length = 0;
|
execFileCalls.length = 0;
|
||||||
vi.mocked(getCurrentRepo).mockImplementation(((cwd?: string) =>
|
vi.mocked(getCurrentRepo).mockImplementation(((cwd?: string) =>
|
||||||
cwd ? { owner: "central-owner", repo: "central-repo" } : null) as never);
|
cwd ? { owner: "central-owner", repo: "central-repo" } : null) as never);
|
||||||
|
vi.mocked(getPushRepo).mockReturnValue({ owner: "central-owner", repo: "central-repo" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const githubStub = () => ({
|
const githubStub = () => ({
|
||||||
@@ -1940,6 +1980,23 @@ describe("createPrNodeGithubOps repo resolution (gh-4)", () => {
|
|||||||
expect(result.prNumber).toBe(9);
|
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 () => {
|
it("mergePr passes owner/repo parsed from entity.repo", async () => {
|
||||||
const github = githubStub();
|
const github = githubStub();
|
||||||
const ops = createPrNodeGithubOps(github as never);
|
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 {
|
import {
|
||||||
resolveTaskMergeTarget,
|
resolveTaskMergeTarget,
|
||||||
getCurrentRepo,
|
getCurrentRepo,
|
||||||
|
getPushRepo,
|
||||||
isBranchGroupMemberLanded,
|
isBranchGroupMemberLanded,
|
||||||
resolveEffectiveSettings,
|
resolveEffectiveSettings,
|
||||||
isWorkspaceTask,
|
isWorkspaceTask,
|
||||||
@@ -134,6 +135,26 @@ export function getTaskBranchName(taskId: string): string {
|
|||||||
return `fusion/${taskId.toLowerCase()}`;
|
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>`
|
* 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
|
* can find it. Idempotent: creates the remote branch on first push and
|
||||||
@@ -313,12 +334,14 @@ export function createGroupPrCallback(
|
|||||||
title: member.title,
|
title: member.title,
|
||||||
branchName: getTaskBranchName(member.id),
|
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({
|
const created = await github.createPr({
|
||||||
owner: repo.owner,
|
owner: repo.owner,
|
||||||
repo: repo.repo,
|
repo: repo.repo,
|
||||||
title: buildGroupPullRequestTitle(group, members),
|
title: buildGroupPullRequestTitle(group, members),
|
||||||
body: buildGroupPullRequestBody(group, membersWithBranch),
|
body: buildGroupPullRequestBody(group, membersWithBranch),
|
||||||
head: headBranch,
|
head: qualifyForkAwarePrHead(cwd, repo.owner, headBranch),
|
||||||
base: baseBranch,
|
base: baseBranch,
|
||||||
});
|
});
|
||||||
return { prNumber: created.number, prUrl: created.url, prState: toBranchGroupPrState(created) };
|
return { prNumber: created.number, prUrl: created.url, prState: toBranchGroupPrState(created) };
|
||||||
@@ -493,12 +516,14 @@ export function createPrNodeGithubOps(
|
|||||||
const headBranch = entity.headBranch || getTaskBranchName(task.id);
|
const headBranch = entity.headBranch || getTaskBranchName(task.id);
|
||||||
await pushTaskBranchToOrigin(cwd, headBranch);
|
await pushTaskBranchToOrigin(cwd, headBranch);
|
||||||
const { owner, name } = splitRepoSlug(entity.repo);
|
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({
|
const created = await github.createPr({
|
||||||
owner,
|
owner,
|
||||||
repo: name,
|
repo: name,
|
||||||
title: task.title ?? `Task ${task.id}`,
|
title: task.title ?? `Task ${task.id}`,
|
||||||
body: task.description ?? "",
|
body: task.description ?? "",
|
||||||
head: headBranch,
|
head: qualifyForkAwarePrHead(cwd, owner, headBranch),
|
||||||
base: entity.baseBranch,
|
base: entity.baseBranch,
|
||||||
});
|
});
|
||||||
const headOid = await resolveBranchHeadOid(cwd, headBranch);
|
const headOid = await resolveBranchHeadOid(cwd, headBranch);
|
||||||
@@ -868,12 +893,14 @@ export async function processPullRequestMergeTask(
|
|||||||
if (!groupPrInfo) {
|
if (!groupPrInfo) {
|
||||||
await pushTaskBranchToOrigin(cwd, branchGroup.branchName);
|
await pushTaskBranchToOrigin(cwd, branchGroup.branchName);
|
||||||
try {
|
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({
|
groupPrInfo = await github.createPr({
|
||||||
owner: prRepo.owner,
|
owner: prRepo.owner,
|
||||||
repo: prRepo.repo,
|
repo: prRepo.repo,
|
||||||
title: buildGroupPullRequestTitle(branchGroup, members),
|
title: buildGroupPullRequestTitle(branchGroup, members),
|
||||||
body: buildGroupPullRequestBody(branchGroup, membersWithCommits),
|
body: buildGroupPullRequestBody(branchGroup, membersWithCommits),
|
||||||
head: branchGroup.branchName,
|
head: qualifyForkAwarePrHead(cwd, prRepo.owner, branchGroup.branchName),
|
||||||
base: projectDefaultBranch,
|
base: projectDefaultBranch,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -974,12 +1001,14 @@ export async function processPullRequestMergeTask(
|
|||||||
await pushTaskBranchToOrigin(cwd, branch);
|
await pushTaskBranchToOrigin(cwd, branch);
|
||||||
}
|
}
|
||||||
try {
|
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({
|
prInfo = existingPr ?? await github.createPr({
|
||||||
owner: prRepo.owner,
|
owner: prRepo.owner,
|
||||||
repo: prRepo.repo,
|
repo: prRepo.repo,
|
||||||
title: buildPullRequestTitle(task),
|
title: buildPullRequestTitle(task),
|
||||||
body: buildPullRequestBody(task),
|
body: buildPullRequestBody(task),
|
||||||
head: branch,
|
head: qualifyForkAwarePrHead(cwd, prRepo.owner, branch),
|
||||||
base: mergeTarget.branch,
|
base: mergeTarget.branch,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -502,3 +502,35 @@ export function getCurrentRepo(cwd?: string): { owner: string; repo: string } |
|
|||||||
return null;
|
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,
|
ensureGhAuth,
|
||||||
parseRepoFromRemote,
|
parseRepoFromRemote,
|
||||||
getCurrentRepo,
|
getCurrentRepo,
|
||||||
|
getPushRepo,
|
||||||
type GhError,
|
type GhError,
|
||||||
type GhErrorCode,
|
type GhErrorCode,
|
||||||
type StructuredGhError,
|
type StructuredGhError,
|
||||||
|
|||||||
@@ -1228,6 +1228,7 @@ export {
|
|||||||
ensureGhAuth,
|
ensureGhAuth,
|
||||||
parseRepoFromRemote,
|
parseRepoFromRemote,
|
||||||
getCurrentRepo,
|
getCurrentRepo,
|
||||||
|
getPushRepo,
|
||||||
type GhError,
|
type GhError,
|
||||||
type GhErrorCode,
|
type GhErrorCode,
|
||||||
type StructuredGhError,
|
type StructuredGhError,
|
||||||
|
|||||||
Reference in New Issue
Block a user