1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as DateTime from "effect/DateTime";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Redacted from "effect/Redacted";
9import { ChildProcessSpawner } from "effect/process";
10import { VcsRepositoryDetectionError } from "@t3tools/contracts";
11
12import * as ServerSettings from "../serverSettings.ts";
13import * as ServerConfig from "../config.ts";
14import type * as VcsDriver from "../vcs/VcsDriver.ts";
15import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
16import * as VcsProcess from "../vcs/VcsProcess.ts";
17import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
18import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";
19import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
20import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi";
21import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
22import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi";
23import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi";
24import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
25import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
26import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
27import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
28import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi";
29import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials";
30import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
31import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
32
33const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
34
35const processOutput = (
36 stdout: string,
37 options?: {
38 readonly stderr?: string;
39 readonly exitCode?: ChildProcessSpawner.ExitCode;
40 },
41): VcsProcess.VcsProcessOutput => ({
42 exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0),
43 stdout,
44 stderr: options?.stderr ?? "",
45 stdoutTruncated: false,
46 stderrTruncated: false,
47});
48
49function makeRegistry(input: {
50 readonly remotes: ReadonlyArray<{
51 readonly name: string;
52 readonly url: string;
53 }>;
54 readonly process?: Partial<VcsProcess.VcsProcess["Service"]>;
55 readonly githubApi?: Partial<GitHubApi.GitHubApi["Service"]>;
56 readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>;
57 readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"];
58}) {
59 const driver = {
60 listRemotes: () =>
61 Effect.succeed({
62 remotes: input.remotes.map((remote) => ({
63 ...remote,
64 pushUrl: Option.none(),
65 isPrimary: remote.name === "origin",
66 })),
67 freshness: {
68 source: "live-local" as const,
69 observedAt: TEST_EPOCH,
70 expiresAt: Option.none(),
71 },
72 }),
73 } satisfies Partial<VcsDriver.VcsDriver["Service"]>;
74
75 const layerRegistry = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
76 get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]),
77 resolve:
78 input.resolve ??
79 (() =>
80 Effect.succeed({
81 kind: "git",
82 repository: {
83 kind: "git",
84 rootPath: "/repo",
85 metadataPath: null,
86 freshness: {
87 source: "live-local" as const,
88 observedAt: TEST_EPOCH,
89 expiresAt: Option.none(),
90 },
91 },
92 driver: driver as unknown as VcsDriver.VcsDriver["Service"],
93 })),
94 });
95
96 const layerProcess = Layer.mock(VcsProcess.VcsProcess)({
97 run: () => Effect.succeed(processOutput("")),
98 ...input.process,
99 });
100
101 return SourceControlProviderRegistry.make.pipe(
102 Effect.provide(
103 Layer.mergeAll(
104 NodeServices.layer,
105 layerRegistry,
106 layerProcess,
107 ServerSourceControlHost.layer.pipe(
108 Layer.provide(
109 Layer.mergeAll(
110 layerProcess,
111 layerRegistry,
112 ServerSettings.ServerSettingsService.layerTest(),
113 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
114 ),
115 ),
116 ),
117 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
118 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}),
119 Layer.mock(BitbucketApi.BitbucketApi)({}),
120 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}),
121 ServerSettings.ServerSettingsService.layerTest(),
122 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}),
123 Layer.mock(GitHubApi.GitHubApi)({
124 // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed.
125 credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })),
126 ...input.githubApi,
127 }),
128 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
129 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
130 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),
131 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
132 Layer.mock(GitCafeApi.GitCafeApi)({}),
133 Layer.mock(GitCafeCredentials.GitCafeCredentials)({
134 cliEnv: () => Effect.succeed({}),
135 }),
136 ServerConfig.layerTest(process.cwd(), {
137 prefix: "t3-source-control-registry-test-",
138 }).pipe(Layer.provide(NodeServices.layer)),
139 ),
140 ),
141 );
142}
143
144it.effect("routes GitHub remotes to the GitHub provider", () =>
145 Effect.gen(function* () {
146 const registry = yield* makeRegistry({
147 remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }],
148 });
149
150 const provider = yield* registry.resolve({ cwd: "/repo" });
151
152 assert.strictEqual(provider.kind, "github");
153 }),
154);
155
156it.effect("routes directly by provider kind for remote-first workflows", () =>
157 Effect.gen(function* () {
158 const registry = yield* makeRegistry({
159 remotes: [],
160 });
161
162 const provider = yield* registry.get(SourceControlProviderKind.make("github"));
163
164 assert.strictEqual(provider.kind, "github");
165 }),
166);
167
168it.effect("includes the request cwd when an unregistered provider is used", () =>
169 Effect.gen(function* () {
170 const registry = yield* makeRegistry({ remotes: [] });
171 const provider = yield* registry.get(SourceControlProviderKind.make("unknown"));
172
173 const error = yield* provider
174 .getChangeRequest({ cwd: "/repo", reference: "#42" })
175 .pipe(Effect.flip);
176
177 assert.strictEqual(error.provider, "unknown");
178 assert.strictEqual(error.operation, "getChangeRequest");
179 assert.strictEqual(error.cwd, "/repo");
180 assert.strictEqual(error.reference, "#42");
181 }),
182);
183
184it.effect("retains VCS detection failures with structured cwd context", () =>
185 Effect.gen(function* () {
186 const cause = new VcsRepositoryDetectionError({
187 operation: "resolve",
188 cwd: "/repo",
189 detail: "raw VCS detection failure",
190 cause: new Error("raw nested failure"),
191 });
192 const registry = yield* makeRegistry({
193 remotes: [],
194 resolve: () => Effect.fail(cause),
195 });
196
197 const error = yield* registry.resolve({ cwd: "/repo" }).pipe(Effect.flip);
198
199 assert.strictEqual(error.provider, "unknown");
200 assert.strictEqual(error.operation, "detectProvider");
201 assert.strictEqual(error.cwd, "/repo");
202 assert.strictEqual(error.detail, "Failed to detect source control provider.");
203 assert.strictEqual(error.cause, cause);
204 assert.equal(error.message.includes(cause.message), false);
205 }),
206);
207
208it.effect("routes GitLab remotes to the GitLab provider", () =>
209 Effect.gen(function* () {
210 const registry = yield* makeRegistry({
211 remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }],
212 });
213
214 const provider = yield* registry.resolve({ cwd: "/repo" });
215
216 assert.strictEqual(provider.kind, "gitlab");
217 }),
218);
219
220it.effect("routes authenticated self-hosted GitLab remotes without relying on host naming", () =>
221 Effect.gen(function* () {
222 const registry = yield* makeRegistry({
223 remotes: [{ name: "origin", url: "https://self-hosted.example.test/group/project.git" }],
224 process: {
225 run: () =>
226 Effect.succeed(
227 processOutput(
228 `gitlab.com
229 x gitlab.com: API call failed: 401 Unauthorized
230 ! No token found
231self-hosted.example.test
232 ✓ Logged in to self-hosted.example.test as gitlab-user
233 ✓ Token found: ******
234`,
235 { exitCode: ChildProcessSpawner.ExitCode(1) },
236 ),
237 ),
238 },
239 });
240
241 const provider = yield* registry.resolve({ cwd: "/repo" });
242
243 assert.strictEqual(provider.kind, "gitlab");
244 }),
245);
246
247it.effect("refines the caller-selected remote instead of choosing another configured remote", () =>
248 Effect.gen(function* () {
249 const registry = yield* makeRegistry({
250 remotes: [{ name: "origin", url: "git@github.com:fork/project.git" }],
251 process: {
252 run: () =>
253 Effect.succeed(
254 processOutput(`self-hosted.example.test
255 ✓ Logged in to self-hosted.example.test as gitlab-user
256`),
257 ),
258 },
259 });
260
261 const handle = yield* registry.resolveHandle({
262 cwd: "/repo",
263 context: {
264 provider: {
265 kind: SourceControlProviderKind.make("unknown"),
266 name: "self-hosted.example.test",
267 baseUrl: "https://self-hosted.example.test",
268 },
269 remoteName: "upstream",
270 remoteUrl: "https://self-hosted.example.test/group/project.git",
271 },
272 });
273
274 assert.strictEqual(handle.context?.provider.kind, "gitlab");
275 assert.strictEqual(handle.context?.remoteName, "upstream");
276 }),
277);
278
279it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports", () =>
280 Effect.gen(function* () {
281 const registry = yield* makeRegistry({
282 remotes: [{ name: "origin", url: "https://self-hosted.example.test:8443/group/project.git" }],
283 process: {
284 run: () =>
285 Effect.succeed(
286 processOutput(
287 `self-hosted.example.test:8443
288 ✓ Logged in to self-hosted.example.test:8443 as gitlab-user
289 ✓ Token found: ******
290`,
291 ),
292 ),
293 },
294 });
295
296 const provider = yield* registry.resolve({ cwd: "/repo" });
297
298 assert.strictEqual(provider.kind, "gitlab");
299 }),
300);
301
302it.effect("routes Bitbucket remotes to the Bitbucket provider", () =>
303 Effect.gen(function* () {
304 const registry = yield* makeRegistry({
305 remotes: [{ name: "origin", url: "git@bitbucket.org:pingdotgg/t3code.git" }],
306 });
307
308 const provider = yield* registry.resolve({ cwd: "/repo" });
309
310 assert.strictEqual(provider.kind, "bitbucket");
311 }),
312);
313
314it.effect("routes Azure DevOps remotes to the Azure DevOps provider", () =>
315 Effect.gen(function* () {
316 const registry = yield* makeRegistry({
317 remotes: [{ name: "origin", url: "https://dev.azure.com/acme/project/_git/repo" }],
318 });
319
320 const provider = yield* registry.resolve({ cwd: "/repo" });
321
322 assert.strictEqual(provider.kind, "azure-devops");
323 }),
324);
325
326it.effect("falls back to a non-origin remote when origin is not configured", () =>
327 Effect.gen(function* () {
328 const registry = yield* makeRegistry({
329 remotes: [{ name: "upstream", url: "https://dev.azure.com/acme/project/_git/repo" }],
330 });
331
332 const provider = yield* registry.resolve({ cwd: "/repo" });
333
334 assert.strictEqual(provider.kind, "azure-devops");
335 }),
336);
337
338it.effect(
339 "routes linked subjects by URL independently of the checkout and skips unsupported links",
340 () =>
341 Effect.gen(function* () {
342 const registry = yield* makeRegistry({
343 remotes: [{ name: "origin", url: "https://github.com/unrelated/checkout.git" }],
344 githubApi: {
345 rest: () =>
346 Effect.succeed({
347 status: 200,
348 headers: {},
349 body: JSON.stringify({ title: "GitHub issue", body: null }),
350 truncated: false,
351 invalidUtf8: false,
352 }),
353 },
354 gitlab: {
355 execute: () =>
356 Effect.succeed(
357 processOutput(JSON.stringify({ title: "GitLab MR", description: "Nested project" })),
358 ),
359 },
360 });
361 for (const [url, expected] of [
362 ["https://github.com/team/project/issues/1", { title: "GitHub issue", body: null }],
363 [
364 "https://gitlab.com/team/sub/project/-/merge_requests/2",
365 { title: "GitLab MR", body: "Nested project" },
366 ],
367 ] as const) {
368 const lookup = registry.resolveLink({ cwd: "/unrelated", url: new URL(url) });
369 assert.ok(lookup);
370 assert.deepStrictEqual(yield* lookup, expected);
371 }
372 for (const url of [
373 "https://example.test/team/project/issues/1",
374 "https://github.attacker.test/team/project/issues/1",
375 "https://gitlab.attacker.test/team/project/-/issues/1",
376 "https://github.com/team/project",
377 "https://codeberg.org/team/project/issues/1",
378 "https://bitbucket.org/team/project/pull-requests/1",
379 "https://dev.azure.com/org/project/_git/repo/pullrequest/1",
380 "http://github.com/team/project/issues/1",
381 "https://user:secret@github.com/team/project/issues/1",
382 ]) {
383 assert.strictEqual(
384 registry.resolveLink({ cwd: "/unrelated", url: new URL(url) }),
385 undefined,
386 );
387 }
388 }).pipe(Effect.scoped),
389);
390
391it.effect.each([
392 { name: "a credential", credential: "found", expected: "github" },
393 // Claimed so the error says the host is turned off, not that it is unsupported.
394 { name: "a host turned off in Settings", credential: "disabled", expected: "github" },
395 { name: "no credential", credential: "missing", expected: "unknown" },
396] as const)("resolves custom-host remotes with $name", (scenario) =>
397 Effect.gen(function* () {
398 const hosts: Array<string> = [];
399 const commands: Array<string> = [];
400 const registry = yield* makeRegistry({
401 remotes: [{ name: "origin", url: "git@code.example.test:team/project.git" }],
402 process: {
403 run: ({ command }) => {
404 commands.push(command);
405 return Effect.succeed(processOutput(""));
406 },
407 },
408 githubApi: {
409 credential: (host) => {
410 hosts.push(host);
411 return scenario.credential === "found"
412 ? Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:fingerprint` })
413 : scenario.credential === "disabled"
414 ? Effect.fail(new GitHubCredentials.GitHubHostDisabledError({ host }))
415 : Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host }));
416 },
417 },
418 });
419 const handle = yield* registry.resolveHandle({ cwd: "/repo" });
420 assert.strictEqual(handle.provider.kind, SourceControlProviderKind.make(scenario.expected));
421 assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test");
422 assert.deepStrictEqual(hosts, ["code.example.test"]);
423 // Recognition goes through the credential layer; gh is only one of its sources.
424 assert.notInclude(commands, "gh");
425 }),
426);
427
428it.effect("skips GitHub discovery for the identity resolver's empty base URL", () =>
429 Effect.gen(function* () {
430 const hosts: string[] = [];
431 const remoteUrl = "git@githubenterprise.dev.example.com:team/workspace.git";
432 const registry = yield* makeRegistry({
433 remotes: [{ name: "origin", url: remoteUrl }],
434 githubApi: {
435 credential: (host) => {
436 hosts.push(host);
437 return Effect.succeed({
438 token: Redacted.make("token"),
439 fingerprint: `${host}:fingerprint`,
440 });
441 },
442 },
443 });
444 const context = {
445 provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" },
446 remoteName: "origin",
447 remoteUrl,
448 };
449 const unresolved = yield* registry.resolveHandle({ cwd: "/repo", context });
450 assert.deepStrictEqual(unresolved.context, context);
451 assert.deepStrictEqual(hosts, []);
452
453 const resolved = yield* registry.resolveHandle({ cwd: "/repo" });
454 assert.strictEqual(resolved.provider.kind, "github");
455 assert.strictEqual(
456 resolved.context?.provider.baseUrl,
457 "https://githubenterprise.dev.example.com",
458 );
459 assert.deepStrictEqual(hosts, ["githubenterprise.dev.example.com"]);
460 }),
461);