Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,12 @@ describe("buildPrCheckoutPayload", () => {
url: "https://github.com/o/r/pull/42",
title: "Fix typo",
branch: "fix/typo",
headRefOid: "c4ecea7dec8c6d09cf54fe0ad2f9edb8a24fd45a",
baseBranch: "main",
headRepositoryOwner: "kietho",
headRepositoryName: "r",
isCrossRepository: true,
isDraft: false,
state: "open",
body: "body text",
};
Expand All @@ -217,9 +220,12 @@ describe("buildPrCheckoutPayload", () => {
url: "https://github.com/o/r/pull/42",
title: "Fix typo",
headRefName: "fix/typo",
headRefOid: "c4ecea7dec8c6d09cf54fe0ad2f9edb8a24fd45a",
baseRefName: "main",
headRepositoryOwner: "kietho",
headRepositoryName: "r",
isCrossRepository: true,
isDraft: false,
state: "open",
});
expect(payload.branch).toBeUndefined();
Expand Down Expand Up @@ -285,15 +291,15 @@ describe("buildPrCheckoutPayload", () => {
expect(payload.pr?.state).toBe("open");
});

test("throws clear error for cross-repo PR with deleted fork (null owner)", () => {
test("allows cross-repo PR with deleted fork so host-service can recover from refs/pull", () => {
const pending = makePending({ intent: "pr-checkout" });
expect(() =>
buildPrCheckoutPayload("pid", pending, {
...prContent,
headRepositoryOwner: null,
isCrossRepository: true,
}),
).toThrow("head fork repository has been deleted");
const payload = buildPrCheckoutPayload("pid", pending, {
...prContent,
headRepositoryOwner: null,
isCrossRepository: true,
});
expect(payload.pr?.headRepositoryOwner).toBe("");
expect(payload.pr?.isCrossRepository).toBe(true);
});

test("same-repo PR with null owner is fine (owner not needed)", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,22 +97,15 @@ export function buildPrCheckoutPayload(
url: string;
title: string;
branch: string; // headRefName
headRefOid: string;
baseBranch: string; // baseRefName
headRepositoryOwner: string | null;
headRepositoryName?: string | null;
isCrossRepository: boolean;
isDraft?: boolean;
state: string;
},
): CheckoutWorkspaceInput {
// Null owner on a cross-repo PR means the head fork repo has been
// deleted. We can't derive `<owner>/<headRefName>` without it, and
// `gh pr checkout` wouldn't have a fork to configure push against.
// Fail early with a clear error rather than a cryptic server-side
// "headRepositoryOwner is required".
if (prContent.isCrossRepository && !prContent.headRepositoryOwner) {
throw new Error(
`Cannot check out PR #${prContent.number}: the head fork repository has been deleted.`,
);
}
const linked = mapLinkedContextFromPending(pending);
const normalizedState: "open" | "closed" | "merged" =
prContent.state === "closed"
Expand All @@ -130,11 +123,14 @@ export function buildPrCheckoutPayload(
url: prContent.url,
title: prContent.title,
headRefName: prContent.branch,
headRefOid: prContent.headRefOid,
baseRefName: prContent.baseBranch,
// Same-repo PRs don't need an owner for branch derivation; pass an
// empty string rather than leaking null into the server input.
headRepositoryOwner: prContent.headRepositoryOwner ?? "",
headRepositoryName: prContent.headRepositoryName ?? null,
isCrossRepository: prContent.isCrossRepository,
isDraft: prContent.isDraft ?? false,
state: normalizedState,
},
composer: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ export interface CheckoutWorkspaceInput {
url: string;
title: string;
headRefName: string;
headRefOid: string;
baseRefName: string;
headRepositoryOwner: string;
headRepositoryName?: string | null;
isCrossRepository: boolean;
isDraft?: boolean;
state: "open" | "closed" | "merged";
};
composer: {
Expand Down
1 change: 1 addition & 0 deletions packages/host-service/src/runtime/pull-requests/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {
type CheckoutPullRequestMetadata,
PullRequestRuntimeManager,
type PullRequestRuntimeManagerOptions,
type PullRequestStateSnapshot,
Expand Down
241 changes: 241 additions & 0 deletions packages/host-service/src/runtime/pull-requests/pull-requests.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { describe, expect, test } from "bun:test";
import { pullRequests, workspaces } from "../../db/schema";
import { PullRequestRuntimeManager } from "./pull-requests";

const PROJECT_ID = "project-1";
const WORKSPACE_ID = "workspace-1";

interface FakeProject {
id: string;
repoPath: string;
repoProvider: "github";
repoOwner: string;
repoName: string;
repoUrl: string;
remoteName: string;
}

interface FakeWorkspace {
id: string;
projectId: string;
worktreePath: string;
branch: string;
headSha: string | null;
upstreamOwner: string | null;
upstreamRepo: string | null;
upstreamBranch: string | null;
pullRequestId: string | null;
}

interface FakePullRequest {
id: string;
projectId: string;
repoProvider: "github";
repoOwner: string;
repoName: string;
prNumber: number;
url: string;
title: string;
state: string;
isDraft: boolean;
headBranch: string;
headSha: string;
reviewDecision: string | null;
checksStatus: string;
checksJson: string;
lastFetchedAt: number | null;
error: string | null;
createdAt: number;
updatedAt: number;
}

interface FakeState {
project: FakeProject;
workspace: FakeWorkspace;
pullRequest: FakePullRequest | undefined;
}

function makeState(branch: string): FakeState {
return {
project: {
id: PROJECT_ID,
repoPath: "/repo",
repoProvider: "github",
repoOwner: "base-owner",
repoName: "base-repo",
repoUrl: "https://github.com/base-owner/base-repo.git",
remoteName: "origin",
},
workspace: {
id: WORKSPACE_ID,
projectId: PROJECT_ID,
worktreePath: `/repo/.worktrees/${branch}`,
branch,
headSha: null,
upstreamOwner: null,
upstreamRepo: null,
upstreamBranch: null,
pullRequestId: null,
},
pullRequest: undefined,
};
}

function createFakeDb(state: FakeState) {
return {
query: {
projects: {
findFirst: () => ({ sync: () => state.project }),
},
pullRequests: {
findFirst: () => ({ sync: () => state.pullRequest }),
},
},
insert: (table: unknown) => ({
values: (values: FakePullRequest) => ({
run: () => {
if (table === pullRequests) {
state.pullRequest = values;
}
},
}),
}),
update: (table: unknown) => ({
set: (values: Partial<FakeWorkspace> | Partial<FakePullRequest>) => ({
where: () => ({
run: () => {
if (table === workspaces) {
state.workspace = {
...state.workspace,
...(values as Partial<FakeWorkspace>),
};
}
if (table === pullRequests && state.pullRequest) {
state.pullRequest = {
...state.pullRequest,
...(values as Partial<FakePullRequest>),
};
}
},
}),
}),
}),
select: (shape?: unknown) => ({
from: (table: unknown) => ({
where: () => ({
all: () => {
if (table !== workspaces) return [];
if (shape) return [{ projectId: state.workspace.projectId }];
return [state.workspace];
},
}),
all: () => {
if (table !== workspaces) return [];
if (shape) return [{ projectId: state.workspace.projectId }];
return [state.workspace];
},
}),
}),
};
}

function createManager(state: FakeState) {
return new PullRequestRuntimeManager({
db: createFakeDb(state) as never,
git: async () => {
throw new Error("git should not be used when project metadata is set");
},
github: async () => {
throw new Error("github should not be used for direct PR linking");
},
});
}

describe("PullRequestRuntimeManager direct checkout PR linking", () => {
test("links a fork PR workspace to the selected PR and records fork upstream", async () => {
const state = makeState("fork-owner/fix-typo");
const manager = createManager(state);

const prId = await manager.linkWorkspaceToCheckoutPullRequest({
workspaceId: WORKSPACE_ID,
projectId: PROJECT_ID,
pullRequest: {
number: 42,
url: "https://github.com/base-owner/base-repo/pull/42",
title: "Fix typo",
state: "open",
isDraft: false,
headRefName: "fix-typo",
headRefOid: "abc123",
headRepositoryOwner: "fork-owner",
headRepositoryName: "fork-repo",
isCrossRepository: true,
},
});

expect(state.workspace.pullRequestId).toBe(prId);
expect(state.workspace.upstreamOwner).toBe("fork-owner");
expect(state.workspace.upstreamRepo).toBe("fork-repo");
expect(state.workspace.upstreamBranch).toBe("fix-typo");
expect(state.pullRequest?.prNumber).toBe(42);
expect(state.pullRequest?.repoOwner).toBe("base-owner");
expect(state.pullRequest?.repoName).toBe("base-repo");
expect(state.pullRequest?.headBranch).toBe("fix-typo");
});

test("keeps a deleted-fork PR link when no upstream can be recorded", async () => {
const state = makeState("pr/42");
const manager = createManager(state);

const prId = await manager.linkWorkspaceToCheckoutPullRequest({
workspaceId: WORKSPACE_ID,
projectId: PROJECT_ID,
pullRequest: {
number: 42,
url: "https://github.com/base-owner/base-repo/pull/42",
title: "Deleted fork",
state: "merged",
headRefName: "fix-typo",
headRefOid: "abc123",
headRepositoryOwner: null,
headRepositoryName: null,
isCrossRepository: true,
},
});

expect(state.workspace.pullRequestId).toBe(prId);
expect(state.workspace.upstreamOwner).toBeNull();
expect(state.workspace.upstreamRepo).toBeNull();
expect(state.workspace.upstreamBranch).toBeNull();

await manager.refreshPullRequestsByWorkspaces([WORKSPACE_ID]);

expect(state.workspace.pullRequestId).toBe(prId);
});

test("clears a no-upstream PR link when workspace HEAD no longer matches the PR", async () => {
const state = makeState("pr/42");
const manager = createManager(state);

await manager.linkWorkspaceToCheckoutPullRequest({
workspaceId: WORKSPACE_ID,
projectId: PROJECT_ID,
pullRequest: {
number: 42,
url: "https://github.com/base-owner/base-repo/pull/42",
title: "Deleted fork",
state: "merged",
headRefName: "fix-typo",
headRefOid: "abc123",
headRepositoryOwner: null,
headRepositoryName: null,
isCrossRepository: true,
},
});
state.workspace.headSha = "def456";

await manager.refreshPullRequestsByWorkspaces([WORKSPACE_ID]);

expect(state.workspace.pullRequestId).toBeNull();
});
});
Loading
Loading