| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import * as PlatformError from "effect/PlatformError"; |
| 8 | import { ChildProcessSpawner } from "effect/process"; |
| 9 | import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; |
| 10 | |
| 11 | import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; |
| 12 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; |
| 13 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| 14 | |
| 15 | const processOutput = (stdout: string): SourceControlHost.SourceControlProcessOutput => ({ |
| 16 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 17 | stdout, |
| 18 | stderr: "", |
| 19 | stdoutTruncated: false, |
| 20 | stderrTruncated: false, |
| 21 | }); |
| 22 | |
| 23 | const mockRun = vi.fn<SourceControlHost.SourceControlHost["Service"]["process"]["run"]>(); |
| 24 | |
| 25 | const layerSupport = Layer.mergeAll( |
| 26 | TestSourceControlHost.layer({ process: { run: mockRun } }), |
| 27 | NodeServices.layer, |
| 28 | ); |
| 29 | const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(layerSupport)), layerSupport); |
| 30 | |
| 31 | afterEach(() => { |
| 32 | mockRun.mockReset(); |
| 33 | }); |
| 34 | |
| 35 | describe("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 | }); |