| author | |
| committer | |
| log | cdd331b6c3f78bf24b039b09937b837d2d71a4e7 |
| tree | 6276ce1794951b1cff692b928762c184e90dc15c |
| parent | 740f591221bd9651f172237d68b7daba9d050dbd |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | } |
| 518 | 518 | ||
| 519 | it.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 | |||
| 519 | it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => | 566 | it.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 @@ |
| 1 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | 2 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * 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 | |||
| 973 | it.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 | |||
| 1076 | it.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"; |
| 32 | import { | 32 | import { |
| 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 | }); |
| 261 | 285 | ||
| ... | @@ -469,13 +493,18 @@ export const make = Effect.gen(function* () { | ... | @@ -469,13 +493,18 @@ export const make = Effect.gen(function* () { |
| 469 | }); | 493 | }); |
| 470 | 494 | ||
| 471 | /** | 495 | /** |
| 472 | * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else | 496 | * 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 the | 629 | // `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* fileSystem | 981 | 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"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 7 | import * as Redacted from "effect/Redacted"; | ||
| 7 | import { ChildProcessSpawner } from "effect/process"; | 8 | import { ChildProcessSpawner } from "effect/process"; |
| 8 | import { VcsRepositoryDetectionError } from "@t3tools/contracts"; | 9 | import { VcsRepositoryDetectionError } from "@t3tools/contracts"; |
| 9 | 10 | ||
| ... | @@ -16,6 +17,7 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | ... | @@ -16,6 +17,7 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| 16 | import * as BitbucketApi from "./BitbucketApi.ts"; | 17 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 17 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 18 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 18 | import * as GitHubApi from "./GitHubApi.ts"; | 19 | import * as GitHubApi from "./GitHubApi.ts"; |
| 20 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | ||
| 19 | import * as GitLabCli from "./GitLabCli.ts"; | 21 | import * as GitLabCli from "./GitLabCli.ts"; |
| 20 | import * as ForgejoCli from "./ForgejoCli.ts"; | 22 | import * as ForgejoCli from "./ForgejoCli.ts"; |
| 21 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | 23 | import * 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 | |||
| 365 | it.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 | |||
| 402 | it.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 = fetchRemotes | 101 | 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 |
| 24 | the account each host uses or turn a host off. A saved token or `GH_TOKEN` takes precedence | 24 | the account each host uses or turn a host off. A saved token or `GH_TOKEN` takes precedence |
| 25 | over that choice; a host turned off stays off either way. | 25 | over that choice; a host turned off stays off either way. |
| 26 | 26 | ||
| 27 | For GitHub Enterprise, sign in with `gh auth login --hostname YOUR_HOST`. T3 Code treats a | ||
| 28 | custom server name as GitHub once it has a credential for that host. | ||
| 29 | |||
| 27 | ### Forgejo and Gitea | 30 | ### Forgejo and Gitea |
| 28 | 31 | ||
| 29 | Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli) or | 32 | Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli) or |