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: {...@@ -516,6 +516,53 @@ function makeService(input: {
516 );516 );
517}517}
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
519it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () =>566it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () =>
520 Effect.gen(function* () {567 Effect.gen(function* () {
521 let refinementCalls = 0;568 let refinementCalls = 0;
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+198-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
1import { assert, describe, it } from "@effect/vitest";2import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -74,6 +75,7 @@ function harness(input: {...@@ -74,6 +75,7 @@ function harness(input: {
74 readonly remotes: string;75 readonly remotes: string;
75 readonly api: Partial<GitHubApi.GitHubApi["Service"]>;76 readonly api: Partial<GitHubApi.GitHubApi["Service"]>;
76 readonly localBranches?: ReadonlyArray<string>;77 readonly localBranches?: ReadonlyArray<string>;
78 readonly resolved?: string;
77 /** Fails the local branch listing, the first git read a checkout makes. */79 /** Fails the local branch listing, the first git read a checkout makes. */
78 readonly gitFailure?: unknown;80 readonly gitFailure?: unknown;
79}) {81}) {
...@@ -110,7 +112,9 @@ function harness(input: {...@@ -110,7 +112,9 @@ function harness(input: {
110 const process = Layer.mock(VcsProcess.VcsProcess)({112 const process = Layer.mock(VcsProcess.VcsProcess)({
111 run: (args) =>113 run: (args) =>
112 Effect.succeed(114 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),
114 ),118 ),
115 });119 });
116 const layer = Layer.mergeAll(120 const layer = Layer.mergeAll(
...@@ -965,3 +969,196 @@ it("names the environment token that overrides the Settings choice", () => {...@@ -965,3 +969,196 @@ it("names the environment token that overrides the Settings choice", () => {
965 );969 );
966 assert.strictEqual(auth.accounts?.[2]?.environmentVariable, "GH_TOKEN");970 assert.strictEqual(auth.accounts?.[2]?.environmentVariable, "GH_TOKEN");
967});971});
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 {...@@ -30,6 +30,7 @@ import {
30 type NormalizedGitHubPullRequestRecord,30 type NormalizedGitHubPullRequestRecord,
31} from "./gitHubPullRequests.ts";31} from "./gitHubPullRequests.ts";
32import {32import {
33 gitHubApiHostForRemote,
33 parseFetchRemotes,34 parseFetchRemotes,
34 parseGitHubRepositorySelector,35 parseGitHubRepositorySelector,
35 parsePullRequestReference,36 parsePullRequestReference,
...@@ -255,7 +256,30 @@ export const makeDiscovery = Effect.gen(function* () {...@@ -255,7 +256,30 @@ export const makeDiscovery = Effect.gen(function* () {
255 },256 },
256 } satisfies SourceControlProviderDiscoveryItem;257 } satisfies SourceControlProviderDiscoveryItem;
257 }),258 }),
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 },
259 } satisfies SourceControlManagedCliDiscoverySpec;283 } satisfies SourceControlManagedCliDiscoverySpec;
260});284});
261285
...@@ -469,13 +493,18 @@ export const make = Effect.gen(function* () {...@@ -469,13 +493,18 @@ export const make = Effect.gen(function* () {
469 });493 });
470494
471 /**495 /**
472 * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else496 * Uses GH_REPO, then gh's remote selection on the detected host. The provider context is
473 * the remote the caller resolved the provider from, else the best-ranked GitHub remote.497 * a fallback when the checkout's remotes cannot resolve that repository.
474 */498 */
475 const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")(499 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 }) {
477 const envRepository = environment.GH_REPO?.trim();505 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();
479 if (envRepository) {508 if (envRepository) {
480 const locator = parseGitHubRepositorySelector(envRepository, defaultHost);509 const locator = parseGitHubRepositorySelector(envRepository, defaultHost);
481 if (locator !== null) return locator;510 if (locator !== null) return locator;
...@@ -487,10 +516,18 @@ export const make = Effect.gen(function* () {...@@ -487,10 +516,18 @@ export const make = Effect.gen(function* () {
487 const { host, locator } = resolveGitHubRepository({516 const { host, locator } = resolveGitHubRepository({
488 remotes: remotes?.exitCode === 0 ? remotes.stdout : "",517 remotes: remotes?.exitCode === 0 ? remotes.stdout : "",
489 resolved: resolved?.exitCode === 0 ? resolved.stdout : "",518 resolved: resolved?.exitCode === 0 ? resolved.stdout : "",
490 hostHint: input.host,519 hostHint,
491 defaultHost,520 defaultHost,
492 });521 });
493 if (locator !== null) return locator;522 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 }
494 return yield* failure(531 return yield* failure(
495 `No GitHub repository on ${host} was found among this checkout's git remotes.`,532 `No GitHub repository on ${host} was found among this checkout's git remotes.`,
496 );533 );
...@@ -583,10 +620,11 @@ export const make = Effect.gen(function* () {...@@ -583,10 +620,11 @@ export const make = Effect.gen(function* () {
583 readonly headSelector: string;620 readonly headSelector: string;
584 readonly state: PullRequestListState;621 readonly state: PullRequestListState;
585 readonly limit: number;622 readonly limit: number;
623 readonly context?: SourceControlProvider.SourceControlProviderContext | undefined;
586 readonly host?: string | undefined;624 readonly host?: string | undefined;
587 readonly allowReserve: boolean;625 readonly allowReserve: boolean;
588 }) {626 }) {
589 const locator = yield* resolveRepository({ cwd: input.cwd, host: input.host });627 const locator = yield* resolveRepository(input);
590 const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100);628 const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100);
591 // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the629 // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the
592 // owner is matched on the rows it returns.630 // owner is matched on the rows it returns.
...@@ -613,6 +651,7 @@ export const make = Effect.gen(function* () {...@@ -613,6 +651,7 @@ export const make = Effect.gen(function* () {
613 function* (input: {651 function* (input: {
614 readonly cwd: string;652 readonly cwd: string;
615 readonly reference: string;653 readonly reference: string;
654 readonly context?: SourceControlProvider.SourceControlProviderContext | undefined;
616 readonly host?: string | undefined;655 readonly host?: string | undefined;
617 }) {656 }) {
618 const parsed = parsePullRequestReference(input.reference);657 const parsed = parsePullRequestReference(input.reference);
...@@ -625,6 +664,7 @@ export const make = Effect.gen(function* () {...@@ -625,6 +664,7 @@ export const make = Effect.gen(function* () {
625 state,664 state,
626 limit: 1,665 limit: 1,
627 host: input.host,666 host: input.host,
667 context: input.context,
628 allowReserve: true,668 allowReserve: true,
629 });669 });
630 const [open] = yield* lookup("open");670 const [open] = yield* lookup("open");
...@@ -632,10 +672,7 @@ export const make = Effect.gen(function* () {...@@ -632,10 +672,7 @@ export const make = Effect.gen(function* () {
632 if (found === undefined) return yield* notFound("No pull request has this head branch.");672 if (found === undefined) return yield* notFound("No pull request has this head branch.");
633 return found;673 return found;
634 }674 }
635 const locator =675 const locator = parsed.kind === "url" ? parsed.locator : yield* resolveRepository(input);
636 parsed.kind === "url"
637 ? parsed.locator
638 : yield* resolveRepository({ cwd: input.cwd, host: input.host });
639 const decodeDetail = "GitHub returned an invalid pull request.";676 const decodeDetail = "GitHub returned an invalid pull request.";
640 const decoded = yield* graphqlJson(677 const decoded = yield* graphqlJson(
641 {678 {
...@@ -715,11 +752,11 @@ export const make = Effect.gen(function* () {...@@ -715,11 +752,11 @@ export const make = Effect.gen(function* () {
715 readonly cwd: string;752 readonly cwd: string;
716 readonly reference: string;753 readonly reference: string;
717 readonly force?: boolean;754 readonly force?: boolean;
755 readonly context?: SourceControlProvider.SourceControlProviderContext;
718 }) {756 }) {
719 const reference = parsePullRequestReference(input.reference);757 const reference = parsePullRequestReference(input.reference);
720 const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference });758 const pullRequest = yield* readPullRequest(input);
721 const base =759 const base = reference.kind === "url" ? reference.locator : yield* resolveRepository(input);
722 reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd });
723 const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase();760 const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase();
724 const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null;761 const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null;
725 const isCrossRepository =762 const isCrossRepository =
...@@ -735,8 +772,10 @@ export const make = Effect.gen(function* () {...@@ -735,8 +772,10 @@ export const make = Effect.gen(function* () {
735 const remoteFor = (nameWithOwner: string) =>772 const remoteFor = (nameWithOwner: string) =>
736 parseFetchRemotes(remotes).find(773 parseFetchRemotes(remotes).find(
737 (remote) =>774 (remote) =>
775 (gitHubApiHostForRemote(remote.url) ??
776 normalizeGitRemoteUrl(remote.url).split("/")[0]) === base.host &&
738 normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") ===777 normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") ===
739 nameWithOwner.toLowerCase(),778 nameWithOwner.toLowerCase(),
740 )?.name ?? null;779 )?.name ?? null;
741 const baseRemote = Effect.suspend(() => {780 const baseRemote = Effect.suspend(() => {
742 const known = remoteFor(baseNameWithOwner);781 const known = remoteFor(baseNameWithOwner);
...@@ -920,6 +959,7 @@ export const make = Effect.gen(function* () {...@@ -920,6 +959,7 @@ export const make = Effect.gen(function* () {
920 state: input.state,959 state: input.state,
921 limit: input.limit ?? (input.state === "open" ? 1 : 20),960 limit: input.limit ?? (input.state === "open" ? 1 : 20),
922 host: contextHost(input.context),961 host: contextHost(input.context),
962 context: input.context,
923 allowReserve,963 allowReserve,
924 }),964 }),
925 ),965 ),
...@@ -937,7 +977,7 @@ export const make = Effect.gen(function* () {...@@ -937,7 +977,7 @@ export const make = Effect.gen(function* () {
937 ),977 ),
938 createChangeRequest: (input) =>978 createChangeRequest: (input) =>
939 Effect.gen(function* () {979 Effect.gen(function* () {
940 const locator = yield* resolveRepository({ cwd: input.cwd });980 const locator = yield* resolveRepository(input);
941 const body = yield* fileSystem981 const body = yield* fileSystem
942 .readFileString(input.bodyFile)982 .readFileString(input.bodyFile)
943 .pipe(983 .pipe(
...@@ -969,7 +1009,7 @@ export const make = Effect.gen(function* () {...@@ -969,7 +1009,7 @@ export const make = Effect.gen(function* () {
969 ),1009 ),
970 getRepositoryCloneUrls: (input) =>1010 getRepositoryCloneUrls: (input) =>
971 Effect.gen(function* () {1011 Effect.gen(function* () {
972 const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe(1012 const fallbackHost = (yield* resolveRepository(input).pipe(
973 Effect.map((locator) => locator.host),1013 Effect.map((locator) => locator.host),
974 Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"),1014 Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"),
975 )).toLowerCase();1015 )).toLowerCase();
...@@ -1020,6 +1060,7 @@ export const make = Effect.gen(function* () {...@@ -1020,6 +1060,7 @@ export const make = Effect.gen(function* () {
1020 const locator = yield* resolveRepository({1060 const locator = yield* resolveRepository({
1021 cwd: input.cwd,1061 cwd: input.cwd,
1022 host: contextHost(input.context),1062 host: contextHost(input.context),
1063 context: input.context,
1023 });1064 });
1024 const repository = yield* readRepository(locator);1065 const repository = yield* readRepository(locator);
1025 const branch = repository.default_branch?.trim() ?? "";1066 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";...@@ -4,6 +4,7 @@ import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as Redacted from "effect/Redacted";
7import { ChildProcessSpawner } from "effect/process";8import { ChildProcessSpawner } from "effect/process";
8import { VcsRepositoryDetectionError } from "@t3tools/contracts";9import { VcsRepositoryDetectionError } from "@t3tools/contracts";
910
...@@ -16,6 +17,7 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";...@@ -16,6 +17,7 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
16import * as BitbucketApi from "./BitbucketApi.ts";17import * as BitbucketApi from "./BitbucketApi.ts";
17import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";18import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
18import * as GitHubApi from "./GitHubApi.ts";19import * as GitHubApi from "./GitHubApi.ts";
20import * as GitHubCredentials from "./GitHubCredentials.ts";
19import * as GitLabCli from "./GitLabCli.ts";21import * as GitLabCli from "./GitLabCli.ts";
20import * as ForgejoCli from "./ForgejoCli.ts";22import * as ForgejoCli from "./ForgejoCli.ts";
21import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";23import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
...@@ -97,7 +99,11 @@ function makeRegistry(input: {...@@ -97,7 +99,11 @@ function makeRegistry(input: {
97 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),99 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
98 Layer.mock(BitbucketApi.BitbucketApi)({}),100 Layer.mock(BitbucketApi.BitbucketApi)({}),
99 ServerSettings.ServerSettingsService.layerTest(),101 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 }),
101 Layer.mock(GitVcsDriver.GitVcsDriver)({}),107 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
102 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),108 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
103 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),109 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
...@@ -355,3 +361,75 @@ it.effect(...@@ -355,3 +361,75 @@ it.effect(
355 }361 }
356 }).pipe(Effect.scoped),362 }).pipe(Effect.scoped),
357);363);
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: {...@@ -99,7 +99,9 @@ export function resolveGitHubRepository(input: {
99 // the ones on this host (an SSH alias counts as its API host).99 // the ones on this host (an SSH alias counts as its API host).
100 const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase());100 const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase());
101 const candidates = fetchRemotes101 const candidates = fetchRemotes
102 .filter((remote) => remote.host === host)102 .filter(
103 (remote) => remote.host === host || normalizeGitRemoteUrl(remote.url).split("/")[0] === host,
104 )
103 .toSorted((left, right) => {105 .toSorted((left, right) => {
104 const l = rank(left.name);106 const l = rank(left.name);
105 const r = rank(right.name);107 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...@@ -24,6 +24,9 @@ If `gh` is signed in to several accounts or hosts, expand **GitHub** in the same
24the account each host uses or turn a host off. A saved token or `GH_TOKEN` takes precedence24the account each host uses or turn a host off. A saved token or `GH_TOKEN` takes precedence
25over that choice; a host turned off stays off either way.25over 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
27### Forgejo and Gitea30### Forgejo and Gitea
2831
29Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli) or32Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli) or