1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import * as HttpClientResponse from "effect/http/HttpClientResponse";
12import { VcsProcessSpawnError } from "@t3tools/contracts";
13import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery";
14import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
15
16import * as ForgejoCli from "./ForgejoCli.ts";
17import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
18import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
19
20const processOutput = TestSourceControlHost.processOutput;
21
22const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
23const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
24
25/** The JSON body a request sends. Forgejo encodes bodies as UTF-8 text. */
26const requestJson = (request: HttpClientRequest.HttpClientRequest): unknown => {
27 assert.strictEqual(request.body._tag, "Uint8Array");
28 return request.body._tag === "Uint8Array"
29 ? decodeJson(new TextDecoder().decode(request.body.body))
30 : undefined;
31};
32const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
33
34it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => {
35 const methods: string[] = [];
36 const reviewClient = HttpClient.make((request) => {
37 methods.push(request.method);
38 if (request.method === "GET") {
39 assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42");
40 return Effect.succeed(
41 HttpClientResponse.fromWeb(
42 request,
43 new Response(
44 encodeJson({
45 number: 42,
46 title: "Review target",
47 body: "",
48 html_url: "https://forgejo.test/maria/project/pulls/42",
49 user: { login: "maria" },
50 state: "open",
51 merged: false,
52 head: { ref: "feature", sha: "head", repo: null },
53 base: { ref: "main", sha: "base", repo: null },
54 created_at: "2026-09-13T00:00:00Z",
55 updated_at: "2026-09-13T00:00:00Z",
56 closed_at: null,
57 merged_at: null,
58 labels: [],
59 }),
60 ),
61 ),
62 );
63 }
64 assert.strictEqual(request.method, "POST");
65 assert.strictEqual(
66 request.url,
67 "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews",
68 );
69 assert.deepStrictEqual(requestJson(request), {
70 event: "COMMENT",
71 body: "Review summary",
72 commit_id: "head",
73 comments: [],
74 });
75 return Effect.succeed(
76 HttpClientResponse.fromWeb(request, new Response('{"id":1}', { status: 200 })),
77 );
78 });
79 return Effect.gen(function* () {
80 const cli = yield* ForgejoCli.make;
81 const provider = yield* ForgejoPullRequestProvider.make.pipe(
82 Effect.provideService(ForgejoCli.ForgejoCli, cli),
83 );
84 yield* provider.submitReview({
85 cwd: "/repo",
86 repository: "maria/project",
87 host: "forgejo.test",
88 number: 42,
89 verdict: "comment",
90 body: "Review summary",
91 comments: [],
92 });
93 assert.deepStrictEqual(methods, ["GET", "POST"]);
94 }).pipe(
95 Effect.provideService(HttpClient.HttpClient, reviewClient),
96 Effect.provideService(
97 FileSystem.FileSystem,
98 FileSystem.makeNoop({
99 exists: () => Effect.succeed(true),
100 readFileString: () =>
101 Effect.succeed(
102 encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }),
103 ),
104 }),
105 ),
106 Effect.provide(
107 TestSourceControlHost.layer({
108 process: {
109 run: (input) => {
110 if (input.command === "git") {
111 assert.deepStrictEqual(input.args, ["remote", "-v"]);
112 return Effect.succeed(
113 processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"),
114 );
115 }
116 assert.strictEqual(input.command, "fj");
117 assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]);
118 return Effect.succeed(processOutput(""));
119 },
120 },
121 }),
122 ),
123 );
124});
125
126it.effect("reads Forgejo checks without repository or viewer requests", () => {
127 const paths: string[] = [];
128 return Effect.gen(function* () {
129 const provider = yield* ForgejoPullRequestProvider.make;
130 const read = provider.getChangeRequestChecks;
131 if (read === undefined) return yield* Effect.die("checks read missing");
132 const result = yield* read({
133 cwd: "/repo",
134 repository: "acme/web",
135 host: "forgejo.test",
136 number: 1,
137 });
138 assert.strictEqual(result.state, "open");
139 assert.strictEqual(result.checks[0]?.status, "failure");
140 assert.deepStrictEqual(paths, [
141 "repos/acme/web/pulls/1",
142 "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=1",
143 "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=2",
144 ]);
145 }).pipe(
146 Effect.provide(
147 Layer.mock(ForgejoCli.ForgejoCli)({
148 api: (input) => {
149 paths.push(input.path);
150 assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head)/);
151 return Effect.succeed(
152 processOutput(
153 input.path.endsWith("pulls/1")
154 ? `{"number":1,"title":"Checks","body":"","html_url":"https://forgejo.test/acme/web/pulls/1", "user":null,"state":"open","merged":false,
155 "head":{"ref":"feature","sha":"head","repo":null},"base":{"ref":"main","sha":"base","repo":null},
156 "created_at":"2026-09-16T00:00:00Z","updated_at":"2026-09-16T00:00:00Z","closed_at":null,"merged_at":null,"labels":[]}`
157 : input.path.endsWith("page=1")
158 ? `[{"context":"build","status":"failure","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00Z"}]`
159 : "[]",
160 ),
161 );
162 },
163 }),
164 ),
165 );
166});
167
168it.effect.each([
169 ["Ready", false, true, false, "mergeable"],
170 ["Draft", true, false, true, "unknown"],
171 ["Blocked", false, false, false, "unknown"],
172 ["Unchecked", false, undefined, false, "unknown"],
173 ["WIP: Legacy draft", undefined, false, true, "unknown"],
174 ["[WIP] Legacy draft", undefined, false, true, "unknown"],
175 ["WIP: Explicitly ready", false, false, false, "unknown"],
176] as const)(
177 "reads Forgejo mergeability for %s (draft=%s, mergeable=%s) across list, summary and detail",
178 ([title, draft, mergeable, isDraft, mergeability]) => {
179 const pr = {
180 number: 42,
181 title,
182 body: "",
183 html_url: "https://forgejo.test/maria/project/pulls/42",
184 user: { login: "maria" },
185 state: "open",
186 merged: false,
187 ...(draft === undefined ? {} : { draft }),
188 ...(mergeable === undefined ? {} : { mergeable }),
189 head: { ref: "feature", sha: "head", repo: null },
190 base: { ref: "main", sha: "base", repo: null },
191 created_at: "2026-09-16T00:00:00Z",
192 updated_at: "2026-09-16T00:00:00Z",
193 closed_at: null,
194 merged_at: null,
195 labels: [],
196 };
197 return Effect.gen(function* () {
198 const provider = yield* ForgejoPullRequestProvider.make;
199 const readSummary = provider.getChangeRequestSummary;
200 if (readSummary === undefined) return yield* Effect.die("summary read missing");
201 const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 42 };
202 const list = yield* provider.listChangeRequests({
203 ...input,
204 state: "open",
205 involvement: "all",
206 viewer: "maria",
207 limit: 10,
208 });
209 const summary = yield* readSummary(input);
210 const detail = yield* provider.getChangeRequest(input);
211 assert.strictEqual(list.items.length, 1);
212 assert.strictEqual(detail.headSha, "head");
213 for (const result of [list.items[0]!, summary, detail]) {
214 assert.strictEqual(result.mergeability, mergeability);
215 assert.strictEqual(result.isDraft, isDraft);
216 }
217 }).pipe(
218 Effect.provide(
219 Layer.mock(ForgejoCli.ForgejoCli)({
220 api: (input) => {
221 const [path, query] = input.path.split("?");
222 let response: unknown;
223 switch (path) {
224 case "repos/maria/project/pulls":
225 response = new URLSearchParams(query).get("page") === "1" ? [pr] : [];
226 break;
227 case "repos/maria/project/pulls/42":
228 response = pr;
229 break;
230 case "repos/maria/project":
231 response = { full_name: "maria/project", permissions: { push: true, admin: true } };
232 break;
233 case "user":
234 response = pr.user;
235 break;
236 case "repos/maria/project/statuses/head":
237 response = [];
238 break;
239 default:
240 return Effect.die(`Unexpected Forgejo request: ${input.path}`);
241 }
242 return encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput));
243 },
244 }),
245 ),
246 );
247 },
248);
249
250it.effect("gives Forgejo identities the browser URL of the login that serves them", () =>
251 Effect.gen(function* () {
252 const provider = yield* ForgejoSourceControlProvider.make;
253 const refine = provider.refineRepositoryIdentity;
254 assert.isDefined(refine);
255 const identity = (remoteUrl: string, provider?: string) => ({
256 canonicalKey: "forge.test/team/repo",
257 locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl },
258 rootPath: "/repo",
259 ...(provider === undefined ? {} : { provider }),
260 });
261 const served = (baseUrl: string) => () =>
262 Effect.succeed({
263 provider: {
264 kind: SourceControlProviderKind.make("forgejo"),
265 name: "Forgejo / Gitea",
266 baseUrl,
267 },
268 remoteName: "origin",
269 remoteUrl: "",
270 });
271
272 // An installation mount in the HTTP path is the login's, not the repository's.
273 assert.deepStrictEqual(
274 yield* refine!({
275 identity: identity("http://forge.test:3000/git/team/repo.git"),
276 resolveContext: served("http://forge.test:3000/git/"),
277 }),
278 {
279 ...identity("http://forge.test:3000/git/team/repo.git"),
280 provider: SourceControlProviderKind.make("forgejo"),
281 webUrl: "http://forge.test:3000/git/team/repo",
282 },
283 );
284 // An SSH alias keeps its whole path.
285 assert.strictEqual(
286 (yield* refine!({
287 identity: identity("ssh://git@ssh.forge.test/team/repo.git"),
288 resolveContext: served("http://forge.test:3000/git"),
289 })).webUrl,
290 "http://forge.test:3000/git/team/repo",
291 );
292 // Another host's identity, or a remote no Forgejo login serves, is left alone.
293 const github = identity("https://github.com/team/repo.git", "github");
294 assert.strictEqual(
295 yield* refine!({ identity: github, resolveContext: () => Effect.die("not asked") }),
296 github,
297 );
298 const unserved = identity("http://forge.test:3000/team/repo.git");
299 assert.strictEqual(
300 yield* refine!({ identity: unserved, resolveContext: () => Effect.succeed(null) }),
301 unserved,
302 );
303 }).pipe(
304 Effect.provide(
305 Layer.mergeAll(
306 Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})),
307 TestSourceControlHost.layer(),
308 Layer.mock(ForgejoCli.ForgejoCli)({}),
309 ),
310 ),
311 ),
312);
313
314it.effect("loads Forgejo pull request references from files and commits views", () =>
315 Effect.gen(function* () {
316 const provider = yield* ForgejoSourceControlProvider.make;
317 for (const reference of [
318 "42",
319 "#42",
320 "https://forgejo.test/maria/project/pulls/42",
321 "https://forgejo.test/maria/project/pulls/42/",
322 "https://forgejo.test/maria/project/pulls/42/files?w=1#diff-1",
323 "http://forgejo.test:3000/git/maria/project/pulls/42/commits",
324 ]) {
325 const result = yield* provider.getChangeRequest({ cwd: "/repo", reference });
326 assert.strictEqual(result.number, 42);
327 assert.strictEqual(result.title, "Forgejo view reference");
328 }
329 const invalid = yield* provider
330 .getChangeRequest({
331 cwd: "/repo",
332 reference: "https://forgejo.test/maria/project/pulls/42invalid/files",
333 })
334 .pipe(Effect.result);
335 assert.strictEqual(invalid._tag, "Failure");
336 }).pipe(
337 Effect.provide(
338 Layer.mergeAll(
339 Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})),
340 TestSourceControlHost.layer({ process: {} }),
341 Layer.mock(ForgejoCli.ForgejoCli)({
342 resolveRepository: () =>
343 Effect.succeed({
344 login: "work",
345 repository: "maria/project",
346 baseUrl: "https://forgejo.test",
347 }),
348 api: (input) => {
349 assert.strictEqual(input.path, "repos/maria/project/pulls/42");
350 return encodeJsonEffect({
351 number: 42,
352 title: "Forgejo view reference",
353 html_url: "https://forgejo.test/maria/project/pulls/42",
354 state: "open",
355 merged: false,
356 base: { ref: "main", sha: "base", repo: null },
357 head: { ref: "feature", sha: "head", repo: null },
358 }).pipe(Effect.orDie, Effect.map(processOutput));
359 },
360 }),
361 ),
362 ),
363 ),
364);
365
366it.effect(
367 "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations",
368 () => {
369 const user = { login: "maria" };
370 const review = {
371 id: 8,
372 body: "review body",
373 user,
374 state: "COMMENT",
375 submitted_at: "2026-09-12T00:00:00Z",
376 html_url: "https://forgejo.test/maria/project/pulls/2#issuecomment-37",
377 comments_count: 1,
378 };
379 const comment = { id: 12, body: "ordinary", user, created_at: review.submitted_at };
380 const responses: Record<string, unknown> = {
381 user,
382 "repos/maria/project/issues/2/comments": [comment],
383 "repos/maria/project/pulls/2/reviews": [review],
384 "repos/maria/project/pulls/2/reviews/8": review,
385 "repos/maria/project/pulls/2/reviews/9": { ...review, id: 9, html_url: "" },
386 "repos/maria/project/pulls/2/commits": [],
387 "repos/maria/project/issues/2/reactions": [],
388 "repos/maria/project/pulls/2/reviews/8/comments": [
389 {
390 ...comment,
391 id: 38,
392 body: "inline",
393 path: "file.ts",
394 position: 1,
395 original_position: 1,
396 commit_id: "head",
397 original_commit_id: "head",
398 resolver: null,
399 },
400 ],
401 "repos/maria/project/issues/comments/12/reactions": [{ content: "+1", user }],
402 "repos/maria/project/issues/comments/37/reactions": [{ content: "heart", user }],
403 "repos/maria/project/issues/comments/38/reactions": [
404 { content: "rocket", user: { login: "reviewer" } },
405 ],
406 };
407 const writes: ForgejoCli.ForgejoApiInput[] = [];
408 let reactionReads = 0;
409 return Effect.gen(function* () {
410 const provider = yield* ForgejoPullRequestProvider.make;
411 const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 2 };
412 const activity = yield* provider.getChangeRequestActivity(input);
413 assert.deepStrictEqual(
414 activity.comments.map((entry) => ({
415 id: entry.id,
416 kind: entry.kind,
417 reactions: entry.reactions,
418 })),
419 [
420 {
421 id: "12",
422 kind: "issue-comment",
423 reactions: [{ content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }],
424 },
425 {
426 id: "review:8",
427 kind: "review",
428 reactions: [{ content: "heart", count: 1, actors: [], viewerHasReacted: true }],
429 },
430 {
431 id: "38",
432 kind: "review-comment",
433 reactions: [
434 { content: "rocket", count: 1, actors: ["reviewer"], viewerHasReacted: false },
435 ],
436 },
437 ],
438 );
439 const inlineComment = activity.comments[2];
440 assert.ok(inlineComment);
441 assert.deepStrictEqual(activity.reviewThreads[0]?.comments, [inlineComment]);
442 for (const reacted of [true, false]) {
443 yield* provider.setReaction({ ...input, subjectId: "review:8", content: "heart", reacted });
444 yield* provider.setReaction({ ...input, subjectId: "38", content: "rocket", reacted });
445 }
446 assert.deepStrictEqual(
447 writes.map(({ path, method, body }) => ({ path, method, body })),
448 [
449 {
450 path: "repos/maria/project/issues/comments/37/reactions",
451 method: "POST",
452 body: { content: "heart" },
453 },
454 {
455 path: "repos/maria/project/issues/comments/38/reactions",
456 method: "POST",
457 body: { content: "rocket" },
458 },
459 {
460 path: "repos/maria/project/issues/comments/37/reactions",
461 method: "DELETE",
462 body: { content: "heart" },
463 },
464 {
465 path: "repos/maria/project/issues/comments/38/reactions",
466 method: "DELETE",
467 body: { content: "rocket" },
468 },
469 ],
470 );
471 const missing = yield* provider
472 .setReaction({ ...input, subjectId: "review:9", content: "heart", reacted: true })
473 .pipe(Effect.result);
474 assert.strictEqual(missing._tag, "Failure");
475 if (missing._tag === "Failure") assert.include(missing.failure.detail, "comment ID");
476 assert.strictEqual(writes.length, 4);
477 responses["repos/maria/project/pulls/2/reviews/8/comments"] = Array.from(
478 { length: 501 },
479 (_, index) => ({
480 ...comment,
481 id: 1000 + index,
482 path: "file.ts",
483 position: 1,
484 original_position: 1,
485 commit_id: "head",
486 original_commit_id: "head",
487 resolver: null,
488 }),
489 );
490 for (let index = 0; index < 500; index++) {
491 responses[`repos/maria/project/issues/comments/${1000 + index}/reactions`] = [];
492 }
493 reactionReads = 0;
494 const bounded = yield* provider.getChangeRequestActivity(input);
495 assert.strictEqual(bounded.reviewThreads.length, 500);
496 assert.strictEqual(
497 bounded.comments.filter((entry) => entry.kind === "review-comment").length,
498 500,
499 );
500 assert.strictEqual(bounded.commentsTruncated, true);
501 assert.strictEqual(reactionReads, 502);
502 }).pipe(
503 Effect.provide(
504 Layer.mock(ForgejoCli.ForgejoCli)({
505 api: (input) => {
506 if (input.method) {
507 writes.push(input);
508 return Effect.succeed(processOutput("{}"));
509 }
510 const path = input.path.split("?")[0]!;
511 if (/\/issues\/comments\/\d+\/reactions$/.test(path)) reactionReads++;
512 assert.ok(Object.hasOwn(responses, path), `Unexpected Forgejo request: ${path}`);
513 const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page"));
514 return encodeJsonEffect(page > 1 ? [] : responses[path]).pipe(
515 Effect.orDie,
516 Effect.map(processOutput),
517 );
518 },
519 }),
520 ),
521 );
522 },
523);
524
525it.effect("discovers Forgejo accounts and retains the server port", () =>
526 Effect.gen(function* () {
527 const auth = ForgejoSourceControlProvider.discovery.parseAuth(
528 processOutput(
529 yield* encodeJsonEffect([
530 {
531 name: "work",
532 url: "http://forgejo.local:3000",
533 ssh_host: "git.forgejo.local",
534 user: "maria",
535 default: "true",
536 valid: "true",
537 },
538 ]),
539 ),
540 );
541 assert.deepStrictEqual(
542 firstNonEmptyLine("\u001b[1mtea version 0.16.0\u001b[0m\n"),
543 Option.some("tea version 0.16.0"),
544 );
545 assert.strictEqual(auth.status, "authenticated");
546 assert.deepStrictEqual(auth.account, Option.some("maria"));
547 assert.deepStrictEqual(auth.host, Option.some("forgejo.local:3000"));
548 const revoked = ForgejoSourceControlProvider.discovery.parseAuth(
549 processOutput(
550 encodeJson([
551 {
552 name: "work",
553 url: "http://forgejo.local:3000",
554 user: "maria",
555 default: "true",
556 valid: "false",
557 },
558 ]),
559 ),
560 );
561 assert.strictEqual(revoked.status, "unauthenticated");
562 const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
563 cwd: "/repo",
564 context: {
565 provider: {
566 kind: SourceControlProviderKind.make("unknown"),
567 name: "git.forgejo.local",
568 baseUrl: "https://git.forgejo.local",
569 },
570 remoteName: "origin",
571 remoteUrl: "git@git.forgejo.local:maria/project.git",
572 },
573 auth: processOutput(
574 yield* encodeJsonEffect([
575 {
576 name: "work",
577 url: "http://forgejo.local:3000",
578 ssh_host: "git.forgejo.local",
579 user: "maria",
580 default: "true",
581 },
582 ]),
583 ),
584 });
585 assert.deepStrictEqual(refined, {
586 kind: SourceControlProviderKind.make("forgejo"),
587 name: "Forgejo / Gitea",
588 baseUrl: "http://forgejo.local:3000",
589 });
590 }),
591);
592
593it.effect("does not choose a default Forgejo login across ambiguous SSH server ports", () =>
594 Effect.gen(function* () {
595 const logins = ForgejoCli.parseForgejoLogins(
596 yield* encodeJsonEffect([
597 {
598 name: "one",
599 url: "http://forgejo.local:3000",
600 ssh_host: "forgejo.local",
601 user: "maria",
602 default: "true",
603 },
604 {
605 name: "two",
606 url: "http://forgejo.local:4000",
607 ssh_host: "forgejo.local",
608 user: "maria",
609 default: "false",
610 },
611 ]),
612 );
613 const remote = ForgejoCli.parseForgejoRemote("git@forgejo.local:maria/project.git");
614 assert.isNotNull(remote);
615 assert.deepStrictEqual(
616 ForgejoCli.parseForgejoRemote("forgejo.local:maria/project.git"),
617 remote,
618 );
619 assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!));
620 assert.strictEqual(
621 ForgejoCli.matchForgejoLogin(logins, remote!, "forgejo.local:4000")?.name,
622 "two",
623 );
624 assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!, "other.local:4000"));
625 const alias = ForgejoCli.parseForgejoRemote("git@ssh.forgejo.local:maria/project.git");
626 assert.isNotNull(alias);
627 assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, alias!, "forgejo.local:4000"));
628 const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
629 cwd: "/repo",
630 context: {
631 provider: {
632 kind: SourceControlProviderKind.make("unknown"),
633 name: "Forgejo",
634 baseUrl: "https://forgejo.local",
635 },
636 remoteName: "origin",
637 remoteUrl: "git@forgejo.local:maria/project.git",
638 requestedHost: "forgejo.local:4000",
639 },
640 auth: processOutput(yield* encodeJsonEffect(logins)),
641 });
642 assert.strictEqual(refined?.baseUrl, "http://forgejo.local:4000");
643 const https = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000/maria/project.git");
644 assert.isNotNull(https);
645 assert.strictEqual(ForgejoCli.matchForgejoLogin(logins, https!)?.name, "two");
646 const hostOnly = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000");
647 assert.strictEqual(
648 ForgejoCli.matchForgejoLogin(logins, hostOnly!, undefined, true)?.name,
649 "two",
650 );
651 const mounted = logins.map((login) => ({
652 ...login,
653 url: `http://forgejo.local:4000/${login.name}`,
654 }));
655 assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true));
656 }),
657);
658
659it.effect("rejects HTTP failures even when tea exits successfully", () =>
660 Effect.gen(function* () {
661 const cli = yield* ForgejoCli.make;
662 const result = yield* cli
663 .api({
664 cwd: "/repo",
665 repository: "http://forgejo.local:3000/maria/project",
666 path: "repos/maria/project/pulls/42",
667 method: "PATCH",
668 body: { state: "closed" },
669 })
670 .pipe(Effect.result);
671 assert.strictEqual(result._tag, "Failure");
672 if (result._tag === "Failure")
673 assert.strictEqual(
674 result.failure.detail,
675 "Forgejo repository or pull request was not found.",
676 );
677 }).pipe(
678 Effect.provideService(
679 FileSystem.FileSystem,
680 FileSystem.makeNoop({
681 exists: () => Effect.succeed(false),
682 }),
683 ),
684 Effect.provideService(
685 HttpClient.HttpClient,
686 HttpClient.make(() => {
687 throw new Error("tea must handle its own HTTP request");
688 }),
689 ),
690 Effect.provide(
691 TestSourceControlHost.layer({
692 process: {
693 run: (input) => {
694 if (input.args[0] === "api") {
695 assert.strictEqual(input.stdin, '{"state":"closed"}');
696 assert.include(input.args, "work");
697 assert.include(
698 input.args,
699 "http://forgejo.local:3000/api/v1/repos/maria/project/pulls/42",
700 );
701 }
702 return Effect.succeed(
703 input.args[0] === "login"
704 ? processOutput(
705 encodeJson([
706 {
707 name: "work",
708 url: "http://forgejo.local:3000",
709 ssh_host: "forgejo.local",
710 user: "maria",
711 default: "true",
712 },
713 ]),
714 )
715 : processOutput('{"message":"not found"}', { stderr: "HTTP/1.1 404 Not Found\n" }),
716 );
717 },
718 },
719 }),
720 ),
721 ),
722);
723
724it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () =>
725 Effect.gen(function* () {
726 const cli = yield* ForgejoCli.make;
727 const viewer = yield* cli.api({ cwd: "/upstream-only", host: "code.test", path: "user" });
728 assert.strictEqual(viewer.stdout, "[]");
729 const mountedRepository = yield* cli.resolveRepository({
730 cwd: "/upstream-only",
731 host: "code.test",
732 repository: "maria/project",
733 });
734 assert.strictEqual(mountedRepository.baseUrl, "https://code.test/forgejo");
735 assert.strictEqual(mountedRepository.repository, "maria/project");
736 for (const path of [
737 "repos/forgejo/maria/project/pulls?state=open",
738 "repos/forgejo/maria/project",
739 "repos/reviewer/project/contents/file.ts",
740 ]) {
741 const result = yield* cli.api({
742 cwd: "/repo",
743 repository: "forgejo/maria/project",
744 context: {
745 provider: {
746 kind: SourceControlProviderKind.make("forgejo"),
747 name: "Forgejo",
748 baseUrl: "https://code.test/forgejo",
749 },
750 remoteName: "origin",
751 remoteUrl: "https://code.test/forgejo/maria/project.git",
752 },
753 path,
754 });
755 assert.strictEqual(result.stdout, "[]");
756 }
757 const sameOwnerAsMount = yield* cli.resolveRepository({
758 cwd: "/repo",
759 repository: "forgejo/project",
760 context: {
761 provider: {
762 kind: SourceControlProviderKind.make("forgejo"),
763 name: "Forgejo",
764 baseUrl: "https://code.test/forgejo",
765 },
766 remoteName: "origin",
767 remoteUrl: "ssh://git@code.test/forgejo/project.git",
768 },
769 });
770 assert.strictEqual(sameOwnerAsMount.command, "tea");
771 assert.strictEqual(sameOwnerAsMount.repository, "forgejo/project");
772 }).pipe(
773 Effect.provideService(
774 FileSystem.FileSystem,
775 FileSystem.makeNoop({
776 exists: () => Effect.succeed(true),
777 readFileString: () =>
778 Effect.succeed(
779 encodeJson({
780 hosts: { "code.test/forgejo": { type: "Application", token: "test-token" } },
781 }),
782 ),
783 }),
784 ),
785 Effect.provideService(
786 HttpClient.HttpClient,
787 HttpClient.make(() => {
788 throw new Error("tea must handle its own HTTP request");
789 }),
790 ),
791 Effect.provide(
792 TestSourceControlHost.layer({
793 process: {
794 run: (input) => {
795 if (input.command === "git")
796 return Effect.succeed(
797 processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }),
798 );
799 if (input.args[0] === "login")
800 return Effect.succeed(
801 processOutput(
802 encodeJson([
803 {
804 name: "mounted",
805 url: "https://code.test/forgejo",
806 ssh_host: "code.test",
807 user: "maria",
808 default: "true",
809 },
810 ]),
811 ),
812 );
813 const supported = [
814 "https://code.test/forgejo/api/v1/user",
815 "https://code.test/forgejo/api/v1/repos/maria/project/pulls?state=open",
816 "https://code.test/forgejo/api/v1/repos/maria/project",
817 "https://code.test/forgejo/api/v1/repos/reviewer/project/contents/file.ts",
818 ];
819 if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo");
820 assert.strictEqual(input.command, "tea");
821 return Effect.succeed(
822 supported.includes(input.args.at(-1) ?? "")
823 ? processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" })
824 : processOutput("{}", { stderr: "HTTP/1.1 404 Not Found\n" }),
825 );
826 },
827 },
828 }),
829 ),
830 ),
831);
832
833it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => {
834 const commands: string[] = [];
835 const requests: string[] = [];
836 return Effect.gen(function* () {
837 const cli = yield* ForgejoCli.make;
838 for (const remoteUrl of [
839 "http://forgejo.local:3000/maria/project.git",
840 "ssh://git@ssh.forgejo.local:2222/maria/project.git",
841 "ssh://git@forgejo.local:2222/maria/project.git",
842 ]) {
843 const result = yield* cli.api({
844 cwd: "/repo",
845 repository: "maria/project",
846 context: {
847 provider: {
848 kind: SourceControlProviderKind.make("forgejo"),
849 name: "Forgejo",
850 baseUrl: "http://forgejo.local:3000",
851 },
852 remoteName: "origin",
853 remoteUrl,
854 requestedHost: "forgejo.local:3000",
855 },
856 path: "repos/maria/project/issues/42/comments",
857 method: "POST",
858 body: { body: "verified through fj" },
859 });
860 assert.strictEqual(result.stdout, '{"id":99}');
861 }
862 assert.deepStrictEqual(commands, ["fj"]);
863 assert.deepStrictEqual(requests, [
864 "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
865 "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
866 "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
867 ]);
868 const viewer = yield* cli.api({
869 cwd: "/no-remotes",
870 host: "forgejo.local:3000",
871 path: "user",
872 });
873 assert.strictEqual(viewer.stdout, '{"login":"maria"}');
874 assert.strictEqual(requests.at(-1), "https://forgejo.local:3000/api/v1/user");
875 const upstreamViewer = yield* cli.api({
876 cwd: "/upstream-only",
877 host: "forgejo.local:3000",
878 path: "user",
879 });
880 assert.strictEqual(upstreamViewer.stdout, '{"login":"maria"}');
881 assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user");
882 const upstreamRepository = yield* cli.resolveRepository({
883 cwd: "/upstream-only",
884 host: "forgejo.local:3000",
885 repository: "maria/project",
886 });
887 assert.strictEqual(upstreamRepository.baseUrl, "http://forgejo.local:3000");
888 assert.strictEqual(upstreamRepository.repository, "maria/project");
889 const httpViewer = yield* cli.api({ cwd: "/repo", host: "forgejo.local:3000", path: "user" });
890 assert.strictEqual(httpViewer.stdout, '{"login":"maria"}');
891 assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user");
892 }).pipe(
893 Effect.provideService(
894 FileSystem.FileSystem,
895 FileSystem.makeNoop({
896 exists: () => Effect.succeed(true),
897 readFileString: () =>
898 Effect.succeed(
899 encodeJson({
900 hosts: {
901 "forgejo.local:3000": { type: "Application", token: "test-token" },
902 "forgejo.local:4000": { type: "Application", token: "other-token" },
903 },
904 aliases: { "ssh.forgejo.local:2222": "forgejo.local:3000" },
905 }),
906 ),
907 }),
908 ),
909 Effect.provideService(
910 HttpClient.HttpClient,
911 HttpClient.make((request) => {
912 requests.push(request.url);
913 if (request.url.endsWith("/user")) {
914 assert.strictEqual(request.method, "GET");
915 assert.strictEqual(request.headers.authorization, "token test-token");
916 return Effect.succeed(
917 HttpClientResponse.fromWeb(request, new Response('{"login":"maria"}')),
918 );
919 }
920 assert.strictEqual(request.method, "POST");
921 assert.strictEqual(request.headers.authorization, "token test-token");
922 assert.deepStrictEqual(requestJson(request), { body: "verified through fj" });
923 return Effect.succeed(
924 HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })),
925 );
926 }),
927 ),
928 Effect.provide(
929 TestSourceControlHost.layer({
930 process: {
931 run: (input) => {
932 commands.push(input.command);
933 if (input.command === "git")
934 return Effect.succeed(
935 input.cwd === "/no-remotes"
936 ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) })
937 : processOutput(
938 `${input.cwd === "/upstream-only" ? "upstream" : "origin"}\thttp://forgejo.local:3000/maria/project.git (fetch)\nother\thttp://forgejo.local:3000/maria/other.git (fetch)\nunrelated\thttp://other.local:3000/maria/project.git (fetch)`,
939 ),
940 );
941 assert.strictEqual(input.command, "fj");
942 assert.deepStrictEqual(input.args, [
943 "--host",
944 input.cwd === "/no-remotes"
945 ? "https://forgejo.local:3000"
946 : "http://forgejo.local:3000",
947 "whoami",
948 ]);
949 return Effect.succeed(processOutput(""));
950 },
951 },
952 }),
953 ),
954 );
955});
956
957it.effect("loads later fj review pages when the server caps pages below the requested size", () => {
958 const pages: number[] = [];
959 let issueCommentRequests = 0;
960 return Effect.gen(function* () {
961 const cli = yield* ForgejoCli.make;
962 const provider = yield* ForgejoPullRequestProvider.make.pipe(
963 Effect.provideService(ForgejoCli.ForgejoCli, cli),
964 );
965 const activity = yield* provider.getChangeRequestActivity({
966 cwd: "/repo",
967 repository: "maria/project",
968 host: "forgejo.test",
969 number: 42,
970 });
971 assert.strictEqual(activity.commentCount, 42);
972 assert.strictEqual(activity.comments.at(-1)?.id, "review:41");
973 assert.strictEqual(activity.commentsTruncated, false);
974 assert.deepStrictEqual(pages, [1, 2, 3]);
975 assert.strictEqual(issueCommentRequests, 1);
976 }).pipe(
977 Effect.provideService(
978 FileSystem.FileSystem,
979 FileSystem.makeNoop({
980 exists: () => Effect.succeed(true),
981 readFileString: () =>
982 Effect.succeed(
983 encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }),
984 ),
985 }),
986 ),
987 Effect.provideService(
988 HttpClient.HttpClient,
989 HttpClient.make((request) => {
990 const url = new URL(request.url);
991 if (url.pathname === "/api/v1/repos/maria/project/issues/42/comments") {
992 issueCommentRequests++;
993 return Effect.succeed(
994 HttpClientResponse.fromWeb(
995 request,
996 new Response(
997 encodeJson([
998 {
999 id: 100,
1000 body: "Unpaginated issue comment",
1001 user: { login: "maria" },
1002 created_at: "2026-09-13T00:00:00Z",
1003 },
1004 ]),
1005 ),
1006 ),
1007 );
1008 }
1009 if (url.pathname.endsWith("/reviews")) {
1010 const page = Number(url.searchParams.get("page"));
1011 pages.push(page);
1012 assert.ok(page >= 1 && page <= 3);
1013 const reviews = Array.from({ length: page < 3 ? 20 : 1 }, (_, index) => ({
1014 id: (page - 1) * 20 + index + 1,
1015 body: "Review from a capped page",
1016 user: { login: "maria" },
1017 state: "COMMENT",
1018 submitted_at: "2026-09-13T00:00:00Z",
1019 comments_count: 0,
1020 }));
1021 return Effect.succeed(
1022 HttpClientResponse.fromWeb(
1023 request,
1024 new Response(encodeJson(reviews), {
1025 headers:
1026 page === 2
1027 ? {}
1028 : {
1029 Link:
1030 page === 1
1031 ? `<${url.origin}${url.pathname}?limit=50&page=2>; rel="next"`
1032 : `<${url.origin}${url.pathname}?limit=50&page=1>; rel="prev"`,
1033 },
1034 }),
1035 ),
1036 );
1037 }
1038 return Effect.succeed(
1039 HttpClientResponse.fromWeb(
1040 request,
1041 new Response(encodeJson(url.pathname === "/api/v1/user" ? { login: "maria" } : [])),
1042 ),
1043 );
1044 }),
1045 ),
1046 Effect.provide(
1047 TestSourceControlHost.layer({
1048 process: {
1049 run: (input) => {
1050 if (input.command === "git") {
1051 assert.deepStrictEqual(input.args, ["remote", "-v"]);
1052 return Effect.succeed(
1053 processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"),
1054 );
1055 }
1056 assert.strictEqual(input.command, "fj");
1057 assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]);
1058 return Effect.succeed(processOutput(""));
1059 },
1060 },
1061 }),
1062 ),
1063 );
1064});
1065
1066it.effect("falls back to tea when fj is missing or has no account for this server", () =>
1067 Effect.gen(function* () {
1068 for (const scenario of ["missing-cli", "missing-account", "stale-invalid-storage"] as const) {
1069 const commands: string[] = [];
1070 yield* Effect.gen(function* () {
1071 const cli = yield* ForgejoCli.make;
1072 const result = yield* cli.api({
1073 cwd: "/repo",
1074 repository: "https://forgejo.local:3000/maria/project",
1075 path: "repos/maria/project/pulls",
1076 });
1077 assert.strictEqual(result.stdout, "[]");
1078 assert.deepStrictEqual(
1079 commands,
1080 scenario === "missing-account" ? ["tea", "tea"] : ["fj", "tea", "tea"],
1081 );
1082 const viewer = yield* cli.api({
1083 cwd: "/upstream-only",
1084 host: "forgejo.local:3000",
1085 path: "user",
1086 });
1087 assert.strictEqual(viewer.stdout, "[]");
1088 }).pipe(
1089 Effect.provideService(
1090 FileSystem.FileSystem,
1091 FileSystem.makeNoop({
1092 exists: () => Effect.succeed(true),
1093 readFileString: () =>
1094 Effect.succeed(
1095 scenario === "stale-invalid-storage"
1096 ? "invalid json"
1097 : encodeJson({
1098 hosts: {
1099 [scenario === "missing-cli" ? "forgejo.local:3000" : "other.local"]: {
1100 type: "Application",
1101 token: "test-token",
1102 },
1103 },
1104 }),
1105 ),
1106 }),
1107 ),
1108 Effect.provideService(
1109 HttpClient.HttpClient,
1110 HttpClient.make(() => {
1111 throw new Error("tea must handle its own HTTP request");
1112 }),
1113 ),
1114 Effect.provide(
1115 TestSourceControlHost.layer({
1116 process: {
1117 run: (input) => {
1118 commands.push(input.command);
1119 if (input.command === "git")
1120 return Effect.succeed(
1121 processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }),
1122 );
1123 if (input.command === "fj")
1124 return Effect.fail(
1125 new VcsProcessSpawnError({
1126 operation: input.operation,
1127 command: input.command,
1128 cwd: input.cwd,
1129 cause: new Error("fj not found"),
1130 }),
1131 );
1132 assert.strictEqual(input.command, "tea");
1133 if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo");
1134 return Effect.succeed(
1135 input.args[0] === "login"
1136 ? processOutput(
1137 encodeJson([
1138 {
1139 name: "work",
1140 url: "https://forgejo.local:3000",
1141 user: "maria",
1142 default: "true",
1143 valid: "true",
1144 },
1145 ]),
1146 )
1147 : processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }),
1148 );
1149 },
1150 },
1151 }),
1152 ),
1153 );
1154 }
1155 }),
1156);
1157
1158it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () =>
1159 Effect.gen(function* () {
1160 for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) {
1161 let writes = 0;
1162 yield* Effect.gen(function* () {
1163 const cli = yield* ForgejoCli.make;
1164 const result = yield* cli
1165 .api({
1166 cwd: "/repo",
1167 repository: "https://forgejo.local/maria/project",
1168 path: "repos/maria/project/issues/42/comments",
1169 method: "POST",
1170 body: { body: "only once" },
1171 })
1172 .pipe(Effect.result);
1173 assert.strictEqual(result._tag, status < 300 ? "Success" : "Failure");
1174 if (result._tag === "Success") assert.strictEqual(result.success.stdout, "");
1175 if (result._tag === "Failure") {
1176 assert.strictEqual(result.failure.command, "fj");
1177 assert.strictEqual(result.failure.httpStatus, status);
1178 }
1179 assert.strictEqual(writes, 1);
1180 }).pipe(
1181 Effect.provideService(
1182 FileSystem.FileSystem,
1183 FileSystem.makeNoop({
1184 exists: () => Effect.succeed(true),
1185 readFileString: () =>
1186 Effect.succeed(
1187 encodeJson({
1188 hosts: {
1189 "forgejo.local": { type: "Application", token: "test-token" },
1190 },
1191 }),
1192 ),
1193 }),
1194 ),
1195 Effect.provideService(
1196 HttpClient.HttpClient,
1197 HttpClient.make((request) => {
1198 writes++;
1199 assert.strictEqual(
1200 request.url,
1201 "https://forgejo.local/api/v1/repos/maria/project/issues/42/comments",
1202 );
1203 return Effect.succeed(
1204 HttpClientResponse.fromWeb(
1205 request,
1206 new Response(status < 300 ? null : "", {
1207 status,
1208 headers: { location: "https://other.local/" },
1209 }),
1210 ),
1211 );
1212 }),
1213 ),
1214 Effect.provide(
1215 TestSourceControlHost.layer({
1216 process: {
1217 run: (input) => {
1218 assert.strictEqual(
1219 input.command,
1220 "fj",
1221 "a failed mutation must never switch accounts or CLI",
1222 );
1223 return Effect.succeed(processOutput(""));
1224 },
1225 },
1226 }),
1227 ),
1228 );
1229 }
1230 }),
1231);
1232
1233it.effect(
1234 "discovers fj first and retains configured authentication failures instead of switching accounts",
1235 () =>
1236 Effect.gen(function* () {
1237 for (const scenario of ["authenticated", "revoked", "missing", "invalid-storage"] as const) {
1238 const commands: string[] = [];
1239 yield* Effect.gen(function* () {
1240 const spec = yield* ForgejoSourceControlProvider.makeDiscovery;
1241 assert.strictEqual(spec.type, "managed-cli");
1242 if (spec.type !== "managed-cli") return;
1243 const result = yield* spec.probe("/repo");
1244 assert.strictEqual(result.executable, scenario === "missing" ? "tea" : "fj");
1245 assert.strictEqual(
1246 result.auth.status,
1247 scenario === "revoked"
1248 ? "unauthenticated"
1249 : scenario === "invalid-storage"
1250 ? "unknown"
1251 : "authenticated",
1252 );
1253 if (scenario !== "revoked" && scenario !== "invalid-storage")
1254 assert.deepStrictEqual(result.auth.host, Option.some("forgejo.local:3000"));
1255 assert.deepStrictEqual(
1256 result.auth.account,
1257 scenario === "authenticated" || scenario === "missing"
1258 ? Option.some("maria")
1259 : Option.none(),
1260 );
1261 assert.strictEqual(
1262 commands.some((command) => command.startsWith("tea ")),
1263 scenario === "missing",
1264 );
1265 assert.include(commands, "fj version");
1266 }).pipe(
1267 Effect.provide(
1268 Layer.mergeAll(
1269 Layer.mock(ForgejoCli.ForgejoCli)({
1270 getAccount: (input) => {
1271 assert.strictEqual(scenario, "authenticated");
1272 assert.deepStrictEqual(input, {
1273 cwd: "/repo",
1274 baseUrl: "http://forgejo.local:3000",
1275 });
1276 return Effect.succeed("maria");
1277 },
1278 listLogins: (input) => {
1279 assert.strictEqual(
1280 input.remoteUrl,
1281 "http://forgejo.local:3000/maria/project.git",
1282 );
1283 if (scenario === "invalid-storage")
1284 return Effect.fail(
1285 new ForgejoCli.ForgejoCliError({
1286 command: "fj",
1287 cwd: input.cwd,
1288 reason: "authentication",
1289 detail: "fj authentication storage is invalid.",
1290 }),
1291 );
1292 return Effect.succeed([
1293 {
1294 name: "forgejo.local:3000",
1295 url: "http://forgejo.local:3000",
1296 user: "",
1297 default: "false",
1298 },
1299 ]);
1300 },
1301 }),
1302 TestSourceControlHost.layer({
1303 process: {
1304 run: (input) => {
1305 commands.push(`${input.command} ${input.args.join(" ")}`);
1306 if (input.command === "git")
1307 return Effect.succeed(
1308 processOutput("http://forgejo.local:3000/maria/project.git\n"),
1309 );
1310 if (input.command === "fj") {
1311 if (scenario === "missing")
1312 return Effect.fail(
1313 new VcsProcessSpawnError({
1314 operation: input.operation,
1315 command: input.command,
1316 cwd: input.cwd,
1317 cause: new Error("fj not found"),
1318 }),
1319 );
1320 if (input.args[0] === "version")
1321 return Effect.succeed(processOutput("fj 0.10.0"));
1322 if (scenario === "invalid-storage") {
1323 assert.deepStrictEqual(input.args, ["auth", "list"]);
1324 return Effect.succeed(processOutput(""));
1325 }
1326 assert.deepStrictEqual(input.args, [
1327 "--host",
1328 "http://forgejo.local:3000",
1329 "whoami",
1330 ]);
1331 return Effect.succeed(
1332 processOutput("", {
1333 exitCode: ChildProcessSpawner.ExitCode(scenario === "revoked" ? 1 : 0),
1334 }),
1335 );
1336 }
1337 assert.strictEqual(input.command, "tea");
1338 return Effect.succeed(
1339 input.args[0] === "--version"
1340 ? processOutput("tea version 0.16.0")
1341 : processOutput(
1342 encodeJson([
1343 {
1344 name: "work",
1345 url: "http://forgejo.local:3000",
1346 user: "maria",
1347 default: "true",
1348 valid: "true",
1349 },
1350 ]),
1351 ),
1352 );
1353 },
1354 },
1355 }),
1356 ),
1357 ),
1358 );
1359 }
1360 }),
1361);