Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andClaude Opus 5.5 894e38e5f1 fix(server): merge Enterprise PRs whose URL names another hostname
Take owner and name from the PR URL and pair them with the workspace
host, so merge and auto-merge work when gh reaches the server under an
IP, DNS alias, or SSH HostName.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:42:21 +00:00
47b0d8c4cc refactor(server): pass PR lookup helpers one object parameter
Co-authored-by: Olivier Poitrey <rs@rhapsodyk.net>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:10:42 +00:00
e92d934fd6 fix(server): prefer the fork PR carrying the checkout's commit on Enterprise
Named repositories lose gh's branch-tracking choice, so an open
same-repository PR on the branch name returned before the fork-parent
search. When the checkout's commit is known and differs, keep searching
and let the commit pick between the two PRs.

Co-authored-by: Olivier Poitrey <rs@rhapsodyk.net>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:02:43 +00:00
44d1693e02 fix(server): find a numeric same-repository Enterprise branch PR
gh reads a numeric branch argument as a PR number, so Enterprise status
skipped the branch view for numeric branches. With no head owner, a
same-repository upstream PR on a fork clone was then missed, because the
fork-parent search only accepts the fork owner. List the branch's PRs
instead and keep the one gh pr view <branch> would return: a PR whose
head is in the repository it targets.

Co-authored-by: Olivier Poitrey <rs@rhapsodyk.net>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 19:56:28 +00:00
aea6929372 fix(server): find a user's own fork PR on an Enterprise fork clone
Branch status with no head owner asked the selected repository for its
parent. On a fork-plus-upstream clone that is upstream, which has no
parent, so a branch the user pushed to their fork was searched under the
upstream owner and its PR was missed. Parent discovery now reads
origin's repository on Enterprise, where Paseo names repositories for
gh.

Also pass runPullRequestMerge one object parameter.

Co-authored-by: Olivier Poitrey <rs@rhapsodyk.net>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 19:52:08 +00:00
166cb18960 fix(server): target the repository gh selects for every Enterprise command
Enterprise status lookups pinned to origin missed same-repository
upstream PRs on a fork-plus-upstream clone. Every pr, issue and repo
command now names the repository gh would select from the configured
remote URLs, as main did through gh itself. The origin slug resolver
is back to github.com only, so Enterprise workspaces keep per-workspace
polling and Create PR target as on main.

Co-authored-by: Olivier Poitrey <rs@rhapsodyk.net>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 19:43:46 +00:00
09d5afd948 fix(server): look up a checked-out Enterprise fork PR where gh would
On an Enterprise host every gh pr command now names the origin repository,
so a workspace whose origin is a fork and whose upstream remote is the
parent polled the fork for a PR checked out from the parent. The head
owner is known there, which skips parent discovery, so the PR was never
found. gh without --repo picks the upstream remote's repository, which
main relied on. When the head owner is known, branch lookups now use the
same default repository and still filter by that owner.

Also pass the new remote helpers one object parameter, as the server
guide asks.

Co-authored-by: Olivier Poitrey <rs@rhapsodyk.net>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 18:56:20 +00:00
009fc34f65 fix(server): check out PRs from origin when no remote matches the repository
Checkout now prefers remotes whose URL matches the repository gh selected,
so an Enterprise fork fetches the PR from the remote that holds it. When
no remote matches, it threw. A clone made before a repository was renamed
or transferred keeps the old owner/name in origin, gh repo view returns
the new one, and checking out a PR from that workspace failed, though
GitHub still serves refs/pull/<n>/head from the old URL and main fetched
it. Fall back to origin then upstream, as main does.

Co-authored-by: Olivier Poitrey <rs@rhapsodyk.net>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 18:44:15 +00:00
Olivier Poitrey c9f30fbb12 fix(server): fetch PRs from selected GitHub repository 2026-11-14 09:52:12 +01:00
Olivier Poitrey aab75649f7 fix(server): respect Enterprise default repositories 2026-11-14 02:39:23 +01:00
Olivier Poitrey ef4b591e09 fix(server): isolate cached PR checks by host 2026-11-14 02:33:21 +01:00
Olivier Poitrey d6c1a5e523 fix(server): resolve numeric Enterprise branch PRs 2026-11-14 02:25:11 +01:00
Olivier Poitrey 4cb0be2b56 fix: preserve GitHub Enterprise host for PR commands 2026-11-14 01:53:04 +01:00
5 changed files with 1228 additions and 69 deletions
@@ -199,6 +199,29 @@ describe("resolveWorktreeCreationIntent", () => {
expect(intent).not.toHaveProperty("trackOriginHead");
});
test("does not track an unrelated origin when the PR belongs to another remote", async () => {
const deps = createResolverHarness();
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
number: 42,
baseRefName: "main",
headRefName: "feature",
checkoutRefs: [{ remoteName: "github", remoteRef: "refs/pull/42/head" }],
headOwnerLogin: "upstream",
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", githubPrNumber: 42 },
repoRoot,
deps,
);
expect(intent).not.toHaveProperty("trackOriginHead");
expect(intent).toHaveProperty("checkoutRefs", [
{ remoteName: "github", remoteRef: "refs/pull/42/head" },
]);
});
test("configures the contributor remote for fork PR targets", async () => {
const deps = createResolverHarness();
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
@@ -192,7 +192,11 @@ async function resolvePrCheckoutIntent(
const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget });
const crossRepository = resolveCrossRepositoryFields(checkoutTarget);
const trackOriginHead =
!checkoutTarget.isCrossRepository && checkoutTarget.headRefKind !== "pull-ref";
!checkoutTarget.isCrossRepository &&
checkoutTarget.headRefKind !== "pull-ref" &&
(checkoutTarget.checkoutRefs ?? defaultRefs).some(
(ref) => (ref.remoteName ?? "origin") === "origin",
);
return {
kind: "checkout-change-request",
@@ -196,6 +196,7 @@ export interface PullRequestCreateResult {
export type PullRequestMergeMethod = "merge" | "squash" | "rebase";
export interface PullRequestCommandStatus {
url?: string;
mergeable?: PullRequestMergeable;
forgeSpecific?: ForgeSpecificStatusFacts;
}
@@ -428,6 +428,20 @@ function pullRequestCheckoutTargetJson(): string {
});
}
function createEnterpriseOriginRepo(): string {
const cwd = mkdtempSync(join(tmpdir(), "github-enterprise-origin-"));
execFileSync("git", ["init", "-q", cwd]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"origin",
"https://github.acme.internal/acme/repo.git",
]);
return cwd;
}
function repoViewJson(): string {
return JSON.stringify({
owner: { login: "getpaseo" },
@@ -835,40 +849,82 @@ describe("ForgeService", () => {
});
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,
});
vi.useRealTimers();
const cwd = mkdtempSync(join(tmpdir(), "github-checkout-target-"));
execFileSync("git", ["init", "-q", cwd]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"origin",
"https://github.com/getpaseo/paseo.git",
]);
try {
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",
checkoutRefs: [
await expect(service.getPullRequestCheckoutTarget?.({ cwd, number: 526 })).resolves.toEqual({
number: 526,
baseRefName: "main",
headRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/526/head" }],
headOwnerLogin: "therainisme",
headRepositorySshUrl: "git@github.com:therainisme/paseo.git",
headRepositoryUrl: "https://github.com/therainisme/paseo",
isCrossRepository: true,
headRefKind: "branch",
});
expect(runner.calls).toHaveLength(2);
expect(runner.calls[0]).toEqual({
cwd,
args: ["repo", "view", "--json", "owner,name,parent"],
});
expect(runner.calls[1]?.cwd).toBe(cwd);
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");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it("checks out from origin when origin still uses a renamed repository's old name", async () => {
vi.useRealTimers();
const cwd = mkdtempSync(join(tmpdir(), "github-checkout-renamed-"));
execFileSync("git", ["init", "-q", cwd]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"origin",
"https://github.com/old-owner/old-name.git",
]);
try {
const runner = createRunner([repoViewJson(), pullRequestCheckoutTargetJson()]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const target = await service.getPullRequestCheckoutTarget({ cwd, number: 526 });
expect(target.checkoutRefs).toEqual([
{ remoteName: "origin", remoteRef: "refs/pull/526/head" },
{ remoteName: "upstream", remoteRef: "refs/pull/526/head" },
],
headOwnerLogin: "therainisme",
headRepositorySshUrl: "git@github.com:therainisme/paseo.git",
headRepositoryUrl: "https://github.com/therainisme/paseo",
isCrossRepository: true,
headRefKind: "branch",
});
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");
]);
expect(runner.calls[1]?.args).toContain("owner=getpaseo");
service.dispose?.();
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it("populates repoOwner/repoName from a GitHub Enterprise PR URL", async () => {
@@ -1223,6 +1279,81 @@ describe("ForgeService", () => {
service.dispose?.();
});
it("keeps frozen PR checks separate for matching repositories on different hosts", async () => {
let now = 0;
const calls: RunnerCall[] = [];
const runner: GitHubCommandRunner = async (args, options) => {
calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay });
const host = options.envOverlay?.GH_HOST;
if (args[3]?.includes("PaseoBatchPullRequestChecks")) {
return {
stdout: batchPollStatusJson({
t0: batchPollChecksAliasJson([
{
__typename: "StatusContext",
context: "ci",
state: host === "a.example" ? "SUCCESS" : "FAILURE",
},
]),
}),
stderr: "",
};
}
return {
stdout: batchPollStatusJson({
t0: batchPollRepositoryJson([
batchPollPrNodeJson({ url: `https://${host}/acme/widgets/pull/42` }),
]),
}),
stderr: "",
};
};
const service = createGitHubService({
ttlMs: 0,
runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async (cwd) => (cwd === "/ws-a" ? "a.example" : "b.example"),
resolveRepoSlug: async () => "acme/widgets",
now: () => now,
});
const statusesA: Array<CurrentPullRequestStatus | null> = [];
const statusesB: Array<CurrentPullRequestStatus | null> = [];
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
onStatus: (status) => statusesA.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statusesA).toEqual([expect.objectContaining({ checksStatus: "success" })]);
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-b",
headRef: "feat-a",
onStatus: (status) => statusesB.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statusesB).toEqual([expect.objectContaining({ checksStatus: "failure" })]);
now = EXPECTED_GITHUB_SLOW_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
await flushMicrotasks();
expect(statusesA).toHaveLength(2);
expect(statusesB).toHaveLength(2);
expect(statusesA.at(-1)).toEqual(expect.objectContaining({ checksStatus: "success" }));
expect(statusesB.at(-1)).toEqual(expect.objectContaining({ checksStatus: "failure" }));
expect(
calls
.filter((call) => call.args[3]?.includes("PaseoBatchPullRequestChecks"))
.map((call) => call.envOverlay?.GH_HOST),
).toEqual(["a.example", "b.example"]);
subscriptionA?.unsubscribe();
subscriptionB?.unsubscribe();
service.dispose?.();
});
it("batches the first PR status poll after repository identity resolves", async () => {
let now = 0;
let resolveRepositoryIdentity!: () => void;
@@ -4801,6 +4932,759 @@ describe("ForgeService", () => {
expect(runner.calls[1]?.envOverlay).toMatchObject({ GH_HOST: "github.acme.internal" });
});
it("finds a checked-out Enterprise fork PR in the upstream repository gh selects", async () => {
vi.useRealTimers();
const cwd = mkdtempSync(join(tmpdir(), "github-enterprise-upstream-poll-"));
try {
execFileSync("git", ["init", "-q", cwd]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"origin",
"https://github.acme.internal/me/repo.git",
]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"upstream",
"https://github.acme.internal/acme/repo.git",
]);
const pullRequest = JSON.parse(
currentPullRequestJson({
url: "https://github.acme.internal/acme/repo/pull/42",
headRepositoryOwner: { login: "contributor" },
}),
);
const calls: string[][] = [];
const runner: GitHubCommandRunner = async (args) => {
calls.push(args);
if (args[0] === "api") {
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
}
const inUpstream = args[args.indexOf("--repo") + 1] === "github.acme.internal/acme/repo";
if (args[1] === "view") {
if (inUpstream) return { stdout: JSON.stringify(pullRequest), stderr: "" };
throw Object.assign(new Error("gh failed"), {
stderr: 'no pull requests found for branch "feature/fork"',
});
}
return { stdout: JSON.stringify(inUpstream ? [pullRequest] : []), stderr: "" };
};
const service = createGitHubService({
ttlMs: 0,
runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
});
await expect(
service.getCurrentPullRequestStatus({
cwd,
headRef: "feature/fork",
headRepositoryOwner: "contributor",
}),
).resolves.toMatchObject({ number: 42, repoOwner: "acme" });
expect(calls[0]).toContain("github.acme.internal/acme/repo");
service.dispose?.();
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it.each(["feature", "123"])(
"finds a checked-out same-repository upstream PR for branch %s on an Enterprise fork clone",
async (branch) => {
vi.useRealTimers();
const cwd = mkdtempSync(join(tmpdir(), "github-enterprise-upstream-same-repo-"));
try {
execFileSync("git", ["init", "-q", cwd]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"origin",
"https://github.acme.internal/me/repo.git",
]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"upstream",
"https://github.acme.internal/acme/repo.git",
]);
const pullRequest = JSON.parse(
currentPullRequestJson({
url: "https://github.acme.internal/acme/repo/pull/42",
headRefName: branch,
headRepositoryOwner: { login: "acme" },
}),
);
const runner: GitHubCommandRunner = async (args) => {
if (args[0] === "api") {
if (args[1] === "graphql") {
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
}
return { stdout: "[]", stderr: "" };
}
if (args[0] === "repo") {
const fork = args[2] === "me/repo" || args[2] === "github.acme.internal/me/repo";
return {
stdout: JSON.stringify({
owner: { login: fork ? "me" : "acme" },
name: "repo",
parent: fork ? { owner: { login: "acme" }, name: "repo" } : null,
}),
stderr: "",
};
}
const inUpstream = args[args.indexOf("--repo") + 1] === "github.acme.internal/acme/repo";
if (args[1] === "view") {
if (inUpstream) return { stdout: JSON.stringify(pullRequest), stderr: "" };
throw Object.assign(new Error("gh failed"), {
stderr: `no pull requests found for branch "${branch}"`,
});
}
return { stdout: JSON.stringify(inUpstream ? [pullRequest] : []), stderr: "" };
};
const service = createGitHubService({
ttlMs: 0,
runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
});
await expect(
service.getCurrentPullRequestStatus({ cwd, headRef: branch }),
).resolves.toMatchObject({ number: 42, repoOwner: "acme" });
service.dispose?.();
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
it.each(["feature", "123"])(
"prefers the fork PR carrying the checkout's commit for branch %s on an Enterprise fork clone",
async (branch) => {
vi.useRealTimers();
const cwd = mkdtempSync(join(tmpdir(), "github-enterprise-two-prs-"));
try {
execFileSync("git", ["init", "-q", cwd]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"origin",
"https://github.acme.internal/me/repo.git",
]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"upstream",
"https://github.acme.internal/acme/repo.git",
]);
const forkSha = "2222222222222222222222222222222222222222";
const upstreamPullRequest = JSON.parse(
currentPullRequestJson({
url: "https://github.acme.internal/acme/repo/pull/42",
headRefName: branch,
headRepositoryOwner: { login: "acme" },
}),
);
const forkPullRequest = JSON.parse(
currentPullRequestJson({
number: 7,
url: "https://github.acme.internal/acme/repo/pull/7",
headRefName: branch,
headRefOid: forkSha,
headRepositoryOwner: { login: "me" },
}),
);
const runner: GitHubCommandRunner = async (args) => {
if (args[0] === "api") {
if (args[1] === "graphql") {
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
}
const ownPulls =
args[1]?.startsWith("repos/acme/repo/pulls?") &&
args[1].includes(`head=me%3A${branch}`);
return {
stdout: JSON.stringify(ownPulls ? [{ number: 7, head: { sha: forkSha } }] : []),
stderr: "",
};
}
if (args[0] === "repo") {
const fork = args[2] === "me/repo" || args[2] === "github.acme.internal/me/repo";
return {
stdout: JSON.stringify({
owner: { login: fork ? "me" : "acme" },
name: "repo",
parent: fork ? { owner: { login: "acme" }, name: "repo" } : null,
}),
stderr: "",
};
}
if (args[1] === "view") {
const pullRequest = args[2] === "7" ? forkPullRequest : upstreamPullRequest;
return { stdout: JSON.stringify(pullRequest), stderr: "" };
}
return { stdout: JSON.stringify([upstreamPullRequest]), stderr: "" };
};
const service = createGitHubService({
ttlMs: 0,
runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
});
await expect(
service.getCurrentPullRequestStatus({ cwd, headRef: branch, headSha: forkSha }),
).resolves.toMatchObject({ number: 7, repoOwner: "acme" });
service.dispose?.();
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
it.each(["feature", "123"])(
"finds the user's own fork PR for branch %s on an Enterprise fork clone",
async (branch) => {
vi.useRealTimers();
const cwd = mkdtempSync(join(tmpdir(), "github-enterprise-own-fork-pr-"));
try {
execFileSync("git", ["init", "-q", cwd]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"origin",
"https://github.acme.internal/me/repo.git",
]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"upstream",
"https://github.acme.internal/acme/repo.git",
]);
const pullRequest = JSON.parse(
currentPullRequestJson({
number: 7,
url: "https://github.acme.internal/acme/repo/pull/7",
headRefName: branch,
headRepositoryOwner: { login: "me" },
}),
);
const runner: GitHubCommandRunner = async (args) => {
if (args[0] === "api") {
if (args[1] === "graphql") {
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
}
const ownPulls =
args[1]?.startsWith("repos/acme/repo/pulls?") &&
args[1].includes(`head=me%3A${branch}`);
return { stdout: JSON.stringify(ownPulls ? [{ number: 7 }] : []), stderr: "" };
}
if (args[0] === "repo") {
const fork = args[2] === "me/repo" || args[2] === "github.acme.internal/me/repo";
return {
stdout: JSON.stringify({
owner: { login: fork ? "me" : "acme" },
name: "repo",
parent: fork ? { owner: { login: "acme" }, name: "repo" } : null,
}),
stderr: "",
};
}
if (args[1] === "view" && args[2] === "7") {
return { stdout: JSON.stringify(pullRequest), stderr: "" };
}
if (args[1] === "view") {
throw Object.assign(new Error("gh failed"), {
stderr: `no pull requests found for branch "${branch}"`,
});
}
return { stdout: "[]", stderr: "" };
};
const service = createGitHubService({
ttlMs: 0,
runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
});
await expect(
service.getCurrentPullRequestStatus({ cwd, headRef: branch }),
).resolves.toMatchObject({ number: 7, repoOwner: "acme" });
service.dispose?.();
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
it("uses the selected Enterprise default repository for PR and issue lookups despite Git rewrites", async () => {
vi.useRealTimers();
const cwd = mkdtempSync(join(tmpdir(), "github-service-default-repo-"));
try {
execFileSync("git", ["init", "-q", cwd]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"origin",
"https://github.acme.internal/fork/repo.git",
]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"github",
"https://github.acme.internal/acme/repo.git",
]);
execFileSync("git", [
"-C",
cwd,
"config",
"url.https://git.acme.internal/mirrors/.insteadOf",
"https://github.acme.internal/",
]);
execFileSync("git", ["-C", cwd, "config", "remote.github.gh-resolved", "base"]);
const calls: RunnerCall[] = [];
const runner: GitHubCommandRunner = async (args, options) => {
calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay });
if (args[0] === "repo") {
return {
stdout: JSON.stringify({
owner: { login: args.includes("github.acme.internal/acme/repo") ? "acme" : "fork" },
name: "repo",
}),
stderr: "",
};
}
if (args[0] === "api") {
return { stdout: pullRequestCheckoutTargetJson(), stderr: "" };
}
const repo = args[args.indexOf("--repo") + 1];
const title = repo === "github.acme.internal/acme/repo" ? "upstream" : "fork";
const list = args[0] === "issue" ? issueJson(title) : pullRequestJson(title);
return {
stdout: args[1] === "view" ? JSON.stringify(JSON.parse(list)[0]) : list,
stderr: "",
};
};
const service = createGitHubService({
ttlMs: 0,
runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
});
expect(await service.listPullRequests({ cwd })).toEqual([
expect.objectContaining({ title: "upstream" }),
]);
expect(await service.listIssues({ cwd })).toEqual([
expect.objectContaining({ title: "upstream" }),
]);
expect(await service.getPullRequest({ cwd, number: 123 })).toEqual(
expect.objectContaining({ title: "upstream" }),
);
expect(calls.map((call) => call.envOverlay?.GH_HOST)).toEqual([
"github.acme.internal",
"github.acme.internal",
"github.acme.internal",
]);
expect(
(await service.getPullRequestCheckoutTarget({ cwd, number: 526 })).checkoutRefs,
).toEqual([{ remoteName: "github", remoteRef: "refs/pull/526/head" }]);
expect(calls[3]?.args).toContain("github.acme.internal/acme/repo");
expect(calls[4]?.args).toContain("owner=acme");
execFileSync("git", ["-C", cwd, "config", "--unset", "remote.github.gh-resolved"]);
expect(await service.listPullRequests({ cwd, query: "implicit" })).toEqual([
expect.objectContaining({ title: "upstream" }),
]);
execFileSync("git", ["-C", cwd, "config", "remote.origin.gh-resolved", "acme/repo"]);
expect(await service.listPullRequests({ cwd, query: "parent" })).toEqual([
expect.objectContaining({ title: "upstream" }),
]);
execFileSync("git", ["-C", cwd, "config", "remote.origin.gh-resolved", "base"]);
expect(await service.listPullRequests({ cwd, query: "changed" })).toEqual([
expect.objectContaining({ title: "fork" }),
]);
execFileSync("git", ["-C", cwd, "config", "remote.origin.gh-resolved", "missing/repo"]);
const missingRepoRunner: GitHubCommandRunner = async (args) => ({
stdout:
args[0] === "api"
? pullRequestCheckoutTargetJson()
: JSON.stringify({ owner: { login: "missing" }, name: "repo" }),
stderr: "",
});
const missingRepoService = createGitHubService({
runner: missingRepoRunner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
});
expect(
(await missingRepoService.getPullRequestCheckoutTarget({ cwd, number: 526 })).checkoutRefs,
).toEqual([
{ remoteName: "origin", remoteRef: "refs/pull/526/head" },
{ remoteName: "upstream", remoteRef: "refs/pull/526/head" },
]);
missingRepoService.dispose?.();
service.dispose?.();
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it("fetches the selected repository's PR when origin has the same PR number", async () => {
vi.useRealTimers();
const root = mkdtempSync(join(tmpdir(), "github-checkout-duplicate-pr-"));
const cwd = join(root, "workspace");
const origin = join(root, "origin.git");
const base = join(root, "base.git");
try {
execFileSync("git", ["init", "-q", cwd]);
execFileSync("git", ["init", "-q", "--bare", origin]);
execFileSync("git", ["init", "-q", "--bare", base]);
const commit = (message: string) => {
execFileSync("git", [
"-C",
cwd,
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--allow-empty",
"-qm",
message,
]);
return execFileSync("git", ["-C", cwd, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
};
const forkSha = commit("fork PR");
const baseSha = commit("base PR");
execFileSync("git", ["-C", cwd, "push", "-q", origin, `${forkSha}:refs/pull/526/head`]);
execFileSync("git", ["-C", cwd, "push", "-q", base, `${baseSha}:refs/pull/526/head`]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"origin",
"https://github.acme.internal/fork/repo.git",
]);
execFileSync("git", [
"-C",
cwd,
"remote",
"add",
"github",
"https://github.acme.internal/acme/repo.git",
]);
execFileSync("git", ["-C", cwd, "config", "remote.github.gh-resolved", "base"]);
execFileSync("git", [
"-C",
cwd,
"config",
`url.file://${origin}.insteadOf`,
"https://github.acme.internal/fork/repo.git",
]);
execFileSync("git", [
"-C",
cwd,
"config",
`url.file://${base}.insteadOf`,
"https://github.acme.internal/acme/repo.git",
]);
const runner = createRunner([
JSON.stringify({ owner: { login: "acme" }, name: "repo" }),
pullRequestCheckoutTargetJson(),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
});
const target = await service.getPullRequestCheckoutTarget({ cwd, number: 526 });
expect(target.checkoutRefs).toEqual([
{ remoteName: "github", remoteRef: "refs/pull/526/head" },
]);
const ref = target.checkoutRefs?.[0];
if (!ref?.remoteName) throw new Error("Missing checkout remote");
execFileSync("git", ["-C", cwd, "fetch", "-q", ref.remoteName, ref.remoteRef]);
expect(
execFileSync("git", ["-C", cwd, "rev-parse", "FETCH_HEAD"], { encoding: "utf8" }).trim(),
).toBe(baseSha);
expect(baseSha).not.toBe(forkSha);
service.dispose?.();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("selects the original Enterprise repository for PR lookup when Git rewrites remotes", async () => {
const cwd = createEnterpriseOriginRepo();
try {
execFileSync("git", [
"-C",
cwd,
"config",
"url.https://proxy.acme.internal/.insteadOf",
"https://github.acme.internal/",
]);
const runner = createRunner([currentPullRequestJson()]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
});
await service.getCurrentPullRequestStatus({ cwd, headRef: "feature/fork" });
expect(runner.calls[0]).toMatchObject({
args: [
"pr",
"view",
"feature/fork",
"--json",
CURRENT_PR_STATUS_FIELDS,
"--repo",
"github.acme.internal/acme/repo",
],
envOverlay: { GH_HOST: "github.acme.internal" },
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it("finds an Enterprise PR for a numeric branch without treating it as a PR number", async () => {
const cwd = createEnterpriseOriginRepo();
try {
const runner = createRunner([
JSON.stringify([
JSON.parse(
currentPullRequestJson({
number: 42,
url: "https://github.acme.internal/acme/repo/pull/42",
headRefName: "123",
headRepositoryOwner: { login: "acme" },
}),
),
]),
currentPullRequestGithubFactsJson(),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
});
await expect(
service.getCurrentPullRequestStatus({
cwd,
headRef: "123",
headRepositoryOwner: "acme",
}),
).resolves.toMatchObject({ number: 42, headRefName: "123", repoOwner: "acme" });
expect(runner.calls[0]).toMatchObject({
args: [
"pr",
"list",
"--state",
"all",
"--head",
"123",
"--limit",
"10",
"--json",
CURRENT_PR_STATUS_FIELDS,
"--repo",
"github.acme.internal/acme/repo",
],
envOverlay: { GH_HOST: "github.acme.internal" },
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it("returns no Enterprise PR for a numeric branch with no matches", async () => {
const cwd = createEnterpriseOriginRepo();
try {
const runner = createRunner(["[]"]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
});
await expect(
service.getCurrentPullRequestStatus({
cwd,
headRef: "123",
headRepositoryOwner: "acme",
}),
).resolves.toBeNull();
expect(runner.calls.map((call) => call.args.slice(0, 2))).toEqual([["pr", "list"]]);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it("finds an Enterprise fork PR for a numeric branch in its parent repository", async () => {
const runner = createRunner([
"[]",
JSON.stringify({
owner: { login: "forkOwner" },
name: "repo",
parent: { owner: { login: "parentOwner" }, name: "repo" },
}),
JSON.stringify([{ number: 42 }]),
currentPullRequestJson({
number: 42,
url: "https://github.acme.internal/parentOwner/repo/pull/42",
headRefName: "123",
headRepositoryOwner: { login: "forkOwner" },
}),
currentPullRequestGithubFactsJson(),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
resolveRepoSlug: async () => "forkOwner/repo",
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "123" }),
).resolves.toMatchObject({ number: 42, headRefName: "123", repoOwner: "parentOwner" });
expect(runner.calls[2]?.args).toEqual([
"api",
"repos/parentOwner/repo/pulls?state=all&per_page=100&head=forkOwner%3A123",
]);
expect(runner.calls[3]).toMatchObject({
args: ["pr", "view", "42", "--repo", "parentOwner/repo", "--json", CURRENT_PR_STATUS_FIELDS],
envOverlay: { GH_HOST: "github.acme.internal" },
});
});
it("merges an Enterprise fork PR in its base repository", async () => {
const runner = createRunner([""]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
resolveRepoSlug: async () => "fork/repo",
});
await service.mergePullRequest({
cwd: "/repo",
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
url: "https://github.acme.internal/upstream/repo/pull/42",
forgeSpecific: githubStatusFacts(),
}),
});
expect(runner.calls[0]?.args).toEqual([
"pr",
"merge",
"42",
"--squash",
"--repo",
"github.acme.internal/upstream/repo",
]);
});
it("merges an Enterprise PR whose URL names another hostname than the remote", async () => {
const runner = createRunner([""]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "10.0.0.5",
resolveRepoSlug: async () => "upstream/repo",
});
await service.mergePullRequest({
cwd: "/repo",
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
url: "https://github.acme.internal/upstream/repo/pull/42",
forgeSpecific: githubStatusFacts(),
}),
});
expect(runner.calls[0]?.args).toEqual([
"pr",
"merge",
"42",
"--squash",
"--repo",
"10.0.0.5/upstream/repo",
]);
});
it.skipIf(isPlatform("win32"))(
"authenticates and creates a PR on the configured Enterprise host when Git rewrites origin",
async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.acme.internal/acme/repo.git",
authStatusSucceeds: true,
});
try {
execFileSync("git", [
"-C",
fixture.cwd,
"config",
"url.https://git.acme.internal/mirrors/.insteadOf",
"https://github.acme.internal/",
]);
expect(
execFileSync("git", ["-C", fixture.cwd, "remote", "get-url", "origin"], {
encoding: "utf8",
}).trim(),
).toBe("https://git.acme.internal/mirrors/acme/repo.git");
const service = createGitHubService();
await expect(service.isAuthenticated({ cwd: fixture.cwd })).resolves.toBe(true);
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
expect(readFakeGitHubCliLog(fixture.logPath)).toEqual(
expect.arrayContaining([
"auth status --hostname github.acme.internal|",
"auth status --hostname github.acme.internal|github.acme.internal",
"api -X POST repos/acme/repo/pulls -f title=Add thing -f head=feature -f base=main|github.acme.internal",
]),
);
} finally {
fixture.dispose();
}
},
);
it.skipIf(isPlatform("win32"))(
"routes default gh calls to GitHub Enterprise only after host auth succeeds",
async () => {
@@ -4824,7 +5708,6 @@ describe("ForgeService", () => {
expect(readFakeGitHubCliLog(fixture.logPath)).toEqual(
expect.arrayContaining([
"auth status --hostname github.acme.internal|",
"repo view --json owner,name,parent|github.acme.internal",
"api -X POST repos/acme/repo/pulls -f title=Add thing -f head=feature -f base=main|github.acme.internal",
]),
);
@@ -4869,7 +5752,6 @@ describe("ForgeService", () => {
).toHaveLength(2);
expect(log).toEqual(
expect.arrayContaining([
"repo view --json owner,name,parent|github.acme.internal",
"api -X POST repos/acme/repo/pulls -f title=Add thing -f head=feature -f base=main|github.acme.internal",
]),
);
+284 -35
View File
@@ -1331,20 +1331,24 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
if (!ghPath) {
throw new GitHubCliMissingError();
}
// Route every gh invocation to the workspace's host. `gh api`/`graphql`
// otherwise default to github.com regardless of the resolved repository,
// which silently queries the wrong server on GitHub Enterprise. GH_HOST is
// safe even for auto-routing subcommands because it matches the repo's host.
// Git may rewrite the transport URL to a proxy host. gh would then infer the
// wrong host and repository, so name the repository gh would select from
// the configured remote URLs instead.
const host = await resolveRepoHostCached(runOptions.cwd);
const slug =
host && ["pr", "issue", "repo"].includes(args[0] ?? "")
? await resolveGitHubDefaultRepoSlug({ cwd: runOptions.cwd, host })
: null;
const effectiveArgs = selectGitHubCommandRepository({ args, host, slug });
const effectiveOptions: GitHubCommandRunnerOptions = host
? { ...runOptions, envOverlay: { ...runOptions.envOverlay, GH_HOST: host } }
: runOptions;
try {
const result = await deps.runner(args, effectiveOptions);
const result = await deps.runner(effectiveArgs, effectiveOptions);
return result.stdout.trim();
} catch (error) {
throw githubCliRunner.normalizeError(error, {
args,
args: effectiveArgs,
cwd: runOptions.cwd,
});
}
@@ -1364,6 +1368,43 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
});
}
// gh names the repository from the cwd when Paseo does not. On Enterprise
// Paseo names it, so parent discovery must ask about origin itself: a branch
// that tracks origin carries no head owner, and its PR lives in origin's
// parent under origin's owner.
async function resolveCurrentPullRequestRepositories(
cwd: string,
): Promise<{ explicitRepo: boolean; originRepository?: string }> {
if ((await resolveRepoHostCached(cwd)) === null) {
return { explicitRepo: false };
}
const originRepository = await deps.resolveOriginRepository(cwd);
return { explicitRepo: true, ...(originRepository ? { originRepository } : {}) };
}
async function runPullRequestMerge({
args,
input,
}: {
args: string[];
input: { cwd: string; prNumber: number; status?: PullRequestCommandStatus | null };
}): Promise<void> {
const host = await resolveRepoHostCached(input.cwd);
let effectiveArgs = args;
if (host) {
// The PR URL carries the instance's web hostname, which can differ from the
// name the remote and gh use (an IP, a DNS alias, an SSH HostName), so only
// owner and name come from the URL.
const url = input.status?.url;
const repo = url ? parseGitHubPullRequestRepo(url) : null;
if (!url || !repo || !url.endsWith(`/pull/${input.prNumber}`)) {
throw new Error("Cannot identify the GitHub repository for this pull request");
}
effectiveArgs = [...args, "--repo", `${host}/${repo.owner}/${repo.name}`];
}
await run(effectiveArgs, { cwd: input.cwd, envOverlay: { GH_PROMPT_DISABLED: "1" } });
}
function getPollTargetKey(target: {
cwd: string;
headRef: string;
@@ -1762,7 +1803,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
}
const frozenKey =
node.state !== "OPEN" && node.headRefOid.length > 0
? `${entry.owner}/${entry.name}#${node.number}@${node.headRefOid}`
? `${entry.host}/${entry.owner}/${entry.name}#${node.number}@${node.headRefOid}`
: null;
const cachedRollup = frozenKey ? frozenPullRequestChecksCache.get(frozenKey) : undefined;
if (frozenKey && cachedRollup) {
@@ -2142,6 +2183,12 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
if (!owner || !name) {
throw new Error("Unable to resolve GitHub repository for pull request checkout");
}
const host = (await resolveRepoHostCached(input.cwd)) ?? "github.com";
const remoteNames = await resolveGitHubCheckoutRemotes({
cwd: input.cwd,
host,
repository: `${owner}/${name}`,
});
const parsed = await runGhJson(
[
@@ -2160,7 +2207,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
PullRequestCheckoutTargetSchema,
"{}",
);
return toPullRequestCheckoutTarget(parsed);
return toPullRequestCheckoutTarget({ parsed, remoteNames });
},
});
},
@@ -2181,6 +2228,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
...(await resolveCurrentPullRequestRepositories(input.cwd)),
run,
});
return addCurrentPullRequestGithubFacts({ cwd: input.cwd, status, run });
@@ -2526,27 +2574,27 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
async mergePullRequest(input) {
assertDirectPullRequestMergeReady(input);
await run(["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`], {
cwd: input.cwd,
envOverlay: { GH_PROMPT_DISABLED: "1" },
await runPullRequestMerge({
args: ["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`],
input,
});
return { success: true };
},
async enablePullRequestAutoMerge(input) {
assertPullRequestAutoMergeEnableReady(input);
await run(["pr", "merge", String(input.prNumber), "--auto", `--${input.mergeMethod}`], {
cwd: input.cwd,
envOverlay: { GH_PROMPT_DISABLED: "1" },
await runPullRequestMerge({
args: ["pr", "merge", String(input.prNumber), "--auto", `--${input.mergeMethod}`],
input,
});
return { success: true };
},
async disablePullRequestAutoMerge(input) {
assertPullRequestAutoMergeDisableReady(input);
await run(["pr", "merge", String(input.prNumber), "--disable-auto"], {
cwd: input.cwd,
envOverlay: { GH_PROMPT_DISABLED: "1" },
await runPullRequestMerge({
args: ["pr", "merge", String(input.prNumber), "--disable-auto"],
input,
});
return { success: true };
},
@@ -2693,6 +2741,33 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return api;
}
function selectGitHubCommandRepository({
args,
host,
slug,
}: {
args: string[];
host: string | null;
slug: string | null;
}): string[] {
if (args[0] === "auth" && args[1] === "status" && !args.includes("--hostname")) {
return [...args, "--hostname", host ?? "github.com"];
}
if (!host || !slug) return args;
if (args[0] === "repo" && args[1] === "view" && args[2]?.startsWith("-")) {
return [args[0], args[1], `${host}/${slug}`, ...args.slice(2)];
}
if (
(args[0] === "pr" || args[0] === "issue") &&
args[1] !== "merge" &&
!args.includes("--repo") &&
!args.includes("-R")
) {
return [...args, "--repo", `${host}/${slug}`];
}
return args;
}
function getGithubStatusFacts(
status: PullRequestCommandStatus | null | undefined,
): GitHubPullRequestStatusFacts | null {
@@ -2984,30 +3059,97 @@ function isGitHubRateLimitError(error: unknown): boolean {
);
}
async function tryCurrentPullRequestBranchView(options: {
cwd: string;
explicitRepo?: boolean;
headRef: string;
headRepositoryOwner?: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<ResolvedPullRequestCandidate | null> {
// gh interprets a numeric positional argument as a PR number, not a branch,
// so list the branch's PRs and keep what `gh pr view <branch>` would return:
// a PR whose head branch lives in the repository it targets.
if (options.explicitRepo && /^\d+$/u.test(options.headRef)) {
// A known head owner is matched by the branch listing that follows.
if (options.headRepositoryOwner) return null;
const candidates = await listCurrentPullRequestCandidates(options);
return pickPullRequestCandidate({
candidates: candidates.filter(isSameRepositoryPullRequestCandidate),
headRef: options.headRef,
});
}
return tryCurrentPullRequestView({
...options,
args: options.explicitRepo ? ["pr", "view", options.headRef] : undefined,
});
}
function isSameRepositoryPullRequestCandidate(candidate: ResolvedPullRequestCandidate): boolean {
const owner = candidate.headRepositoryOwner?.toLowerCase();
return owner !== undefined && owner === candidate.status.repoOwner?.toLowerCase();
}
async function findCurrentPullRequestViewMatch(options: {
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
explicitRepo?: boolean;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<ResolvedPullRequestCandidate | null> {
const viewCandidate = await tryCurrentPullRequestBranchView(options);
if (!viewCandidate) return null;
return pickPullRequestCandidate({
candidates: [viewCandidate],
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.headRepositoryOwner,
});
}
// Bare `gh pr view` picks the PR from the branch's tracking config. Named
// repositories lose that, so a same-repository PR whose head is not the
// checkout's commit must not shadow the user's fork PR on the same branch.
function isViewMatchForCheckout({
viewMatch,
checkout,
}: {
viewMatch: ResolvedPullRequestCandidate;
checkout: { explicitRepo?: boolean; headRepositoryOwner?: string; headSha?: string };
}): boolean {
return (
!checkout.explicitRepo ||
checkout.headRepositoryOwner !== undefined ||
checkout.headSha === undefined ||
viewMatch.headSha === checkout.headSha
);
}
async function resolveCurrentPullRequestView(options: {
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
explicitRepo?: boolean;
originRepository?: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> {
const viewCandidate = await tryCurrentPullRequestView(options);
const viewMatch = viewCandidate
? pickPullRequestCandidate({
candidates: [viewCandidate],
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.headRepositoryOwner,
})
: null;
if (viewMatch?.status.state === "open") {
const viewMatch = await findCurrentPullRequestViewMatch(options);
if (
viewMatch?.status.state === "open" &&
isViewMatchForCheckout({ viewMatch, checkout: options })
) {
return viewMatch.status;
}
let headRepositoryOwner = options.headRepositoryOwner;
if (!headRepositoryOwner) {
const repo = await getGitHubRepoView(options);
const repo = await getGitHubRepoView({
cwd: options.cwd,
repo: options.originRepository,
run: options.run,
});
const forkOwner = repo?.owner?.login;
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
@@ -3238,6 +3380,109 @@ async function runCurrentPullRequestStatusCommand(options: {
}
}
async function resolveGitHubCheckoutRemotes({
cwd,
host,
repository,
}: {
cwd: string;
host: string;
repository: string;
}): Promise<string[]> {
const { stdout } = await runGitCommand(
["config", "--null", "--get-regexp", "^remote\\..*\\.url$"],
{
cwd,
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
timeout: GIT_ORIGIN_URL_READ_TIMEOUT_MS,
acceptExitCodes: [0, 1],
},
);
const matches: string[] = [];
const seen = new Set<string>();
for (const entry of stdout.split("\0")) {
const separator = entry.indexOf("\n");
if (separator < 0) continue;
const name = /^remote\.(.+)\.url$/u.exec(entry.slice(0, separator))?.[1];
if (!name || seen.has(name)) continue;
// Git fetch uses the first configured URL for a remote.
seen.add(name);
const location = parseGitRemoteLocation(entry.slice(separator + 1));
if (!location) continue;
const remoteHost =
location.transport === "scp" || location.transport === "ssh"
? ((await resolveSshHostname(location.host)) ?? location.host)
: location.host;
const sameHost =
remoteHost.toLowerCase() === host.toLowerCase() ||
(isGitHubHost(remoteHost) && isGitHubHost(host));
if (
sameHost &&
parseGitHubRemoteIdentity(location.path)?.repo.toLowerCase() === repository.toLowerCase()
) {
matches.push(name);
}
}
// A clone made before a repository was renamed or transferred keeps the old
// owner/name in its remote URL, and GitHub still serves pull refs from it.
return matches.length > 0 ? matches : ["origin", "upstream"];
}
async function resolveGitHubDefaultRepoSlug({
cwd,
host,
}: {
cwd: string;
host: string;
}): Promise<string | null> {
// `gh repo set-default` stores either "base" or an owner/name in the remote's
// gh-resolved config. Read the raw URLs because `git remote` applies rewrites.
let stdout: string;
try {
({ stdout } = await runGitCommand(
["config", "--null", "--get-regexp", "^remote\\..*\\.(gh-resolved|url)$"],
{
cwd,
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
timeout: GIT_ORIGIN_URL_READ_TIMEOUT_MS,
acceptExitCodes: [0, 1],
},
));
} catch {
return null;
}
const config = new Map<string, string>();
for (const entry of stdout.split("\0")) {
const separator = entry.indexOf("\n");
if (separator !== -1) config.set(entry.slice(0, separator), entry.slice(separator + 1));
}
const priorities: Record<string, number> = { upstream: 3, github: 2, origin: 1 };
const remotes = [...config.keys()]
.map((key) => /^remote\.(.+)\.url$/u.exec(key)?.[1])
.filter((name): name is string => name !== undefined)
.sort(
(a, b) =>
(priorities[b.toLowerCase()] ?? 0) - (priorities[a.toLowerCase()] ?? 0) ||
a.localeCompare(b),
);
const candidates: Array<{ path: string; selected?: string }> = [];
for (const name of remotes) {
const location = parseGitRemoteLocation(config.get(`remote.${name}.url`) ?? "");
if (!location) continue;
const remoteHost =
location.transport === "scp" || location.transport === "ssh"
? ((await resolveSshHostname(location.host)) ?? location.host)
: location.host;
if (remoteHost.toLowerCase() !== host.toLowerCase()) continue;
candidates.push({ path: location.path, selected: config.get(`remote.${name}.gh-resolved`) });
}
const candidate = candidates.find((remote) => remote.selected) ?? candidates[0];
if (!candidate) return null;
const path =
!candidate.selected || candidate.selected === "base" ? candidate.path : candidate.selected;
return parseGitHubRemoteIdentity(path)?.repo ?? null;
}
async function resolveGitHubSlugFromOrigin(cwd: string): Promise<string | null> {
let stdout: string;
try {
@@ -3569,9 +3814,13 @@ function normalizeRepositorySummary(repository: {
};
}
function toPullRequestCheckoutTarget(
parsed: z.infer<typeof PullRequestCheckoutTargetSchema>,
): PullRequestCheckoutTarget {
function toPullRequestCheckoutTarget({
parsed,
remoteNames,
}: {
parsed: z.infer<typeof PullRequestCheckoutTargetSchema>;
remoteNames: string[];
}): PullRequestCheckoutTarget {
const pullRequest = parsed.data.repository.pullRequest;
if (!pullRequest) {
throw new Error("Pull request not found");
@@ -3580,10 +3829,10 @@ function toPullRequestCheckoutTarget(
number: pullRequest.number,
baseRefName: pullRequest.baseRefName,
headRefName: pullRequest.headRefName,
checkoutRefs: [
{ remoteName: "origin", remoteRef: `refs/pull/${pullRequest.number}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${pullRequest.number}/head` },
],
checkoutRefs: remoteNames.map((remoteName) => ({
remoteName,
remoteRef: `refs/pull/${pullRequest.number}/head`,
})),
headOwnerLogin: pullRequest.headRepositoryOwner?.login || null,
headRepositorySshUrl: pullRequest.headRepository?.sshUrl || null,
headRepositoryUrl: pullRequest.headRepository?.url || null,