mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 04:45:11 +00:00
fix: preserve GitHub Enterprise host for PR commands
This commit is contained in:
@@ -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",
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user