1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it, afterEach, describe, vi } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as PlatformError from "effect/PlatformError";
7import { ChildProcessSpawner } from "effect/process";
8import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";
9
10import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
11import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
12import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
13
14const processOutput = (stdout: string): SourceControlHost.SourceControlProcessOutput => ({
15 exitCode: ChildProcessSpawner.ExitCode(0),
16 stdout,
17 stderr: "",
18 stdoutTruncated: false,
19 stderrTruncated: false,
20});
21
22const mockRun = vi.fn<SourceControlHost.SourceControlHost["Service"]["process"]["run"]>();
23
24const layerSupport = Layer.mergeAll(
25 TestSourceControlHost.layer({ process: { run: mockRun } }),
26 NodeServices.layer,
27);
28const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(layerSupport)), layerSupport);
29
30afterEach(() => {
31 mockRun.mockReset();
32});
33
34describe("AzureDevOpsCli.layer", () => {
35 it.effect("parses pull request view output", () =>
36 Effect.gen(function* () {
37 mockRun.mockReturnValueOnce(
38 Effect.succeed(
39 processOutput(
40 JSON.stringify({
41 pullRequestId: 42,
42 title: "Add Azure provider",
43 sourceRefName: "refs/heads/feature/source-control",
44 targetRefName: "refs/heads/main",
45 lastMergeSourceCommit: { commitId: "a".repeat(40) },
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.headSha, "a".repeat(40));
71 assert.strictEqual(result.state, "open");
72 assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag);
73 const { classifyFailure, ...call } = mockRun.mock.calls.at(-1)?.[0] ?? {};
74 assert.strictEqual(
75 classifyFailure?.("Please run az login to setup account."),
76 "authentication",
77 );
78 assert.strictEqual(
79 classifyFailure?.("Please run 'az login' to setup account."),
80 "authentication",
81 );
82 assert.strictEqual(
83 classifyFailure?.("TF401180: The requested pull request was not found."),
84 "not-found",
85 );
86 assert.strictEqual(
87 classifyFailure?.(
88 "ERROR: TF400733: The request has been canceled: Request was blocked due to exceeding usage of resource 'Global' in namespace 'User'.",
89 ),
90 "rate-limited",
91 );
92 assert.deepStrictEqual(call, {
93 operation: "AzureDevOpsCli.execute",
94 command: "az",
95 args: [
96 "repos",
97 "pr",
98 "show",
99 "--detect",
100 "true",
101 "--id",
102 "42",
103 "--only-show-errors",
104 "--output",
105 "json",
106 ],
107 cwd: "/repo",
108 notFoundDetail: "Pull request not found.",
109 timeoutMs: 30_000,
110 });
111 assert.strictEqual(result.isCrossRepository, false);
112 }).pipe(Effect.provide(layer)),
113 );
114
115 it.effect("marks a pull request from a fork as cross-repository", () =>
116 Effect.gen(function* () {
117 mockRun.mockReturnValueOnce(
118 Effect.succeed(
119 processOutput(
120 JSON.stringify({
121 pullRequestId: 43,
122 title: "From a fork",
123 sourceRefName: "refs/heads/main",
124 targetRefName: "refs/heads/main",
125 status: "active",
126 forkSource: { name: "refs/heads/main", repository: { name: "repo-fork" } },
127 }),
128 ),
129 ),
130 );
131
132 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
133 const result = yield* az.getPullRequest({ cwd: "/repo", reference: "43" });
134
135 assert.strictEqual(result.isCrossRepository, true);
136 }).pipe(Effect.provide(layer)),
137 );
138
139 it.effect("builds a web URL when Azure returns only the pull request REST URL", () =>
140 Effect.gen(function* () {
141 mockRun.mockReturnValueOnce(
142 Effect.succeed(
143 processOutput(
144 JSON.stringify({
145 pullRequestId: 863,
146 title: "Fix Azure link",
147 url: "https://dev.azure.com/saplyai/a8fe4088-ad76-4eda-aaaa-e3329713a097/_apis/git/repositories/16108a25-93a3-4eff-b77d-85e894ee0fe4/pullRequests/863",
148 repository: {
149 name: "CV-engine",
150 project: {
151 name: "CV-engine",
152 },
153 },
154 sourceRefName: "refs/heads/feature/azure-pr-link",
155 targetRefName: "refs/heads/main",
156 status: "active",
157 }),
158 ),
159 ),
160 );
161
162 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
163 const result = yield* az.getPullRequest({
164 cwd: "/repo",
165 reference: "863",
166 });
167
168 assert.strictEqual(
169 result.url,
170 "https://dev.azure.com/saplyai/CV-engine/_git/CV-engine/pullrequest/863",
171 );
172 }).pipe(Effect.provide(layer)),
173 );
174
175 it.effect("reads repository clone URLs", () =>
176 Effect.gen(function* () {
177 mockRun.mockReturnValueOnce(
178 Effect.succeed(
179 processOutput(
180 JSON.stringify({
181 name: "repo",
182 webUrl: "https://dev.azure.com/acme/project/_git/repo",
183 remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
184 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
185 project: {
186 name: "project",
187 },
188 }),
189 ),
190 ),
191 );
192
193 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
194 const result = yield* az.getRepositoryCloneUrls({
195 cwd: "/repo",
196 repository: "repo",
197 });
198
199 assert.deepStrictEqual(result, {
200 nameWithOwner: "project/repo",
201 url: "https://dev.azure.com/acme/project/_git/repo",
202 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
203 });
204 }).pipe(Effect.provide(layer)),
205 );
206
207 it.effect("looks up a project/repository path as that project's repository", () =>
208 Effect.gen(function* () {
209 mockRun.mockReturnValueOnce(
210 Effect.succeed(
211 processOutput(
212 JSON.stringify({
213 name: "repo",
214 webUrl: "https://dev.azure.com/acme/project/_git/repo",
215 remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
216 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
217 project: { name: "project" },
218 }),
219 ),
220 ),
221 );
222
223 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
224 yield* az.getRepositoryCloneUrls({ cwd: "/somewhere", repository: "project/repo" });
225
226 assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0].args, [
227 "repos",
228 "show",
229 "--detect",
230 "true",
231 "--repository",
232 "repo",
233 "--project",
234 "project",
235 "--only-show-errors",
236 "--output",
237 "json",
238 ]);
239 }).pipe(Effect.provide(layer)),
240 );
241
242 it.effect("percent-encodes the source branch while preserving recognized ref prefixes", () =>
243 Effect.gen(function* () {
244 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]")));
245
246 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
247 yield* az.listPullRequests({
248 cwd: "/somewhere",
249 headSelector: "user/feat/1-(#2)-slug",
250 state: "open",
251 });
252
253 const args = mockRun.mock.calls.at(-1)?.[0].args ?? [];
254 assert.strictEqual(args[args.indexOf("--source-branch") + 1], "user%2Ffeat%2F1-(%232)-slug");
255
256 for (const prefix of ["refs/heads/", "refs/pull/", "refs/tags/"]) {
257 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]")));
258 yield* az.listPullRequests({
259 cwd: "/somewhere",
260 headSelector: "fallback",
261 source: { refName: `${prefix}user/feat/1-(#2)-slug` },
262 state: "open",
263 });
264
265 const refArgs = mockRun.mock.calls.at(-1)?.[0].args ?? [];
266 assert.strictEqual(
267 refArgs[refArgs.indexOf("--source-branch") + 1],
268 `${prefix}user%2Ffeat%2F1-(%232)-slug`,
269 );
270 }
271 }).pipe(Effect.provide(layer)),
272 );
273
274 it.effect("preserves VCS causes without copying upstream details into messages", () =>
275 Effect.gen(function* () {
276 const cause = new VcsProcessExitError({
277 operation: "AzureDevOpsCli.execute",
278 command: "az repos list --organization sensitive-upstream-detail",
279 cwd: "/repo",
280 exitCode: 1,
281 detail: "sensitive-upstream-detail",
282 });
283 mockRun.mockReturnValueOnce(Effect.fail(cause));
284
285 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
286 const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip);
287
288 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError);
289 assert.strictEqual(error.operation, "execute");
290 assert.strictEqual(error.command, "az");
291 assert.strictEqual(error.cwd, "/repo");
292 assert.strictEqual(error.argumentCount, 2);
293 assert.strictEqual(error.detail, "Azure DevOps CLI command failed.");
294 assert.strictEqual(error.cause, cause);
295 assert.equal(error.message.includes("sensitive-upstream-detail"), false);
296 }).pipe(Effect.provide(layer)),
297 );
298
299 it.effect("does not report a missing working directory as a missing Azure CLI", () =>
300 Effect.gen(function* () {
301 const cwd = "/missing/repo";
302 const platformCause = PlatformError.systemError({
303 _tag: "NotFound",
304 module: "ChildProcess",
305 method: "spawn",
306 syscall: "chdir",
307 pathOrDescriptor: cwd,
308 });
309 const cause = new VcsProcessSpawnError({
310 operation: "AzureDevOpsCli.execute",
311 command: "az",
312 cwd,
313 argumentCount: 2,
314 cause: platformCause,
315 });
316 mockRun.mockReturnValueOnce(Effect.fail(cause));
317
318 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
319 const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip);
320
321 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError);
322 assert.strictEqual(error.cwd, cwd);
323 assert.strictEqual(error.cause, cause);
324 }).pipe(Effect.provide(layer)),
325 );
326
327 it.effect("preserves rate-limit failures as a distinct error", () =>
328 Effect.gen(function* () {
329 const cause = new VcsProcessExitError({
330 operation: "AzureDevOpsCli.execute",
331 command: "az",
332 cwd: "/repo",
333 argumentCount: 2,
334 exitCode: 1,
335 detail: "API rate limit exceeded.",
336 failureKind: "rate-limited",
337 });
338 mockRun.mockReturnValueOnce(Effect.fail(cause));
339
340 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
341 const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip);
342
343 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCliRateLimitError);
344 assert.strictEqual(error.cause, cause);
345 }).pipe(Effect.provide(layer)),
346 );
347});