fix: preserve GitHub Enterprise host for PR commands

This commit is contained in:
Olivier Poitrey
2026-11-14 01:53:04 +01:00
parent d13438ba50
commit 4cb0be2b56
3 changed files with 179 additions and 23 deletions
@@ -196,6 +196,7 @@ export interface PullRequestCreateResult {
export type PullRequestMergeMethod = "merge" | "squash" | "rebase";
export interface PullRequestCommandStatus {
url?: string;
mergeable?: PullRequestMergeable;
forgeSpecific?: ForgeSpecificStatusFacts;
}
@@ -4801,6 +4801,105 @@ describe("ForgeService", () => {
expect(runner.calls[1]?.envOverlay).toMatchObject({ GH_HOST: "github.acme.internal" });
});
it("selects the original Enterprise repository for PR lookup when Git rewrites remotes", async () => {
const runner = createRunner([currentPullRequestJson()]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => "github.acme.internal",
resolveRepoSlug: async () => "acme/repo",
});
await service.getCurrentPullRequestStatus({ cwd: "/repo", 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" },
});
});
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.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 +4923,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 +4967,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",
]),
);
+79 -21
View File
@@ -2,7 +2,6 @@ import { z } from "zod";
import {
isGitHubHost,
parseGitHubRemoteIdentity,
parseGitHubRemoteUrl,
parseGitRemoteLocation,
} from "@getpaseo/protocol/git-remote";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
@@ -1331,20 +1330,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. Preserve the original
// Enterprise host and repository when invoking gh instead of asking gh to
// infer them from the rewritten URL.
const host = await resolveRepoHostCached(runOptions.cwd);
const slug =
host && ["pr", "issue", "repo"].includes(args[0] ?? "")
? await resolveRepoSlugCached(runOptions.cwd)
: 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 +1367,28 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
});
}
async function runPullRequestMerge(
args: string[],
input: { cwd: string; prNumber: number; status?: PullRequestCommandStatus | null },
): Promise<void> {
const host = await resolveRepoHostCached(input.cwd);
let effectiveArgs = args;
if (host) {
const url = input.status?.url;
const repo = url ? parseGitHubPullRequestRepo(url) : null;
if (
!url ||
!repo ||
new URL(url).hostname !== host ||
!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;
@@ -2181,6 +2206,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
explicitRepo: (await resolveRepoHostCached(input.cwd)) !== null,
run,
});
return addCurrentPullRequestGithubFacts({ cwd: input.cwd, status, run });
@@ -2526,28 +2552,25 @@ 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(
["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(
["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(["pr", "merge", String(input.prNumber), "--disable-auto"], input);
return { success: true };
},
@@ -2693,6 +2716,29 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return api;
}
function selectGitHubCommandRepository(
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,14 +3030,25 @@ function isGitHubRateLimitError(error: unknown): boolean {
);
}
function enterprisePullRequestViewArgs(options: {
explicitRepo?: boolean;
headRef: string;
}): string[] | undefined {
return options.explicitRepo ? ["pr", "view", options.headRef] : undefined;
}
async function resolveCurrentPullRequestView(options: {
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
explicitRepo?: boolean;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> {
const viewCandidate = await tryCurrentPullRequestView(options);
const viewCandidate = await tryCurrentPullRequestView({
...options,
args: enterprisePullRequestViewArgs(options),
});
const viewMatch = viewCandidate
? pickPullRequestCandidate({
candidates: [viewCandidate],
@@ -3249,7 +3306,8 @@ async function resolveGitHubSlugFromOrigin(cwd: string): Promise<string | null>
} catch {
return null;
}
return parseGitHubRemoteUrl(stdout.trim())?.repo ?? null;
const location = parseGitRemoteLocation(stdout.trim());
return location ? (parseGitHubRemoteIdentity(location.path)?.repo ?? null) : null;
}
async function resolveOriginRepositoryPath(cwd: string): Promise<string | null> {