authorgravatar for 4248465+alimek@users.noreply.github.comGrzegorz Mandziak <4248465+alimek@users.noreply.github.com> 2026-10-08 21:46:02+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 19:46:02+00:00
logcdd331b6c3f78bf24b039b09937b837d2d71a4e7
tree6276ce1794951b1cff692b928762c184e90dc15c
parent740f591221bd9651f172237d68b7daba9d050dbd
signature Signed by PGP key B5690EEEBB952194

fix(server): recognize authenticated GitHub Enterprise hosts (#11059)

Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

6 files changed, 388 insertions(+), 20 deletions(-)

apps/server/src/pullRequest/PullRequestService.test.ts+47
......@@ -516,6 +516,53 @@ function makeService(input: {
516516 );
517517}
518518
519it.effect("lists GitHub Enterprise PRs for a stored unknown repository after host discovery", () =>
520 Effect.gen(function* () {
521 const service = yield* makeService({
522 projects: [
523 project({
524 id: "p1",
525 title: "enterprise",
526 workspaceRoot: "/repo",
527 repository: "team/project",
528 provider: "unknown",
529 host: "code.example.test",
530 }),
531 ],
532 providers: [
533 fakeProvider("github", {
534 /** Supplies a PR only after verifying discovery retained the Enterprise repository target. */
535 listChangeRequests: ({ host, repository }) => {
536 assert.strictEqual(host, "code.example.test");
537 assert.strictEqual(repository, "team/project");
538 return Effect.succeed({
539 items: [changeRequest(42, "2026-07-05T00:00:00Z")],
540 truncated: false,
541 continues: false,
542 });
543 },
544 }),
545 ],
546 /** Stands in for discovery claiming the custom host as GitHub. */
547 resolveHandle: ({ context }) => {
548 assert.ok(context);
549 return Effect.succeed({
550 context: {
551 ...context,
552 provider: { ...context.provider, kind: "github", name: "GitHub Self-Hosted" },
553 },
554 provider: undefined as never,
555 });
556 },
557 });
558 const result = yield* service.list({ state: "open" });
559 assert.deepStrictEqual(
560 result.entries.map(({ host, number }) => [host, number]),
561 [["code.example.test", 42]],
562 );
563 }),
564);
565
519566it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () =>
520567 Effect.gen(function* () {
521568 let refinementCalls = 0;
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+198-1
......@@ -1,3 +1,4 @@
1import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
12import { assert, describe, it } from "@effect/vitest";
23import * as NodeServices from "@effect/platform-node/NodeServices";
34import * as Effect from "effect/Effect";
......@@ -74,6 +75,7 @@ function harness(input: {
7475 readonly remotes: string;
7576 readonly api: Partial<GitHubApi.GitHubApi["Service"]>;
7677 readonly localBranches?: ReadonlyArray<string>;
78 readonly resolved?: string;
7779 /** Fails the local branch listing, the first git read a checkout makes. */
7880 readonly gitFailure?: unknown;
7981}) {
......@@ -110,7 +112,9 @@ function harness(input: {
110112 const process = Layer.mock(VcsProcess.VcsProcess)({
111113 run: (args) =>
112114 Effect.succeed(
113 args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1),
115 args.args[0] === "remote"
116 ? processOutput(input.remotes)
117 : processOutput(input.resolved ?? "", input.resolved ? 0 : 1),
114118 ),
115119 });
116120 const layer = Layer.mergeAll(
......@@ -965,3 +969,196 @@ it("names the environment token that overrides the Settings choice", () => {
965969 );
966970 assert.strictEqual(auth.accounts?.[2]?.environmentVariable, "GH_TOKEN");
967971});
972
973it.live.each([
974 "git@code.example.test:team/project.git",
975 "ssh://git@code.example.test/team/project.git",
976 "https://code.example.test/team/project.git",
977])("keeps Enterprise operations on the selected remote: %s", (remoteUrl) => {
978 const graphql: GitHubApi.GitHubGraphQlInput[] = [];
979 const rest: GitHubApi.GitHubRestInput[] = [];
980 const api = {
981 graphql: (input: GitHubApi.GitHubGraphQlInput) =>
982 Effect.sync(() => {
983 graphql.push(input);
984 const pr = {
985 ...node(42, "feature", "team"),
986 url: "https://code.example.test/team/project/pull/42",
987 isCrossRepository: false,
988 headRepository: { name: "project", nameWithOwner: "team/project" },
989 };
990 return encodeJson({
991 data: {
992 repository:
993 input.operation === "getPullRequest" ? { pullRequest: pr } : { h0: { nodes: [pr] } },
994 },
995 });
996 }),
997 rest: (input: GitHubApi.GitHubRestInput) =>
998 Effect.sync(() => {
999 rest.push(input);
1000 return restResponse({
1001 full_name: "team/project",
1002 html_url: `https://${input.host}/team/project`,
1003 ssh_url: `git@${input.host}:team/project.git`,
1004 default_branch: "main",
1005 });
1006 }),
1007 };
1008 const { layer, git } = harness({
1009 remotes: remotesOutput(
1010 ["origin", "git@github.com:team/project.git"],
1011 ["enterprise", remoteUrl],
1012 ),
1013 api,
1014 });
1015 return Effect.gen(function* () {
1016 const provider = yield* GitHubSourceControlProvider.make;
1017 const fs = yield* FileSystem.FileSystem;
1018 const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" });
1019 yield* fs.writeFileString(bodyFile, "Enterprise PR");
1020 const input = {
1021 cwd: "/repo",
1022 context: {
1023 provider: {
1024 kind: "github" as const,
1025 name: "GitHub Self-Hosted",
1026 baseUrl: "https://code.example.test",
1027 },
1028 remoteName: "enterprise",
1029 remoteUrl,
1030 },
1031 };
1032 for (const state of ["open", "closed"] as const) {
1033 const prs = yield* provider.listChangeRequests({
1034 ...input,
1035 state,
1036 headSelector: "feature",
1037 });
1038 assert.strictEqual(prs[0]?.url, "https://code.example.test/team/project/pull/42");
1039 }
1040 yield* provider.getChangeRequest({ ...input, reference: "42" });
1041 yield* provider.getChangeRequest({ ...input, reference: "feature" });
1042 yield* provider.createChangeRequest({
1043 ...input,
1044 baseRefName: "main",
1045 headSelector: "feature",
1046 title: "PR",
1047 bodyFile,
1048 });
1049 assert.strictEqual(yield* provider.getDefaultBranch(input), "main");
1050 yield* provider.checkoutChangeRequest({ ...input, reference: "42" });
1051 yield* provider.getRepositoryCloneUrls({ ...input, repository: "team/other" });
1052 yield* provider.getRepositoryCloneUrls({ ...input, repository: "github.com/team/explicit" });
1053 assert.strictEqual(graphql.length, 5);
1054 for (const request of graphql) {
1055 assert.strictEqual(request.host, "code.example.test");
1056 assert.strictEqual(request.variables?.owner, "team");
1057 assert.strictEqual(request.variables?.name, "project");
1058 }
1059 assert.deepStrictEqual(
1060 rest.map(({ host, path }) => [host, path]),
1061 [
1062 ["code.example.test", "repos/team/project/pulls"],
1063 ["code.example.test", "repos/team/project"],
1064 ["code.example.test", "repos/team/other"],
1065 ["github.com", "repos/team/explicit"],
1066 ],
1067 );
1068 assert.deepStrictEqual(git.find(([name]) => name === "fetchRemoteTrackingBranch")?.[1], {
1069 cwd: "/repo",
1070 remoteName: "enterprise",
1071 remoteBranch: "feature",
1072 });
1073 }).pipe(Effect.provide(layer), Effect.provideService(HostProcessEnvironment, {}), Effect.scoped);
1074});
1075
1076it.effect.each([
1077 { host: "github.com", resolved: "", envRepository: "", expected: "acme/web" },
1078 { host: "code.example.test", resolved: "", envRepository: "", expected: "acme/web" },
1079 {
1080 host: "github.com",
1081 resolved: "remote.origin.gh-resolved base",
1082 envRepository: "",
1083 expected: "me/web",
1084 },
1085 {
1086 host: "code.example.test",
1087 resolved: "remote.origin.gh-resolved base",
1088 envRepository: "",
1089 expected: "me/web",
1090 },
1091 {
1092 host: "github.com",
1093 resolved: "remote.upstream.gh-resolved acme/other",
1094 envRepository: "",
1095 expected: "acme/other",
1096 },
1097 {
1098 host: "code.example.test",
1099 resolved: "remote.upstream.gh-resolved acme/other",
1100 envRepository: "",
1101 expected: "acme/other",
1102 },
1103 { host: "github.com", resolved: "", envRepository: "chosen/repo", expected: "chosen/repo" },
1104 {
1105 host: "code.example.test",
1106 resolved: "",
1107 envRepository: "chosen/repo",
1108 expected: "chosen/repo",
1109 },
1110])(
1111 "preserves repository precedence on $host: $resolved $envRepository",
1112 ({ host, resolved, envRepository, expected }) => {
1113 const targets: string[] = [];
1114 const { layer } = harness({
1115 remotes: remotesOutput(
1116 ["origin", `git@${host}:me/web.git`],
1117 ["upstream", `git@${host}:acme/web.git`],
1118 ),
1119 resolved,
1120 api: {
1121 rest: (input) =>
1122 Effect.sync(() => {
1123 targets.push(`${input.host}/${input.path}`);
1124 return restResponse({
1125 full_name: expected,
1126 html_url: `https://${host}/${expected}`,
1127 ssh_url: `git@${host}:${expected}.git`,
1128 default_branch: "main",
1129 });
1130 }),
1131 },
1132 });
1133 return Effect.gen(function* () {
1134 const provider = yield* GitHubSourceControlProvider.make;
1135 const fs = yield* FileSystem.FileSystem;
1136 const bodyFile = yield* fs.makeTempFileScoped();
1137 yield* fs.writeFileString(bodyFile, "Body");
1138 const input = {
1139 cwd: "/repo",
1140 context: {
1141 provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` },
1142 remoteName: "origin",
1143 remoteUrl: `git@${host}:me/web.git`,
1144 },
1145 };
1146 yield* provider.getDefaultBranch(input);
1147 yield* provider.createChangeRequest({
1148 ...input,
1149 baseRefName: "main",
1150 headSelector: "me:feature",
1151 title: "PR",
1152 bodyFile,
1153 });
1154 assert.deepStrictEqual(targets, [
1155 `${host}/repos/${expected}`,
1156 `${host}/repos/${expected}/pulls`,
1157 ]);
1158 }).pipe(
1159 Effect.provide(layer),
1160 Effect.provideService(HostProcessEnvironment, { GH_REPO: envRepository }),
1161 Effect.scoped,
1162 );
1163 },
1164);
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+58-17
......@@ -30,6 +30,7 @@ import {
3030 type NormalizedGitHubPullRequestRecord,
3131} from "./gitHubPullRequests.ts";
3232import {
33 gitHubApiHostForRemote,
3334 parseFetchRemotes,
3435 parseGitHubRepositorySelector,
3536 parsePullRequestReference,
......@@ -255,7 +256,30 @@ export const makeDiscovery = Effect.gen(function* () {
255256 },
256257 } satisfies SourceControlProviderDiscoveryItem;
257258 }),
258 refineUnknownRemote: () => Effect.succeed(null),
259 /**
260 * Claims a custom host GitHub holds a credential for, from whichever source supplies it
261 * (Settings, the environment or gh). A host turned off in Settings is claimed too, so its
262 * error says so instead of "unsupported host".
263 */
264 refineUnknownRemote: ({ context }) => {
265 // Identity resolution also probes providers before a web base URL is known.
266 const url = URL.parse(context.provider.baseUrl);
267 if (!url?.host) return Effect.succeed(null);
268 return api.credential(url.host).pipe(
269 Effect.as(true),
270 Effect.catchTags({ GitHubHostDisabledError: () => Effect.succeed(true) }),
271 Effect.orElseSucceed(() => false),
272 Effect.map((known) =>
273 known
274 ? ({
275 kind: "github",
276 name: "GitHub Self-Hosted",
277 baseUrl: context.provider.baseUrl,
278 } as const)
279 : null,
280 ),
281 );
282 },
259283 } satisfies SourceControlManagedCliDiscoverySpec;
260284});
261285
......@@ -469,13 +493,18 @@ export const make = Effect.gen(function* () {
469493 });
470494
471495 /**
472 * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else
473 * the remote the caller resolved the provider from, else the best-ranked GitHub remote.
496 * Uses GH_REPO, then gh's remote selection on the detected host. The provider context is
497 * a fallback when the checkout's remotes cannot resolve that repository.
474498 */
475499 const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")(
476 function* (input: { readonly cwd: string; readonly host?: string | undefined }) {
500 function* (input: {
501 readonly cwd: string;
502 readonly host?: string | undefined;
503 readonly context?: SourceControlProvider.SourceControlProviderContext | undefined;
504 }) {
477505 const envRepository = environment.GH_REPO?.trim();
478 const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase();
506 const hostHint = input.host ?? contextHost(input.context);
507 const defaultHost = (hostHint ?? environment.GH_HOST ?? "github.com").toLowerCase();
479508 if (envRepository) {
480509 const locator = parseGitHubRepositorySelector(envRepository, defaultHost);
481510 if (locator !== null) return locator;
......@@ -487,10 +516,18 @@ export const make = Effect.gen(function* () {
487516 const { host, locator } = resolveGitHubRepository({
488517 remotes: remotes?.exitCode === 0 ? remotes.stdout : "",
489518 resolved: resolved?.exitCode === 0 ? resolved.stdout : "",
490 hostHint: input.host,
519 hostHint,
491520 defaultHost,
492521 });
493522 if (locator !== null) return locator;
523 if (input.context) {
524 const remote = normalizeGitRemoteUrl(input.context.remoteUrl);
525 const fallback = parseGitHubRepositorySelector(
526 `${host}/${remote.slice(remote.indexOf("/") + 1)}`,
527 host,
528 );
529 if (fallback !== null) return fallback;
530 }
494531 return yield* failure(
495532 `No GitHub repository on ${host} was found among this checkout's git remotes.`,
496533 );
......@@ -583,10 +620,11 @@ export const make = Effect.gen(function* () {
583620 readonly headSelector: string;
584621 readonly state: PullRequestListState;
585622 readonly limit: number;
623 readonly context?: SourceControlProvider.SourceControlProviderContext | undefined;
586624 readonly host?: string | undefined;
587625 readonly allowReserve: boolean;
588626 }) {
589 const locator = yield* resolveRepository({ cwd: input.cwd, host: input.host });
627 const locator = yield* resolveRepository(input);
590628 const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100);
591629 // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the
592630 // owner is matched on the rows it returns.
......@@ -613,6 +651,7 @@ export const make = Effect.gen(function* () {
613651 function* (input: {
614652 readonly cwd: string;
615653 readonly reference: string;
654 readonly context?: SourceControlProvider.SourceControlProviderContext | undefined;
616655 readonly host?: string | undefined;
617656 }) {
618657 const parsed = parsePullRequestReference(input.reference);
......@@ -625,6 +664,7 @@ export const make = Effect.gen(function* () {
625664 state,
626665 limit: 1,
627666 host: input.host,
667 context: input.context,
628668 allowReserve: true,
629669 });
630670 const [open] = yield* lookup("open");
......@@ -632,10 +672,7 @@ export const make = Effect.gen(function* () {
632672 if (found === undefined) return yield* notFound("No pull request has this head branch.");
633673 return found;
634674 }
635 const locator =
636 parsed.kind === "url"
637 ? parsed.locator
638 : yield* resolveRepository({ cwd: input.cwd, host: input.host });
675 const locator = parsed.kind === "url" ? parsed.locator : yield* resolveRepository(input);
639676 const decodeDetail = "GitHub returned an invalid pull request.";
640677 const decoded = yield* graphqlJson(
641678 {
......@@ -715,11 +752,11 @@ export const make = Effect.gen(function* () {
715752 readonly cwd: string;
716753 readonly reference: string;
717754 readonly force?: boolean;
755 readonly context?: SourceControlProvider.SourceControlProviderContext;
718756 }) {
719757 const reference = parsePullRequestReference(input.reference);
720 const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference });
721 const base =
722 reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd });
758 const pullRequest = yield* readPullRequest(input);
759 const base = reference.kind === "url" ? reference.locator : yield* resolveRepository(input);
723760 const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase();
724761 const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null;
725762 const isCrossRepository =
......@@ -735,8 +772,10 @@ export const make = Effect.gen(function* () {
735772 const remoteFor = (nameWithOwner: string) =>
736773 parseFetchRemotes(remotes).find(
737774 (remote) =>
775 (gitHubApiHostForRemote(remote.url) ??
776 normalizeGitRemoteUrl(remote.url).split("/")[0]) === base.host &&
738777 normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") ===
739 nameWithOwner.toLowerCase(),
778 nameWithOwner.toLowerCase(),
740779 )?.name ?? null;
741780 const baseRemote = Effect.suspend(() => {
742781 const known = remoteFor(baseNameWithOwner);
......@@ -920,6 +959,7 @@ export const make = Effect.gen(function* () {
920959 state: input.state,
921960 limit: input.limit ?? (input.state === "open" ? 1 : 20),
922961 host: contextHost(input.context),
962 context: input.context,
923963 allowReserve,
924964 }),
925965 ),
......@@ -937,7 +977,7 @@ export const make = Effect.gen(function* () {
937977 ),
938978 createChangeRequest: (input) =>
939979 Effect.gen(function* () {
940 const locator = yield* resolveRepository({ cwd: input.cwd });
980 const locator = yield* resolveRepository(input);
941981 const body = yield* fileSystem
942982 .readFileString(input.bodyFile)
943983 .pipe(
......@@ -969,7 +1009,7 @@ export const make = Effect.gen(function* () {
9691009 ),
9701010 getRepositoryCloneUrls: (input) =>
9711011 Effect.gen(function* () {
972 const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe(
1012 const fallbackHost = (yield* resolveRepository(input).pipe(
9731013 Effect.map((locator) => locator.host),
9741014 Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"),
9751015 )).toLowerCase();
......@@ -1020,6 +1060,7 @@ export const make = Effect.gen(function* () {
10201060 const locator = yield* resolveRepository({
10211061 cwd: input.cwd,
10221062 host: contextHost(input.context),
1063 context: input.context,
10231064 });
10241065 const repository = yield* readRepository(locator);
10251066 const branch = repository.default_branch?.trim() ?? "";
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+79-1
......@@ -4,6 +4,7 @@ import * as DateTime from "effect/DateTime";
44import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
7import * as Redacted from "effect/Redacted";
78import { ChildProcessSpawner } from "effect/process";
89import { VcsRepositoryDetectionError } from "@t3tools/contracts";
910
......@@ -16,6 +17,7 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
1617import * as BitbucketApi from "./BitbucketApi.ts";
1718import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1819import * as GitHubApi from "./GitHubApi.ts";
20import * as GitHubCredentials from "./GitHubCredentials.ts";
1921import * as GitLabCli from "./GitLabCli.ts";
2022import * as ForgejoCli from "./ForgejoCli.ts";
2123import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
......@@ -97,7 +99,11 @@ function makeRegistry(input: {
9799 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
98100 Layer.mock(BitbucketApi.BitbucketApi)({}),
99101 ServerSettings.ServerSettingsService.layerTest(),
100 Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}),
102 Layer.mock(GitHubApi.GitHubApi)({
103 // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed.
104 credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })),
105 ...input.githubApi,
106 }),
101107 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
102108 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
103109 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
......@@ -355,3 +361,75 @@ it.effect(
355361 }
356362 }).pipe(Effect.scoped),
357363);
364
365it.effect.each([
366 { name: "a credential", credential: "found", expected: "github" },
367 // Claimed so the error says the host is turned off, not that it is unsupported.
368 { name: "a host turned off in Settings", credential: "disabled", expected: "github" },
369 { name: "no credential", credential: "missing", expected: "unknown" },
370] as const)("resolves custom-host remotes with $name", (scenario) =>
371 Effect.gen(function* () {
372 const hosts: Array<string> = [];
373 const commands: Array<string> = [];
374 const registry = yield* makeRegistry({
375 remotes: [{ name: "origin", url: "git@code.example.test:team/project.git" }],
376 process: {
377 run: ({ command }) => {
378 commands.push(command);
379 return Effect.succeed(processOutput(""));
380 },
381 },
382 githubApi: {
383 credential: (host) => {
384 hosts.push(host);
385 return scenario.credential === "found"
386 ? Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:fingerprint` })
387 : scenario.credential === "disabled"
388 ? Effect.fail(new GitHubCredentials.GitHubHostDisabledError({ host }))
389 : Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host }));
390 },
391 },
392 });
393 const handle = yield* registry.resolveHandle({ cwd: "/repo" });
394 assert.strictEqual(handle.provider.kind, scenario.expected);
395 assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test");
396 assert.deepStrictEqual(hosts, ["code.example.test"]);
397 // Recognition goes through the credential layer; gh is only one of its sources.
398 assert.notInclude(commands, "gh");
399 }),
400);
401
402it.effect("skips GitHub discovery for the identity resolver's empty base URL", () =>
403 Effect.gen(function* () {
404 const hosts: string[] = [];
405 const remoteUrl = "git@githubenterprise.dev.example.com:team/workspace.git";
406 const registry = yield* makeRegistry({
407 remotes: [{ name: "origin", url: remoteUrl }],
408 githubApi: {
409 credential: (host) => {
410 hosts.push(host);
411 return Effect.succeed({
412 token: Redacted.make("token"),
413 fingerprint: `${host}:fingerprint`,
414 });
415 },
416 },
417 });
418 const context = {
419 provider: { kind: "unknown" as const, name: "Unknown", baseUrl: "" },
420 remoteName: "origin",
421 remoteUrl,
422 };
423 const unresolved = yield* registry.resolveHandle({ cwd: "/repo", context });
424 assert.deepStrictEqual(unresolved.context, context);
425 assert.deepStrictEqual(hosts, []);
426
427 const resolved = yield* registry.resolveHandle({ cwd: "/repo" });
428 assert.strictEqual(resolved.provider.kind, "github");
429 assert.strictEqual(
430 resolved.context?.provider.baseUrl,
431 "https://githubenterprise.dev.example.com",
432 );
433 assert.deepStrictEqual(hosts, ["githubenterprise.dev.example.com"]);
434 }),
435);
apps/server/src/sourceControl/gitHubRepositoryResolution.ts+3-1
......@@ -99,7 +99,9 @@ export function resolveGitHubRepository(input: {
9999 // the ones on this host (an SSH alias counts as its API host).
100100 const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase());
101101 const candidates = fetchRemotes
102 .filter((remote) => remote.host === host)
102 .filter(
103 (remote) => remote.host === host || normalizeGitRemoteUrl(remote.url).split("/")[0] === host,
104 )
103105 .toSorted((left, right) => {
104106 const l = rank(left.name);
105107 const r = rank(right.name);
docs/user/source-control.md+3
......@@ -24,6 +24,9 @@ If `gh` is signed in to several accounts or hosts, expand **GitHub** in the same
2424the account each host uses or turn a host off. A saved token or `GH_TOKEN` takes precedence
2525over that choice; a host turned off stays off either way.
2626
27For GitHub Enterprise, sign in with `gh auth login --hostname YOUR_HOST`. T3 Code treats a
28custom server name as GitHub once it has a credential for that host.
29
2730### Forgejo and Gitea
2831
2932Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli) or