fix(server): checkout fork PRs into owner branches

This commit is contained in:
Mohamed Boudra
2026-04-22 17:53:53 +07:00
parent 274a7fd217
commit dd98ae9359
6 changed files with 481 additions and 43 deletions
@@ -161,6 +161,34 @@ function pullRequestJson(title: string): string {
]);
}
function pullRequestCheckoutTargetJson(): string {
return JSON.stringify({
data: {
repository: {
pullRequest: {
number: 526,
baseRefName: "main",
headRefName: "main",
isCrossRepository: true,
headRepositoryOwner: { login: "therainisme" },
headRepository: {
sshUrl: "git@github.com:therainisme/paseo.git",
url: "https://github.com/therainisme/paseo",
},
},
},
},
});
}
function repoViewJson(): string {
return JSON.stringify({
owner: { login: "getpaseo" },
name: "paseo",
parent: null,
});
}
function issueJson(title: string): string {
return JSON.stringify([
{
@@ -275,6 +303,38 @@ describe("GitHubService", () => {
expect(computeGithubNextInterval(stableStatus, 4)).toBe(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS);
});
it("loads pull request checkout target details through GraphQL", async () => {
const runner = createRunner([repoViewJson(), pullRequestCheckoutTargetJson()]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await expect(
service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 526 }),
).resolves.toEqual({
number: 526,
baseRefName: "main",
headRefName: "main",
headOwnerLogin: "therainisme",
headRepositorySshUrl: "git@github.com:therainisme/paseo.git",
headRepositoryUrl: "https://github.com/therainisme/paseo",
isCrossRepository: true,
});
expect(runner.calls).toHaveLength(2);
expect(runner.calls[0]).toEqual({
cwd: "/repo",
args: ["repo", "view", "--json", "owner,name,parent"],
});
expect(runner.calls[1]?.cwd).toBe("/repo");
expect(runner.calls[1]?.args.slice(0, 3)).toEqual(["api", "graphql", "-f"]);
expect(runner.calls[1]?.args).toContain("owner=getpaseo");
expect(runner.calls[1]?.args).toContain("name=paseo");
expect(runner.calls[1]?.args).toContain("number=526");
});
it("polls PR status at fast cadence while checks are pending", async () => {
let now = 0;
const runner = createRunner([
@@ -185,6 +185,53 @@ const GitHubRepoViewSchema = z.object({
.optional(),
});
const PullRequestCheckoutTargetSchema = z.object({
data: z.object({
repository: z.object({
pullRequest: z
.object({
number: z.number(),
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
isCrossRepository: z.boolean().catch(false),
headRepositoryOwner: z
.object({
login: z.string().catch(""),
})
.nullable()
.optional(),
headRepository: z
.object({
sshUrl: z.string().nullable().optional(),
url: z.string().nullable().optional(),
})
.nullable()
.optional(),
})
.nullable(),
}),
}),
});
const PULL_REQUEST_CHECKOUT_TARGET_QUERY = `
query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
number
baseRefName
headRefName
isCrossRepository
headRepositoryOwner {
login
}
headRepository {
sshUrl
url
}
}
}
}`;
const CURRENT_PR_STATUS_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner";
@@ -266,6 +313,16 @@ export interface GitHubPullRequestSummary {
updatedAt: string;
}
export interface GitHubPullRequestCheckoutTarget {
number: number;
baseRefName: string;
headRefName: string;
headOwnerLogin: string | null;
headRepositorySshUrl: string | null;
headRepositoryUrl: string | null;
isCrossRepository: boolean;
}
export interface GitHubIssueSummary {
number: number;
title: string;
@@ -417,6 +474,9 @@ export interface GitHubService {
listIssues(options: ListGitHubIssuesOptions): Promise<GitHubIssueSummary[]>;
getPullRequest(options: GetGitHubPullRequestOptions): Promise<GitHubPullRequestSummary>;
getPullRequestHeadRef(options: GetGitHubPullRequestOptions): Promise<string>;
getPullRequestCheckoutTarget?(
options: GetGitHubPullRequestOptions,
): Promise<GitHubPullRequestCheckoutTarget>;
getCurrentPullRequestStatus(
options: {
cwd: string;
@@ -756,6 +816,40 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return pullRequest.headRefName;
},
getPullRequestCheckoutTarget(options) {
return cached({
cwd: options.cwd,
method: "getPullRequestCheckoutTarget",
args: { number: options.number },
readOptions: options,
load: async () => {
const repo = await getGitHubRepoView({ cwd: options.cwd, run });
const owner = repo?.owner?.login;
const name = repo?.name;
if (!owner || !name) {
throw new Error("Unable to resolve GitHub repository for pull request checkout");
}
const stdout = await run(
[
"api",
"graphql",
"-f",
`query=${PULL_REQUEST_CHECKOUT_TARGET_QUERY}`,
"-F",
`owner=${owner}`,
"-F",
`name=${name}`,
"-F",
`number=${options.number}`,
],
{ cwd: options.cwd },
);
return parsePullRequestCheckoutTarget(stdout);
},
});
},
getCurrentPullRequestStatus(options) {
return cached({
cwd: options.cwd,
@@ -1364,6 +1458,23 @@ function parsePullRequestSummary(stdout: string): GitHubPullRequestSummary {
return toPullRequestSummary(GitHubPullRequestSummarySchema.parse(JSON.parse(stdout || "{}")));
}
function parsePullRequestCheckoutTarget(stdout: string): GitHubPullRequestCheckoutTarget {
const parsed = PullRequestCheckoutTargetSchema.parse(JSON.parse(stdout || "{}"));
const pullRequest = parsed.data.repository.pullRequest;
if (!pullRequest) {
throw new Error("Pull request not found");
}
return {
number: pullRequest.number,
baseRefName: pullRequest.baseRefName,
headRefName: pullRequest.headRefName,
headOwnerLogin: pullRequest.headRepositoryOwner?.login || null,
headRepositorySshUrl: pullRequest.headRepository?.sshUrl || null,
headRepositoryUrl: pullRequest.headRepository?.url || null,
isCrossRepository: pullRequest.isCrossRepository,
};
}
function toPullRequestSummary(
item: z.infer<typeof GitHubPullRequestSummarySchema>,
): GitHubPullRequestSummary {