Skip to content
Source
Sign inSign up

SourceControlProviderRegistry.test.ts

490 lines16.7 KB

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 Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as PlatformError from "effect/PlatformError";
11import * as PubSub from "effect/PubSub";
12import * as Schema from "effect/Schema";
13import * as Stream from "effect/Stream";
14import * as TestClock from "effect/testing/TestClock";
15import {
16 DEFAULT_SERVER_SETTINGS,
17 VcsProcessSpawnError,
18 VcsProcessTimeoutError,
19} from "@t3tools/contracts";
20import type {
21 SourceControlCliDiscoverySpec,
22 SourceControlManagedCliDiscoverySpec,
23 SourceControlProviderDiscoverySpec,
24} from "@t3tools/source-control-core/server/discovery";
25import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
26import { fakeSourceControlProvider } from "@t3tools/source-control-testing/fakeSourceControlDriver";
27import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
28​
29import * as ServerSettings from "../serverSettings.ts";
30import * as ServerConfig from "../config.ts";
31import type * as VcsDriver from "../vcs/VcsDriver.ts";
32import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
33import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
34​
35const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
36const kind = SourceControlProviderKind.make;
37​
38class NotAnswered extends Schema.TaggedError<NotAnswered>()("NotAnswered", {}) {}
39​
40const linkSubject = (host: string) => () =>
41 Effect.succeed({ title: `${host} subject`, body: null });
42​
43/** Only GitHub and GitLab read linked subjects, as in production. */
44const PROVIDERS = [
45 fakeSourceControlProvider("github", { resolveLink: linkSubject("github") }),
46 fakeSourceControlProvider("gitlab", { resolveLink: linkSubject("gitlab") }),
47 fakeSourceControlProvider("forgejo"),
48 fakeSourceControlProvider("bitbucket"),
49 fakeSourceControlProvider("azure-devops"),
50];
51​
52/** A host CLI that claims every server it is signed in to, as `glab` does. */
53const claimingCli = (claims: string): SourceControlCliDiscoverySpec => ({
54 type: "cli",
55 kind: kind(claims),
56 label: claims,
57 installHint: `Install ${claims}.`,
58 executable: `${claims}-cli`,
59 versionArgs: ["--version"],
60 authArgs: ["auth", "status"],
61 remoteRefinementArgs: ({ provider }) => ["auth", "status", "--hostname", provider.name],
62 parseAuth: () => ({
63 status: "unknown",
64 account: Option.none(),
65 host: Option.none(),
66 detail: Option.none(),
67 }),
68 refineUnknownRemote: ({ context, auth }) =>
69 auth.stdout.includes(`Logged in to ${context.provider.name}`)
70 ? { kind: kind(claims), name: `${claims} Self-Hosted`, baseUrl: context.provider.baseUrl }
71 : null,
72});
73​
74const managedCli = (
75 claims: string,
76 refine: SourceControlManagedCliDiscoverySpec["refineUnknownRemote"],
77): SourceControlManagedCliDiscoverySpec => ({
78 type: "managed-cli",
79 kind: kind(claims),
80 label: claims,
81 installHint: `Install ${claims}.`,
82 probe: () => Effect.die("unused"),
83 refineUnknownRemote: refine,
84});
85​
86function makeRegistry(input: {
87 readonly remotes: ReadonlyArray<{
88 readonly name: string;
89 readonly url: string;
90 }>;
91 readonly discovery?: ReadonlyArray<SourceControlProviderDiscoverySpec>;
92 readonly run?: SourceControlHost.SourceControlHost["Service"]["process"]["run"];
93 readonly settings?: Partial<ServerSettings.ServerSettingsService["Service"]>;
94}) {
95 const driver = {
96 listRemotes: () =>
97 Effect.succeed({
98 remotes: input.remotes.map((remote) => ({
99 ...remote,
100 pushUrl: Option.none(),
101 isPrimary: remote.name === "origin",
102 })),
103 freshness: {
104 source: "live-local" as const,
105 observedAt: TEST_EPOCH,
106 expiresAt: Option.none(),
107 },
108 }),
109 } satisfies Partial<VcsDriver.VcsDriver["Service"]>;
110​
111 const discovery = input.discovery ?? [claimingCli("gitlab")];
112 return SourceControlProviderRegistry.makeWithProviders(
113 PROVIDERS.map((provider) => ({
114 kind: provider.kind,
115 provider,
116 discovery: discovery.find((spec) => spec.kind === provider.kind) ?? {
117 type: "api",
118 kind: provider.kind,
119 label: provider.kind,
120 installHint: "unused",
121 probeAuth: Effect.succeed({
122 status: "unknown",
123 account: Option.none(),
124 host: Option.none(),
125 detail: Option.none(),
126 }),
127 },
128 })),
129 ).pipe(
130 Effect.provide(
131 Layer.mergeAll(
132 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
133 resolve: () =>
134 Effect.succeed({
135 kind: "git",
136 repository: {
137 kind: "git",
138 rootPath: "/repo",
139 metadataPath: null,
140 freshness: {
141 source: "live-local" as const,
142 observedAt: TEST_EPOCH,
143 expiresAt: Option.none(),
144 },
145 },
146 driver: driver as unknown as VcsDriver.VcsDriver["Service"],
147 }),
148 }),
149 TestSourceControlHost.layer({
150 process: {
151 run: input.run ?? (() => Effect.succeed(TestSourceControlHost.processOutput(""))),
152 },
153 }),
154 input.settings
155 ? Layer.mock(ServerSettings.ServerSettingsService)(input.settings)
156 : ServerSettings.ServerSettingsService.layerTest(),
157 ServerConfig.layerTest(process.cwd(), {
158 prefix: "t3-source-control-registry-test-",
159 }).pipe(Layer.provide(NodeServices.layer)),
160 ),
161 ),
162 );
164​
165const signedIn = (host: string) => () =>
166 Effect.succeed(TestSourceControlHost.processOutput(` ✓ Logged in to ${host} as user\n`));
167​
168it.effect("routes an unknown remote to the integration that claims its server", () =>
169 Effect.gen(function* () {
170 const asked: Array<string> = [];
171 const registry = yield* makeRegistry({
172 remotes: [{ name: "origin", url: "git@self-hosted.example.test:group/project.git" }],
173 run: (input) => {
174 asked.push([input.command, ...input.args].join(" "));
175 return signedIn("self-hosted.example.test")();
176 },
177 });
178​
179 const handle = yield* registry.resolveHandle({ cwd: "/repo" });
180​
181 assert.strictEqual(handle.provider.kind, "gitlab");
182 assert.deepStrictEqual(handle.context?.provider, {
183 kind: kind("gitlab"),
184 name: "gitlab Self-Hosted",
185 baseUrl: "https://self-hosted.example.test",
186 });
187 assert.deepStrictEqual(asked, ["gitlab-cli auth status --hostname self-hosted.example.test"]);
188 }),
189);
190​
191it.effect("leaves a remote no integration claims unknown, with its server's address", () =>
192 Effect.gen(function* () {
193 const registry = yield* makeRegistry({
194 remotes: [{ name: "origin", url: "git@code.example.test:team/project.git" }],
195 });
196 const handle = yield* registry.resolveHandle({ cwd: "/repo" });
197 assert.strictEqual(handle.provider.kind, kind("unknown"));
198 assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test");
199 }),
200);
201​
202it.effect.each([
203 ["github.com:team/project.git", "github"],
204 ["gitlab.com:team/project.git", "gitlab"],
205 ["bitbucket.org:team/project.git", "bitbucket"],
206 ["code.example.test:team/project.git", "unknown"],
207] as const)(
208 "reads the host of an SSH remote that leaves its user to ssh config: %s",
209 ([url, expected]) =>
210 Effect.gen(function* () {
211 const registry = yield* makeRegistry({ remotes: [{ name: "origin", url }] });
212​
213 const handle = yield* registry.resolveHandle({ cwd: "/repo" });
214​
215 assert.strictEqual(handle.provider.kind, kind(expected));
216 assert.strictEqual(handle.context?.remoteUrl, url);
217 }),
218);
219​
220it.effect("refines the caller-selected remote instead of choosing another configured remote", () =>
221 Effect.gen(function* () {
222 const registry = yield* makeRegistry({
223 remotes: [{ name: "origin", url: "git@github.com:fork/project.git" }],
224 run: signedIn("self-hosted.example.test"),
225 });
226​
227 const handle = yield* registry.resolveHandle({
228 cwd: "/repo",
229 context: {
230 provider: {
231 kind: kind("unknown"),
232 name: "self-hosted.example.test",
233 baseUrl: "https://self-hosted.example.test",
234 },
235 remoteName: "upstream",
236 remoteUrl: "https://self-hosted.example.test/group/project.git",
237 },
238 });
239​
240 assert.strictEqual(handle.context?.provider.kind, "gitlab");
241 assert.strictEqual(handle.context?.remoteName, "upstream");
242 }),
243);
244​
245it.effect("falls back to a non-origin remote when origin is not configured", () =>
246 Effect.gen(function* () {
247 const registry = yield* makeRegistry({
248 remotes: [{ name: "upstream", url: "https://dev.azure.com/acme/project/_git/repo" }],
249 });
250​
251 const provider = yield* registry.resolve({ cwd: "/repo" });
252​
253 assert.strictEqual(provider.kind, "azure-devops");
254 }),
255);
256​
257it.effect(
258 "routes linked subjects to the host that serves them, independently of the checkout",
259 () =>
260 Effect.gen(function* () {
261 const registry = yield* makeRegistry({
262 remotes: [{ name: "origin", url: "https://github.com/unrelated/checkout.git" }],
263 });
264 for (const [url, host] of [
265 ["https://github.com/team/project/issues/1", "github"],
266 ["https://gitlab.com/team/sub/project/-/merge_requests/2", "gitlab"],
267 ] as const) {
268 const lookup = registry.resolveLink({ cwd: "/unrelated", url: new URL(url) });
269 assert.ok(lookup);
270 assert.deepStrictEqual(yield* lookup, { title: `${host} subject`, body: null });
271 }
272 for (const url of [
273 "https://example.test/team/project/issues/1",
274 "https://codeberg.org/team/project/issues/1",
275 "https://bitbucket.org/team/project/pull-requests/1",
276 "https://dev.azure.com/org/project/_git/repo/pullrequest/1",
277 "http://github.com/team/project/issues/1",
278 "https://user:secret@github.com/team/project/issues/1",
279 ]) {
280 assert.strictEqual(
281 registry.resolveLink({ cwd: "/unrelated", url: new URL(url) }),
282 undefined,
283 url,
284 );
285 }
286 }),
287);
288​
289it.effect("does not refine a context whose server address is empty", () =>
290 Effect.gen(function* () {
291 let asked = 0;
292 const remoteUrl = "git@githubenterprise.dev.example.com:team/workspace.git";
293 const registry = yield* makeRegistry({
294 remotes: [{ name: "origin", url: remoteUrl }],
295 discovery: [
296 managedCli("github", ({ context }) => {
297 asked += 1;
298 return Effect.succeed(
299 context.provider.baseUrl === ""
300 ? null
301 : { kind: kind("github"), name: "GitHub", baseUrl: context.provider.baseUrl },
302 );
303 }),
304 ],
305 });
306 const context = {
307 provider: { kind: kind("unknown"), name: "Unknown", baseUrl: "" },
308 remoteName: "origin",
309 remoteUrl,
310 };
311 assert.deepStrictEqual(
312 (yield* registry.resolveHandle({ cwd: "/repo", context })).context,
313 context,
314 );
315​
316 const resolved = yield* registry.resolveHandle({ cwd: "/repo" });
317 assert.strictEqual(resolved.provider.kind, "github");
318 assert.strictEqual(
319 resolved.context?.provider.baseUrl,
320 "https://githubenterprise.dev.example.com",
321 );
322 assert.strictEqual(asked, 2);
323 }),
324);
325​
326const notFound = (command: string, cwd: string) =>
327 new VcsProcessSpawnError({
328 operation: "test",
329 command,
330 cwd,
331 cause: PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn" }),
332 });
333​
334const resolveUnknown = (
335 registry: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"],
336 repository: string,
337) =>
338 registry.resolveHandle({
339 cwd: `/${repository}`,
340 context: {
341 provider: {
342 kind: kind("unknown"),
343 name: "git.example.internal",
344 baseUrl: "https://git.example.internal",
345 },
346 remoteName: "origin",
347 remoteUrl: `git@Git.Example.Internal:team/${repository}.git`,
348 },
349 });
350​
351it.effect("asks once for every checkout of a server no integration claims", () =>
352 Effect.gen(function* () {
353 const asked: Array<string> = [];
354 const release = yield* Deferred.make<void>();
355 const registry = yield* makeRegistry({
356 remotes: [],
357 discovery: [
358 claimingCli("gitlab"),
359 managedCli("github", ({ context }) => {
360 asked.push(`managed ${context.provider.name}`);
361 return Effect.succeed(null);
362 }),
363 ],
364 run: ({ command, args, cwd }) => {
365 asked.push([command, ...args].join(" "));
366 return Deferred.await(release).pipe(Effect.andThen(Effect.fail(notFound(command, cwd))));
367 },
368 });
369​
370 const started = yield* Effect.forEach(["a", "b", "c"], () => Deferred.make<void>());
371 const callers = yield* Effect.forEach(
372 ["a", "b", "c"],
373 (repository, index) =>
374 Deferred.succeed(started[index]!, undefined).pipe(
375 Effect.andThen(resolveUnknown(registry, repository)),
376 ),
377 { concurrency: "unbounded" },
378 ).pipe(Effect.forkChild);
379 yield* Effect.forEach(started, Deferred.await);
380 yield* Deferred.succeed(release, undefined);
381 const handles = yield* Fiber.join(callers);
382 yield* resolveUnknown(registry, "d");
383 assert.deepStrictEqual(
384 handles.map((handle) => handle.provider.kind),
385 ["unknown", "unknown", "unknown"],
386 );
387 const once = asked.slice();
388 assert.sameMembers(once, [
389 "managed git.example.internal",
390 "gitlab-cli auth status --hostname git.example.internal",
391 ]);
392​
393 yield* TestClock.adjust("5 minutes");
394 yield* resolveUnknown(registry, "a");
395 assert.deepStrictEqual(asked, [...once, ...once]);
396 }),
397);
398​
399it.effect("asks again after a check that could not answer", () =>
400 Effect.gen(function* () {
401 let asks = 0;
402 let managedAsks = 0;
403 const registry = yield* makeRegistry({
404 remotes: [],
405 discovery: [
406 claimingCli("gitlab"),
407 managedCli("github", () => {
408 managedAsks += 1;
409 return Effect.fail(new NotAnswered());
410 }),
411 ],
412 run: ({ command, cwd }) => {
413 asks += 1;
414 return Effect.fail(
415 new VcsProcessTimeoutError({ operation: "test", command, cwd, timeoutMs: 1 }),
416 );
417 },
418 });
419 yield* resolveUnknown(registry, "a");
420 yield* resolveUnknown(registry, "b");
421 assert.strictEqual(asks, 2);
422 assert.strictEqual(managedAsks, 2);
423 }),
424);
425​
426it.effect("asks again once Settings change", () =>
427 Effect.gen(function* () {
428 const changes = yield* PubSub.unbounded<typeof DEFAULT_SERVER_SETTINGS>();
429 const handled = yield* Deferred.make<void>();
430 let claims = false;
431 const registry = yield* makeRegistry({
432 remotes: [],
433 settings: {
434 subscribeChanges: PubSub.subscribe(changes).pipe(
435 Effect.map((subscription) =>
436 Stream.fromSubscription(subscription).pipe(
437 Stream.flatMap((settings) =>
438 Stream.concat(
439 Stream.make(settings),
440 Stream.fromEffectDrain(Deferred.succeed(handled, undefined)),
441 ),
442 ),
443 ),
444 ),
445 ),
446 },
447 discovery: [
448 managedCli("github", ({ context }) =>
449 Effect.succeed(
450 claims
451 ? { kind: kind("github"), name: "GitHub", baseUrl: context.provider.baseUrl }
452 : null,
453 ),
454 ),
455 ],
456 });
457 assert.strictEqual((yield* resolveUnknown(registry, "a")).provider.kind, "unknown");
458 claims = true;
459 assert.strictEqual((yield* resolveUnknown(registry, "b")).provider.kind, "unknown");
460​
461 yield* PubSub.publish(changes, DEFAULT_SERVER_SETTINGS);
462 yield* Deferred.await(handled);
463 assert.strictEqual((yield* resolveUnknown(registry, "c")).provider.kind, "github");
464 }),
465);
466​
467it.effect("answers discovery without waiting on a hosting API that stalls", () =>
468 Effect.gen(function* () {
469 const registry = yield* makeRegistry({
470 remotes: [],
471 discovery: [
472 claimingCli("gitlab"),
473 {
474 type: "api",
475 kind: kind("bitbucket"),
476 label: "Bitbucket",
477 installHint: "unused",
478 probeAuth: Effect.never,
479 },
480 ],
481 });
482 const discovering = yield* registry.discover.pipe(Effect.forkChild);
483 yield* TestClock.adjust("3 seconds");
484 const items = yield* Fiber.join(discovering);
485 const bitbucket = items.find((item) => item.kind === "bitbucket");
486 assert.strictEqual(bitbucket?.status, "available");
487 assert.strictEqual(bitbucket?.auth.status, "unknown");
488 assert.strictEqual(items.find((item) => item.kind === "gitlab")?.status, "available");
489 }),
490);