diff --git a/packages/server/src/services/forge-service.ts b/packages/server/src/services/forge-service.ts index fbf7f6522d..e92f859db1 100644 --- a/packages/server/src/services/forge-service.ts +++ b/packages/server/src/services/forge-service.ts @@ -196,6 +196,7 @@ export interface PullRequestCreateResult { export type PullRequestMergeMethod = "merge" | "squash" | "rebase"; export interface PullRequestCommandStatus { + url?: string; mergeable?: PullRequestMergeable; forgeSpecific?: ForgeSpecificStatusFacts; } diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index 02aa8d4993..157238813c 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -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", ]), ); diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index 189d53de5b..e095c0a27c 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -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 { + 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; }): Promise { - 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 } 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 {