1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Effect from "effect/Effect";
5import type * as Context from "effect/Context";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import { ChildProcessSpawner } from "effect/process";
12import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";
13import { VcsProcessSpawnError } from "@t3tools/contracts";
14
15import * as ServerSettings from "../serverSettings.ts";
16import * as ServerConfig from "../config.ts";
17import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
18import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
19import * as VcsProcess from "../vcs/VcsProcess.ts";
20import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
21import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";
22import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
23import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi";
24import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi";
25import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi";
26import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
27import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
28import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
29import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi";
30import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials";
31import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
32import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
33import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
34import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
35import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
36import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
37
38const layerSourceControlProviderRegistryTest = (input: {
39 readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>;
40 readonly process: Partial<VcsProcess.VcsProcess["Service"]>;
41}) =>
42 SourceControlProviderRegistry.layer.pipe(
43 Layer.provide(
44 Layer.mergeAll(
45 ServerConfig.layerTest(process.cwd(), {
46 prefix: "t3-source-control-registry-test-",
47 }).pipe(Layer.provide(NodeServices.layer)),
48 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
49 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}),
50 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
51 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}),
52 ServerSettings.ServerSettingsService.layerTest(),
53 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}),
54 Layer.mock(GitHubApi.GitHubApi)({}),
55 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
56 Layer.mock(GitLabCli.GitLabCli)({}),
57 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),
58 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
59 Layer.mock(GitCafeApi.GitCafeApi)({}),
60 Layer.mock(GitCafeCredentials.GitCafeCredentials)({
61 cliEnv: () => Effect.succeed({}),
62 }),
63 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
64 Layer.mock(VcsProcess.VcsProcess)(input.process),
65 ServerSourceControlHost.layer.pipe(
66 Layer.provide(
67 Layer.mergeAll(
68 Layer.mock(VcsProcess.VcsProcess)(input.process),
69 ServerSettings.ServerSettingsService.layerTest(),
70 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
71 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
72 ),
73 ),
74 ),
75 ),
76 ),
77 );
78
79const processOutput = (
80 stdout: string,
81 options?: {
82 readonly stderr?: string;
83 readonly exitCode?: ChildProcessSpawner.ExitCode;
84 },
85): VcsProcess.VcsProcessOutput => ({
86 exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0),
87 stdout,
88 stderr: options?.stderr ?? "",
89 stdoutTruncated: false,
90 stderrTruncated: false,
91});
92
93const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
94const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
95
96it.effect("reports implemented tools separately from locally available executables", () => {
97 const processMock = {
98 run: (input: VcsProcess.VcsProcessInput) => {
99 if (input.command === "git") {
100 return Effect.succeed(processOutput("git version 2.51.0\n"));
101 }
102 if (input.command === "gh" && input.args[0] === "--version") {
103 return Effect.succeed(processOutput("gh version 2.83.0\n"));
104 }
105 if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {
106 return Effect.succeed(
107 processOutput(
108 encodeJson({
109 hosts: {
110 "github.com": [
111 {
112 state: "success",
113 active: true,
114 host: "github.com",
115 login: "juliusmarminge",
116 tokenSource: "keyring",
117 gitProtocol: "ssh",
118 },
119 ],
120 },
121 }),
122 ),
123 );
124 }
125 return Effect.fail(
126 new VcsProcessSpawnError({
127 operation: input.operation,
128 command: input.command,
129 cwd: input.cwd,
130 cause: new Error(`${input.command} not found`),
131 }),
132 );
133 },
134 } satisfies Partial<VcsProcess.VcsProcess["Service"]>;
135 const layerTest = SourceControlDiscovery.layer.pipe(
136 Layer.provide(
137 ServerConfig.layerTest(process.cwd(), {
138 prefix: "t3-source-control-discovery-",
139 }),
140 ),
141 Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)),
142 Layer.provide(
143 layerSourceControlProviderRegistryTest({
144 process: processMock,
145 bitbucket: {
146 probeAuth: Effect.succeed({
147 status: "unauthenticated",
148 account: Option.none(),
149 host: Option.some("bitbucket.org"),
150 detail: Option.some(
151 "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.",
152 ),
153 }),
154 },
155 }),
156 ),
157 Layer.provideMerge(NodeServices.layer),
158 );
159
160 return Effect.gen(function* () {
161 const discovery = yield* SourceControlDiscovery.SourceControlDiscovery;
162 const result = yield* discovery.discover;
163
164 assert.deepStrictEqual(
165 result.versionControlSystems.map((item) => ({
166 kind: item.kind,
167 implemented: item.implemented,
168 status: item.status,
169 })),
170 [
171 { kind: "git", implemented: true, status: "available" },
172 { kind: "jj", implemented: false, status: "missing" },
173 ],
174 );
175 assert.deepStrictEqual(
176 result.sourceControlProviders.map((item) => ({
177 kind: item.kind,
178 status: item.status,
179 auth: item.auth.status,
180 account: item.auth.account,
181 })),
182 [
183 {
184 kind: SourceControlProviderKind.make("github"),
185 status: "available",
186 auth: "authenticated",
187 account: Option.some("juliusmarminge"),
188 },
189 {
190 kind: SourceControlProviderKind.make("gitlab"),
191 status: "missing",
192 auth: "unknown",
193 account: Option.none(),
194 },
195 {
196 kind: SourceControlProviderKind.make("azure-devops"),
197 status: "missing",
198 auth: "unknown",
199 account: Option.none(),
200 },
201 {
202 kind: SourceControlProviderKind.make("bitbucket"),
203 status: "available",
204 auth: "unauthenticated",
205 account: Option.none(),
206 },
207 {
208 kind: SourceControlProviderKind.make("forgejo"),
209 status: "missing",
210 auth: "unknown",
211 account: Option.none(),
212 },
213 {
214 kind: SourceControlProviderKind.make("gitcafe"),
215 status: "missing",
216 auth: "unknown",
217 account: Option.none(),
218 },
219 ],
220 );
221 const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");
222 assert.ok(bitbucket);
223 assert.strictEqual(bitbucket.executable, undefined);
224 }).pipe(Effect.provide(layerTest));
225});
226
227it.effect("probes provider authentication without exposing token details", () => {
228 const processMock = {
229 run: (input: VcsProcess.VcsProcessInput) => {
230 if (input.args[0] === "--version") {
231 return Effect.succeed(processOutput(`${input.command} version test\n`));
232 }
233 if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {
234 return Effect.succeed(
235 processOutput(
236 encodeJson({
237 hosts: {
238 "github.com": [
239 {
240 state: "success",
241 active: true,
242 host: "github.com",
243 login: "octocat",
244 tokenSource: "keyring",
245 gitProtocol: "ssh",
246 },
247 ],
248 },
249 }),
250 ),
251 );
252 }
253 if (input.command === "glab" && input.args.join(" ") === "auth status") {
254 return Effect.succeed(
255 processOutput(`gitlab.com
256Logged in to gitlab.com as gitlab-user
257`),
258 );
259 }
260 if (input.command === "tea" && input.args[0] === "login") {
261 return Effect.succeed(
262 processOutput(
263 encodeJson([
264 {
265 name: "forgejo",
266 url: "https://forgejo.example.com",
267 ssh_host: "forgejo.example.com",
268 user: "forgejo-user",
269 valid: "true",
270 default: "true",
271 },
272 ]),
273 ),
274 );
275 }
276 if (
277 input.command === "az" &&
278 input.args.join(" ") === "account show --query user.name -o tsv"
279 ) {
280 return Effect.succeed(processOutput("azure-user@example.com\n"));
281 }
282 if (input.command === "cafe" && input.args.slice(-3).join(" ") === "auth status --json") {
283 return Effect.succeed(
284 processOutput(
285 encodeJson({ schemaVersion: 1, data: { host: "git.cafe", username: "cafe-user" } }),
286 ),
287 );
288 }
289 return Effect.fail(
290 new VcsProcessSpawnError({
291 operation: input.operation,
292 command: input.command,
293 cwd: input.cwd,
294 cause: new Error(`${input.command} not found`),
295 }),
296 );
297 },
298 } satisfies Partial<VcsProcess.VcsProcess["Service"]>;
299 const layerTest = SourceControlDiscovery.layer.pipe(
300 Layer.provide(
301 ServerConfig.layerTest(process.cwd(), {
302 prefix: "t3-source-control-auth-discovery-",
303 }),
304 ),
305 Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)),
306 Layer.provide(
307 layerSourceControlProviderRegistryTest({
308 process: processMock,
309 bitbucket: {
310 probeAuth: Effect.succeed({
311 status: "authenticated",
312 account: Option.some("bitbucket-user"),
313 host: Option.some("bitbucket.org"),
314 detail: Option.none(),
315 }),
316 },
317 }),
318 ),
319 Layer.provideMerge(NodeServices.layer),
320 );
321
322 return Effect.gen(function* () {
323 const discovery = yield* SourceControlDiscovery.SourceControlDiscovery;
324 const result = yield* discovery.discover;
325
326 assert.deepStrictEqual(
327 result.sourceControlProviders.map((item) => ({
328 kind: item.kind,
329 auth: item.auth.status,
330 account: item.auth.account,
331 detail: item.auth.detail,
332 })),
333 [
334 {
335 kind: SourceControlProviderKind.make("github"),
336 auth: "authenticated",
337 account: Option.some("octocat"),
338 detail: Option.none(),
339 },
340 {
341 kind: SourceControlProviderKind.make("gitlab"),
342 auth: "authenticated",
343 account: Option.some("gitlab-user"),
344 detail: Option.none(),
345 },
346 {
347 kind: SourceControlProviderKind.make("azure-devops"),
348 auth: "authenticated",
349 account: Option.some("azure-user@example.com"),
350 detail: Option.none(),
351 },
352 {
353 kind: SourceControlProviderKind.make("bitbucket"),
354 auth: "authenticated",
355 account: Option.some("bitbucket-user"),
356 detail: Option.none(),
357 },
358 {
359 kind: SourceControlProviderKind.make("forgejo"),
360 auth: "authenticated",
361 account: Option.some("forgejo-user"),
362 detail: Option.none(),
363 },
364 {
365 kind: SourceControlProviderKind.make("gitcafe"),
366 auth: "authenticated",
367 account: Option.some("cafe-user"),
368 detail: Option.none(),
369 },
370 ],
371 );
372 }).pipe(Effect.provide(layerTest));
373});
374
375it.effect(
376 "checks out fj pull refs and preserves existing branches and dirty files until forced",
377 () =>
378 Effect.gen(function* () {
379 const fs = yield* FileSystem.FileSystem;
380 const git = yield* VcsProcess.VcsProcess;
381 const path = yield* Path.Path;
382 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fj-checkout-" });
383 const source = path.join(root, "source");
384 const cwd = path.join(root, "checkout");
385 yield* fs.makeDirectory(source);
386 for (const args of [
387 ["init", "-b", "main"],
388 ["config", "user.name", "Test"],
389 ["config", "user.email", "test@example.com"],
390 ])
391 yield* git.run({ operation: "test.setup", command: "git", cwd: source, args });
392 yield* fs.writeFileString(path.join(source, "base.txt"), "base\n");
393 for (const args of [
394 ["add", "base.txt"],
395 ["commit", "-m", "base"],
396 ])
397 yield* git.run({ operation: "test.setup", command: "git", cwd: source, args });
398 const base = (yield* git.run({
399 operation: "test.setup",
400 command: "git",
401 cwd: source,
402 args: ["rev-parse", "HEAD"],
403 })).stdout.trim();
404 yield* git.run({
405 operation: "test.setup",
406 command: "git",
407 cwd: root,
408 args: ["clone", source, cwd],
409 });
410 yield* fs.writeFileString(path.join(source, "feature.txt"), "pull request change\n");
411 for (const args of [
412 ["add", "feature.txt"],
413 ["commit", "-m", "feature"],
414 ["update-ref", "refs/pull/42/head", "HEAD"],
415 ])
416 yield* git.run({ operation: "test.setup", command: "git", cwd: source, args });
417 const head = (yield* git.run({
418 operation: "test.setup",
419 command: "git",
420 cwd: source,
421 args: ["rev-parse", "HEAD"],
422 })).stdout.trim();
423 const fetched: string[] = [];
424 const host = yield* SourceControlHost.SourceControlHost;
425 const provider = yield* ForgejoSourceControlProvider.make.pipe(
426 Effect.provideService(
427 SourceControlHost.SourceControlHost,
428 SourceControlHost.SourceControlHost.of({
429 settings: host.settings,
430 git: host.git,
431 process: {
432 run: (input) => {
433 if (input.args[0] !== "fetch") return git.run(input);
434 const url = input.args[2];
435 assert.isDefined(url);
436 fetched.push(url!);
437 // Only SSH transport is substituted; both paths fetch the real pull ref.
438 return git.run({
439 ...input,
440 args: input.args.map((arg) =>
441 arg === "git@forgejo.test:reviewer/project.git" ? source : arg,
442 ),
443 });
444 },
445 },
446 }),
447 ),
448 Effect.provide(
449 Layer.mock(ForgejoCli.ForgejoCli)({
450 resolveRepository: () =>
451 Effect.succeed({
452 command: "fj",
453 login: "work",
454 repository: "reviewer/project",
455 baseUrl: "https://forgejo.test",
456 }),
457 api: (input) => {
458 assert.include(
459 ["repos/reviewer/project", "repos/reviewer/project/pulls/42"],
460 input.path,
461 );
462 return Effect.succeed(
463 processOutput(
464 encodeJson(
465 input.path.endsWith("/pulls/42")
466 ? {
467 number: 42,
468 title: "Checkout",
469 html_url: "https://forgejo.test/reviewer/project/pulls/42",
470 state: "open",
471 merged: false,
472 base: { ref: "main", sha: base, repo: null },
473 head: { ref: "feature", sha: head, repo: null },
474 }
475 : {
476 full_name: "reviewer/project",
477 clone_url: source,
478 ssh_url: "git@forgejo.test:reviewer/project.git",
479 default_branch: "main",
480 },
481 ),
482 ),
483 );
484 },
485 }),
486 ),
487 );
488 yield* provider.checkoutChangeRequest({
489 cwd,
490 reference: "https://forgejo.test/reviewer/project/pulls/42",
491 });
492 assert.strictEqual(
493 (yield* git.run({
494 operation: "test.verify",
495 command: "git",
496 cwd,
497 args: ["branch", "--show-current"],
498 })).stdout.trim(),
499 "pulls/42",
500 );
501 assert.strictEqual(
502 (yield* git.run({
503 operation: "test.verify",
504 command: "git",
505 cwd,
506 args: ["rev-parse", "HEAD"],
507 })).stdout.trim(),
508 head,
509 );
510 assert.strictEqual(
511 yield* fs.readFileString(path.join(cwd, "feature.txt")),
512 "pull request change\n",
513 );
514 for (const args of [
515 ["checkout", "main"],
516 ["branch", "-f", "pulls/42", "main"],
517 ])
518 yield* git.run({ operation: "test.setup", command: "git", cwd, args });
519 yield* fs.writeFileString(path.join(cwd, "base.txt"), "uncommitted work\n");
520 yield* provider.checkoutChangeRequest({ cwd, reference: "42" });
521 assert.strictEqual(
522 (yield* git.run({
523 operation: "test.verify",
524 command: "git",
525 cwd,
526 args: ["rev-parse", "HEAD"],
527 })).stdout.trim(),
528 base,
529 );
530 assert.strictEqual(yield* fs.exists(path.join(cwd, "feature.txt")), false);
531 yield* provider.checkoutChangeRequest({
532 cwd,
533 reference: "42",
534 force: true,
535 context: {
536 provider: {
537 kind: SourceControlProviderKind.make("forgejo"),
538 name: "Forgejo",
539 baseUrl: "https://forgejo.test",
540 },
541 remoteName: "origin",
542 remoteUrl: "git@forgejo.test:maria/project.git",
543 },
544 });
545 assert.strictEqual(
546 (yield* git.run({
547 operation: "test.verify",
548 command: "git",
549 cwd,
550 args: ["rev-parse", "HEAD"],
551 })).stdout.trim(),
552 head,
553 );
554 assert.strictEqual(
555 yield* fs.readFileString(path.join(cwd, "base.txt")),
556 "uncommitted work\n",
557 );
558 assert.strictEqual(
559 yield* fs.readFileString(path.join(cwd, "feature.txt")),
560 "pull request change\n",
561 );
562 assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]);
563 }).pipe(
564 Effect.scoped,
565 Effect.provide(
566 ServerSourceControlHost.layer.pipe(
567 Layer.provideMerge(VcsProcess.layer),
568 Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()),
569 Layer.provideMerge(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
570 Layer.provideMerge(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})),
571 Layer.provideMerge(NodeServices.layer),
572 ),
573 ),
574 ),
575);