1import { afterEach, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { ChildProcessSpawner } from "effect/process";
5import { VcsProcessExitError } from "@t3tools/contracts";
6
7import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
8import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
9import * as GitLabCli from "./GitLabCli.ts";
10import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
11
12const run = vi.fn<SourceControlHost.SourceControlHost["Service"]["process"]["run"]>();
13const layer = it.layer(
14 GitLabPullRequestCli.layer.pipe(
15 Layer.provide(GitLabCli.layer),
16 Layer.provide(TestSourceControlHost.layer({ process: { run } })),
17 ),
18);
19const input = { cwd: "/repo", repository: "acme/nested/web", number: 7 };
20const file = {
21 old_path: "src/old.ts",
22 new_path: "src/new.ts",
23 renamed_file: true,
24 new_file: false,
25 deleted_file: false,
26 a_mode: "100644",
27 b_mode: "100644",
28 diff: "@@ -1 +1 @@\n-before\n+after\n",
29};
30function output(body: unknown, stdoutTruncated = false) {
31 return Effect.succeed({
32 exitCode: ChildProcessSpawner.ExitCode(0),
33 stdout: JSON.stringify(body),
34 stderr: "",
35 stdoutTruncated,
36 stderrTruncated: false,
37 });
38}
39function failure(failureKind: "not-found" | "authentication" | "rate-limited" | "command-failed") {
40 return Effect.fail(
41 new VcsProcessExitError({
42 operation: "GitLabCli.execute",
43 command: "glab",
44 cwd: input.cwd,
45 exitCode: 1,
46 detail: "GitLab HTTP failure",
47 failureKind,
48 }),
49 );
50}
51afterEach(() => run.mockReset());
52
53layer("legacy GitLab diff requests through GitLabCli", (it) => {
54 it.effect.each([false, true])("accepts an unavailable legacy count (overflow: %s)", (overflow) =>
55 Effect.gen(function* () {
56 run.mockReturnValueOnce(failure("not-found"));
57 run.mockReturnValueOnce(output({ changes: [file], changes_count: null, overflow }));
58 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
59 const result = yield* cli.getMergeRequestDiff(input);
60 expect(result.truncated).toBe(overflow);
61 expect(result.nextCursor).toBeNull();
62 expect(result.patch).toContain("@@ -1 +1 @@\n-before\n+after\n");
63 }),
64 );
65 it.effect.each([{ overflow: true }, { changes_count: "247+" }])(
66 "marks legacy limits %j truncated",
67 (metadata) =>
68 Effect.gen(function* () {
69 run.mockReturnValueOnce(failure("not-found"));
70 run.mockReturnValueOnce(output({ changes: [file], ...metadata }));
71 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
72 expect(yield* cli.getMergeRequestDiff(input)).toMatchObject({
73 truncated: true,
74 nextCursor: null,
75 });
76 }),
77 );
78 it.effect.each([false, true])("marks skipped file entries truncated (legacy: %s)", (legacy) =>
79 Effect.gen(function* () {
80 const entries = [file, { new_path: "missing-old-path.ts" }];
81 if (legacy) run.mockReturnValueOnce(failure("not-found"));
82 run.mockReturnValueOnce(output(legacy ? { changes: entries } : entries));
83 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
84 const result = yield* cli.getMergeRequestDiff(input);
85 expect(result.truncated).toBe(true);
86 expect(result.nextCursor).toBeNull();
87 expect(result.patch.match(/^diff --git /gm)).toHaveLength(1);
88 expect(result.patch).toContain("@@ -1 +1 @@\n-before\n+after\n");
89 }),
90 );
91 it.effect("rejects a byte-truncated legacy response", () =>
92 Effect.gen(function* () {
93 run.mockReturnValueOnce(failure("not-found"));
94 run.mockReturnValueOnce(output({ changes: [file] }, true));
95 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
96 expect((yield* cli.getMergeRequestDiff(input).pipe(Effect.flip))._tag).toBe(
97 "GitLabMergeRequestReadError",
98 );
99 }),
100 );
101 it.effect.each(["authentication", "rate-limited", "command-failed"] as const)(
102 "does not fall back on %s",
103 (kind) =>
104 Effect.gen(function* () {
105 run.mockReturnValueOnce(failure(kind));
106 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
107 yield* cli.getMergeRequestDiff(input).pipe(Effect.flip);
108 expect(run).toHaveBeenCalledTimes(1);
109 }),
110 );
111 it.effect("does not fall back for a missing commit or later MR page", () =>
112 Effect.gen(function* () {
113 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
114 for (const target of [
115 { ...input, commit: "abc1234" },
116 { ...input, cursor: "2" },
117 ]) {
118 run.mockReturnValueOnce(failure("not-found"));
119 yield* cli.getMergeRequestDiff(target).pipe(Effect.flip);
120 }
121 expect(run).toHaveBeenCalledTimes(2);
122 }),
123 );
124 // regression: #15886 the older-GitLab fallback still surfaces its own failure
125 it.effect("propagates failure of the legacy endpoint", () =>
126 Effect.gen(function* () {
127 run.mockReturnValueOnce(failure("not-found"));
128 run.mockReturnValueOnce(failure("not-found"));
129 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
130 expect((yield* cli.getMergeRequestDiff(input).pipe(Effect.flip))._tag).toBe(
131 "GitLabCliCommandError",
132 );
133 expect(run).toHaveBeenCalledTimes(2);
134 }),
135 );
136 it.effect("does not fall back on an unreadable successful modern response", () =>
137 Effect.gen(function* () {
138 run.mockReturnValueOnce(output({ changes: [file] }));
139 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
140 expect((yield* cli.getMergeRequestDiff(input).pipe(Effect.flip))._tag).toBe(
141 "GitLabMergeRequestReadError",
142 );
143 expect(run).toHaveBeenCalledTimes(1);
144 }),
145 );
146 it.effect("keeps modern pagination without requesting legacy changes", () =>
147 Effect.gen(function* () {
148 run.mockReturnValueOnce(output(Array.from({ length: 30 }, () => file)));
149 run.mockReturnValueOnce(output([file]));
150 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
151 const first = yield* cli.getMergeRequestDiff(input);
152 expect(first.nextCursor).toBe("2");
153 expect(
154 (yield* cli.getMergeRequestDiff({ ...input, cursor: first.nextCursor! })).nextCursor,
155 ).toBeNull();
156 expect(run).toHaveBeenCalledTimes(2);
157 expect(run.mock.calls[1]?.[0].args).toContain(
158 "projects/acme%2Fnested%2Fweb/merge_requests/7/diffs?per_page=30&page=2",
159 );
160 }),
161 );
162});