1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import { ChildProcessSpawner } from "effect/process";
7
8import * as GitLabCli from "./GitLabCli.ts";
9import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
10import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
11
12function makeProvider(gitlab: Partial<GitLabCli.GitLabCli["Service"]>) {
13 return GitLabSourceControlProvider.make.pipe(
14 Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)),
15 );
16}
17
18it.effect("maps GitLab MR summaries into provider-neutral change requests", () =>
19 Effect.gen(function* () {
20 const provider = yield* makeProvider({
21 getMergeRequest: () =>
22 Effect.succeed({
23 number: 42,
24 title: "Add GitLab provider",
25 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
26 baseRefName: "main",
27 headRefName: "feature/source-control",
28 state: "closed",
29 closedAt: "2026-08-23T10:00:00Z",
30 isCrossRepository: true,
31 headRepositoryNameWithOwner: "fork/t3code",
32 headRepositoryOwnerLogin: "fork",
33 }),
34 });
35
36 const changeRequest = yield* provider.getChangeRequest({
37 cwd: "/repo",
38 reference: "42",
39 });
40
41 assert.deepStrictEqual(changeRequest, {
42 provider: SourceControlProviderKind.make("gitlab"),
43 number: 42,
44 title: "Add GitLab provider",
45 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
46 baseRefName: "main",
47 headRefName: "feature/source-control",
48 state: "closed",
49 closedAt: "2026-08-23T10:00:00Z",
50 mergedAt: null,
51 updatedAt: Option.none(),
52 isCrossRepository: true,
53 headRepositoryNameWithOwner: "fork/t3code",
54 headRepositoryOwnerLogin: "fork",
55 });
56 }),
57);
58
59it.effect("adds repository context while retaining GitLab CLI causes", () =>
60 Effect.gen(function* () {
61 const cause = new GitLabCli.GitLabCliCommandError({
62 operation: "execute",
63 command: "glab",
64 cwd: "/repo",
65 cause: new Error("raw upstream detail that should remain in the cause"),
66 });
67 const provider = yield* makeProvider({
68 createRepository: () => Effect.fail(cause),
69 });
70
71 const error = yield* provider
72 .createRepository({
73 cwd: "/repo",
74 repository: "owner/repo",
75 visibility: "private",
76 })
77 .pipe(Effect.flip);
78
79 assert.deepStrictEqual(
80 {
81 provider: error.provider,
82 operation: error.operation,
83 command: error.command,
84 cwd: error.cwd,
85 repository: error.repository,
86 detail: error.detail,
87 },
88 {
89 provider: "gitlab",
90 operation: "createRepository",
91 command: "glab",
92 cwd: "/repo",
93 repository: "owner/repo",
94 detail: "GitLab CLI command failed.",
95 },
96 );
97 assert.strictEqual(error.cause, cause);
98 assert.equal(error.message.includes("raw upstream detail"), false);
99 }),
100);
101
102it.effect("lists GitLab MRs through provider-neutral input names", () =>
103 Effect.gen(function* () {
104 let listInput: Parameters<GitLabCli.GitLabCli["Service"]["listMergeRequests"]>[0] | null = null;
105 const provider = yield* makeProvider({
106 listMergeRequests: (input) => {
107 listInput = input;
108 return Effect.succeed([]);
109 },
110 });
111
112 yield* provider.listChangeRequests({
113 cwd: "/repo",
114 headSelector: "feature/provider",
115 state: "all",
116 limit: 10,
117 });
118
119 assert.deepStrictEqual(listInput, {
120 cwd: "/repo",
121 headSelector: "feature/provider",
122 state: "all",
123 limit: 10,
124 });
125 }),
126);
127
128it.effect("creates GitLab MRs through provider-neutral input names", () =>
129 Effect.gen(function* () {
130 let createInput: Parameters<GitLabCli.GitLabCli["Service"]["createMergeRequest"]>[0] | null =
131 null;
132 const provider = yield* makeProvider({
133 createMergeRequest: (input) => {
134 createInput = input;
135 return Effect.void;
136 },
137 });
138
139 yield* provider.createChangeRequest({
140 cwd: "/repo",
141 baseRefName: "main",
142 headSelector: "owner:feature/provider",
143 title: "Provider MR",
144 bodyFile: "/tmp/body.md",
145 });
146
147 assert.deepStrictEqual(createInput, {
148 cwd: "/repo",
149 baseBranch: "main",
150 headSelector: "owner:feature/provider",
151 source: {
152 owner: "owner",
153 refName: "feature/provider",
154 },
155 title: "Provider MR",
156 bodyFile: "/tmp/body.md",
157 });
158 }),
159);
160
161it("accepts authenticated GitLab hosts when another configured host fails", () => {
162 const auth = GitLabSourceControlProvider.discovery.parseAuth({
163 exitCode: ChildProcessSpawner.ExitCode(1),
164 stdout: `gitlab.com
165 x gitlab.com: API call failed: 401 Unauthorized
166 ! No token found
167self-hosted.example.test
168 ✓ Logged in to self-hosted.example.test as gitlab-user
169 ✓ Token found: ******
170`,
171 stderr: "",
172 });
173
174 assert.deepStrictEqual(
175 {
176 status: auth.status,
177 account: auth.account,
178 host: auth.host,
179 },
180 {
181 status: "authenticated",
182 account: Option.some("gitlab-user"),
183 host: Option.some("self-hosted.example.test"),
184 },
185 );
186});
187
188it("refines unknown GitLab remotes with mixed-case provider hosts", () => {
189 const provider = GitLabSourceControlProvider.discovery.refineUnknownRemote?.({
190 cwd: "/repo",
191 context: {
192 provider: {
193 kind: SourceControlProviderKind.make("unknown"),
194 name: "Self-Hosted.Example.Test",
195 baseUrl: "https://Self-Hosted.Example.Test",
196 },
197 remoteName: "origin",
198 remoteUrl: "https://Self-Hosted.Example.Test/group/project.git",
199 },
200 auth: {
201 exitCode: ChildProcessSpawner.ExitCode(0),
202 stdout: `self-hosted.example.test
203 ✓ Logged in to self-hosted.example.test as gitlab-user
204 ✓ Token found: ******
205`,
206 stderr: "",
207 },
208 });
209
210 assert.deepStrictEqual(provider, {
211 kind: "gitlab",
212 name: "GitLab Self-Hosted",
213 baseUrl: "https://Self-Hosted.Example.Test",
214 });
215});
216
217it("parses authenticated GitLab auth status hosts with ports and single-label names", () => {
218 assert.deepStrictEqual(
219 parseGitLabAuthStatusHosts(`localhost:8080
220 ✓ Logged in to localhost:8080 as local-user
221selfhosted
222 ✓ Logged in to selfhosted as single-label-user
223`),
224 [
225 { host: "localhost:8080", account: "local-user" },
226 { host: "selfhosted", account: "single-label-user" },
227 ],
228 );
229});
230
231it.effect.each(["merge_requests", "issues"])(
232 "resolves %s subjects on the linked host without using the checkout",
233 (kind) =>
234 Effect.gen(function* () {
235 const provider = yield* makeProvider({
236 execute: (input) => {
237 assert.deepStrictEqual(input.args, [
238 "api",
239 "--hostname",
240 "gitlab.com",
241 `projects/group%2Fsubgroup%2Fproject/${kind}/42`,
242 ]);
243 assert.strictEqual(input.maxOutputBytes, 32_000);
244 assert.strictEqual(input.timeoutMs, 3_000);
245 return Effect.succeed({
246 exitCode: ChildProcessSpawner.ExitCode(0),
247 stdout: JSON.stringify({
248 title: "Pairing expiry",
249 description: "Preserve remote access",
250 }),
251 stderr: "",
252 stdoutTruncated: false,
253 stderrTruncated: false,
254 });
255 },
256 });
257 const lookup = provider.resolveLink?.({
258 cwd: "/unrelated",
259 url: new URL(`https://gitlab.com/group/subgroup/project/-/${kind}/42`),
260 });
261 assert.ok(lookup);
262 assert.deepStrictEqual(yield* lookup, {
263 title: "Pairing expiry",
264 body: "Preserve remote access",
265 });
266 assert.strictEqual(
267 provider.resolveLink?.({
268 cwd: "/unrelated",
269 url: new URL("https://gitlab.com/owner/repo"),
270 }),
271 undefined,
272 );
273 }),
274);
275
276it.effect.each(["read", "decode"] as const)(
277 "retains the %s failure without exposing its raw contents",
278 (stage) =>
279 Effect.gen(function* () {
280 const cause = new GitLabCli.GitLabCliCommandError({
281 command: "glab",
282 cwd: "/repo",
283 operation: "execute",
284 cause: new Error("private response text"),
285 });
286 const provider = yield* makeProvider({
287 execute: () =>
288 stage === "read"
289 ? Effect.fail(cause)
290 : Effect.succeed({
291 exitCode: ChildProcessSpawner.ExitCode(0),
292 stdout: "private response text",
293 stderr: "",
294 stdoutTruncated: false,
295 stderrTruncated: false,
296 }),
297 });
298 const lookup = provider.resolveLink?.({
299 cwd: "/repo",
300 url: new URL("https://gitlab.com/owner/repo/-/issues/42"),
301 });
302 assert.ok(lookup);
303 const error = yield* Effect.flip(lookup);
304 assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode");
305 assert.strictEqual(error.detail, "The linked subject could not be read.");
306 assert.notInclude(error.message, "private response text");
307 if (stage === "read") assert.strictEqual(error.cause, cause);
308 else assert.propertyVal(error.cause, "_tag", "SchemaError");
309 }),
310);