1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it, afterEach, describe, expect, vi } 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 PlatformError from "effect/PlatformError";
8import { ChildProcessSpawner } from "effect/process";
9import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";
10
11import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
12import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
13import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
14
15const processOutput = (stdout: string): SourceControlHost.SourceControlProcessOutput => ({
16 exitCode: ChildProcessSpawner.ExitCode(0),
17 stdout,
18 stderr: "",
19 stdoutTruncated: false,
20 stderrTruncated: false,
21});
22
23const mockRun = vi.fn<SourceControlHost.SourceControlHost["Service"]["process"]["run"]>();
24
25const layerSupport = Layer.mergeAll(
26 TestSourceControlHost.layer({ process: { run: mockRun } }),
27 NodeServices.layer,
28);
29const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(layerSupport)), layerSupport);
30
31afterEach(() => {
32 mockRun.mockReset();
33});
34
35describe("AzureDevOpsCli.layer", () => {
36 it.effect("parses pull request view output", () =>
37 Effect.gen(function* () {
38 mockRun.mockReturnValueOnce(
39 Effect.succeed(
40 processOutput(
41 JSON.stringify({
42 pullRequestId: 42,
43 title: "Add Azure provider",
44 sourceRefName: "refs/heads/feature/source-control",
45 targetRefName: "refs/heads/main",
46 status: "active",
47 creationDate: "2026-01-02T00:00:00.000Z",
48 closedDate: null,
49 _links: {
50 web: {
51 href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
52 },
53 },
54 }),
55 ),
56 ),
57 );
58
59 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
60 const result = yield* az.getPullRequest({
61 cwd: "/repo",
62 reference: "#42",
63 });
64
65 assert.strictEqual(result.number, 42);
66 assert.strictEqual(result.title, "Add Azure provider");
67 assert.strictEqual(result.url, "https://dev.azure.com/acme/project/_git/repo/pullrequest/42");
68 assert.strictEqual(result.baseRefName, "main");
69 assert.strictEqual(result.headRefName, "feature/source-control");
70 assert.strictEqual(result.state, "open");
71 assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag);
72 assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], {
73 operation: "AzureDevOpsCli.execute",
74 command: "az",
75 args: [
76 "repos",
77 "pr",
78 "show",
79 "--detect",
80 "true",
81 "--id",
82 "42",
83 "--only-show-errors",
84 "--output",
85 "json",
86 ],
87 cwd: "/repo",
88 timeoutMs: 30_000,
89 });
90 assert.strictEqual(result.isCrossRepository, false);
91 }).pipe(Effect.provide(layer)),
92 );
93
94 it.effect("marks a pull request from a fork as cross-repository", () =>
95 Effect.gen(function* () {
96 mockRun.mockReturnValueOnce(
97 Effect.succeed(
98 processOutput(
99 JSON.stringify({
100 pullRequestId: 43,
101 title: "From a fork",
102 sourceRefName: "refs/heads/main",
103 targetRefName: "refs/heads/main",
104 status: "active",
105 forkSource: { name: "refs/heads/main", repository: { name: "repo-fork" } },
106 }),
107 ),
108 ),
109 );
110
111 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
112 const result = yield* az.getPullRequest({ cwd: "/repo", reference: "43" });
113
114 assert.strictEqual(result.isCrossRepository, true);
115 }).pipe(Effect.provide(layer)),
116 );
117
118 it.effect("builds a web URL when Azure returns only the pull request REST URL", () =>
119 Effect.gen(function* () {
120 mockRun.mockReturnValueOnce(
121 Effect.succeed(
122 processOutput(
123 JSON.stringify({
124 pullRequestId: 863,
125 title: "Fix Azure link",
126 url: "https://dev.azure.com/saplyai/a8fe4088-ad76-4eda-aaaa-e3329713a097/_apis/git/repositories/16108a25-93a3-4eff-b77d-85e894ee0fe4/pullRequests/863",
127 repository: {
128 name: "CV-engine",
129 project: {
130 name: "CV-engine",
131 },
132 },
133 sourceRefName: "refs/heads/feature/azure-pr-link",
134 targetRefName: "refs/heads/main",
135 status: "active",
136 }),
137 ),
138 ),
139 );
140
141 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
142 const result = yield* az.getPullRequest({
143 cwd: "/repo",
144 reference: "863",
145 });
146
147 assert.strictEqual(
148 result.url,
149 "https://dev.azure.com/saplyai/CV-engine/_git/CV-engine/pullrequest/863",
150 );
151 }).pipe(Effect.provide(layer)),
152 );
153
154 it.effect("lists pull requests with Azure status and source branch arguments", () =>
155 Effect.gen(function* () {
156 mockRun.mockReturnValueOnce(
157 Effect.succeed(
158 processOutput(
159 JSON.stringify([
160 {
161 pullRequestId: 7,
162 title: "Merged work",
163 sourceRefName: "refs/heads/feature/merged",
164 targetRefName: "refs/heads/main",
165 status: "completed",
166 closedDate: "2026-01-03T00:00:00.000Z",
167 _links: {
168 web: {
169 href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7",
170 },
171 },
172 },
173 ]),
174 ),
175 ),
176 );
177
178 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
179 const result = yield* az.listPullRequests({
180 cwd: "/repo",
181 headSelector: "origin:feature/merged",
182 state: "merged",
183 limit: 10,
184 });
185
186 assert.strictEqual(result[0]?.state, "merged");
187 assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z");
188 assert.strictEqual(result[0]?.closedAt, null);
189 expect(mockRun).toHaveBeenCalledWith({
190 operation: "AzureDevOpsCli.execute",
191 command: "az",
192 args: [
193 "repos",
194 "pr",
195 "list",
196 "--detect",
197 "true",
198 "--source-branch",
199 "feature/merged",
200 "--status",
201 "completed",
202 "--top",
203 "10",
204 "--only-show-errors",
205 "--output",
206 "json",
207 ],
208 cwd: "/repo",
209 timeoutMs: 30_000,
210 });
211 }).pipe(Effect.provide(layer)),
212 );
213
214 it.effect("reads repository clone URLs", () =>
215 Effect.gen(function* () {
216 mockRun.mockReturnValueOnce(
217 Effect.succeed(
218 processOutput(
219 JSON.stringify({
220 name: "repo",
221 webUrl: "https://dev.azure.com/acme/project/_git/repo",
222 remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
223 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
224 project: {
225 name: "project",
226 },
227 }),
228 ),
229 ),
230 );
231
232 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
233 const result = yield* az.getRepositoryCloneUrls({
234 cwd: "/repo",
235 repository: "repo",
236 });
237
238 assert.deepStrictEqual(result, {
239 nameWithOwner: "project/repo",
240 url: "https://dev.azure.com/acme/project/_git/repo",
241 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
242 });
243 }).pipe(Effect.provide(layer)),
244 );
245
246 it.effect("creates repositories through Azure Repos", () =>
247 Effect.gen(function* () {
248 mockRun.mockReturnValueOnce(
249 Effect.succeed(
250 processOutput(
251 JSON.stringify({
252 name: "repo",
253 webUrl: "https://dev.azure.com/acme/project/_git/repo",
254 remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
255 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
256 project: {
257 name: "project",
258 },
259 }),
260 ),
261 ),
262 );
263
264 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
265 const result = yield* az.createRepository({
266 cwd: "/repo",
267 repository: "project/repo",
268 visibility: "private",
269 });
270
271 assert.deepStrictEqual(result, {
272 nameWithOwner: "project/repo",
273 url: "https://dev.azure.com/acme/project/_git/repo",
274 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
275 });
276 expect(mockRun).toHaveBeenCalledWith({
277 operation: "AzureDevOpsCli.execute",
278 command: "az",
279 args: [
280 "repos",
281 "create",
282 "--detect",
283 "true",
284 "--name",
285 "repo",
286 "--project",
287 "project",
288 "--only-show-errors",
289 "--output",
290 "json",
291 ],
292 cwd: "/repo",
293 timeoutMs: 30_000,
294 });
295 }).pipe(Effect.provide(layer)),
296 );
297
298 it.effect("creates pull requests using the body file as the Azure description", () =>
299 Effect.gen(function* () {
300 const fileSystem = yield* FileSystem.FileSystem;
301 const bodyFile = `/tmp/t3code-azure-devops-cli-.md`;
302 yield* fileSystem.writeFileString(bodyFile, "Generated body");
303 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}")));
304
305 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
306 yield* az.createPullRequest({
307 cwd: "/repo",
308 baseBranch: "main",
309 headSelector: "feature/provider",
310 title: "Provider PR",
311 bodyFile,
312 });
313
314 expect(mockRun).toHaveBeenCalledWith(
315 expect.objectContaining({
316 command: "az",
317 cwd: "/repo",
318 args: expect.arrayContaining(["--description", `@${bodyFile}`]),
319 }),
320 );
321 expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output");
322 }).pipe(Effect.provide(layer)),
323 );
324
325 it.effect("does not force JSON output on checkout side-effect commands", () =>
326 Effect.gen(function* () {
327 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
328
329 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
330 yield* az.checkoutPullRequest({
331 cwd: "/repo",
332 reference: "42",
333 });
334
335 expect(mockRun).toHaveBeenCalledWith({
336 operation: "AzureDevOpsCli.execute",
337 command: "az",
338 args: [
339 "repos",
340 "pr",
341 "checkout",
342 "--only-show-errors",
343 "--detect",
344 "true",
345 "--id",
346 "42",
347 "--remote-name",
348 "origin",
349 ],
350 cwd: "/repo",
351 timeoutMs: 30_000,
352 });
353 }).pipe(Effect.provide(layer)),
354 );
355
356 it.effect("forwards explicit output limits to the process boundary", () =>
357 Effect.gen(function* () {
358 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
359
360 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
361 yield* az.execute({
362 cwd: "/repo",
363 args: ["repos", "pr", "list"],
364 maxOutputBytes: 16 * 1024 * 1024,
365 });
366
367 expect(mockRun).toHaveBeenCalledWith({
368 operation: "AzureDevOpsCli.execute",
369 command: "az",
370 args: ["repos", "pr", "list"],
371 cwd: "/repo",
372 timeoutMs: 30_000,
373 maxOutputBytes: 16 * 1024 * 1024,
374 });
375 }).pipe(Effect.provide(layer)),
376 );
377
378 it.effect("preserves VCS causes without copying upstream details into messages", () =>
379 Effect.gen(function* () {
380 const cause = new VcsProcessExitError({
381 operation: "AzureDevOpsCli.execute",
382 command: "az repos list --organization sensitive-upstream-detail",
383 cwd: "/repo",
384 exitCode: 1,
385 detail: "sensitive-upstream-detail",
386 });
387 mockRun.mockReturnValueOnce(Effect.fail(cause));
388
389 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
390 const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip);
391
392 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError);
393 assert.strictEqual(error.operation, "execute");
394 assert.strictEqual(error.command, "az");
395 assert.strictEqual(error.cwd, "/repo");
396 assert.strictEqual(error.argumentCount, 2);
397 assert.strictEqual(error.detail, "Azure DevOps CLI command failed.");
398 assert.strictEqual(error.cause, cause);
399 assert.equal(error.message.includes("sensitive-upstream-detail"), false);
400 }).pipe(Effect.provide(layer)),
401 );
402
403 it.effect("does not report a missing working directory as a missing Azure CLI", () =>
404 Effect.gen(function* () {
405 const cwd = "/missing/repo";
406 const platformCause = PlatformError.systemError({
407 _tag: "NotFound",
408 module: "ChildProcess",
409 method: "spawn",
410 syscall: "chdir",
411 pathOrDescriptor: cwd,
412 });
413 const cause = new VcsProcessSpawnError({
414 operation: "AzureDevOpsCli.execute",
415 command: "az",
416 cwd,
417 argumentCount: 2,
418 cause: platformCause,
419 });
420 mockRun.mockReturnValueOnce(Effect.fail(cause));
421
422 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
423 const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip);
424
425 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError);
426 assert.strictEqual(error.cwd, cwd);
427 assert.strictEqual(error.cause, cause);
428 }).pipe(Effect.provide(layer)),
429 );
430
431 it.effect("keeps invalid pull request output diagnostics structured", () =>
432 Effect.gen(function* () {
433 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json")));
434
435 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
436 const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip);
437
438 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError);
439 assert.strictEqual(error.operation, "getPullRequest");
440 assert.strictEqual(error.command, "az");
441 assert.strictEqual(error.cwd, "/repo");
442 assert.strictEqual(error.outputLength, 8);
443 assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON.");
444 assert.exists(error.cause);
445 assert.strictEqual(
446 error.message,
447 "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.",
448 );
449 }).pipe(Effect.provide(layer)),
450 );
451
452 it.effect("preserves rate-limit failures as a distinct error", () =>
453 Effect.gen(function* () {
454 const cause = new VcsProcessExitError({
455 operation: "AzureDevOpsCli.execute",
456 command: "az",
457 cwd: "/repo",
458 argumentCount: 2,
459 exitCode: 1,
460 detail: "API rate limit exceeded.",
461 failureKind: "rate-limited",
462 });
463 mockRun.mockReturnValueOnce(Effect.fail(cause));
464
465 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
466 const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip);
467
468 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCliRateLimitError);
469 assert.strictEqual(error.cause, cause);
470 }).pipe(Effect.provide(layer)),
471 );
472});