authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 14:50:00-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 14:50:00-07:00
logd01febb509a376d93af2d298a7787449792b5fcb
tree5846048f7587d66b6639b6ed42858fb6063a5d9b
parentf6e45028efb69d208a45aee8c2fa514adde44977
signature Signed by PGP key B5690EEEBB952194

refactor(source-control): Azure DevOps lives in @t3tools/source-control-azure-devops (#17592)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

43 files changed, 6966 insertions(+), 6844 deletions(-)

apps/server/package.json+1-1
...@@ -45,9 +45,9 @@...@@ -45,9 +45,9 @@
45 "@t3tools/provider-muse": "workspace:*",45 "@t3tools/provider-muse": "workspace:*",
46 "@t3tools/provider-opencode": "workspace:*",46 "@t3tools/provider-opencode": "workspace:*",
47 "@t3tools/provider-pi": "workspace:*",47 "@t3tools/provider-pi": "workspace:*",
48 "@t3tools/source-control-azure-devops": "workspace:*",
48 "@t3tools/source-control-core": "workspace:*",49 "@t3tools/source-control-core": "workspace:*",
49 "@t3tools/source-control-forgejo": "workspace:*",50 "@t3tools/source-control-forgejo": "workspace:*",
50 "diff": "8.0.3",
51 "effect": "catalog:",51 "effect": "catalog:",
52 "jose": "catalog:",52 "jose": "catalog:",
53 "node-pty": "^1.2.0-beta.15",53 "node-pty": "^1.2.0-beta.15",
apps/server/scripts/evaluate-thread-titles.ts-2
...@@ -26,7 +26,6 @@ import * as SourceControlProviderRegistry from "../src/sourceControl/SourceContr...@@ -26,7 +26,6 @@ import * as SourceControlProviderRegistry from "../src/sourceControl/SourceContr
26import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";26import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
27import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";27import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
28import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts";28import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts";
29import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";
30import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";29import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";
31import * as ServerSettings from "../src/serverSettings.ts";30import * as ServerSettings from "../src/serverSettings.ts";
32import * as VcsProcess from "../src/vcs/VcsProcess.ts";31import * as VcsProcess from "../src/vcs/VcsProcess.ts";
...@@ -155,7 +154,6 @@ await Effect.runPromise(...@@ -155,7 +154,6 @@ await Effect.runPromise(
155 GitHubApi.layerWithDependencies,154 GitHubApi.layerWithDependencies,
156 GitLabCli.layer,155 GitLabCli.layer,
157 SourceControlBuiltInDrivers.layer,156 SourceControlBuiltInDrivers.layer,
158 AzureDevOpsCli.layer,
159 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.157 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
160 BitbucketApi.layer,158 BitbucketApi.layer,
161 ),159 ),
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts deleted-1462
...@@ -1,1462 +0,0 @@
1import { afterEach, assert, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { ChildProcessSpawner } from "effect/process";
5
6import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
7import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
8import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";
9
10const mockedExecute = vi.fn<AzureDevOpsCli.AzureDevOpsCli["Service"]["execute"]>();
11
12const layer = it.layer(
13 AzureDevOpsPullRequestCli.layer.pipe(
14 Layer.provide(
15 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({
16 execute: mockedExecute,
17 }),
18 ),
19 ),
20);
21
22function output(stdout: string) {
23 return {
24 exitCode: ChildProcessSpawner.ExitCode(0),
25 stdout,
26 stderr: "",
27 stdoutTruncated: false,
28 stderrTruncated: false,
29 };
30}
31
32/** What VcsProcess allows a read that asked for no ceiling of its own. */
33const VCS_DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
34
35/**
36 * The runner as it really behaves: it cuts stdout at the ceiling its caller asked for, and cuts
37 * it at the process default when the caller asked for none. A read whose response is larger than
38 * its ceiling gets JSON that stops mid-string, which is the whole cost of an unset ceiling.
39 */
40function outputWithin(maxOutputBytes: number | undefined, response: string) {
41 const ceiling = maxOutputBytes ?? VCS_DEFAULT_MAX_OUTPUT_BYTES;
42 return ceiling >= Buffer.byteLength(response)
43 ? output(response)
44 : { ...output(response.slice(0, ceiling)), stdoutTruncated: true };
45}
46
47/** A fixture's own shape, spelled the way `az` would answer with it. */
48const json = (value: Record<string, unknown>) => JSON.stringify(value);
49
50const pullRequestRow = {
51 pullRequestId: 42,
52 title: "Add the page",
53 status: "active",
54 sourceRefName: "refs/heads/feat/page",
55 targetRefName: "refs/heads/main",
56 creationDate: "2026-07-01T00:00:00Z",
57 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
58 repository: { name: "web", project: { name: "platform" } },
59};
60
61const oneIteration = {
62 value: [
63 {
64 id: 1,
65 sourceRefCommit: { commitId: "a".repeat(40) },
66 commonRefCommit: { commitId: "b".repeat(40) },
67 },
68 ],
69};
70
71function pullRequestRows(
72 count: number,
73 firstNumber: number,
74): ReadonlyArray<Record<string, unknown>> {
75 return Array.from({ length: count }, (_, index) => ({
76 pullRequestId: firstNumber + index,
77 title: `Pull request ${firstNumber + index}`,
78 status: "active",
79 sourceRefName: "refs/heads/feat/page",
80 targetRefName: "refs/heads/main",
81 creationDate: "2026-07-01T00:00:00Z",
82 repository: { name: "web", project: { name: "platform" } },
83 url: `https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/${firstNumber + index}`,
84 }));
85}
86
87function pullRequests(count: number, firstNumber: number): string {
88 return JSON.stringify(pullRequestRows(count, firstNumber));
89}
90
91/** The arguments of the nth az invocation. */
92function argsOfCall(index: number): ReadonlyArray<string> {
93 const call = mockedExecute.mock.calls[index];
94 assert.isDefined(call);
95 return call[0].args;
96}
97
98/** The output ceiling the nth az invocation asked for, if it asked for one at all. */
99function maxOutputBytesOfCall(index: number) {
100 const call = mockedExecute.mock.calls[index];
101 assert.isDefined(call);
102 return call[0].maxOutputBytes;
103}
104
105/** A page of change entries the size Azure really answers with, url and object ids and all. */
106function changeEntries(count: number): ReadonlyArray<Record<string, unknown>> {
107 const commit = "c".repeat(40);
108 return Array.from({ length: count }, (_, index) => {
109 const path = `/apps/server/src/generated/module-${index}/persisted-projection-${index}.ts`;
110 return {
111 changeType: "edit",
112 item: {
113 path,
114 objectId: "a".repeat(40),
115 originalObjectId: "b".repeat(40),
116 commitId: commit,
117 gitObjectType: "blob",
118 url: `https://dev.azure.com/acme/platform/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/items${path}?versionType=Commit&version=${commit}`,
119 },
120 };
121 });
122}
123
124/** An Azure identity, which rides along with every comment and every push Azure answers with. */
125function identity(name: string) {
126 const id = "6f9c9b7f-0000-0000-0000-000000000000";
127 return {
128 displayName: name,
129 id,
130 uniqueName: `${name.toLowerCase().replace(/ /g, ".")}@acme.com`,
131 descriptor: `aad.${"z".repeat(52)}`,
132 imageUrl: `https://dev.azure.com/acme/_api/_common/identityImage?id=${id}`,
133 url: `https://spsprodweu1.vssps.visualstudio.com/A${id}/_apis/Identities/${id}`,
134 _links: {
135 avatar: {
136 href: `https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.${"z".repeat(52)}`,
137 },
138 },
139 };
140}
141
142/** A review's threads the shape Azure answers with, system threads and identities and all. */
143function threadRows(count: number): ReadonlyArray<Record<string, unknown>> {
144 return Array.from({ length: count }, (_, index) => ({
145 id: index + 1,
146 publishedDate: "2026-07-02T00:00:00Z",
147 lastUpdatedDate: "2026-07-02T00:00:00Z",
148 status: "active",
149 threadContext: { filePath: `/apps/server/src/generated/module-${index}.ts` },
150 identities: { 1: identity("Reviewer One") },
151 isDeleted: false,
152 comments: [
153 {
154 id: 1,
155 parentCommentId: 0,
156 author: identity("Reviewer One"),
157 content: `Comment ${index}: ${"this needs another look. ".repeat(20)}`,
158 publishedDate: "2026-07-02T00:00:00Z",
159 lastUpdatedDate: "2026-07-02T00:00:00Z",
160 commentType: "text",
161 usersLiked: [],
162 },
163 ],
164 }));
165}
166
167/** A review's iterations the shape Azure answers with, one per push. */
168function iterationRows(count: number): ReadonlyArray<Record<string, unknown>> {
169 return Array.from({ length: count }, (_, index) => ({
170 id: index + 1,
171 description: `Pushed ${index} commits`,
172 author: identity("Author One"),
173 createdDate: "2026-07-02T00:00:00Z",
174 updatedDate: "2026-07-02T00:00:00Z",
175 sourceRefCommit: { commitId: `${index}`.padStart(40, "a") },
176 targetRefCommit: { commitId: "b".repeat(40) },
177 commonRefCommit: { commitId: "c".repeat(40) },
178 hasMultipleCommits: true,
179 reason: "push",
180 push: { pushId: index + 1, date: "2026-07-02T00:00:00Z", pushedBy: identity("Author One") },
181 }));
182}
183
184afterEach(() => {
185 mockedExecute.mockReset();
186});
187
188layer("AzureDevOpsPullRequestCli.layer", (it) => {
189 it.effect("asks for one row more than the page, to probe for a next page", () =>
190 Effect.gen(function* () {
191 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
192 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
193
194 const batch = yield* cli.listPullRequests({
195 cwd: "/w",
196 repository: "web",
197 state: "open",
198 involvement: "all",
199 viewer: "bilal@acme.dev",
200 limit: 10,
201 });
202
203 assert.strictEqual(batch.items.length, 3);
204 assert.isFalse(batch.truncated);
205 expect(argsOfCall(0)).toEqual([
206 "repos",
207 "pr",
208 "list",
209 "--detect",
210 "true",
211 "--repository",
212 "web",
213 "--status",
214 "active",
215 "--include-links",
216 "--top",
217 "11",
218 "--only-show-errors",
219 "--output",
220 "json",
221 ]);
222 }),
223 );
224
225 it.effect("reads an Azure pull request page larger than the VCS default output limit", () =>
226 Effect.gen(function* () {
227 const rows = pullRequestRows(100, 1).map((row) => ({
228 ...row,
229 description: "x".repeat(10_000),
230 }));
231 const response = JSON.stringify(rows);
232 expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000);
233
234 mockedExecute.mockImplementationOnce((input) =>
235 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
236 );
237 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
238
239 const batch = yield* cli.listPullRequests({
240 cwd: "/w",
241 repository: "web",
242 state: "merged",
243 involvement: "all",
244 viewer: "bilal@acme.dev",
245 limit: 99,
246 });
247
248 assert.strictEqual(batch.items.length, 99);
249 assert.isTrue(batch.truncated);
250 }),
251 );
252
253 it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () =>
254 Effect.gen(function* () {
255 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
256 const provider = yield* AzureDevOpsPullRequestProvider.make;
257
258 const page = yield* provider.listChangeRequests({
259 cwd: "/w",
260 repository: "web",
261 host: "dev.azure.com",
262 state: "open",
263 involvement: "all",
264 viewer: "bilal@acme.dev",
265 limit: 10,
266 query: "page",
267 });
268
269 // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at
270 // all. The rows come back as they would have without a search, for the caller to narrow;
271 // nothing of the search reaches the command, where it could only mean the wrong thing.
272 assert.strictEqual(page.items.length, 3);
273 expect(argsOfCall(0)).toEqual([
274 "repos",
275 "pr",
276 "list",
277 "--detect",
278 "true",
279 "--repository",
280 "web",
281 "--status",
282 "active",
283 "--include-links",
284 "--top",
285 "11",
286 "--only-show-errors",
287 "--output",
288 "json",
289 ]);
290 }),
291 );
292
293 it.effect("steps over what it has already handed over, which is all Azure can be told", () =>
294 Effect.gen(function* () {
295 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
296 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
297
298 yield* cli.listPullRequests({
299 cwd: "/w",
300 repository: "web",
301 state: "open",
302 involvement: "all",
303 viewer: "bilal@acme.dev",
304 limit: 10,
305 // The instant is the same cursor every other host reads; Azure has no filter for it and
306 // takes the count instead.
307 cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 20 },
308 });
309
310 const args = argsOfCall(0);
311 expect(args).toContain("--skip");
312 assert.strictEqual(args[args.indexOf("--skip") + 1], "20");
313 expect(args).not.toContain("2026-07-02T00:00:00Z");
314 }),
315 );
316
317 it.effect("reports truncation from the extra row", () =>
318 Effect.gen(function* () {
319 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1))));
320 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
321
322 const batch = yield* cli.listPullRequests({
323 cwd: "/w",
324 repository: "web",
325 state: "open",
326 involvement: "all",
327 viewer: "bilal@acme.dev",
328 limit: 10,
329 });
330
331 assert.strictEqual(batch.items.length, 10);
332 assert.isTrue(batch.truncated);
333 assert.strictEqual(batch.cursorAdvance, 10);
334 }),
335 );
336
337 it.effect("advances by malformed raw rows and keeps reading until the page is full", () =>
338 Effect.gen(function* () {
339 mockedExecute
340 .mockReturnValueOnce(
341 Effect.succeed(
342 output(
343 JSON.stringify([
344 { pullRequestId: "malformed" },
345 pullRequestRows(1, 1)[0],
346 { pullRequestId: "also malformed" },
347 ]),
348 ),
349 ),
350 )
351 .mockReturnValueOnce(Effect.succeed(output(pullRequests(2, 2))));
352 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
353
354 const batch = yield* cli.listPullRequests({
355 cwd: "/w",
356 repository: "web",
357 state: "open",
358 involvement: "all",
359 viewer: "bilal@acme.dev",
360 limit: 2,
361 });
362
363 expect(batch.items.map((item) => item.number)).toEqual([1, 2]);
364 assert.isTrue(batch.truncated);
365 // Three raw rows from the first request and one from the second produced this page.
366 assert.strictEqual(batch.cursorAdvance, 4);
367 const secondArgs = argsOfCall(1);
368 assert.strictEqual(secondArgs[secondArgs.indexOf("--skip") + 1], "3");
369 assert.strictEqual(secondArgs[secondArgs.indexOf("--top") + 1], "2");
370 }),
371 );
372
373 it.effect("narrows to the author on the authored tab", () =>
374 Effect.gen(function* () {
375 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
376 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
377
378 yield* cli.listPullRequests({
379 cwd: "/w",
380 repository: "web",
381 state: "closed",
382 involvement: "authored",
383 viewer: "bilal@acme.dev",
384 limit: 10,
385 });
386
387 expect(argsOfCall(0)).toContain("--creator");
388 expect(argsOfCall(0)).toContain("bilal@acme.dev");
389 // Azure calls a closed pull request abandoned.
390 expect(argsOfCall(0)).toContain("abandoned");
391 }),
392 );
393
394 it.effect("asks Azure for every status on the All tab", () =>
395 Effect.gen(function* () {
396 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
397 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
398
399 yield* cli.listPullRequests({
400 cwd: "/w",
401 repository: "web",
402 state: "all",
403 involvement: "all",
404 viewer: "bilal@acme.dev",
405 limit: 10,
406 });
407
408 expect(argsOfCall(0)).toContain("--status");
409 expect(argsOfCall(0)).toContain("all");
410 }),
411 );
412
413 it.effect("narrows to the reviewer on the reviewing tab", () =>
414 Effect.gen(function* () {
415 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
416 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
417
418 yield* cli.listPullRequests({
419 cwd: "/w",
420 repository: "web",
421 state: "open",
422 involvement: "reviewing",
423 viewer: "bilal@acme.dev",
424 limit: 10,
425 });
426
427 expect(argsOfCall(0)).toContain("--reviewer");
428 }),
429 );
430
431 it.effect("reads the signed-in account, which az reports as a bare value", () =>
432 Effect.gen(function* () {
433 // `--query user` unwraps the object, so the wrapper has to put it back.
434 mockedExecute.mockReturnValueOnce(
435 Effect.succeed(output(JSON.stringify({ name: "bilal@acme.dev", type: "user" }))),
436 );
437 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
438
439 const viewer = yield* cli.getViewer({ cwd: "/w" });
440
441 assert.strictEqual(viewer, "bilal@acme.dev");
442 expect(argsOfCall(0)).toEqual([
443 "account",
444 "show",
445 "--query",
446 "user",
447 "--only-show-errors",
448 "--output",
449 "json",
450 ]);
451 }),
452 );
453
454 it.effect("fails when nobody is signed in", () =>
455 Effect.gen(function* () {
456 mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));
457 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
458
459 const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" }));
460
461 assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError");
462 }),
463 );
464
465 it.effect("completes a pull request to merge it, squashing only when asked", () =>
466 Effect.gen(function* () {
467 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
468 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
469
470 yield* cli.runPullRequestAction({
471 cwd: "/w",
472 number: 42,
473 action: "merge",
474 mergeMethod: "squash",
475 });
476
477 expect(argsOfCall(0)).toEqual([
478 "repos",
479 "pr",
480 "update",
481 "--detect",
482 "true",
483 "--id",
484 "42",
485 "--status",
486 "completed",
487 "--squash",
488 "true",
489 "--only-show-errors",
490 "--output",
491 "json",
492 ]);
493 }),
494 );
495
496 it.effect("stores the squash choice with an auto-completion, as a merge now does", () =>
497 Effect.gen(function* () {
498 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
499 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
500
501 yield* cli.runPullRequestAction({
502 cwd: "/w",
503 number: 42,
504 action: "enable-auto-merge",
505 mergeMethod: "squash",
506 });
507
508 expect(argsOfCall(0)).toEqual([
509 "repos",
510 "pr",
511 "update",
512 "--detect",
513 "true",
514 "--id",
515 "42",
516 "--auto-complete",
517 "true",
518 "--squash",
519 "true",
520 "--only-show-errors",
521 "--output",
522 "json",
523 ]);
524 }),
525 );
526
527 it.effect.each([
528 { action: "enable-auto-merge", expected: ["--auto-complete", "true"] },
529 { action: "disable-auto-merge", expected: ["--auto-complete", "false"] },
530 { action: "draft", expected: ["--draft", "true"] },
531 { action: "ready", expected: ["--draft", "false"] },
532 { action: "close", expected: ["--status", "abandoned"] },
533 { action: "reopen", expected: ["--status", "active"] },
534 ] as const)("moves a pull request with $action", ({ action, expected }) =>
535 Effect.gen(function* () {
536 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
537 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
538
539 yield* cli.runPullRequestAction({ cwd: "/w", number: 42, action });
540
541 expect(argsOfCall(0)).toEqual([
542 "repos",
543 "pr",
544 "update",
545 "--detect",
546 "true",
547 "--id",
548 "42",
549 ...expected,
550 "--only-show-errors",
551 "--output",
552 "json",
553 ]);
554 }),
555 );
556
557 it.effect.each([
558 { name: "a title", rewrite: { title: "Add the page" }, expected: ["--title=Add the page"] },
559 {
560 name: "a description",
561 rewrite: { body: "Why the page changed" },
562 expected: ["--description=Why the page changed"],
563 },
564 {
565 name: "both",
566 rewrite: { title: "Add the page", body: "Why the page changed" },
567 expected: ["--title=Add the page", "--description=Why the page changed"],
568 },
569 ] as const)("rewrites $name, sending nothing it was not given", ({ rewrite, expected }) =>
570 Effect.gen(function* () {
571 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
572 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
573
574 yield* cli.updatePullRequest({ cwd: "/w", number: 42, ...rewrite });
575
576 expect(argsOfCall(0)).toEqual([
577 "repos",
578 "pr",
579 "update",
580 "--detect",
581 "true",
582 "--id",
583 "42",
584 ...expected,
585 "--only-show-errors",
586 "--output",
587 "json",
588 ]);
589 }),
590 );
591
592 it.effect("sends a description that starts with a dash as one value, not as a flag", () =>
593 Effect.gen(function* () {
594 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
595 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
596
597 yield* cli.updatePullRequest({
598 cwd: "/w",
599 number: 42,
600 body: "- rewrote the page\n- kept the rest",
601 });
602
603 // One argument, so the leading dash of an ordinary bullet list never reaches az as a flag,
604 // and the whole text stays together where `--description` would otherwise take several.
605 expect(argsOfCall(0)).toContain("--description=- rewrote the page\n- kept the rest");
606 }),
607 );
608
609 it.effect("rewrites through the provider, which says it takes one", () =>
610 Effect.gen(function* () {
611 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
612 const provider = yield* AzureDevOpsPullRequestProvider.make;
613
614 // False for a remark because nothing here can post one, so there is none to rewrite.
615 expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: false });
616 assert.isDefined(provider.updateChangeRequest);
617 yield* provider.updateChangeRequest({
618 cwd: "/w",
619 repository: "web",
620 host: "dev.azure.com",
621 number: 42,
622 title: "Add the page",
623 });
624
625 expect(argsOfCall(0)).toContain("--title=Add the page");
626 expect(argsOfCall(0)).not.toContain("--description");
627 }),
628 );
629
630 it.effect("names the head's blob as what a cleared file was cleared at", () =>
631 Effect.gen(function* () {
632 mockedExecute
633 .mockReturnValueOnce(
634 Effect.succeed(
635 output(
636 JSON.stringify({
637 pullRequestId: 42,
638 title: "Add the page",
639 status: "active",
640 sourceRefName: "refs/heads/feat/page",
641 targetRefName: "refs/heads/main",
642 creationDate: "2026-07-01T00:00:00Z",
643 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
644 repository: { name: "web", project: { name: "platform" } },
645 }),
646 ),
647 ),
648 )
649 .mockReturnValueOnce(
650 Effect.succeed(
651 output(
652 JSON.stringify({
653 value: [
654 {
655 id: 1,
656 sourceRefCommit: { commitId: "a".repeat(40) },
657 commonRefCommit: { commitId: "b".repeat(40) },
658 },
659 {
660 id: 2,
661 sourceRefCommit: { commitId: "c".repeat(40) },
662 commonRefCommit: { commitId: "b".repeat(40) },
663 },
664 ],
665 }),
666 ),
667 ),
668 )
669 .mockReturnValueOnce(
670 Effect.succeed(
671 output(
672 JSON.stringify({
673 changeEntries: [
674 { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } },
675 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
676 ],
677 }),
678 ),
679 ),
680 );
681 const provider = yield* AzureDevOpsPullRequestProvider.make;
682
683 // Kept here rather than on Azure: its own record of what a reader has read is behind an
684 // undocumented endpoint, so the marks belong to this environment and need a revision of
685 // their own to tell a re-push from a file still as it was read.
686 assert.strictEqual(provider.capabilities.viewedFiles, "environment");
687 assert.isDefined(provider.getFileRevisions);
688 const answer = yield* provider.getFileRevisions({
689 cwd: "/w",
690 repository: "web",
691 host: "dev.azure.com",
692 number: 42,
693 paths: ["README.md"],
694 });
695
696 // The latest push, since an iteration's changes are reported against the merge base rather
697 // than against the push before it.
698 expect(argsOfCall(2)).toContain("iterationId=2");
699 // Only what was asked for. DEMO.md changed too, and nobody has marked it.
700 expect([...answer.revisions]).toEqual([["README.md", "8f80"]]);
701 }),
702 );
703
704 it.effect("reads where a pull request lives once, however often it is asked about", () =>
705 Effect.gen(function* () {
706 // A pull request cannot move repositories, and the marks would otherwise pay for a whole
707 // pull request read every time they checked whether a file had been pushed to.
708 const pullRequest = Effect.succeed(
709 output(
710 JSON.stringify({
711 pullRequestId: 42,
712 title: "Add the page",
713 status: "active",
714 sourceRefName: "refs/heads/feat/page",
715 targetRefName: "refs/heads/main",
716 creationDate: "2026-07-01T00:00:00Z",
717 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
718 repository: { name: "web", project: { name: "platform" } },
719 }),
720 ),
721 );
722 const iterations = () =>
723 Effect.succeed(
724 output(
725 JSON.stringify({
726 value: [
727 {
728 id: 1,
729 sourceRefCommit: { commitId: "a".repeat(40) },
730 commonRefCommit: { commitId: "b".repeat(40) },
731 },
732 ],
733 }),
734 ),
735 );
736 const changes = () =>
737 Effect.succeed(
738 output(
739 JSON.stringify({
740 changeEntries: [
741 { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } },
742 ],
743 }),
744 ),
745 );
746 mockedExecute
747 .mockReturnValueOnce(pullRequest)
748 .mockReturnValueOnce(iterations())
749 .mockReturnValueOnce(changes())
750 .mockReturnValueOnce(iterations())
751 .mockReturnValueOnce(changes());
752 const provider = yield* AzureDevOpsPullRequestProvider.make;
753 const read = provider.getFileRevisions;
754 assert.isDefined(read);
755 const ask = () =>
756 read({
757 cwd: "/w",
758 repository: "web",
759 host: "dev.azure.com",
760 number: 42,
761 paths: ["README.md"],
762 });
763
764 yield* ask();
765 const again = yield* ask();
766
767 assert.strictEqual(mockedExecute.mock.calls.length, 5);
768 // The second read goes straight to the pushes, and still answers with the head's blob.
769 expect(argsOfCall(3)).toContain("pullRequestIterations");
770 expect([...again.revisions]).toEqual([["README.md", "8f80"]]);
771 }),
772 );
773
774 it.effect(
775 "answers for a marked file the pull request no longer changes as the empty version",
776 () =>
777 Effect.gen(function* () {
778 // Which is what was stored for it when it was ticked with nothing on the head, so a file
779 // the pull request deletes is cleared once and stays cleared.
780 mockedExecute
781 .mockReturnValueOnce(
782 Effect.succeed(
783 output(
784 json({
785 pullRequestId: 42,
786 title: "Add the page",
787 status: "active",
788 sourceRefName: "refs/heads/feat/page",
789 targetRefName: "refs/heads/main",
790 creationDate: "2026-07-01T00:00:00Z",
791 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
792 repository: { name: "web", project: { name: "platform" } },
793 }),
794 ),
795 ),
796 )
797 .mockReturnValueOnce(
798 Effect.succeed(
799 output(
800 json({
801 value: [
802 {
803 id: 1,
804 sourceRefCommit: { commitId: "a".repeat(40) },
805 commonRefCommit: { commitId: "b".repeat(40) },
806 },
807 ],
808 }),
809 ),
810 ),
811 )
812 .mockReturnValueOnce(Effect.succeed(output('{"changeEntries":[]}')));
813 const provider = yield* AzureDevOpsPullRequestProvider.make;
814 assert.isDefined(provider.getFileRevisions);
815
816 const answer = yield* provider.getFileRevisions({
817 cwd: "/w",
818 repository: "web",
819 host: "dev.azure.com",
820 number: 42,
821 paths: ["GONE.md"],
822 });
823
824 expect([...answer.revisions]).toEqual([["GONE.md", ""]]);
825 }),
826 );
827
828 it.effect("says nothing about the files past the end of a change it gave up following", () =>
829 Effect.gen(function* () {
830 // Every page is an `az` process of its own, so a change past the ceiling stops being
831 // followed. A path nobody looked at must not be answered for as deleted.
832 const entries = (from: number, count: number) =>
833 Array.from({ length: count }, (_, index) => ({
834 changeType: "edit",
835 item: { path: `/src/f${from + index}.ts`, objectId: `blob-${from + index}` },
836 }));
837 mockedExecute
838 .mockReturnValueOnce(
839 Effect.succeed(
840 output(
841 json({
842 pullRequestId: 42,
843 title: "Add the page",
844 status: "active",
845 sourceRefName: "refs/heads/feat/page",
846 targetRefName: "refs/heads/main",
847 creationDate: "2026-07-01T00:00:00Z",
848 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
849 repository: { name: "web", project: { name: "platform" } },
850 }),
851 ),
852 ),
853 )
854 .mockReturnValueOnce(
855 Effect.succeed(
856 output(
857 json({
858 value: [
859 {
860 id: 1,
861 sourceRefCommit: { commitId: "a".repeat(40) },
862 commonRefCommit: { commitId: "b".repeat(40) },
863 },
864 ],
865 }),
866 ),
867 ),
868 )
869 .mockReturnValueOnce(
870 Effect.succeed(output(json({ changeEntries: entries(0, 5_000), nextSkip: 5_000 }))),
871 )
872 .mockReturnValueOnce(
873 Effect.succeed(output(json({ changeEntries: entries(5_000, 5_000), nextSkip: 10_000 }))),
874 );
875 const provider = yield* AzureDevOpsPullRequestProvider.make;
876 assert.isDefined(provider.getFileRevisions);
877
878 const answer = yield* provider.getFileRevisions({
879 cwd: "/w",
880 repository: "web",
881 host: "dev.azure.com",
882 number: 42,
883 paths: ["src/f1.ts", "src/f5001.ts", "src/past-the-cut.ts"],
884 });
885
886 // The second page picks up where the first said it ended.
887 expect(argsOfCall(3)).toContain("$skip=5000");
888 expect([...answer.revisions]).toEqual([
889 ["src/f1.ts", "blob-1"],
890 ["src/f5001.ts", "blob-5001"],
891 ]);
892 }),
893 );
894
895 it.effect("stops following pages by what Azure counts, not by what survives decoding", () =>
896 Effect.gen(function* () {
897 mockedExecute
898 .mockReturnValueOnce(
899 Effect.succeed(
900 output(
901 json({
902 pullRequestId: 42,
903 title: "Add the page",
904 status: "active",
905 sourceRefName: "refs/heads/feat/page",
906 targetRefName: "refs/heads/main",
907 creationDate: "2026-07-01T00:00:00Z",
908 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
909 repository: { name: "web", project: { name: "platform" } },
910 }),
911 ),
912 ),
913 )
914 .mockReturnValueOnce(
915 Effect.succeed(
916 output(
917 json({
918 value: [
919 {
920 id: 1,
921 sourceRefCommit: { commitId: "a".repeat(40) },
922 commonRefCommit: { commitId: "b".repeat(40) },
923 },
924 ],
925 }),
926 ),
927 ),
928 )
929 // Nothing a review can show, so every page decodes to nothing at all and a ceiling counted
930 // in files would never be reached however long the walk went on.
931 .mockImplementation((command) => {
932 const skip = command.args.find((arg) => arg.startsWith("$skip="));
933 const from = Number(skip?.slice("$skip=".length) ?? 0);
934 return Effect.succeed(
935 output(
936 json({
937 changeEntries: [{ changeType: "add", item: { path: "/src", isFolder: true } }],
938 nextSkip: from + 2_000,
939 }),
940 ),
941 );
942 });
943 const provider = yield* AzureDevOpsPullRequestProvider.make;
944 assert.isDefined(provider.getFileRevisions);
945
946 const answer = yield* provider.getFileRevisions({
947 cwd: "/w",
948 repository: "web",
949 host: "dev.azure.com",
950 number: 42,
951 paths: ["src/page.ts"],
952 });
953
954 // The pull request, its pushes, and five pages: the walk gives up on Azure's own offset
955 // rather than spending an `az` process a page for as long as Azure keeps paging.
956 assert.strictEqual(mockedExecute.mock.calls.length, 7);
957 // And it read part of a change, so it says nothing about the file it never saw.
958 assert.strictEqual(answer.revisions.size, 0);
959 }),
960 );
961
962 it.effect("leaves one file the host would not hand over listed without its hunks", () =>
963 Effect.gen(function* () {
964 mockedExecute
965 .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow))))
966 .mockReturnValueOnce(Effect.succeed(output(json(oneIteration))))
967 .mockReturnValueOnce(
968 Effect.succeed(
969 output(
970 json({
971 changeEntries: [
972 { changeType: "add", item: { path: "/huge.bin", objectId: "8f80" } },
973 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
974 ],
975 }),
976 ),
977 ),
978 )
979 .mockReturnValueOnce(
980 Effect.fail(
981 new AzureDevOpsCli.AzureDevOpsCommandFailedError({
982 operation: "execute",
983 command: "az",
984 cwd: "/w",
985 argumentCount: 1,
986 cause: "the blob is past what the route will carry",
987 }),
988 ),
989 )
990 .mockReturnValueOnce(Effect.succeed(output(json({ content: "hello\n" }))));
991 const provider = yield* AzureDevOpsPullRequestProvider.make;
992
993 const slice = yield* provider.getDiff({
994 cwd: "/w",
995 repository: "web",
996 host: "dev.azure.com",
997 number: 42,
998 });
999
1000 assert.isTrue(slice.truncated);
1001 expect(slice.patch).toContain("diff --git a/huge.bin b/huge.bin");
1002 // And the file behind it still renders, which is the point of giving up on one file.
1003 expect(slice.patch).toContain("+hello");
1004 }),
1005 );
1006
1007 it.effect("fails the whole read when it is the connection that would not answer", () =>
1008 Effect.gen(function* () {
1009 // A rate limit is not this file's problem, and answering with a change full of files listed
1010 // without their hunks would read as a change nobody can see rather than as a host to wait
1011 // for.
1012 mockedExecute
1013 .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow))))
1014 .mockReturnValueOnce(Effect.succeed(output(json(oneIteration))))
1015 .mockReturnValueOnce(
1016 Effect.succeed(
1017 output(
1018 json({
1019 changeEntries: [
1020 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
1021 ],
1022 }),
1023 ),
1024 ),
1025 )
1026 .mockReturnValueOnce(
1027 Effect.fail(
1028 new AzureDevOpsCli.AzureDevOpsCliRateLimitError({
1029 operation: "execute",
1030 command: "az",
1031 cwd: "/w",
1032 argumentCount: 1,
1033 cause: "429",
1034 }),
1035 ),
1036 );
1037 const provider = yield* AzureDevOpsPullRequestProvider.make;
1038
1039 const error = yield* Effect.flip(
1040 provider.getDiff({ cwd: "/w", repository: "web", host: "dev.azure.com", number: 42 }),
1041 );
1042
1043 assert.strictEqual(error.reason, "rate-limited");
1044 }),
1045 );
1046
1047 it.effect("takes Azure's own word on a file it will not spell out", () =>
1048 Effect.gen(function* () {
1049 mockedExecute
1050 .mockReturnValueOnce(
1051 Effect.succeed(
1052 output(
1053 json({
1054 pullRequestId: 42,
1055 title: "Add the page",
1056 status: "active",
1057 sourceRefName: "refs/heads/feat/page",
1058 targetRefName: "refs/heads/main",
1059 creationDate: "2026-07-01T00:00:00Z",
1060 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
1061 repository: { name: "web", project: { name: "platform" } },
1062 }),
1063 ),
1064 ),
1065 )
1066 .mockReturnValueOnce(
1067 Effect.succeed(
1068 output(
1069 json({
1070 value: [
1071 {
1072 id: 1,
1073 sourceRefCommit: { commitId: "a".repeat(40) },
1074 commonRefCommit: { commitId: "b".repeat(40) },
1075 },
1076 ],
1077 }),
1078 ),
1079 ),
1080 )
1081 .mockReturnValueOnce(
1082 Effect.succeed(
1083 output(
1084 json({
1085 changeEntries: [
1086 { changeType: "add", item: { path: "/logo.png", objectId: "8f80" } },
1087 ],
1088 }),
1089 ),
1090 ),
1091 )
1092 .mockReturnValueOnce(
1093 Effect.succeed(
1094 output(json({ content: "iVBORw0KGgo=", contentMetadata: { isBinary: true } })),
1095 ),
1096 );
1097 const provider = yield* AzureDevOpsPullRequestProvider.make;
1098
1099 const slice = yield* provider.getDiff({
1100 cwd: "/w",
1101 repository: "web",
1102 host: "dev.azure.com",
1103 number: 42,
1104 });
1105
1106 // Azure leaves the metadata out unless it is asked for, and without it every file reads as
1107 // text however it was stored.
1108 expect(argsOfCall(3)).toContain("includeContentMetadata=true");
1109 expect(slice.patch).toContain("Binary files a/logo.png and b/logo.png differ");
1110 assert.isTrue(slice.truncated);
1111 }),
1112 );
1113
1114 it.effect("stops following pages when one of them does not move the cursor on", () =>
1115 Effect.gen(function* () {
1116 mockedExecute
1117 .mockReturnValueOnce(
1118 Effect.succeed(
1119 output(
1120 json({
1121 pullRequestId: 42,
1122 title: "Add the page",
1123 status: "active",
1124 sourceRefName: "refs/heads/feat/page",
1125 targetRefName: "refs/heads/main",
1126 creationDate: "2026-07-01T00:00:00Z",
1127 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
1128 repository: { name: "web", project: { name: "platform" } },
1129 }),
1130 ),
1131 ),
1132 )
1133 .mockReturnValueOnce(
1134 Effect.succeed(
1135 output(
1136 json({
1137 value: [
1138 {
1139 id: 1,
1140 sourceRefCommit: { commitId: "a".repeat(40) },
1141 commonRefCommit: { commitId: "b".repeat(40) },
1142 },
1143 ],
1144 }),
1145 ),
1146 ),
1147 )
1148 .mockReturnValueOnce(
1149 Effect.succeed(
1150 output(
1151 json({
1152 changeEntries: [{ changeType: "edit", item: { path: "/a.ts", objectId: "8f80" } }],
1153 nextSkip: 2_000,
1154 }),
1155 ),
1156 ),
1157 )
1158 .mockReturnValueOnce(
1159 Effect.succeed(
1160 output(
1161 json({
1162 changeEntries: [{ changeType: "edit", item: { path: "/b.ts", objectId: "0ca4" } }],
1163 nextSkip: 2_000,
1164 }),
1165 ),
1166 ),
1167 );
1168 const provider = yield* AzureDevOpsPullRequestProvider.make;
1169 assert.isDefined(provider.getFileRevisions);
1170
1171 const answer = yield* provider.getFileRevisions({
1172 cwd: "/w",
1173 repository: "web",
1174 host: "dev.azure.com",
1175 number: 42,
1176 paths: ["a.ts", "b.ts", "unlisted.ts"],
1177 });
1178
1179 // Four reads and no more: a page pointing at where it already is would be read forever.
1180 assert.strictEqual(mockedExecute.mock.calls.length, 4);
1181 // And what was read is not the whole change, so the file nobody listed is left unanswered
1182 // rather than reported as gone from the change request.
1183 expect([...answer.revisions]).toEqual([
1184 ["a.ts", "8f80"],
1185 ["b.ts", "0ca4"],
1186 ]);
1187 }),
1188 );
1189
1190 it.effect("asks Azure nothing when no file has been ticked off", () =>
1191 Effect.gen(function* () {
1192 mockedExecute.mockReturnValue(Effect.succeed(output('{"changeEntries":[]}')));
1193 const provider = yield* AzureDevOpsPullRequestProvider.make;
1194 assert.isDefined(provider.getFileRevisions);
1195
1196 const answer = yield* provider.getFileRevisions({
1197 cwd: "/w",
1198 repository: "web",
1199 host: "dev.azure.com",
1200 number: 42,
1201 paths: [],
1202 });
1203
1204 expect(answer.revisions.size).toBe(0);
1205 // Nothing was marked, so Azure was not asked at all.
1206 assert.strictEqual(mockedExecute.mock.calls.length, 0);
1207 }),
1208 );
1209
1210 it.effect("reads the conversation through the REST API, pinned to a version", () =>
1211 Effect.gen(function* () {
1212 mockedExecute.mockReturnValueOnce(
1213 Effect.succeed(
1214 output(
1215 JSON.stringify({
1216 value: [
1217 {
1218 id: 1,
1219 comments: [
1220 { id: 1, content: "Looks good.", publishedDate: "2026-07-02T00:00:00Z" },
1221 ],
1222 },
1223 ],
1224 }),
1225 ),
1226 ),
1227 );
1228 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1229
1230 const comments = yield* cli.listThreads({
1231 cwd: "/w",
1232 location: { project: "platform", repository: "web" },
1233 number: 42,
1234 });
1235
1236 assert.strictEqual(comments.length, 1);
1237 // `az devops invoke` rather than `az rest`: it signs in the way the azure-devops extension
1238 // does, and `az rest` mints its own token against whichever tenant `az` defaults to.
1239 expect(argsOfCall(0)).toContain("invoke");
1240 expect(argsOfCall(0)).toContain("pullRequestThreads");
1241 expect(argsOfCall(0)).toContain("project=platform");
1242 expect(argsOfCall(0)).toContain("repositoryId=web");
1243 expect(argsOfCall(0)).toContain("pullRequestId=42");
1244 // A review's threads grow with how long it ran, and this route does not page, so the read
1245 // asks for more than the process default rather than taking whatever it is given.
1246 expect(maxOutputBytesOfCall(0)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1247 }),
1248 );
1249
1250 it.effect("reads a long review's threads, which are past the default output limit", () =>
1251 Effect.gen(function* () {
1252 const response = json({ value: threadRows(800) });
1253 // Azure opens a thread per vote and per ref update beside the ones people wrote, and every
1254 // comment carries a full identity, so a review argued over for weeks outgrows the default.
1255 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1256 mockedExecute.mockImplementationOnce((input) =>
1257 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1258 );
1259 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1260
1261 const comments = yield* cli.listThreads({
1262 cwd: "/w",
1263 location: { project: "platform", repository: "web" },
1264 number: 42,
1265 });
1266
1267 assert.strictEqual(comments.length, 800);
1268 }),
1269 );
1270
1271 it.effect("reads a long review's iterations, which are past the default output limit", () =>
1272 Effect.gen(function* () {
1273 const response = json({ value: iterationRows(1_200) });
1274 // This route does not page, so the whole history arrives at once. Cut at the default it is
1275 // JSON stopping mid-string, and every diff and file revision read on this host fails with
1276 // it, since each of them starts by asking which iteration is the latest.
1277 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1278 mockedExecute.mockImplementationOnce((input) =>
1279 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1280 );
1281 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1282
1283 const iterations = yield* cli.listIterations({
1284 cwd: "/w",
1285 location: { project: "platform", repository: "web" },
1286 number: 42,
1287 });
1288
1289 assert.strictEqual(iterations.length, 1_200);
1290 assert.strictEqual(iterations.at(-1)?.id, 1_200);
1291 }),
1292 );
1293
1294 it.effect("reads a full page of change entries, which is past the default output limit", () =>
1295 Effect.gen(function* () {
1296 const response = json({ changeEntries: changeEntries(2_000) });
1297 // Azure's own maximum for this route, and every entry carries a path, a url and three
1298 // object ids, so an ordinary page of a large change already outgrows the default.
1299 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1300 mockedExecute.mockImplementationOnce((input) =>
1301 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1302 );
1303 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1304
1305 const page = yield* cli.listIterationChanges({
1306 cwd: "/w",
1307 location: { project: "platform", repository: "web" },
1308 number: 42,
1309 iterationId: 1,
1310 });
1311
1312 // Cut at the default this would arrive as JSON stopping mid-string, and a perfectly
1313 // ordinary page would be reported as a host answering with nonsense.
1314 assert.strictEqual(page.changes.length, 2_000);
1315 assert.isFalse(page.truncated);
1316 }),
1317 );
1318
1319 it.effect("reads a file whose JSON envelope is past the default output limit", () =>
1320 Effect.gen(function* () {
1321 // Under a megabyte as bytes on the host, so this is a file the other hosts hand over.
1322 const file = "const value = 1;\n".repeat(57_000);
1323 const response = json({ content: file });
1324 expect(Buffer.byteLength(file)).toBeLessThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1325 // And past it once Azure wraps it, because there is no route here that serves the bytes.
1326 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1327 mockedExecute.mockImplementationOnce((input) =>
1328 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1329 );
1330 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1331
1332 const item = yield* cli.readItemContent({
1333 cwd: "/w",
1334 location: { project: "platform", repository: "web" },
1335 path: "src/generated/schema.ts",
1336 commit: "a".repeat(40),
1337 });
1338
1339 assert.strictEqual(item.contents, file);
1340 assert.isFalse(item.isBinary);
1341 }),
1342 );
1343
1344 it.effect("asks for a file by Azure's own spelling of its path", () =>
1345 Effect.gen(function* () {
1346 mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "const a = 1;" }))));
1347 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1348
1349 // The path carried around here has had Azure's leading slash taken off so it matches the
1350 // patch and the viewed mark. The items route is documented in Azure's spelling, so it goes
1351 // back on the way out rather than being sent as the shorter name.
1352 yield* cli.readItemContent({
1353 cwd: "/w",
1354 location: { project: "platform", repository: "web" },
1355 path: "src/app.ts",
1356 commit: "a".repeat(40),
1357 });
1358
1359 expect(argsOfCall(0)).toContain("path=/src/app.ts");
1360 }),
1361 );
1362
1363 it.effect("reports a pull request it cannot place as its own outcome", () =>
1364 Effect.gen(function* () {
1365 mockedExecute.mockReturnValueOnce(
1366 Effect.succeed(
1367 output(
1368 // Well-formed, but with nothing to build a link from: not a decode failure.
1369 JSON.stringify({
1370 pullRequestId: 42,
1371 title: "Add the page",
1372 sourceRefName: "refs/heads/feat/page",
1373 targetRefName: "refs/heads/main",
1374 creationDate: "2026-07-01T00:00:00Z",
1375 }),
1376 ),
1377 ),
1378 );
1379 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1380
1381 const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 }));
1382
1383 assert.strictEqual(error._tag, "AzureDevOpsPullRequestIncompleteError");
1384 }),
1385 );
1386
1387 it.effect("fails the read when az returns something unreadable", () =>
1388 Effect.gen(function* () {
1389 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}')));
1390 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1391
1392 const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 }));
1393
1394 assert.strictEqual(error._tag, "AzureDevOpsPullRequestReadError");
1395 }),
1396 );
1397
1398 it.effect("adds reviewers with the one command Azure has for it", () =>
1399 Effect.gen(function* () {
1400 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1401 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1402
1403 yield* cli.setPullRequestReviewers({
1404 cwd: "/w",
1405 number: 42,
1406 reviewers: ["octocat@acme.test", "hubot@acme.test"],
1407 requested: true,
1408 });
1409
1410 expect(argsOfCall(0)).toEqual([
1411 "repos",
1412 "pr",
1413 "reviewer",
1414 "add",
1415 "--detect",
1416 "true",
1417 "--id",
1418 "42",
1419 "--reviewers",
1420 "octocat@acme.test",
1421 "hubot@acme.test",
1422 "--only-show-errors",
1423 "--output",
1424 "json",
1425 ]);
1426 }),
1427 );
1428
1429 it.effect("takes a reviewer off the pull request with the same command's counterpart", () =>
1430 Effect.gen(function* () {
1431 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1432 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1433
1434 yield* cli.setPullRequestReviewers({
1435 cwd: "/w",
1436 number: 42,
1437 reviewers: ["octocat@acme.test"],
1438 requested: false,
1439 });
1440
1441 expect(argsOfCall(0)).toContain("remove");
1442 }),
1443 );
1444
1445 it.effect("refuses a reviewer az would read as a flag, before running anything", () =>
1446 Effect.gen(function* () {
1447 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1448
1449 const error = yield* Effect.flip(
1450 cli.setPullRequestReviewers({
1451 cwd: "/w",
1452 number: 42,
1453 reviewers: ["--query"],
1454 requested: true,
1455 }),
1456 );
1457
1458 assert.strictEqual(error._tag, "AzureDevOpsReviewerNameError");
1459 assert.strictEqual(mockedExecute.mock.calls.length, 0);
1460 }),
1461 );
1462});
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts deleted-744
...@@ -1,744 +0,0 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";
6import type {
7 PullRequestAction,
8 PullRequestComment,
9 PullRequestInvolvement,
10 PullRequestListState,
11 PullRequestMergeMethod,
12} from "@t3tools/contracts";
13
14import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
15import {
16 decodeItemContentJson,
17 decodeIterationChangesJson,
18 decodeIterationsJson,
19 decodePullRequestJson,
20 decodePullRequestListJson,
21 decodeThreadsJson,
22 decodeViewerJson,
23 type AzureDevOpsChangeEntry,
24 type AzureDevOpsItemContent,
25 type AzureDevOpsIteration,
26 type AzureDevOpsPullRequest,
27 type AzureDevOpsRepositoryLocation,
28} from "./azureDevOpsPullRequestJson.ts";
29import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
30
31/**
32 * Names the read that produced unusable output, so a failure reports the call it came from
33 * rather than borrowing another operation's message.
34 */
35export class AzureDevOpsPullRequestReadError extends Schema.TaggedError<AzureDevOpsPullRequestReadError>()(
36 "AzureDevOpsPullRequestReadError",
37 {
38 command: Schema.Literal("az"),
39 cwd: Schema.String,
40 operation: Schema.String,
41 cause: Schema.Defect(),
42 },
43) {
44 get detail(): string {
45 return `Azure CLI returned an unreadable ${this.operation} response.`;
46 }
47
48 override get message(): string {
49 return `Azure CLI failed in ${this.operation}: ${this.detail}`;
50 }
51}
52
53/** Not a decode failure: az answered, the account it answered for just has no name. */
54export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError<AzureDevOpsViewerUnavailableError>()(
55 "AzureDevOpsViewerUnavailableError",
56 {
57 command: Schema.Literal("az"),
58 cwd: Schema.String,
59 },
60) {
61 get detail(): string {
62 return "Azure CLI returned no account for the current sign-in.";
63 }
64
65 override get message(): string {
66 return `Azure CLI failed in getViewer: ${this.detail}`;
67 }
68}
69
70/**
71 * Not a decode failure either: az answered with a well-formed pull request that simply carries
72 * no branch or link, which is a response this cannot place rather than one it cannot read.
73 */
74export class AzureDevOpsPullRequestIncompleteError extends Schema.TaggedError<AzureDevOpsPullRequestIncompleteError>()(
75 "AzureDevOpsPullRequestIncompleteError",
76 {
77 command: Schema.Literal("az"),
78 cwd: Schema.String,
79 number: Schema.Int,
80 },
81) {
82 get detail(): string {
83 return "Azure DevOps returned no branch or link for the pull request.";
84 }
85
86 override get message(): string {
87 return `Azure CLI failed in getPullRequest: ${this.detail}`;
88 }
89}
90
91/**
92 * Not a decode failure: the reader named a reviewer `az` would read as a flag of its own. The
93 * reviewers travel as argv rather than in a request body — `az repos pr reviewer` takes them no
94 * other way — so anything that could leave the value position is refused rather than sent.
95 */
96export class AzureDevOpsReviewerNameError extends Schema.TaggedError<AzureDevOpsReviewerNameError>()(
97 "AzureDevOpsReviewerNameError",
98 {
99 command: Schema.Literal("az"),
100 cwd: Schema.String,
101 },
102) {
103 get detail(): string {
104 return "A reviewer is named by an email address or an identity id.";
105 }
106
107 override get message(): string {
108 return `Azure CLI failed in setPullRequestReviewers: ${this.detail}`;
109 }
110}
111
112export type AzureDevOpsPullRequestCliError =
113 | AzureDevOpsCli.AzureDevOpsCliError
114 | AzureDevOpsPullRequestReadError
115 | AzureDevOpsPullRequestIncompleteError
116 | AzureDevOpsReviewerNameError
117 | AzureDevOpsViewerUnavailableError;
118
119/** The version every REST call below is pinned to, so a new default cannot reshape a response. */
120const REST_API_VERSION = "7.1";
121const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
122/**
123 * A full page of change entries is two thousand files, each carrying its path, its url and
124 * several object ids, which is past the megabyte a read is given by default. Output cut at that
125 * ceiling arrives here as JSON that will not parse, so a change large enough to be paged would
126 * report itself as a host returning nonsense rather than as the ordinary page it is.
127 */
128const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
129/**
130 * Four times the megabyte of file the other hosts hand over, because Azure has no route that
131 * serves the bytes themselves: the file arrives inside a JSON envelope, escaped if it is text and
132 * base64 if it is not, and both are larger than the file they carry.
133 */
134const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
135/**
136 * What a review's own history is given. Neither route pages, so each answers with the whole of it
137 * at once and grows with how long the review ran rather than with how large the change is. Cut at
138 * the default, both arrive as JSON that stops mid-string, and a long review would report its host
139 * as answering with nonsense.
140 */
141const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
142
143/** Azure's own ceiling for one page of an iteration's changes. */
144const CHANGE_ENTRIES_PER_PAGE = 2000;
145
146/**
147 * Where following the pages stops, counted in the entries Azure was asked to skip rather than in
148 * the files that survived decoding: a page can be entirely folders and other entries a review has
149 * nothing to show for, and bounding on what was kept would follow such a change forever. Every
150 * page is an `az` process of its own, so the read gives up and reports itself incomplete rather
151 * than presenting five pages as the whole change.
152 */
153const MAX_CHANGE_ENTRIES = 10_000;
154
155/** What an iteration changed, and whether following its pages reached the end of it. */
156export interface AzureDevOpsIterationChanges {
157 readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>;
158 readonly truncated: boolean;
159}
160
161export class AzureDevOpsPullRequestCli extends Context.Service<
162 AzureDevOpsPullRequestCli,
163 {
164 readonly getViewer: (input: {
165 readonly cwd: string;
166 }) => Effect.Effect<string, AzureDevOpsPullRequestCliError>;
167
168 readonly listPullRequests: (input: {
169 readonly cwd: string;
170 readonly repository: string;
171 readonly state: PullRequestListState;
172 readonly involvement: PullRequestInvolvement;
173 readonly viewer: string;
174 readonly limit: number;
175 /**
176 * Where to carry on from. Azure has no date filter for a pull request listing, so the only
177 * part of a cursor it can use is how many rows have already been handed over.
178 */
179 readonly cursor?: ProviderListCursor | undefined;
180 }) => Effect.Effect<
181 {
182 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
183 readonly truncated: boolean;
184 /** Raw Azure rows consumed to produce this page, including malformed rows. */
185 readonly cursorAdvance: number;
186 },
187 AzureDevOpsPullRequestCliError
188 >;
189
190 readonly getPullRequest: (input: {
191 readonly cwd: string;
192 readonly number: number;
193 }) => Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError>;
194
195 /** Threads are not reachable through `az repos pr`, so they come from the REST API. */
196 readonly listThreads: (input: {
197 readonly cwd: string;
198 readonly location: AzureDevOpsRepositoryLocation;
199 readonly number: number;
200 }) => Effect.Effect<ReadonlyArray<PullRequestComment>, AzureDevOpsPullRequestCliError>;
201
202 /**
203 * The pushes a pull request has had, oldest first. Azure hangs the changed files off an
204 * iteration rather than off the pull request, so reading a diff starts here.
205 */
206 readonly listIterations: (input: {
207 readonly cwd: string;
208 readonly location: AzureDevOpsRepositoryLocation;
209 readonly number: number;
210 }) => Effect.Effect<ReadonlyArray<AzureDevOpsIteration>, AzureDevOpsPullRequestCliError>;
211
212 /**
213 * What one iteration changed, against the merge base rather than against the previous push,
214 * which is the whole of the pull request rather than the latest slice of it.
215 */
216 readonly listIterationChanges: (input: {
217 readonly cwd: string;
218 readonly location: AzureDevOpsRepositoryLocation;
219 readonly number: number;
220 readonly iterationId: number;
221 }) => Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError>;
222
223 /**
224 * One file's text at one commit. Azure has no diff route that carries content, so both sides
225 * of every changed file are read this way and the patch is made from them here.
226 */
227 readonly readItemContent: (input: {
228 readonly cwd: string;
229 readonly location: AzureDevOpsRepositoryLocation;
230 readonly path: string;
231 readonly commit: string;
232 }) => Effect.Effect<AzureDevOpsItemContent, AzureDevOpsPullRequestCliError>;
233
234 readonly runPullRequestAction: (input: {
235 readonly cwd: string;
236 readonly number: number;
237 readonly action: PullRequestAction;
238 readonly mergeMethod?: PullRequestMergeMethod;
239 }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;
240
241 /** Rewrites the pull request's own words, through the same command that moves it. */
242 readonly updatePullRequest: (input: {
243 readonly cwd: string;
244 readonly number: number;
245 readonly title?: string | undefined;
246 readonly body?: string | undefined;
247 }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;
248
249 /**
250 * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole
251 * of what Azure offers here: it adds and removes named identities, and has no counterpart that
252 * says who could be named.
253 */
254 readonly setPullRequestReviewers: (input: {
255 readonly cwd: string;
256 readonly number: number;
257 readonly reviewers: ReadonlyArray<string>;
258 readonly requested: boolean;
259 }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;
260 }
261>()("t3/pullRequest/AzureDevOpsPullRequestCli") {}
262
263function statusArgs(state: PullRequestListState): ReadonlyArray<string> {
264 switch (state) {
265 case "open":
266 return ["--status", "active"];
267 case "merged":
268 return ["--status", "completed"];
269 case "closed":
270 return ["--status", "abandoned"];
271 case "all":
272 return ["--status", "all"];
273 }
274}
275
276function involvementArgs(input: {
277 readonly involvement: PullRequestInvolvement;
278 readonly viewer: string;
279}): ReadonlyArray<string> {
280 switch (input.involvement) {
281 case "authored":
282 return ["--creator", input.viewer];
283 case "reviewing":
284 return ["--reviewer", input.viewer];
285 case "all":
286 return [];
287 }
288}
289
290/**
291 * Azure moves a pull request by setting its state rather than by named commands: completing it
292 * is the merge, abandoning it is the close, and reactivating it is the reopen. Squashing is a
293 * completion option rather than a strategy of its own.
294 */
295function actionArgs(
296 action: PullRequestAction,
297 mergeMethod: PullRequestMergeMethod | undefined,
298): ReadonlyArray<string> {
299 switch (action) {
300 case "merge":
301 return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"];
302 // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes
303 // it once its policies pass. The squash choice is stored with it, as it is for a merge now.
304 case "enable-auto-merge":
305 return [
306 "--auto-complete",
307 "true",
308 ...(mergeMethod === undefined
309 ? []
310 : ["--squash", mergeMethod === "squash" ? "true" : "false"]),
311 ];
312 case "disable-auto-merge":
313 return ["--auto-complete", "false"];
314 case "ready":
315 return ["--draft", "false"];
316 case "draft":
317 return ["--draft", "true"];
318 case "close":
319 return ["--status", "abandoned"];
320 // Never reached: this host does not declare the action, so nothing offers it.
321 case "update-branch":
322 return [];
323 case "reopen":
324 return ["--status", "active"];
325 // Never reached: this host does not declare the action, so the service refuses it first.
326 case "revert":
327 case "approve-workflows":
328 throw new Error(`Azure DevOps pull request action ${action} is unsupported`);
329 }
330}
331
332/**
333 * A reviewer Azure could be given: an email address, a display name or an identity guid, and
334 * nothing that starts with a dash. The dash is the whole point — these are argv, and a value that
335 * looks like a flag stops being a value.
336 */
337function isReviewerName(value: string): boolean {
338 const name = value.trim();
339 return name.length > 0 && !name.startsWith("-");
340}
341
342/** @public Service construction is part of the canonical Effect module API. */
343export const make = Effect.gen(function* () {
344 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
345
346 // Every command resolves the organization, project and repository from the checkout, which is
347 // what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows
348 // how to read all of them.
349 const detectArgs = ["--detect", "true"] as const;
350
351 const executeJson = (input: {
352 readonly cwd: string;
353 readonly args: ReadonlyArray<string>;
354 readonly maxOutputBytes?: number;
355 }) =>
356 azure.execute({
357 cwd: input.cwd,
358 args: [...input.args, "--only-show-errors", "--output", "json"],
359 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
360 });
361
362 /**
363 * A REST route reached through `az devops invoke`, which addresses it by area, resource and
364 * route parameters rather than by URL. Used in place of `az rest` because it signs in the way
365 * the azure-devops extension does: `az rest` mints its own token against the tenant `az`
366 * defaults to, and an organisation in any other tenant answers that with a sign-in page, which
367 * arrives here as unreadable output rather than as a failure.
368 */
369 const invoke = <A>(input: {
370 readonly cwd: string;
371 readonly operation: string;
372 readonly resource: string;
373 readonly routeParameters: ReadonlyArray<string>;
374 readonly queryParameters?: ReadonlyArray<string>;
375 readonly maxOutputBytes?: number;
376 readonly decode: (raw: string) => Result.Result<A, unknown>;
377 }): Effect.Effect<A, AzureDevOpsPullRequestCliError> =>
378 executeJson({
379 cwd: input.cwd,
380 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
381 args: [
382 "devops",
383 "invoke",
384 ...detectArgs,
385 "--area",
386 "git",
387 "--resource",
388 input.resource,
389 "--api-version",
390 REST_API_VERSION,
391 "--route-parameters",
392 ...input.routeParameters,
393 ...(input.queryParameters === undefined
394 ? []
395 : ["--query-parameters", ...input.queryParameters]),
396 ],
397 }).pipe(
398 Effect.flatMap((result) => {
399 const decoded = input.decode(result.stdout.trim());
400 return Result.isSuccess(decoded)
401 ? Effect.succeed(decoded.success)
402 : Effect.fail(
403 new AzureDevOpsPullRequestReadError({
404 command: "az",
405 cwd: input.cwd,
406 operation: input.operation,
407 cause: decoded.failure,
408 }),
409 );
410 }),
411 );
412
413 /**
414 * Azure names its own items with a leading slash, which the repository paths carried around
415 * here have had taken off so they match the patch and the viewed mark. Put it back on the way
416 * out, because the items route is documented in Azure's own spelling.
417 */
418 const toItemPath = (path: string) => (path.startsWith("/") ? path : `/${path}`);
419
420 const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray<string> => [
421 `project=${location.project}`,
422 `repositoryId=${location.repository}`,
423 ];
424
425 const pullRequestRoute = (input: {
426 readonly location: AzureDevOpsRepositoryLocation;
427 readonly number: number;
428 }): ReadonlyArray<string> => [
429 ...repositoryRoute(input.location),
430 `pullRequestId=${input.number}`,
431 ];
432
433 /**
434 * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and
435 * retain the raw count so the next public cursor skips every row this walk consumed.
436 */
437 const listPullRequestPage = (input: {
438 readonly cwd: string;
439 readonly repository: string;
440 readonly state: PullRequestListState;
441 readonly involvement: PullRequestInvolvement;
442 readonly viewer: string;
443 readonly limit: number;
444 readonly skip: number;
445 readonly cursorAdvance: number;
446 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
447 }): Effect.Effect<
448 {
449 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
450 readonly truncated: boolean;
451 readonly cursorAdvance: number;
452 },
453 AzureDevOpsPullRequestCliError
454 > => {
455 const remaining = input.limit - input.items.length;
456 const top = remaining + 1;
457 return executeJson({
458 cwd: input.cwd,
459 maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES,
460 args: [
461 "repos",
462 "pr",
463 "list",
464 ...detectArgs,
465 "--repository",
466 input.repository,
467 ...statusArgs(input.state),
468 ...involvementArgs(input),
469 // A web link per row, which is the only url that needs no assembling.
470 "--include-links",
471 ...(input.skip === 0 ? [] : ["--skip", String(input.skip)]),
472 "--top",
473 String(top),
474 ],
475 }).pipe(
476 Effect.flatMap((result) => {
477 const raw = result.stdout.trim();
478 if (raw.length === 0) {
479 return Effect.succeed({
480 items: input.items,
481 truncated: false,
482 cursorAdvance: input.cursorAdvance,
483 });
484 }
485 const decoded = decodePullRequestListJson(raw);
486 if (!Result.isSuccess(decoded)) {
487 return Effect.fail(
488 new AzureDevOpsPullRequestReadError({
489 command: "az",
490 cwd: input.cwd,
491 operation: "listPullRequests",
492 cause: decoded.failure,
493 }),
494 );
495 }
496
497 const lastItemIndex = decoded.success.rawIndexes[remaining - 1];
498 if (lastItemIndex !== undefined) {
499 const consumed = lastItemIndex + 1;
500 return Effect.succeed({
501 items: [...input.items, ...decoded.success.items.slice(0, remaining)],
502 // A full raw response may have more rows even when malformed entries used the probe.
503 truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top,
504 cursorAdvance: input.cursorAdvance + consumed,
505 });
506 }
507
508 const items = [...input.items, ...decoded.success.items];
509 if (decoded.success.rawCount < top) {
510 return Effect.succeed({
511 items,
512 truncated: false,
513 cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
514 });
515 }
516 return listPullRequestPage({
517 ...input,
518 skip: input.skip + decoded.success.rawCount,
519 cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
520 items,
521 });
522 }),
523 );
524 };
525
526 return AzureDevOpsPullRequestCli.of({
527 getViewer: (input) =>
528 executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe(
529 Effect.flatMap((result): Effect.Effect<string, AzureDevOpsPullRequestCliError> => {
530 // `--query user` narrows the payload to the account, so it is nested back under the
531 // key the decoder reads to keep one shape for the signed-in user.
532 const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`);
533 if (!Result.isSuccess(decoded)) {
534 return Effect.fail(
535 new AzureDevOpsPullRequestReadError({
536 command: "az",
537 cwd: input.cwd,
538 operation: "getViewer",
539 cause: decoded.failure,
540 }),
541 );
542 }
543 return decoded.success === null
544 ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }))
545 : Effect.succeed(decoded.success);
546 }),
547 ),
548
549 listPullRequests: (input) =>
550 listPullRequestPage({
551 cwd: input.cwd,
552 repository: input.repository,
553 state: input.state,
554 involvement: input.involvement,
555 viewer: input.viewer,
556 limit: input.limit,
557 // Azure counts rather than filters, so a slice carries on by stepping over every raw row
558 // the prior slice consumed. That is an offset into a list that can shift underneath it:
559 // a pull request opened between two slices moves everything down one, and the row on the
560 // seam is the one that pays for it.
561 skip: input.cursor?.delivered ?? 0,
562 cursorAdvance: 0,
563 items: [],
564 }),
565
566 getPullRequest: (input) =>
567 executeJson({
568 cwd: input.cwd,
569 args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)],
570 }).pipe(
571 Effect.flatMap(
572 (result): Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError> => {
573 const decoded = decodePullRequestJson(result.stdout.trim());
574 if (!Result.isSuccess(decoded)) {
575 return Effect.fail(
576 new AzureDevOpsPullRequestReadError({
577 command: "az",
578 cwd: input.cwd,
579 operation: "getPullRequest",
580 cause: decoded.failure,
581 }),
582 );
583 }
584 // Null means Azure answered with too little to place the pull request. Nothing
585 // failed underneath it, so it is its own outcome rather than a decode failure.
586 return decoded.success === null
587 ? Effect.fail(
588 new AzureDevOpsPullRequestIncompleteError({
589 command: "az",
590 cwd: input.cwd,
591 number: input.number,
592 }),
593 )
594 : Effect.succeed(decoded.success);
595 },
596 ),
597 ),
598
599 listThreads: (input) =>
600 invoke({
601 cwd: input.cwd,
602 operation: "listThreads",
603 resource: "pullRequestThreads",
604 routeParameters: pullRequestRoute(input),
605 maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES,
606 decode: decodeThreadsJson,
607 }),
608
609 listIterations: (input) =>
610 invoke({
611 cwd: input.cwd,
612 operation: "listIterations",
613 resource: "pullRequestIterations",
614 routeParameters: pullRequestRoute(input),
615 maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES,
616 decode: decodeIterationsJson,
617 }),
618
619 listIterationChanges: (input) => {
620 const page = (skip: number) =>
621 invoke({
622 cwd: input.cwd,
623 operation: "listIterationChanges",
624 resource: "pullRequestIterationChanges",
625 routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`],
626 // Azure pages this route at 1000 entries by default; this is its own maximum per page,
627 // and it names where the next page starts rather than answering with the whole change.
628 queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`],
629 maxOutputBytes: CHANGE_ENTRIES_MAX_OUTPUT_BYTES,
630 decode: decodeIterationChangesJson,
631 });
632 const from = (
633 skip: number,
634 collected: ReadonlyArray<AzureDevOpsChangeEntry>,
635 ): Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError> =>
636 page(skip).pipe(
637 Effect.flatMap((answer) => {
638 const changes = [...collected, ...answer.changes];
639 // The last page names no page after it, and only that is the end of the change.
640 if (answer.nextSkip === null) return Effect.succeed({ changes, truncated: false });
641 // A page pointing at where the read already is would be followed forever, and one
642 // past the ceiling is a change nobody will read to the end of. Both stop the read
643 // and both say so, rather than presenting part of a change as the whole of it.
644 return answer.nextSkip <= skip || answer.nextSkip >= MAX_CHANGE_ENTRIES
645 ? Effect.succeed({ changes, truncated: true })
646 : from(answer.nextSkip, changes);
647 }),
648 );
649 return from(0, []);
650 },
651
652 readItemContent: (input) =>
653 invoke({
654 cwd: input.cwd,
655 operation: "readItemContent",
656 resource: "items",
657 routeParameters: repositoryRoute(input.location),
658 queryParameters: [
659 `path=${toItemPath(input.path)}`,
660 "versionDescriptor.versionType=commit",
661 `versionDescriptor.version=${input.commit}`,
662 "includeContent=true",
663 // Azure leaves `contentMetadata` out unless this is asked for, and with it goes its own
664 // word on whether the file is binary, which is the only reliable one, since a binary
665 // file arrives encoded rather than as the bytes it is on the host.
666 "includeContentMetadata=true",
667 // Without this Azure answers with the file's own bytes rather than with a JSON
668 // envelope, and `az devops invoke` refuses anything it cannot parse as JSON.
669 "$format=json",
670 ],
671 maxOutputBytes: ITEM_CONTENT_MAX_OUTPUT_BYTES,
672 decode: decodeItemContentJson,
673 }),
674
675 setPullRequestReviewers: (input) =>
676 input.reviewers.some((reviewer) => !isReviewerName(reviewer))
677 ? Effect.fail(new AzureDevOpsReviewerNameError({ command: "az", cwd: input.cwd }))
678 : azure
679 .execute({
680 cwd: input.cwd,
681 args: [
682 "repos",
683 "pr",
684 "reviewer",
685 input.requested ? "add" : "remove",
686 ...detectArgs,
687 "--id",
688 String(input.number),
689 // One `--reviewers` takes them all, because az reads the flag as a list and a
690 // second one would replace the first rather than add to it.
691 "--reviewers",
692 ...input.reviewers,
693 "--only-show-errors",
694 "--output",
695 "json",
696 ],
697 })
698 .pipe(Effect.asVoid),
699
700 runPullRequestAction: (input) =>
701 azure
702 .execute({
703 cwd: input.cwd,
704 args: [
705 "repos",
706 "pr",
707 "update",
708 ...detectArgs,
709 "--id",
710 String(input.number),
711 ...actionArgs(input.action, input.mergeMethod),
712 "--only-show-errors",
713 "--output",
714 "json",
715 ],
716 })
717 .pipe(Effect.asVoid),
718
719 updatePullRequest: (input) =>
720 azure
721 .execute({
722 cwd: input.cwd,
723 args: [
724 "repos",
725 "pr",
726 "update",
727 ...detectArgs,
728 "--id",
729 String(input.number),
730 // One argument rather than a flag and a value beside it: a description usually opens
731 // with a bullet, and az reads a dash in the next argv slot as a flag of its own.
732 // `--description` also takes several strings, and this keeps the whole text as one.
733 ...(input.title === undefined ? [] : [`--title=${input.title}`]),
734 ...(input.body === undefined ? [] : [`--description=${input.body}`]),
735 "--only-show-errors",
736 "--output",
737 "json",
738 ],
739 })
740 .pipe(Effect.asVoid),
741 });
742});
743
744export const layer = Layer.effect(AzureDevOpsPullRequestCli, make);
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts deleted-420
...@@ -1,420 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
6import {
7 LOCATION_CACHE_CAPACITY,
8 make,
9 MAX_DIFF_SPAWNS,
10} from "./AzureDevOpsPullRequestProvider.ts";
11import {
12 byteLength,
13 MAX_DIFF_SLICE_BYTES,
14 MAX_DIFF_SLICE_FILES,
15 MAX_FILE_DIFF_EDITS,
16 parseAzureDevOpsDiffCursor,
17} from "./azureDevOpsDiff.ts";
18import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts";
19
20const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" };
21
22const PULL_REQUEST = {
23 number: 7,
24 title: "Pull request 7",
25 url: "https://dev.azure.com/acme/web/_git/web/pullrequest/7",
26 author: null,
27 headBranch: "feat/page",
28 baseBranch: "main",
29 state: "open" as const,
30 isDraft: false,
31 mergeability: "mergeable" as const,
32 createdAt: "2026-07-01T00:00:00Z",
33 updatedAt: "2026-07-02T00:00:00Z",
34 closedAt: null,
35 body: "",
36 reviewRequestLogins: [],
37 reviewers: [],
38 location: { project: "acme", repository: "web" },
39 autoMergeEnabled: false,
40};
41
42function change(
43 path: string,
44 changeKind: AzureDevOpsChangeEntry["changeKind"] = "change",
45): AzureDevOpsChangeEntry {
46 return { path, oldPath: path, changeKind, objectId: "8f80", originalObjectId: "0ca4" };
47}
48
49/**
50 * A file whose two sides share no line, so its patch is `lines` removals and `lines` additions of
51 * `width` characters each: the diff work and the patch bytes one file costs are both dialled from
52 * here, and they are what a slice is bounded by. Each line carries its own number and a prefix
53 * as well, so `width` is a floor on how long a line is rather than its byte count.
54 */
55function side(prefix: string, lines: number, width: number): string {
56 const pad = "z".repeat(width);
57 return `${Array.from({ length: lines }, (_, line) => `${prefix} ${line} ${pad}`).join("\n")}\n`;
58}
59
60const readSlice = (input: {
61 readonly paths: ReadonlyArray<string>;
62 readonly lines: number;
63 readonly width: number;
64 /** Paths the host refuses, which is one file's problem rather than the read's. */
65 readonly refused?: ReadonlyArray<string>;
66 /** Paths the change creates, so the host has nothing to hand back for their old side. */
67 readonly created?: ReadonlyArray<string>;
68 readonly cursor?: string;
69}) =>
70 Effect.gen(function* () {
71 const reads: string[] = [];
72 const refused = new Set(input.refused ?? []);
73 const created = new Set(input.created ?? []);
74 let inFlight = 0;
75 let peakInFlight = 0;
76
77 const provider = yield* make.pipe(
78 Effect.provide(
79 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
80 getPullRequest: () => Effect.succeed(PULL_REQUEST),
81 listIterations: () => Effect.succeed([ITERATION]),
82 listIterationChanges: () =>
83 Effect.succeed({
84 changes: input.paths.map((path) =>
85 change(path, created.has(path) ? "new" : "change"),
86 ),
87 truncated: false,
88 }),
89 readItemContent: (item) =>
90 Effect.gen(function* () {
91 reads.push(item.path);
92 inFlight += 1;
93 peakInFlight = Math.max(peakInFlight, inFlight);
94 // Every read suspends before it answers, as a subprocess would, so what runs at
95 // once is the scheduler's answer rather than an artefact of resolving inline. The
96 // later a file is listed the sooner it answers, to leave the assembled patch
97 // nothing but the change list to take its order from.
98 const answersAfter = input.paths.length - input.paths.indexOf(item.path);
99 for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow;
100 inFlight -= 1;
101 if (refused.has(item.path)) {
102 return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({
103 command: "az",
104 cwd: "/w",
105 operation: "readItemContent",
106 cause: "refused",
107 });
108 }
109 const isOldSide = item.commit !== ITERATION.headCommit;
110 if (isOldSide && created.has(item.path)) return { contents: "", isBinary: false };
111 return {
112 contents: side(isOldSide ? "old" : "new", input.lines, input.width),
113 isBinary: false,
114 };
115 }),
116 }),
117 ),
118 );
119
120 const slice = yield* provider.getDiff({
121 cwd: "/w",
122 repository: "acme/web",
123 host: "dev.azure.com",
124 number: 7,
125 ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
126 });
127 return { slice, reads, peakInFlight };
128 });
129
130/** Which files the patch carries a section for, in the order it carries them. */
131function patchedPaths(patch: string): ReadonlyArray<string> {
132 return [...patch.matchAll(/^diff --git a\/(?<path>\S+) b\//gmu)].map(
133 (match) => match.groups?.path ?? "",
134 );
135}
136
137describe("getChangeRequestSummary", () => {
138 it.effect("costs the one pull request read, not the iterations changedFiles needs", () =>
139 Effect.gen(function* () {
140 let pullRequestReads = 0;
141
142 const provider = yield* make.pipe(
143 Effect.provide(
144 // listIterations and listIterationChanges are left unimplemented here, so a summary
145 // that reached for either would die with UnimplementedError instead of this passing.
146 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
147 getPullRequest: () => {
148 pullRequestReads += 1;
149 return Effect.succeed(PULL_REQUEST);
150 },
151 }),
152 ),
153 );
154
155 const readSummary = provider.getChangeRequestSummary;
156 if (readSummary === undefined) return yield* Effect.die("summary read was not implemented");
157 const summary = yield* readSummary({
158 cwd: "/w",
159 repository: "acme/web",
160 host: "dev.azure.com",
161 number: 7,
162 });
163
164 expect(pullRequestReads).toBe(1);
165 expect(summary.title).toBe(PULL_REQUEST.title);
166 expect(summary.changedFiles).toBeUndefined();
167 }),
168 );
169});
170
171describe("getChangeRequest", () => {
172 it.effect("still reports the file count the detail panel needs", () =>
173 Effect.gen(function* () {
174 const provider = yield* make.pipe(
175 Effect.provide(
176 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
177 getPullRequest: () => Effect.succeed(PULL_REQUEST),
178 listIterations: () => Effect.succeed([ITERATION]),
179 listIterationChanges: () =>
180 Effect.succeed({ changes: [change("a.ts"), change("b.ts")], truncated: false }),
181 }),
182 ),
183 );
184
185 const detail = yield* provider.getChangeRequest({
186 cwd: "/w",
187 repository: "acme/web",
188 host: "dev.azure.com",
189 number: 7,
190 });
191
192 expect(detail.changedFiles).toBe(2);
193 }),
194 );
195});
196
197describe("getDiff reads", () => {
198 it.effect("holds every reader together to one request's worth of processes", () =>
199 Effect.gen(function* () {
200 // The fan-out inside a read bounds one Code tab. Two people opening two Azure reviews at
201 // once are two reads, so without a ceiling above them both they are twice a request's
202 // processes, each paying a Python interpreter's start-up on the same machine.
203 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
204 let inFlight = 0;
205 let peakInFlight = 0;
206
207 const provider = yield* make.pipe(
208 Effect.provide(
209 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
210 getPullRequest: () => Effect.succeed(PULL_REQUEST),
211 listIterations: () => Effect.succeed([ITERATION]),
212 listIterationChanges: () =>
213 Effect.succeed({ changes: paths.map((path) => change(path)), truncated: false }),
214 readItemContent: () =>
215 Effect.gen(function* () {
216 inFlight += 1;
217 peakInFlight = Math.max(peakInFlight, inFlight);
218 // Suspends before answering, as a subprocess would, so what is out at once is the
219 // scheduler's answer rather than an artefact of resolving inline.
220 yield* Effect.yieldNow;
221 yield* Effect.yieldNow;
222 inFlight -= 1;
223 return { contents: side("new", 2, 4), isBinary: false };
224 }),
225 }),
226 ),
227 );
228
229 const readDiff = (number: number) =>
230 provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number });
231
232 yield* Effect.all([readDiff(7), readDiff(8)], { concurrency: 2 });
233
234 expect(peakInFlight).toBeLessThanOrEqual(MAX_DIFF_SPAWNS);
235 }),
236 );
237
238 it.effect("leaves a run of files it could not diff at all for the next slice", () =>
239 Effect.gen(function* () {
240 // A binary, oversize, purely renamed or unreadable entry is a header apiece, a couple of
241 // hundred bytes with no edits in it, so a change made of them spends neither budget: the
242 // byte one would take well over a thousand of them, and the edit one never fills at all.
243 // A listing holds up to ten thousand entries and each still costs its two reads, which is
244 // what a file count is here to bound.
245 const paths = Array.from({ length: MAX_DIFF_SLICE_FILES + 20 }, (_, at) => `gen/a${at}.bin`);
246 const read = yield* readSlice({ paths, lines: 2, width: 4, refused: paths });
247
248 expect(patchedPaths(read.slice.patch)).toHaveLength(MAX_DIFF_SLICE_FILES);
249 // Well inside the byte budget, so the file count is what stopped it rather than either of
250 // the budgets that were already there.
251 expect(byteLength(read.slice.patch)).toBeLessThan(MAX_DIFF_SLICE_BYTES);
252 expect(parseAzureDevOpsDiffCursor(read.slice.nextCursor)?.fileIndex).toBe(
253 MAX_DIFF_SLICE_FILES,
254 );
255 }),
256 );
257
258 it.effect("asks for both sides of several files at once rather than one side at a time", () =>
259 Effect.gen(function* () {
260 // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a
261 // slice read one side after another is most of what the Code tab waits for.
262 const read = yield* readSlice({
263 paths: ["a.ts", "b.ts", "c.ts", "d.ts"],
264 lines: 2,
265 width: 4,
266 });
267
268 expect(read.peakInFlight).toBeGreaterThan(2);
269 }),
270 );
271
272 it.effect("holds the number of files it reads at once down", () =>
273 Effect.gen(function* () {
274 // The host throttles, and `az` is a process on the same machine the reader runs agents on,
275 // so a long change is read in batches rather than all at once.
276 const paths = Array.from({ length: 24 }, (_, file) => `file-${file}.ts`);
277 const read = yield* readSlice({ paths, lines: 2, width: 4 });
278
279 expect(read.reads).toHaveLength(paths.length * 2);
280 expect(read.peakInFlight).toBeLessThanOrEqual(8);
281 }),
282 );
283
284 it.effect("keeps the patch in the order the change was listed, whoever answered first", () =>
285 Effect.gen(function* () {
286 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"];
287 const read = yield* readSlice({ paths, lines: 2, width: 4 });
288
289 expect(patchedPaths(read.slice.patch)).toEqual(paths);
290 expect(read.slice.nextCursor).toBeNull();
291 }),
292 );
293
294 it.effect("leaves a file the host refused listed without its hunks, in its place", () =>
295 Effect.gen(function* () {
296 const paths = ["a.ts", "b.ts", "c.ts"];
297 const read = yield* readSlice({ paths, lines: 2, width: 4, refused: ["b.ts"] });
298
299 expect(patchedPaths(read.slice.patch)).toEqual(paths);
300 expect(read.slice.truncated).toBe(true);
301 // Its section ends at its header, and the files around it still carry their hunks.
302 expect(read.slice.patch).toContain("+++ b/b.ts\ndiff --git a/c.ts");
303 expect(read.slice.patch.match(/^@@ /gmu)).toHaveLength(2);
304 }),
305 );
306});
307
308describe("what one diff slice spends", () => {
309 it.effect("stops on the byte ceiling without carrying what it read past it", () =>
310 Effect.gen(function* () {
311 // Two of these fill the slice, and the batch they were read in reached two files further.
312 // Those two belong to the next slice: carrying them would put the request past a ceiling
313 // that is there to bound what one answer weighs.
314 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
315 const read = yield* readSlice({ paths, lines: 100, width: 900 });
316
317 expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES);
318 expect(patchedPaths(read.slice.patch)).toEqual(["a.ts", "b.ts"]);
319 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:2`);
320 expect(new Set(read.reads)).toEqual(new Set(["a.ts", "b.ts", "c.ts", "d.ts"]));
321 }),
322 );
323
324 it.effect("narrows what it reads at once as the slice fills", () =>
325 Effect.gen(function* () {
326 // Four files fit inside the budget and the fifth is past half of what is left of it, so
327 // reading four more would throw most of them away and read them again next slice. Every
328 // file is two `az` invocations, so the batch is judged against what the files before it
329 // weighed rather than left at its full width to the last file.
330 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts", "g.ts", "h.ts"];
331 const read = yield* readSlice({ paths, lines: 50, width: 450 });
332
333 expect(read.slice.nextCursor).not.toBeNull();
334 expect(new Set(read.reads)).toEqual(new Set(patchedPaths(read.slice.patch)));
335 }),
336 );
337
338 it.effect("stops once the diff work one request may do is spent", () =>
339 Effect.gen(function* () {
340 // Short lines are cheap on the wire and dear to diff, so the byte ceiling alone would let
341 // one request hold the thread through a dozen of them. Each of these is half of what one
342 // file is allowed, and the slice ends while there is still room for another.
343 const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`);
344 const read = yield* readSlice({ paths, lines: MAX_FILE_DIFF_EDITS / 4, width: 1 });
345
346 expect(read.slice.patch.length).toBeLessThan(MAX_DIFF_SLICE_BYTES);
347 expect(patchedPaths(read.slice.patch)).toEqual(paths.slice(0, 5));
348 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:5`);
349 }),
350 );
351
352 it.effect("carries a whole new file and stops the slice on what it weighed", () =>
353 Effect.gen(function* () {
354 // A creation has no edit distance to search out, so no edit bound applies to it and its
355 // section is the whole file. What keeps a run of them from filling one answer is the bytes
356 // they weighed, which the slice has to be charged for.
357 const paths = ["new.ts", "b.ts", "c.ts", "d.ts"];
358 const read = yield* readSlice({ paths, lines: 8_000, width: 30, created: ["new.ts"] });
359
360 expect(patchedPaths(read.slice.patch)).toEqual(["new.ts"]);
361 expect(read.slice.patch).toContain("--- /dev/null");
362 expect(read.slice.patch).toContain("@@ -0,0 +1,8000 @@");
363 expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES);
364 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:1`);
365 }),
366 );
367
368 it.effect("carries on from where the last slice stopped", () =>
369 Effect.gen(function* () {
370 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
371 const read = yield* readSlice({
372 paths,
373 lines: 100,
374 width: 900,
375 cursor: `${ITERATION.id}:2`,
376 });
377
378 expect(patchedPaths(read.slice.patch)).toEqual(["c.ts", "d.ts"]);
379 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:4`);
380 }),
381 );
382 it.effect("keeps the pull request being read, not the one looked up first", () =>
383 Effect.gen(function* () {
384 // Where a pull request lives is read from the pull request itself, so an evicted entry
385 // costs a whole pull request read before any file can be asked for. Ordered by insertion
386 // alone a hit does not renew its entry, so the review being worked through is the first
387 // thing dropped once a listing has walked a cache's worth of cold pull requests.
388 const HOT = 7;
389 const readsOf = new Map<number, number>();
390
391 const provider = yield* make.pipe(
392 Effect.provide(
393 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
394 getPullRequest: (input) =>
395 Effect.sync(() => {
396 readsOf.set(input.number, (readsOf.get(input.number) ?? 0) + 1);
397 return { ...PULL_REQUEST, number: input.number };
398 }),
399 listIterations: () => Effect.succeed([ITERATION]),
400 listIterationChanges: () =>
401 Effect.succeed({ changes: [change("a.ts")], truncated: false }),
402 readItemContent: () => Effect.succeed({ contents: side("new", 2, 4), isBinary: false }),
403 }),
404 ),
405 );
406
407 const readDiff = (number: number) =>
408 provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number });
409
410 yield* readDiff(HOT);
411 // A cache's worth of cold pull requests, with the open one read in between each of them.
412 for (let filled = 0; filled < LOCATION_CACHE_CAPACITY; filled += 1) {
413 yield* readDiff(HOT + 1 + filled);
414 yield* readDiff(HOT);
415 }
416
417 expect(readsOf.get(HOT)).toBe(1);
418 }),
419 );
420});
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts deleted-647
...@@ -1,647 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as Semaphore from "effect/Semaphore";
3import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
4
5import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
6import {
7 azureDevOpsFilePatch,
8 azureDevOpsUnreadableFilePatch,
9 formatAzureDevOpsDiffCursor,
10 parseAzureDevOpsDiffCursor,
11 MAX_DIFF_SLICE_BYTES,
12 MAX_DIFF_SLICE_EDITS,
13 MAX_DIFF_SLICE_FILES,
14 byteLength,
15 MAX_FILE_DIFF_EDITS,
16 type AzureDevOpsFileTexts,
17} from "./azureDevOpsDiff.ts";
18import {
19 PullRequestProviderError,
20 type PullRequestProviderFailure,
21 type ProviderChangeRequest,
22 type ProviderChangeRequestActivity,
23 type ProviderChangeRequestDetail,
24 type ProviderChangeRequestSummary,
25 type ProviderDiffSlice,
26 type PullRequestProviderApi,
27} from "@t3tools/source-control-core/server/PullRequestProvider";
28import type {
29 AzureDevOpsChangeEntry,
30 AzureDevOpsItemContent,
31 AzureDevOpsIteration,
32 AzureDevOpsPullRequest,
33 AzureDevOpsRepositoryLocation,
34} from "./azureDevOpsPullRequestJson.ts";
35
36/**
37 * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a
38 * Python interpreter's start-up, so reading them one after another is most of what the Code tab
39 * waits for. Four files means eight processes at once: the fan-out the GitHub CLI reads its
40 * per-file stats with here, and low enough not to swamp the host's throttling or the machine.
41 */
42const DIFF_FILE_CONCURRENCY = 4;
43
44/**
45 * How many `az` processes this build will have out at once, counted across every reader rather
46 * than per request. The fan-out above bounds one Code tab, so two people opening two Azure reviews
47 * had sixteen Python interpreters starting at once and nothing above them. Held at what one
48 * request at full width spends, so a second reader waits behind the first instead of adding to it.
49 */
50export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY;
51
52/** How many pull requests' repository locations one provider remembers at once. */
53export const LOCATION_CACHE_CAPACITY = 128;
54
55const CAPABILITIES: PullRequestCapabilities = {
56 // Azure serves no patch of its own, so the one the Code tab reads is built here out of the
57 // files an iteration changed and both sides of each of them.
58 diff: true,
59 // Reading a conversation is a plain REST read, but posting one is not something this can
60 // claim without having run it, so the composer stays hidden.
61 comment: false,
62 actions: [
63 "merge",
64 "ready",
65 "draft",
66 "close",
67 "reopen",
68 "enable-auto-merge",
69 "disable-auto-merge",
70 ],
71 // Azure squashes as a completion option; it has no rebase strategy of its own.
72 mergeMethods: ["merge", "squash"],
73 // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all.
74 search: false,
75 reactions: false,
76 // The patch has lines to write against, but writing a remark at all is what Azure is not
77 // offered for here, so nothing in a review is either.
78 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
79 // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos`
80 // lists the ones this repository could name — that lives behind the identity and graph APIs, a
81 // different service with its own permissions. So the page takes a name here rather than being
82 // handed a menu built out of a guess.
83 reviewers: { request: true, listCandidates: false },
84 // A new title and description travel on the same `az repos pr update` that moves a pull request.
85 // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on
86 // Azure DevOps at all, so there is nothing here it could rewrite either.
87 edit: { changeRequest: true, comment: false },
88 // Azure does keep a viewed record of its own, but only behind the undocumented contribution
89 // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway.
90 // So they are kept here instead, and the client says whose they are rather than implying the
91 // Azure DevOps page will show them.
92 viewedFiles: "environment",
93};
94
95/**
96 * Everything this host offers, granted to whoever is signed in. Azure DevOps states no permission
97 * anywhere `az repos pr show` or `az repos pr list` reach: the answer lives in the security
98 * namespaces, behind identity descriptors and token paths that would be several calls per pull
99 * request to resolve.
100 *
101 * So the actions stay live and a viewer who may not take one is told so by Azure, at the moment
102 * they try. That is the safer half of an unknown: hiding a control from someone entitled to it
103 * leaves them no way through and no reason given.
104 */
105const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = {
106 actions: CAPABILITIES.actions,
107 comment: CAPABILITIES.comment,
108 resolve: CAPABILITIES.review.resolve,
109 verdicts: CAPABILITIES.review.verdicts,
110 requestReviewers: CAPABILITIES.reviewers.request,
111};
112
113/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */
114export function azureDevOpsProviderFailure(
115 error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError,
116): PullRequestProviderFailure {
117 if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" };
118 if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" };
119 if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" };
120 if (error._tag === "AzureDevOpsPullRequestNotFoundError") return { reason: "not-found" };
121 return { reason: "failed" };
122}
123
124function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeRequest {
125 return {
126 number: pullRequest.number,
127 title: pullRequest.title,
128 url: pullRequest.url,
129 author: pullRequest.author,
130 headBranch: pullRequest.headBranch,
131 baseBranch: pullRequest.baseBranch,
132 state: pullRequest.state,
133 isDraft: pullRequest.isDraft,
134 mergeability: pullRequest.mergeability,
135 // Azure counts a pull request's files but never its lines, and counting them here would mean
136 // reading every file on both sides of every row of a listing.
137 additions: 0,
138 deletions: 0,
139 createdAt: pullRequest.createdAt,
140 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
141 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
142 updatedAt: pullRequest.updatedAt,
143 reviewRequestLogins: pullRequest.reviewRequestLogins,
144 // Azure keeps labels on work items rather than on the pull request.
145 labels: [],
146 };
147}
148
149export const make = Effect.gen(function* () {
150 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
151 // Made once with the provider, which the registry builds once, so this is the whole build's
152 // allowance rather than one request's.
153 const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS);
154 const readItemContent = (input: Parameters<typeof cli.readItemContent>[0]) =>
155 diffSpawns.withPermits(1)(cli.readItemContent(input));
156
157 const fail =
158 (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) =>
159 new PullRequestProviderError({
160 provider: "azure-devops",
161 operation,
162 ...azureDevOpsProviderFailure(error),
163 detail: error.detail,
164 cause: error,
165 });
166
167 /** Refuses what the capabilities already say this host cannot do. */
168 const unsupported = (operation: string) =>
169 Effect.fail(
170 new PullRequestProviderError({
171 provider: "azure-devops",
172 operation,
173 reason: "failed",
174 detail: "Azure DevOps reviews cannot be written from here yet.",
175 }),
176 );
177
178 /** A pull request Azure could not place has no diff to read, which reads as an empty one. */
179 const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null };
180
181 /**
182 * Where a pull request's repository lives, which is the route every other read of it needs and
183 * the one thing only the pull request itself states. A pull request cannot move between
184 * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for
185 * a whole pull request read every time they checked whether a file had been pushed to. Least
186 * recently used, so a listing walking cold pull requests cannot evict the one being read.
187 */
188 const locations = new Map<string, AzureDevOpsRepositoryLocation>();
189
190 const locationOf = (input: { readonly cwd: string; readonly number: number }) => {
191 const key = `${input.cwd} ${input.number}`;
192 const held = locations.get(key);
193 if (held !== undefined) {
194 locations.delete(key);
195 locations.set(key, held);
196 return Effect.succeed(held);
197 }
198 return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
199 Effect.map((pullRequest) => {
200 const location = pullRequest.location;
201 if (location === null) return null;
202 if (locations.size >= LOCATION_CACHE_CAPACITY) {
203 const oldest = locations.keys().next().value;
204 if (oldest !== undefined) locations.delete(oldest);
205 }
206 locations.set(key, location);
207 return location;
208 }),
209 );
210 };
211
212 /**
213 * Everything a diff read needs before it can ask for a file: where the repository lives, and
214 * which pushes the pull request has had. A client names neither, and the iterations are read
215 * afresh every time because the newest one is what a push adds.
216 */
217 const diffScope = (input: { readonly cwd: string; readonly number: number }) =>
218 Effect.gen(function* () {
219 const location = yield* locationOf(input);
220 if (location === null) return null;
221 const iterations = yield* cli.listIterations({
222 cwd: input.cwd,
223 location,
224 number: input.number,
225 });
226 return { location, iterations };
227 });
228
229 const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false };
230
231 /**
232 * Both sides of one changed file, read at once because neither answer depends on the other and
233 * `az` pays a Python interpreter's start-up for each. Only the sides a change actually has are
234 * asked for: Azure answers for a file that is not at a commit with a failure rather than with
235 * nothing.
236 */
237 const readTexts = (input: {
238 readonly cwd: string;
239 readonly location: AzureDevOpsRepositoryLocation;
240 readonly iteration: AzureDevOpsIteration;
241 readonly change: Pick<AzureDevOpsChangeEntry, "changeKind" | "path" | "oldPath">;
242 }) =>
243 Effect.all(
244 [
245 input.change.changeKind === "new"
246 ? Effect.succeed(EMPTY_ITEM)
247 : readItemContent({
248 cwd: input.cwd,
249 location: input.location,
250 path: input.change.oldPath,
251 commit: input.iteration.mergeBaseCommit,
252 }),
253 input.change.changeKind === "deleted"
254 ? Effect.succeed(EMPTY_ITEM)
255 : readItemContent({
256 cwd: input.cwd,
257 location: input.location,
258 path: input.change.path,
259 commit: input.iteration.headCommit,
260 }),
261 ],
262 { concurrency: 2 },
263 ).pipe(
264 Effect.map(([oldItem, newItem]): AzureDevOpsFileTexts => ({
265 oldContents: oldItem.contents,
266 newContents: newItem.contents,
267 // Azure hands a file it calls binary over in an encoding of its own, so its own word on
268 // that is taken rather than looked for in bytes it may never have sent verbatim.
269 binary: oldItem.isBinary || newItem.isBinary,
270 })),
271 );
272
273 /**
274 * What the whole pull request changed, taken from its latest push. An iteration's changes are
275 * reported against the merge base rather than against the push before it, so the newest one is
276 * the whole of the change rather than the last slice of it.
277 */
278 const listLatestChanges = (input: {
279 readonly cwd: string;
280 readonly location: AzureDevOpsRepositoryLocation;
281 readonly number: number;
282 readonly iterations: ReadonlyArray<AzureDevOpsIteration>;
283 }) => {
284 const latest = input.iterations.at(-1);
285 return latest === undefined
286 ? Effect.succeed({
287 changes: [],
288 truncated: false,
289 } as AzureDevOpsPullRequestCli.AzureDevOpsIterationChanges)
290 : cli.listIterationChanges({
291 cwd: input.cwd,
292 location: input.location,
293 number: input.number,
294 iterationId: latest.id,
295 });
296 };
297
298 const provider: PullRequestProviderApi = {
299 kind: "azure-devops",
300 capabilities: CAPABILITIES,
301
302 getViewer: (input) =>
303 cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))),
304
305 // `input.query` is deliberately dropped: `az repos pr list` filters by status, creator,
306 // reviewer and branch, and has nothing that matches text. Sending it as one of those would
307 // narrow by the wrong thing, so the page comes back unnarrowed and the caller filters it.
308 listChangeRequests: (input) =>
309 cli
310 .listPullRequests({
311 cwd: input.cwd,
312 repository: input.repository,
313 state: input.state,
314 involvement: input.involvement,
315 viewer: input.viewer,
316 limit: input.limit,
317 cursor: input.cursor,
318 })
319 .pipe(
320 Effect.mapError(fail("listChangeRequests")),
321 Effect.map((batch) => ({
322 items: batch.items.map(toChangeRequest),
323 truncated: batch.truncated,
324 cursorAdvance: batch.cursorAdvance,
325 // Azure answers in one order whether or not it is being carried on from, so a slice
326 // can always be stepped past — by counting, which is all Azure offers.
327 continues: true,
328 })),
329 ),
330
331 // The polled path a linked thread's row stays live on: one `az` read, no iterations or
332 // changes behind it, since the file count that would cost is not shown here.
333 getChangeRequestSummary: (input) =>
334 cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
335 Effect.mapError(fail("getChangeRequestSummary")),
336 Effect.map((pullRequest): ProviderChangeRequestSummary => ({
337 number: pullRequest.number,
338 title: pullRequest.title,
339 url: pullRequest.url,
340 author: pullRequest.author,
341 headBranch: pullRequest.headBranch,
342 baseBranch: pullRequest.baseBranch,
343 state: pullRequest.state,
344 isDraft: pullRequest.isDraft,
345 mergeability: pullRequest.mergeability,
346 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
347 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
348 updatedAt: pullRequest.updatedAt,
349 })),
350 ),
351
352 getChangeRequest: (input) =>
353 Effect.gen(function* () {
354 const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number });
355 const location = pullRequest.location;
356 // The file count is two reads past the pull request itself, and it is the only thing
357 // riding on them, so a failure leaves it unknown rather than losing the whole detail.
358 const changedFiles =
359 location === null
360 ? 0
361 : yield* cli.listIterations({ cwd: input.cwd, location, number: input.number }).pipe(
362 Effect.flatMap((iterations) =>
363 listLatestChanges({
364 cwd: input.cwd,
365 location,
366 number: input.number,
367 iterations,
368 }),
369 ),
370 Effect.map((listed) => listed.changes.length),
371 Effect.orElseSucceed(() => 0),
372 );
373 const detail: ProviderChangeRequestDetail = {
374 ...toChangeRequest(pullRequest),
375 body: pullRequest.body,
376 changedFiles,
377 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
378 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
379 reviewers: pullRequest.reviewers,
380 checks: [],
381 mergeCapabilities: { merge: true, squash: true, rebase: false },
382 viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS,
383 autoMergeEnabled: pullRequest.autoMergeEnabled,
384 ...(pullRequest.autoMergeMethod === undefined
385 ? {}
386 : { autoMergeMethod: pullRequest.autoMergeMethod }),
387 };
388 return detail;
389 }).pipe(Effect.mapError(fail("getChangeRequest"))),
390
391 getChangeRequestActivity: (input) =>
392 cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
393 Effect.mapError(fail("getChangeRequestActivity")),
394 Effect.flatMap((pullRequest) =>
395 (pullRequest.location === null
396 ? Effect.succeed({ comments: [], truncated: true })
397 : cli
398 .listThreads({
399 cwd: input.cwd,
400 location: pullRequest.location,
401 number: input.number,
402 })
403 .pipe(
404 Effect.map((comments) => ({ comments, truncated: false })),
405 Effect.orElseSucceed(() => ({ comments: [], truncated: true })),
406 )
407 ).pipe(
408 Effect.map((conversation): ProviderChangeRequestActivity => ({
409 comments: conversation.comments,
410 commentCount: conversation.comments.length,
411 commentsTruncated: conversation.truncated,
412 reviewThreads: [],
413 commits: [],
414 })),
415 ),
416 ),
417 ),
418
419 // No request at all: Azure has nothing to say about the viewer that a pull request read can
420 // reach, so the answer is the same constant the detail carries.
421 getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS),
422
423 // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so
424 // the Code tab has nothing to scope itself to and always asks for the whole change.
425 getDiff: (input) =>
426 Effect.gen(function* () {
427 const scope = yield* diffScope(input);
428 if (scope === null) return EMPTY_DIFF_SLICE;
429 const cursor = parseAzureDevOpsDiffCursor(input.cursor);
430 // Reading on stays with the push the first slice was taken against. A push landing
431 // mid-read would otherwise renumber the files and hand the reader one twice, or none.
432 const iteration =
433 cursor === null
434 ? scope.iterations.at(-1)
435 : scope.iterations.find((candidate) => candidate.id === cursor.iterationId);
436 if (iteration === undefined) return EMPTY_DIFF_SLICE;
437 const listed = yield* cli.listIterationChanges({
438 cwd: input.cwd,
439 location: scope.location,
440 number: input.number,
441 iterationId: iteration.id,
442 });
443 const changes = listed.changes;
444
445 const sections: string[] = [];
446 let truncated = listed.truncated;
447 let bytes = 0;
448 let edits = 0;
449 let index = cursor?.fileIndex ?? 0;
450 let full = false;
451 // How many files to read at once. Every file costs a request a side, and everything read
452 // past the point the slice fills is thrown away and read again by the next slice, so the
453 // batch narrows as the budgets do: what is left of each, over what a file has spent of it
454 // on average so far, which is the only estimate there is before a file is read. At least
455 // one, so a file heavier than the whole budget still moves the cursor.
456 const batchWidth = () => {
457 if (sections.length === 0) return DIFF_FILE_CONCURRENCY;
458 const admits = (left: number, spent: number) =>
459 Math.ceil(left / Math.max(1, spent / sections.length));
460 return Math.max(
461 1,
462 Math.min(
463 DIFF_FILE_CONCURRENCY,
464 // The file budget needs no estimate: a file spends exactly one of it.
465 MAX_DIFF_SLICE_FILES - sections.length,
466 admits(MAX_DIFF_SLICE_BYTES - bytes, bytes),
467 admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits),
468 ),
469 );
470 };
471 while (!full && index < changes.length) {
472 const batch = changes.slice(index, index + batchWidth());
473 const read = yield* Effect.forEach(
474 batch,
475 (change) =>
476 // A pair Azure refuses is one file rather than the whole slice: an oversize blob or
477 // a path `az` will not carry through leaves that file listed without its hunks, and
478 // everything around it still renders.
479 readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe(
480 // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az`
481 // at all is the read failing rather than the file, and belongs to the caller,
482 // which pauses the host rather than showing every file as unreadable.
483 Effect.catchTags({
484 AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null),
485 AzureDevOpsCommandFailedError: () => Effect.succeed(null),
486 AzureDevOpsPullRequestReadError: () => Effect.succeed(null),
487 }),
488 Effect.map((texts) => ({ change, texts })),
489 ),
490 { concurrency: DIFF_FILE_CONCURRENCY },
491 );
492 for (const { change, texts } of read) {
493 // The diff is synchronous and the reads no longer stand between one file and the next
494 // to let anything else on the server run, so the thread is handed back here.
495 yield* Effect.yieldNow;
496 const file =
497 texts === null
498 ? azureDevOpsUnreadableFilePatch(change)
499 : azureDevOpsFilePatch({ change, texts });
500 sections.push(file.section);
501 bytes += byteLength(file.section);
502 edits += file.edits;
503 truncated = truncated || file.truncated;
504 index += 1;
505 // A file whose diff was given up on spent the whole of what one file is allowed and
506 // has only a header to show for it, and a file the diff never ran on at all weighs
507 // almost nothing in either budget and still costs its two reads. So the file count
508 // bounds the request alongside the bytes.
509 // Checked after the file is added rather than before it, so every slice carries at
510 // least one: a section heavier than the whole budget would otherwise never be added,
511 // and the read would answer the same slice forever without moving the cursor.
512 if (
513 bytes >= MAX_DIFF_SLICE_BYTES ||
514 edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS ||
515 sections.length >= MAX_DIFF_SLICE_FILES ||
516 file.abandoned
517 ) {
518 full = true;
519 break;
520 }
521 }
522 }
523
524 const slice: ProviderDiffSlice = {
525 patch: sections.join(""),
526 truncated,
527 nextCursor:
528 index >= changes.length
529 ? null
530 : formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }),
531 };
532 return slice;
533 }).pipe(Effect.mapError(fail("getDiff"))),
534
535 // The patch is built from whole files, so opening the lines around a hunk is the same two
536 // reads over again rather than a wider request. Read against the latest iteration: nothing in
537 // the request says which push the reader is looking at, and expansion is stale after a
538 // mid-review push on every host here anyway.
539 getDiffFileContents: (input) =>
540 Effect.gen(function* () {
541 const scope = yield* diffScope(input);
542 const iteration = scope?.iterations.at(-1);
543 if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" };
544 return yield* readTexts({
545 cwd: input.cwd,
546 location: scope.location,
547 iteration,
548 change: {
549 changeKind: input.changeType,
550 path: input.newPath,
551 oldPath: input.oldPath,
552 },
553 });
554 }).pipe(Effect.mapError(fail("getDiffFileContents"))),
555
556 /**
557 * What the head has of each marked file, which is the blob Azure already names on the change
558 * it reports. One read covers every path, since the latest iteration lists the whole change.
559 *
560 * A path the change does not carry is at the empty revision, which is where a file the pull
561 * request deletes sits. When the change was too long to follow to its end, those paths are
562 * left out instead: reporting them as deleted would clear a file nobody has read.
563 */
564 getFileRevisions: (input) =>
565 Effect.gen(function* () {
566 const revisions = new Map<string, string>();
567 if (input.paths.length === 0) return { revisions };
568 const scope = yield* diffScope(input);
569 if (scope === null) return { revisions };
570 const listed = yield* listLatestChanges({
571 ...scope,
572 cwd: input.cwd,
573 number: input.number,
574 });
575 const marked = new Set(input.paths);
576 for (const change of listed.changes) {
577 if (!marked.has(change.path) || change.objectId === null) continue;
578 revisions.set(change.path, change.objectId);
579 }
580 if (!listed.truncated) {
581 for (const path of input.paths) {
582 if (!revisions.has(path)) revisions.set(path, "");
583 }
584 }
585 return { revisions };
586 }).pipe(Effect.mapError(fail("getFileRevisions"))),
587
588 runAction: (input) =>
589 cli
590 .runPullRequestAction({
591 cwd: input.cwd,
592 number: input.number,
593 action: input.action,
594 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
595 })
596 .pipe(Effect.mapError(fail("runAction"))),
597
598 updateChangeRequest: (input) =>
599 cli
600 .updatePullRequest({
601 cwd: input.cwd,
602 number: input.number,
603 title: input.title,
604 body: input.body,
605 })
606 .pipe(Effect.mapError(fail("updateChangeRequest"))),
607
608 // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the
609 // list without it.
610 listReviewerCandidates: () =>
611 Effect.fail(
612 new PullRequestProviderError({
613 provider: "azure-devops",
614 operation: "listReviewerCandidates",
615 reason: "failed",
616 detail: "Azure DevOps cannot say who may review a pull request.",
617 }),
618 ),
619
620 setReviewerRequest: (input) =>
621 cli
622 .setPullRequestReviewers({
623 cwd: input.cwd,
624 number: input.number,
625 // Azure names an identity by an email address or a guid, and has no team to ask, so a
626 // candidate's id is the whole of what it takes.
627 reviewers: input.reviewers.map((reviewer) => reviewer.id),
628 requested: input.requested,
629 })
630 .pipe(Effect.mapError(fail("setReviewerRequest"))),
631
632 // Never called: `capabilities.comment` is false, and the service refuses a comment without it.
633 comment: () => unsupported("comment"),
634
635 // Declared unsupported above, so the service refuses these before a provider is reached.
636 // They exist because every provider answers the whole port.
637 submitReview: () => unsupported("submitReview"),
638
639 replyToThread: () => unsupported("replyToThread"),
640
641 setThreadResolution: () => unsupported("setThreadResolution"),
642
643 setReaction: () => unsupported("setReaction"),
644 };
645
646 return provider;
647});
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-2
...@@ -1,11 +1,11 @@...@@ -1,11 +1,11 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
22
3import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";3import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
4import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";4import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
5import * as GitHubApi from "../sourceControl/GitHubApi.ts";5import * as GitHubApi from "../sourceControl/GitHubApi.ts";
6import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";6import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
7import * as GitLabCli from "../sourceControl/GitLabCli.ts";7import * as GitLabCli from "../sourceControl/GitLabCli.ts";
8import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts";8import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider";
9import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts";9import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts";
10import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts";10import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts";
11import { gitLabProviderFailure } from "./GitLabPullRequestProvider.ts";11import { gitLabProviderFailure } from "./GitLabPullRequestProvider.ts";
apps/server/src/pullRequest/PullRequestProviderRegistry.ts-5
...@@ -3,12 +3,9 @@ import * as Effect from "effect/Effect";...@@ -3,12 +3,9 @@ import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import type { SourceControlProviderKind } from "@t3tools/contracts";4import type { SourceControlProviderKind } from "@t3tools/contracts";
55
6import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
7import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";6import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
8import * as GitHubApi from "../sourceControl/GitHubApi.ts";7import * as GitHubApi from "../sourceControl/GitHubApi.ts";
9import * as GitLabCli from "../sourceControl/GitLabCli.ts";8import * as GitLabCli from "../sourceControl/GitLabCli.ts";
10import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
11import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";
12import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";9import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
13import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts";10import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts";
14import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";11import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
...@@ -49,7 +46,6 @@ export const make = Effect.gen(function* () {...@@ -49,7 +46,6 @@ export const make = Effect.gen(function* () {
49 GitHubPullRequestProvider.make,46 GitHubPullRequestProvider.make,
50 GitLabPullRequestProvider.make,47 GitLabPullRequestProvider.make,
51 BitbucketPullRequestProvider.make,48 BitbucketPullRequestProvider.make,
52 AzureDevOpsPullRequestProvider.make,
53 ]);49 ]);
54 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>50 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
55 driver.make.pipe(Effect.map((instance) => instance.pullRequests)),51 driver.make.pipe(Effect.map((instance) => instance.pullRequests)),
...@@ -72,5 +68,4 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(...@@ -72,5 +68,4 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
72 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),68 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),
73 Layer.provide(BuiltInDrivers.layer),69 Layer.provide(BuiltInDrivers.layer),
74 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),70 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),
75 Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))),
76);71);
apps/server/src/pullRequest/azureDevOpsDiff.test.ts deleted-459
...@@ -1,459 +0,0 @@
1import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 azureDevOpsFilePatch,
6 azureDevOpsUnreadableFilePatch,
7 byteLength,
8 formatAzureDevOpsDiffCursor,
9 MAX_FILE_DIFF_EDITS,
10 parseAzureDevOpsDiffCursor,
11} from "./azureDevOpsDiff.ts";
12import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts";
13
14function change(overrides: Partial<AzureDevOpsChangeEntry> = {}): AzureDevOpsChangeEntry {
15 return {
16 path: "README.md",
17 oldPath: "README.md",
18 changeKind: "change",
19 objectId: "8f80",
20 originalObjectId: "0ca4",
21 ...overrides,
22 };
23}
24
25function texts(oldContents: string, newContents: string, binary = false) {
26 return { oldContents, newContents, binary };
27}
28
29describe("azureDevOpsFilePatch", () => {
30 it("writes a changed file as the unified patch every diff viewer already reads", () => {
31 const patch = azureDevOpsFilePatch({
32 change: change(),
33 texts: texts("one\ntwo\nthree\n", "one\ntwo again\nthree\n"),
34 });
35
36 expect(patch.truncated).toBe(false);
37 expect(patch.section).toBe(
38 [
39 "diff --git a/README.md b/README.md",
40 "--- a/README.md",
41 "+++ b/README.md",
42 "@@ -1,3 +1,3 @@",
43 " one",
44 "-two",
45 "+two again",
46 " three",
47 "",
48 ].join("\n"),
49 );
50 });
51
52 it("names the side a new file does not have as /dev/null", () => {
53 const patch = azureDevOpsFilePatch({
54 change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }),
55 texts: texts("", "hello\n"),
56 });
57
58 expect(patch.section).toContain("new file mode 100644");
59 expect(patch.section).toContain("--- /dev/null");
60 expect(patch.section).toContain("+++ b/DEMO.md");
61 // Git points the range a new file does not have at line zero, not at line one.
62 expect(patch.section).toContain("@@ -0,0 +1 @@");
63 expect(patch.section).toContain("+hello");
64 });
65
66 it("names the side a deleted file no longer has as /dev/null", () => {
67 const patch = azureDevOpsFilePatch({
68 change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }),
69 texts: texts("gone\n", ""),
70 });
71
72 expect(patch.section).toContain("deleted file mode 100644");
73 expect(patch.section).toContain("--- a/OLD.md");
74 expect(patch.section).toContain("+++ /dev/null");
75 expect(patch.section).toContain("@@ -1 +0,0 @@");
76 expect(patch.section).toContain("-gone");
77 });
78
79 it("keeps the carriage returns of a file with Windows line endings", () => {
80 // They are part of the line rather than around it, so a patch that dropped them would ask
81 // the reader to look at a change that is not the one on the host.
82 const patch = azureDevOpsFilePatch({
83 change: change(),
84 texts: texts("one\r\ntwo\r\n", "one\r\ntwo again\r\n"),
85 });
86
87 expect(patch.section).toContain("-two\r");
88 expect(patch.section).toContain("+two again\r");
89 });
90
91 it("keeps a file that only moved, which has no hunks to give", () => {
92 const patch = azureDevOpsFilePatch({
93 change: change({ path: "docs/new.md", oldPath: "docs/old.md", changeKind: "rename-pure" }),
94 texts: texts("same\n", "same\n"),
95 });
96
97 expect(patch.truncated).toBe(false);
98 expect(patch.section).toBe(
99 [
100 "diff --git a/docs/old.md b/docs/new.md",
101 "rename from docs/old.md",
102 "rename to docs/new.md",
103 "--- a/docs/old.md",
104 "+++ b/docs/new.md",
105 "",
106 ].join("\n"),
107 );
108 });
109
110 it("reports a binary file as changed rather than spelling it out", () => {
111 const patch = azureDevOpsFilePatch({
112 change: change({ path: "logo.png", oldPath: "logo.png" }),
113 texts: texts("PNG\u0000old", "PNG\u0000new"),
114 });
115
116 expect(patch.truncated).toBe(true);
117 expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ");
118 });
119
120 it("shows an overlong file as changed without its hunks", () => {
121 const patch = azureDevOpsFilePatch({
122 change: change({ path: "bundle.js", oldPath: "bundle.js" }),
123 texts: texts("a\n".repeat(400_000), "b\n".repeat(400_000)),
124 });
125
126 expect(patch.truncated).toBe(true);
127 expect(patch.section).toBe(
128 ["diff --git a/bundle.js b/bundle.js", "--- a/bundle.js", "+++ b/bundle.js", ""].join("\n"),
129 );
130 });
131
132 it("takes the host's word that a file is binary, whatever its bytes look like", () => {
133 // Azure hands such a file over base64-encoded, so nothing in the text it sent gives it away.
134 const patch = azureDevOpsFilePatch({
135 change: change({ path: "logo.png", oldPath: "logo.png" }),
136 texts: texts("b2xk", "bmV3", true),
137 });
138
139 expect(patch.truncated).toBe(true);
140 expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ");
141 });
142
143 it("counts an overlong file in bytes rather than in characters", () => {
144 // Three bytes each, so a ceiling counted in code units would let three times the size through.
145 const patch = azureDevOpsFilePatch({
146 change: change({ path: "notes.md", oldPath: "notes.md" }),
147 texts: texts("\u4e00".repeat(200_000), "\u4e8c".repeat(200_000)),
148 });
149
150 expect(patch.truncated).toBe(true);
151 expect(patch.section).toBe(
152 ["diff --git a/notes.md b/notes.md", "--- a/notes.md", "+++ b/notes.md", ""].join("\n"),
153 );
154 });
155
156 const lineRange = (count: number, prefix: string) =>
157 Array.from({ length: count }, (_, line) => `${prefix} ${line}`).join("\n");
158
159 it("lists a file too far apart to diff without its hunks", () => {
160 // Sharing no line at all costs one edit per line on each side, so this pair is twice the
161 // ceiling apart. Left to itself the search costs about the square of that and would hold the
162 // whole server, every websocket client with it, while it worked out a patch nobody reads.
163 // Writing both sides out instead would read as a genuine rewrite: the ceiling is a distance
164 // rather than a proportion, so a long file reaches it having changed in one corner, and that
165 // corner would be buried in a wall of red and green.
166 const lines = (prefix: string) =>
167 Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n");
168 const patch = azureDevOpsFilePatch({
169 change: change({ path: "generated.ts", oldPath: "generated.ts" }),
170 texts: texts(`${lines("old")}\n`, `${lines("new")}\n`),
171 });
172
173 expect(patch.truncated).toBe(true);
174 // And it says the search was given up on, because the reader of a run of files is meant to
175 // stop rather than spend that work again on each of the ones behind it.
176 expect(patch.abandoned).toBe(true);
177 expect(patch.section).toBe(
178 [
179 "diff --git a/generated.ts b/generated.ts",
180 "--- a/generated.ts",
181 "+++ b/generated.ts",
182 "",
183 ].join("\n"),
184 );
185 });
186
187 it("writes out a wholly new file however many lines it has", () => {
188 // Nothing on the old side means there was no edit distance to search out, so this is the
189 // minimal patch and not a stand-in for one. Fifteen thousand lines of thirty bytes is the
190 // shape this used to lose: inside the byte ceiling that gates every file, and well past every
191 // bound a two-sided file answers to, none of which is protecting against anything here.
192 const contents = `${Array.from({ length: 15_000 }, () => "x".repeat(29)).join("\n")}\n`;
193 const patch = azureDevOpsFilePatch({
194 change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }),
195 texts: texts("", contents),
196 });
197
198 expect(byteLength(contents)).toBeLessThan(512 * 1024);
199 expect(patch.truncated).toBe(false);
200 expect(patch.abandoned).toBe(false);
201 expect(patch.edits).toBe(15_000);
202 expect(patch.section).toContain("@@ -0,0 +1,15000 @@");
203 // Only the `+++` of the header on top of the file's own lines, so nothing was dropped out of
204 // the middle.
205 expect(patch.section.match(/^\+/gmu)).toHaveLength(15_001);
206 });
207
208 it("keeps a wholly new file too heavy to write out listed without its hunks", () => {
209 // Every line carries a marker, so a side of very short lines answers with up to twice its own
210 // bytes: these 200,000 one-character lines fit the ceiling each side is read under and weigh
211 // about 600KB written out, more than twice what a whole slice may carry. There is no smaller
212 // true patch for a creation to fall back to, so it is listed without its hunks, the same as a
213 // side too big to read at all, rather than sent at a size the slice budget exists to prevent.
214 const contents = `${Array.from({ length: 200_000 }, () => "x").join("\n")}\n`;
215 const patch = azureDevOpsFilePatch({
216 change: change({ path: "bundle.min.js", oldPath: "bundle.min.js", changeKind: "new" }),
217 texts: texts("", contents),
218 });
219
220 expect(byteLength(contents)).toBeLessThan(512 * 1024);
221 expect(patch.truncated).toBe(true);
222 expect(patch.abandoned).toBe(false);
223 // It still cost the walk over its lines, which is what the slice is charged for.
224 expect(patch.edits).toBe(200_000);
225 expect(patch.section).toBe(
226 [
227 "diff --git a/bundle.min.js b/bundle.min.js",
228 "new file mode 100644",
229 "--- /dev/null",
230 "+++ b/bundle.min.js",
231 "",
232 ].join("\n"),
233 );
234 });
235
236 it("writes out a wholly deleted file however many lines it had", () => {
237 const contents = `${lineRange(20_000, "line")}\n`;
238 const patch = azureDevOpsFilePatch({
239 change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }),
240 texts: texts(contents, ""),
241 });
242
243 expect(patch.truncated).toBe(false);
244 expect(patch.abandoned).toBe(false);
245 expect(patch.edits).toBe(20_000);
246 expect(patch.section).toContain("@@ -1,20000 +0,0 @@");
247 expect(patch.section.match(/^-line /gmu)).toHaveLength(20_000);
248 expect(patch.section.match(/^-/gmu)).toHaveLength(20_001);
249 });
250
251 it("gives an empty new file no hunk to read", () => {
252 // A file with nothing on either side has no lines to claim were replaced, and git writes it
253 // as a header alone.
254 const patch = azureDevOpsFilePatch({
255 change: change({ path: "EMPTY.md", oldPath: "EMPTY.md", changeKind: "new" }),
256 texts: texts("", ""),
257 });
258
259 expect(patch.edits).toBe(0);
260 expect(patch.section).not.toContain("@@");
261 });
262
263 it("marks a wholly new file whose last line has no newline after it", () => {
264 const patch = azureDevOpsFilePatch({
265 change: change({ path: "NOTES.md", oldPath: "NOTES.md", changeKind: "new" }),
266 texts: texts("", "one\ntwo"),
267 });
268
269 expect(patch.section).toBe(
270 [
271 "diff --git a/NOTES.md b/NOTES.md",
272 "new file mode 100644",
273 "--- /dev/null",
274 "+++ b/NOTES.md",
275 "@@ -0,0 +1,2 @@",
276 "+one",
277 "+two",
278 "\\ No newline at end of file",
279 "",
280 ].join("\n"),
281 );
282 });
283
284 it("keeps a file it did diff out of the giving up", () => {
285 const patch = azureDevOpsFilePatch({
286 change: change(),
287 texts: texts("one\ntwo\n", "one\ntwo again\n"),
288 });
289
290 expect(patch.abandoned).toBe(false);
291 });
292
293 it("counts what the diff worked out, which is what the file cost to diff", () => {
294 // The caller spends a budget of these across a slice, so they have to be the edits the search
295 // actually made: one line replaced is a removal and an addition, and the three lines of
296 // context around them cost nothing.
297 const patch = azureDevOpsFilePatch({
298 change: change(),
299 texts: texts("one\ntwo\nthree\nfour\n", "one\ntwo again\nthree\nfour\n"),
300 });
301
302 expect(patch.edits).toBe(2);
303 });
304
305 it("counts nothing for a file it never diffed", () => {
306 const patch = azureDevOpsFilePatch({
307 change: change({ path: "logo.png", oldPath: "logo.png" }),
308 texts: texts("PNG\u0000old", "PNG\u0000new"),
309 });
310
311 expect(patch.edits).toBe(0);
312 });
313
314 it("lists a file whose hunks outweigh its sides without them", () => {
315 // A handful of very long lines is a few edits and nowhere near the edit ceiling, and the
316 // patch carries both sides in full with three lines of context around each hunk, so the
317 // section comes out heavier than either side was. What one file weighs is what a slice's
318 // budget is spent in, so the edit ceiling alone does not bound this.
319 const line = `${"a".repeat(400 * 1024)}\n`;
320 const patch = azureDevOpsFilePatch({
321 change: change({ path: "min.js", oldPath: "min.js" }),
322 texts: texts(line, `${"b".repeat(400 * 1024)}\n`),
323 });
324
325 expect(patch.edits).toBeLessThan(MAX_FILE_DIFF_EDITS);
326 expect(patch.truncated).toBe(true);
327 expect(patch.section).toBe(
328 ["diff --git a/min.js b/min.js", "--- a/min.js", "+++ b/min.js", ""].join("\n"),
329 );
330 expect(byteLength(patch.section)).toBeLessThan(byteLength(line));
331 });
332
333 it("marks a file that does not end in a newline, as git does", () => {
334 const patch = azureDevOpsFilePatch({
335 change: change(),
336 texts: texts("one\n", "two"),
337 });
338
339 expect(patch.section).toContain("\\ No newline at end of file");
340 });
341});
342
343describe("azureDevOpsUnreadableFilePatch", () => {
344 it("keeps a file the host would not hand over, listed without its hunks", () => {
345 const patch = azureDevOpsUnreadableFilePatch(change({ path: "huge.bin", oldPath: "huge.bin" }));
346
347 expect(patch.truncated).toBe(true);
348 expect(patch.section).toBe(
349 ["diff --git a/huge.bin b/huge.bin", "--- a/huge.bin", "+++ b/huge.bin", ""].join("\n"),
350 );
351 });
352});
353
354describe("a file Azure names something a patch header cannot carry plainly", () => {
355 it("writes each side as git's quoted form, the side letter inside the quotes", () => {
356 const patch = azureDevOpsFilePatch({
357 change: change({ path: "notes\treadme.md", oldPath: "notes\treadme.md" }),
358 texts: texts("one\n", "two\n"),
359 });
360
361 expect(patch.section).toBe(
362 [
363 'diff --git "a/notes\\treadme.md" "b/notes\\treadme.md"',
364 '--- "a/notes\\treadme.md"',
365 '+++ "b/notes\\treadme.md"',
366 "@@ -1 +1 @@",
367 "-one",
368 "+two",
369 "",
370 ].join("\n"),
371 );
372 });
373
374 it("keeps a name holding a newline on the one header line it belongs to", () => {
375 const patch = azureDevOpsFilePatch({
376 change: change({ path: "line\nfile.txt", oldPath: "line\nfile.txt" }),
377 texts: texts("one\n", "two\n"),
378 });
379
380 // Written as itself the name would start a line of its own, and a reader would take what
381 // followed for a header the patch never had.
382 expect(patch.section.split("\n").slice(0, 3)).toEqual([
383 'diff --git "a/line\\nfile.txt" "b/line\\nfile.txt"',
384 '--- "a/line\\nfile.txt"',
385 '+++ "b/line\\nfile.txt"',
386 ]);
387 });
388
389 it("quotes the names a rename states, which carry no side letter", () => {
390 const patch = azureDevOpsFilePatch({
391 change: change({
392 path: "docs/new\tname.md",
393 oldPath: "docs/old\tname.md",
394 changeKind: "rename-pure",
395 }),
396 texts: texts("same\n", "same\n"),
397 });
398
399 expect(patch.section).toBe(
400 [
401 'diff --git "a/docs/old\\tname.md" "b/docs/new\\tname.md"',
402 'rename from "docs/old\\tname.md"',
403 'rename to "docs/new\\tname.md"',
404 '--- "a/docs/old\\tname.md"',
405 '+++ "b/docs/new\\tname.md"',
406 "",
407 ].join("\n"),
408 );
409 });
410
411 it("quotes the sides of the one line a binary file gets", () => {
412 const patch = azureDevOpsFilePatch({
413 change: change({ path: "logo\tmark.png", oldPath: "logo\tmark.png" }),
414 texts: texts("PNG\u0000old", "PNG\u0000new"),
415 });
416
417 expect(patch.section).toContain(
418 'Binary files "a/logo\\tmark.png" and "b/logo\\tmark.png" differ',
419 );
420 });
421
422 it("hands a reader of the header back the name Azure gave", () => {
423 const path = 'every\t\n"kind"\\of.md';
424 const patch = azureDevOpsFilePatch({
425 change: change({ path, oldPath: path }),
426 texts: texts("one\n", "two\n"),
427 });
428 const [header, oldLine, newLine] = patch.section.split("\n");
429
430 expect(header).not.toContain("\t");
431 expect(unquoteGitPatchPath(oldLine?.slice(4) ?? "")).toBe(`a/${path}`);
432 expect(unquoteGitPatchPath(newLine?.slice(4) ?? "")).toBe(`b/${path}`);
433 });
434});
435
436describe("a diff cursor", () => {
437 it("carries the push it was taken against back to the next slice", () => {
438 const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 });
439
440 expect(parseAzureDevOpsDiffCursor(cursor)).toEqual({ iterationId: 3, fileIndex: 12 });
441 });
442
443 it("reads anything it did not write as no position at all", () => {
444 // Which starts the read from the top rather than failing it: a cursor is the client's to
445 // hand back, and nothing downstream is worth refusing a whole diff over.
446 for (const raw of [undefined, null, "", "abc", "1", "0:4", "1:-2", "1:2:3"]) {
447 expect(parseAzureDevOpsDiffCursor(raw)).toBeNull();
448 }
449 });
450
451 it("refuses a half it did not write rather than reading it as the first file", () => {
452 // `Number` is wider than the cursor: an empty, padded or hex half would otherwise pass as a
453 // position, and the read would resume against an iteration the client never saw instead of
454 // starting again from the latest one.
455 for (const raw of ["1:", ":4", "1: ", " 1:4", "1:0x2", "0x1:2", "1e2:0", "1:4.0"]) {
456 expect(parseAzureDevOpsDiffCursor(raw)).toBeNull();
457 }
458 });
459});
apps/server/src/pullRequest/azureDevOpsDiff.ts deleted-273
...@@ -1,273 +0,0 @@
1import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2import { structuredPatch } from "diff";
3
4import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts";
5
6/**
7 * How far a diff read got, and which push it was reading. Azure hangs a pull request's changed
8 * files off an iteration, so a push landing mid-read would renumber the list under a cursor that
9 * did not also pin the iteration.
10 */
11export interface AzureDevOpsDiffCursor {
12 readonly iterationId: number;
13 readonly fileIndex: number;
14}
15
16const CURSOR_SEPARATOR = ":";
17
18/**
19 * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as
20 * three.
21 */
22const CURSOR_COMPONENT = /^\d+$/;
23
24export function formatAzureDevOpsDiffCursor(cursor: AzureDevOpsDiffCursor): string {
25 return `${cursor.iterationId}${CURSOR_SEPARATOR}${cursor.fileIndex}`;
26}
27
28/** Null for anything this did not write, which starts the read from the top rather than failing. */
29export function parseAzureDevOpsDiffCursor(
30 raw: string | null | undefined,
31): AzureDevOpsDiffCursor | null {
32 if (raw === null || raw === undefined) return null;
33 const [iteration, file, ...rest] = raw.split(CURSOR_SEPARATOR);
34 if (rest.length > 0) return null;
35 if (iteration === undefined || file === undefined) return null;
36 if (!CURSOR_COMPONENT.test(iteration) || !CURSOR_COMPONENT.test(file)) return null;
37 const iterationId = Number(iteration);
38 const fileIndex = Number(file);
39 if (!Number.isSafeInteger(iterationId) || iterationId <= 0) return null;
40 if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return null;
41 return { iterationId, fileIndex };
42}
43
44/** The two texts of one changed file, empty on whichever side the change does not have. */
45export interface AzureDevOpsFileTexts {
46 readonly oldContents: string;
47 readonly newContents: string;
48 /** Azure's own flag for a file it hands back base64-encoded instead of as text. */
49 readonly binary: boolean;
50}
51
52export interface AzureDevOpsFilePatch {
53 readonly section: string;
54 /** The file changed but its hunks are not in the section, so the patch has a hole in it. */
55 readonly truncated: boolean;
56 /**
57 * The diff was given up on partway, having spent the whole edit budget, so a caller reading a
58 * run of files stops here rather than paying that again for each one behind it.
59 */
60 readonly abandoned: boolean;
61 /**
62 * Lines added or removed, which is the edit distance the diff had to search out, and what a
63 * slice's budget is spent in: a file of short lines is cheap on the wire and dear to diff.
64 */
65 readonly edits: number;
66}
67
68/**
69 * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather
70 * than a patch, so an oversize file is paid for twice over before anything can be diffed.
71 */
72const MAX_FILE_BYTES = 512 * 1024;
73
74/** Git's own default, and what the hunks from this repo's other hosts are already cut to. */
75const PATCH_CONTEXT_LINES = 3;
76
77/**
78 * How far apart one file's two sides may be before it is listed without its hunks. The line diff
79 * costs about the square of the edit distance, so an unbounded pair sharing almost nothing could
80 * hold the whole server. Bounded in edits rather than milliseconds so a change slices the same
81 * way on every machine.
82 */
83export const MAX_FILE_DIFF_EDITS = 2_000;
84
85/**
86 * A backstop for a machine slower than the edit ceiling was tuned for. The ceiling rather than
87 * this is what decides a patch's shape: a timeout that fires would slice the same change one way
88 * here and another on a busier host.
89 */
90const MAX_FILE_DIFF_MILLIS = 2_000;
91
92/** How much diff work one slice does before the rest is left for the next request. */
93export const MAX_DIFF_SLICE_EDITS = 6_000;
94
95/** How much patch one slice carries before the rest is left for the next request. */
96export const MAX_DIFF_SLICE_BYTES = 256 * 1024;
97
98/**
99 * How many files one slice carries however little each one weighs. A binary, oversize, or
100 * unreadable entry is just a header, so neither budget above stops a run of thousands of them.
101 */
102export const MAX_DIFF_SLICE_FILES = 300;
103
104/** Git's own note for a side whose last line has no newline after it. */
105const NO_NEWLINE_MARKER = "\\ No newline at end of file";
106
107/** A text's lines, without the empty one that a trailing newline leaves behind a split. */
108function contentLines(contents: string): ReadonlyArray<string> {
109 if (contents === "") return [];
110 const lines = contents.split("\n");
111 if (lines.at(-1) === "") lines.pop();
112 return lines;
113}
114
115/** A NUL byte is git's own test for it, and it survives Azure's JSON envelope intact. */
116function isBinary(contents: string): boolean {
117 return contents.includes("\u0000");
118}
119
120/**
121 * What a file costs on the wire, which is its bytes rather than its code units: a ceiling counted
122 * in characters lets a file of three-byte glyphs through at three times the intended size.
123 */
124export const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8");
125
126/**
127 * Git points an empty range at the line before it, which is line zero for a file that is wholly
128 * new or wholly gone, and writes a single line as its number alone.
129 */
130function hunkRange(start: number, lines: number): string {
131 if (lines === 0) return `${start - 1},0`;
132 return lines === 1 ? String(start) : `${start},${lines}`;
133}
134
135/**
136 * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file
137 * mode, so the ordinary one stands in. Names are quoted the way git quotes them, prefix inside the
138 * quoting: a header reader stops a bare name at its first tab or newline, so a path holding either
139 * would be truncated and its viewed mark would land on the wrong file.
140 */
141function patchHeader(change: AzureDevOpsChangeEntry): string {
142 const oldSide = quoteGitPatchPath(`a/${change.oldPath}`);
143 const newSide = quoteGitPatchPath(`b/${change.path}`);
144 const lines = [`diff --git ${oldSide} ${newSide}`];
145 if (change.changeKind === "new") lines.push("new file mode 100644");
146 if (change.changeKind === "deleted") lines.push("deleted file mode 100644");
147 if (change.changeKind === "rename-pure" || change.changeKind === "rename-changed") {
148 lines.push(
149 `rename from ${quoteGitPatchPath(change.oldPath)}`,
150 `rename to ${quoteGitPatchPath(change.path)}`,
151 );
152 }
153 lines.push(
154 `--- ${change.changeKind === "new" ? "/dev/null" : oldSide}`,
155 `+++ ${change.changeKind === "deleted" ? "/dev/null" : newSide}`,
156 );
157 return lines.join("\n");
158}
159
160/**
161 * A file written out as wholly replaced: every old line gone, every new line arrived, in one
162 * hunk, which needs no edit-distance search at all.
163 */
164function replacementSection(header: string, texts: AzureDevOpsFileTexts): string {
165 const oldLines = contentLines(texts.oldContents);
166 const newLines = contentLines(texts.newContents);
167 const noNewline = (contents: string, lines: ReadonlyArray<string>) =>
168 lines.length > 0 && !contents.endsWith("\n") ? [NO_NEWLINE_MARKER] : [];
169 return [
170 header,
171 `@@ -${hunkRange(1, oldLines.length)} +${hunkRange(1, newLines.length)} @@`,
172 ...oldLines.map((line) => `-${line}`),
173 ...noNewline(texts.oldContents, oldLines),
174 ...newLines.map((line) => `+${line}`),
175 ...noNewline(texts.newContents, newLines),
176 "",
177 ].join("\n");
178}
179
180/**
181 * One file's section of a unified patch, built here because Azure has no route that returns one:
182 * its diff routes name the files that changed, and the contents are a separate read per side.
183 */
184export function azureDevOpsFilePatch(input: {
185 readonly change: AzureDevOpsChangeEntry;
186 readonly texts: AzureDevOpsFileTexts;
187}): AzureDevOpsFilePatch {
188 const header = patchHeader(input.change);
189 const { oldContents, newContents } = input.texts;
190
191 if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) {
192 // Git's own wording for a file it will not spell out.
193 const oldSide = quoteGitPatchPath(`a/${input.change.oldPath}`);
194 const newSide = quoteGitPatchPath(`b/${input.change.path}`);
195 const binary = `Binary files ${oldSide} and ${newSide} differ`;
196 return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false, edits: 0 };
197 }
198 if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) {
199 return { section: `${header}\n`, truncated: true, abandoned: false, edits: 0 };
200 }
201
202 // A creation or deletion has nothing on one side, so writing both sides in full is already the
203 // minimal patch and needs no diff search.
204 const created = oldContents === "" && newContents !== "";
205 const deleted = newContents === "" && oldContents !== "";
206 if (created || deleted) {
207 const contents = created ? newContents : oldContents;
208 const lines = contentLines(contents);
209 // A `+`/`-` marker on every line can put a side that just fits the size ceiling over it.
210 // Checked against bytes-plus-marker-count rather than building the section, since that upper
211 // bound is cheaper and this is exactly the file shape that would cost the most to build.
212 if (byteLength(contents) + lines.length > MAX_FILE_BYTES) {
213 return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length };
214 }
215 const section = replacementSection(header, input.texts);
216 if (byteLength(section) > MAX_FILE_BYTES) {
217 return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length };
218 }
219 return { section, truncated: false, abandoned: false, edits: lines.length };
220 }
221
222 const patch = structuredPatch(
223 `a/${input.change.oldPath}`,
224 `b/${input.change.path}`,
225 oldContents,
226 newContents,
227 undefined,
228 undefined,
229 {
230 context: PATCH_CONTEXT_LINES,
231 maxEditLength: MAX_FILE_DIFF_EDITS,
232 timeout: MAX_FILE_DIFF_MILLIS,
233 },
234 );
235 // Hitting the edit ceiling lists the file without hunks rather than falling back to a full
236 // replacement: the ceiling is a distance, not a proportion, so a long file can reach it having
237 // changed in one small corner, and both sides in full would bury that corner in a wall of text.
238 if (patch === undefined) {
239 return {
240 section: `${header}\n`,
241 truncated: true,
242 abandoned: true,
243 edits: MAX_FILE_DIFF_EDITS,
244 };
245 }
246
247 let edits = 0;
248 const hunks = patch.hunks.map((hunk) => {
249 for (const line of hunk.lines) {
250 if (line.startsWith("+") || line.startsWith("-")) edits += 1;
251 }
252 return [
253 `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`,
254 ...hunk.lines,
255 ].join("\n");
256 });
257 // A pure rename has no hunks to give but is still listed, since dropping it would take the file
258 // out of the change altogether.
259 const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`;
260 // The edit ceiling bounds edit distance, not the hunks' size: a handful of very long lines plus
261 // context can still exceed the size ceiling even well inside the edit ceiling.
262 if (byteLength(section) > MAX_FILE_BYTES) {
263 return { section: `${header}\n`, truncated: true, abandoned: false, edits };
264 }
265 return { section, truncated: false, abandoned: false, edits };
266}
267
268/** A file listed without its hunks, for when the host would not hand one of its two sides over. */
269export function azureDevOpsUnreadableFilePatch(
270 change: AzureDevOpsChangeEntry,
271): AzureDevOpsFilePatch {
272 return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false, edits: 0 };
273}
apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts deleted-652
...@@ -1,652 +0,0 @@
1import * as Result from "effect/Result";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 decodeItemContentJson,
6 decodeIterationChangesJson,
7 decodeIterationsJson,
8 decodePullRequestJson,
9 decodePullRequestListJson,
10 decodeThreadsJson,
11 decodeViewerJson,
12} from "./azureDevOpsPullRequestJson.ts";
13
14const REST_URL =
15 "https://dev.azure.com/acme/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/pullRequests/42";
16
17/** Shaped after Azure's `GitPullRequest`, trimmed to the fields that are read. */
18function pullRequest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
19 return {
20 pullRequestId: 42,
21 title: "Add the change requests page",
22 description: "Ships the page.",
23 status: "active",
24 isDraft: false,
25 mergeStatus: "succeeded",
26 createdBy: { displayName: "Bilal Hassan", uniqueName: "bilal@acme.dev" },
27 sourceRefName: "refs/heads/feat/page",
28 targetRefName: "refs/heads/main",
29 creationDate: "2026-07-01T00:00:00Z",
30 url: REST_URL,
31 repository: { name: "web", project: { name: "platform" } },
32 ...overrides,
33 };
34}
35
36function expectSuccess<A>(result: Result.Result<A, unknown>): A {
37 expect(Result.isSuccess(result)).toBe(true);
38 if (!Result.isSuccess(result)) throw new Error("expected a successful decode");
39 return result.success;
40}
41
42const asJson = (value: unknown) => JSON.stringify(value);
43
44describe("decodePullRequestListJson", () => {
45 it("reads a pull request as a change request", () => {
46 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()])));
47
48 expect(batch.items).toHaveLength(1);
49 expect(batch.items[0]).toMatchObject({
50 number: 42,
51 title: "Add the change requests page",
52 // The login is an email, because that is what `az account show` reports to compare with.
53 author: { login: "bilal@acme.dev", name: "Bilal Hassan" },
54 // Azure prefixes its refs, which no other host does.
55 headBranch: "feat/page",
56 baseBranch: "main",
57 state: "open",
58 isDraft: false,
59 mergeability: "mergeable",
60 });
61 });
62
63 it("assembles a browser url when Azure reports no web link", () => {
64 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()])));
65
66 expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42");
67 });
68
69 it("prefers the web link Azure sends when asked for one", () => {
70 const batch = expectSuccess(
71 decodePullRequestListJson(
72 asJson([
73 pullRequest({
74 _links: {
75 web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" },
76 },
77 }),
78 ]),
79 ),
80 );
81
82 expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42");
83 });
84
85 it.each([
86 ["active", "open"],
87 ["completed", "merged"],
88 ["abandoned", "closed"],
89 ["something new", "open"],
90 ])("reads the %s status as %s", (status, expected) => {
91 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ status })])));
92
93 expect(batch.items[0]?.state).toBe(expected);
94 });
95
96 it.each([
97 ["succeeded", "mergeable"],
98 ["conflicts", "conflicting"],
99 ["rejectedByPolicy", "conflicting"],
100 ["queued", "unknown"],
101 ["notSet", "unknown"],
102 ])("reads the %s merge status as %s", (mergeStatus, expected) => {
103 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ mergeStatus })])));
104
105 expect(batch.items[0]?.mergeability).toBe(expected);
106 });
107
108 it("stands the closing time in for a last-touched time Azure does not keep", () => {
109 const batch = expectSuccess(
110 decodePullRequestListJson(
111 asJson([pullRequest({ status: "completed", closedDate: "2026-07-05T00:00:00Z" })]),
112 ),
113 );
114
115 expect(batch.items[0]).toMatchObject({
116 createdAt: "2026-07-01T00:00:00Z",
117 updatedAt: "2026-07-05T00:00:00Z",
118 });
119 });
120
121 it("skips a malformed row but still counts it, so paging does not stop early", () => {
122 const batch = expectSuccess(
123 decodePullRequestListJson(asJson([{ pullRequestId: "nope" }, pullRequest()])),
124 );
125
126 expect(batch.items).toHaveLength(1);
127 expect(batch.rawCount).toBe(2);
128 expect(batch.rawIndexes).toEqual([1]);
129 });
130});
131
132describe("decodePullRequestJson", () => {
133 it("reads reviewers as review requests", () => {
134 const detail = expectSuccess(
135 decodePullRequestJson(
136 asJson(
137 pullRequest({
138 reviewers: [{ displayName: "Julius", uniqueName: "julius@acme.dev", vote: 10 }],
139 }),
140 ),
141 ),
142 );
143
144 expect(detail?.reviewRequestLogins).toEqual(["julius@acme.dev"]);
145 expect(detail?.reviewers).toEqual([
146 { login: "julius@acme.dev", name: "Julius", avatarUrl: null },
147 ]);
148 });
149
150 it("reads auto-complete from whoever armed it, and its absence as nobody", () => {
151 const armed = expectSuccess(
152 decodePullRequestJson(
153 asJson(pullRequest({ autoCompleteSetBy: { displayName: "Bilal Hassan" } })),
154 ),
155 );
156 expect(armed?.autoMergeEnabled).toBe(true);
157
158 // Azure leaves the field out entirely rather than sending it empty, so its absence is the
159 // whole of what it says about auto-complete being off.
160 expect(expectSuccess(decodePullRequestJson(asJson(pullRequest())))?.autoMergeEnabled).toBe(
161 false,
162 );
163 });
164
165 it("keeps the strategy stored with auto-complete", () => {
166 const armed = expectSuccess(
167 decodePullRequestJson(
168 asJson(
169 pullRequest({
170 autoCompleteSetBy: { displayName: "Bilal Hassan" },
171 completionOptions: { mergeStrategy: "squash" },
172 }),
173 ),
174 ),
175 );
176
177 expect(armed).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" });
178
179 const unspecified = expectSuccess(
180 decodePullRequestJson(
181 asJson(
182 pullRequest({
183 autoCompleteSetBy: { displayName: "Bilal Hassan" },
184 completionOptions: { squashMerge: false },
185 }),
186 ),
187 ),
188 );
189 expect(unspecified?.autoMergeEnabled).toBe(true);
190 expect(unspecified?.autoMergeMethod).toBeUndefined();
191 });
192
193 it("works out where the repository lives from what Azure returned", () => {
194 const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest())));
195
196 expect(detail?.location).toEqual({ project: "platform", repository: "web" });
197 });
198
199 it("reports no repository location when Azure said too little to name one", () => {
200 // A web link places the pull request, but with no repository named there is nothing to
201 // address the routes that read its files and its conversation.
202 const detail = expectSuccess(
203 decodePullRequestJson(
204 asJson(
205 pullRequest({
206 url: null,
207 repository: null,
208 _links: {
209 web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" },
210 },
211 }),
212 ),
213 ),
214 );
215
216 expect(detail?.location).toBeNull();
217 });
218
219 it("returns nothing when Azure gave no way to place the pull request at all", () => {
220 const detail = expectSuccess(
221 decodePullRequestJson(asJson(pullRequest({ url: null, repository: null }))),
222 );
223
224 expect(detail).toBeNull();
225 });
226});
227
228describe("decodeViewerJson", () => {
229 it("reads the signed-in account name", () => {
230 expect(expectSuccess(decodeViewerJson(asJson({ user: { name: "bilal@acme.dev" } })))).toBe(
231 "bilal@acme.dev",
232 );
233 });
234
235 it("returns nothing when nobody is signed in", () => {
236 expect(expectSuccess(decodeViewerJson(asJson({ user: null })))).toBeNull();
237 });
238});
239
240describe("decodeThreadsJson", () => {
241 it("takes every real comment of every thread, oldest first", () => {
242 const comments = expectSuccess(
243 decodeThreadsJson(
244 asJson({
245 value: [
246 {
247 id: 2,
248 comments: [
249 {
250 id: 1,
251 content: "Second remark.",
252 author: { displayName: "Julius", uniqueName: "julius@acme.dev" },
253 publishedDate: "2026-07-03T00:00:00Z",
254 },
255 ],
256 },
257 {
258 id: 1,
259 comments: [
260 // Azure's own activity notes are events rather than remarks.
261 { id: 1, content: "Bilal voted", commentType: "system", publishedDate: "x" },
262 {
263 id: 2,
264 content: "First remark.",
265 author: { displayName: "Bilal", uniqueName: "bilal@acme.dev" },
266 publishedDate: "2026-07-02T00:00:00Z",
267 },
268 ],
269 },
270 ],
271 }),
272 ),
273 );
274
275 expect(comments.map((comment) => comment.body)).toEqual(["First remark.", "Second remark."]);
276 expect(comments[0]).toMatchObject({
277 kind: "issue-comment",
278 author: { login: "bilal@acme.dev" },
279 });
280 });
281
282 it("reads a thread pinned to a file as a review comment", () => {
283 const comments = expectSuccess(
284 decodeThreadsJson(
285 asJson({
286 value: [
287 {
288 id: 3,
289 threadContext: { filePath: "/src/app.ts" },
290 comments: [{ id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }],
291 },
292 ],
293 }),
294 ),
295 );
296
297 expect(comments[0]).toMatchObject({ kind: "review-comment", path: "/src/app.ts" });
298 });
299
300 it("keeps the replies under a thread, which are as much of the conversation", () => {
301 const comments = expectSuccess(
302 decodeThreadsJson(
303 asJson({
304 value: [
305 {
306 id: 4,
307 threadContext: { filePath: "/src/app.ts" },
308 comments: [
309 { id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" },
310 { id: 2, content: "Renamed.", publishedDate: "2026-07-02T01:00:00Z" },
311 { id: 3, content: "Thanks.", publishedDate: "2026-07-02T02:00:00Z" },
312 ],
313 },
314 ],
315 }),
316 ),
317 );
318
319 expect(comments.map((comment) => comment.id)).toEqual(["4:1", "4:2", "4:3"]);
320 });
321
322 it("drops deleted threads and threads with nothing to show", () => {
323 const comments = expectSuccess(
324 decodeThreadsJson(
325 asJson({
326 value: [
327 {
328 id: 1,
329 isDeleted: true,
330 comments: [{ id: 1, content: "gone", publishedDate: "2026-07-02T00:00:00Z" }],
331 },
332 { id: 2, comments: [] },
333 {
334 id: 3,
335 comments: [{ id: 1, content: " ", publishedDate: "2026-07-02T00:00:00Z" }],
336 },
337 ],
338 }),
339 ),
340 );
341
342 expect(comments).toEqual([]);
343 });
344});
345
346describe("decodeIterationsJson", () => {
347 const iteration = (id: number, head: string, base: string) => ({
348 id,
349 sourceRefCommit: { commitId: head },
350 commonRefCommit: { commitId: base },
351 targetRefCommit: { commitId: base },
352 });
353
354 it("reads every push in order, oldest first", () => {
355 const iterations = expectSuccess(
356 decodeIterationsJson(
357 asJson({ value: [iteration(2, "bbb", "base"), iteration(1, "aaa", "base")] }),
358 ),
359 );
360
361 expect(iterations.map((entry) => entry.id)).toEqual([1, 2]);
362 expect(iterations.at(-1)).toEqual({ id: 2, headCommit: "bbb", mergeBaseCommit: "base" });
363 });
364
365 it("skips a push Azure could not place both ends of", () => {
366 // A patch is taken over a range, and an iteration missing either end names no range at all.
367 const iterations = expectSuccess(
368 decodeIterationsJson(
369 asJson({
370 value: [{ id: 1, sourceRefCommit: { commitId: "aaa" } }, iteration(2, "bbb", "base")],
371 }),
372 ),
373 );
374
375 expect(iterations.map((entry) => entry.id)).toEqual([2]);
376 });
377});
378
379describe("decodeIterationChangesJson", () => {
380 it("names each changed file without the slash Azure leads its paths with", () => {
381 const page = expectSuccess(
382 decodeIterationChangesJson(
383 asJson({
384 changeEntries: [
385 { changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } },
386 {
387 changeType: "edit",
388 item: { path: "/README.md", objectId: "8f80", originalObjectId: "0ca4" },
389 },
390 { changeType: "delete", item: { path: "/OLD.md", originalObjectId: "1111" } },
391 ],
392 }),
393 ),
394 );
395
396 expect(page.changes.map((change) => [change.path, change.changeKind])).toEqual([
397 ["DEMO.md", "new"],
398 ["README.md", "change"],
399 ["OLD.md", "deleted"],
400 ]);
401 });
402
403 it("keeps a space at the end of a file's name, which belongs to the name", () => {
404 // Git will carry a name that ends in a space, and the patch and the viewed mark are both
405 // keyed by it. Tidying it here files the change under a name nothing else uses.
406 const page = expectSuccess(
407 decodeIterationChangesJson(
408 asJson({
409 changeEntries: [
410 { changeType: "edit", item: { path: "/docs/readme.md ", objectId: "ec00" } },
411 {
412 changeType: "rename",
413 sourceServerItem: "/docs/old.md ",
414 item: { path: "/docs/moved.md", objectId: "aaaa", originalObjectId: "aaaa" },
415 },
416 ],
417 }),
418 ),
419 );
420
421 expect(page.changes.map((change) => [change.path, change.oldPath])).toEqual([
422 ["docs/readme.md ", "docs/readme.md "],
423 ["docs/moved.md", "docs/old.md "],
424 ]);
425 });
426
427 it("reads a rename as one file that moved, and says whether it also changed", () => {
428 const page = expectSuccess(
429 decodeIterationChangesJson(
430 asJson({
431 changeEntries: [
432 {
433 changeType: "rename",
434 sourceServerItem: "/docs/old.md",
435 item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" },
436 },
437 {
438 changeType: "edit, rename",
439 sourceServerItem: "/src/old.ts",
440 item: { path: "/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" },
441 },
442 ],
443 }),
444 ),
445 );
446
447 expect(page.changes).toEqual([
448 {
449 path: "docs/new.md",
450 oldPath: "docs/old.md",
451 changeKind: "rename-pure",
452 objectId: "aaaa",
453 originalObjectId: "aaaa",
454 },
455 {
456 path: "src/new.ts",
457 oldPath: "src/old.ts",
458 changeKind: "rename-changed",
459 objectId: "bbbb",
460 originalObjectId: "cccc",
461 },
462 ]);
463 });
464
465 it("drops the folders Azure lists alongside the files that changed", () => {
466 // A review shows files, and a folder has no content on either side to show for one.
467 const page = expectSuccess(
468 decodeIterationChangesJson(
469 asJson({
470 changeEntries: [
471 { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } },
472 { changeType: "add", item: { path: "/docs/page.md", objectId: "dddd" } },
473 ],
474 }),
475 ),
476 );
477
478 expect(page.changes.map((change) => change.path)).toEqual(["docs/page.md"]);
479 });
480
481 it("carries where the next page of a long change starts", () => {
482 const page = expectSuccess(
483 decodeIterationChangesJson(
484 asJson({
485 changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }],
486 nextSkip: 2000,
487 }),
488 ),
489 );
490
491 expect(page.nextSkip).toBe(2000);
492 });
493
494 it("reads the last page, which names no page after it, as the end of the change", () => {
495 const page = expectSuccess(
496 decodeIterationChangesJson(
497 asJson({
498 changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }],
499 }),
500 ),
501 );
502
503 expect(page.nextSkip).toBeNull();
504 });
505
506 it("reads where a rename came from out of either of the two places Azure names it", () => {
507 // The iteration-changes route answers with `originalPath`; the commit routes answer with
508 // `sourceServerItem`, and both are the same fact under two names.
509 const page = expectSuccess(
510 decodeIterationChangesJson(
511 asJson({
512 changeEntries: [
513 {
514 changeType: "rename",
515 originalPath: "/docs/old.md",
516 item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" },
517 },
518 ],
519 }),
520 ),
521 );
522
523 expect(page.changes.at(0)?.oldPath).toBe("docs/old.md");
524 });
525});
526
527describe("decodeItemContentJson", () => {
528 it("reads the file's text out of the envelope Azure wraps it in", () => {
529 expect(
530 expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))),
531 ).toEqual({ contents: "one\ntwo", isBinary: false });
532 });
533
534 it("reads an empty file as empty rather than as a failure to look", () => {
535 expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toEqual({
536 contents: "",
537 isBinary: false,
538 });
539 });
540
541 it("keeps Azure's own word that a file is binary", () => {
542 // Which it answers base64-encoded, so nothing in the text it sent would give it away.
543 expect(
544 expectSuccess(
545 decodeItemContentJson(
546 asJson({ path: "/logo.png", content: "b2xk", contentMetadata: { isBinary: true } }),
547 ),
548 ),
549 ).toEqual({ contents: "b2xk", isBinary: true });
550 });
551});
552
553/**
554 * Every other fixture in this file is written from Azure's published contract. These are the
555 * shapes a real organisation answered with, read back through `az devops invoke` rather than
556 * `az rest`: the extension hands over the route's own body with one key of its own added, and a
557 * live repository leaves out fields the contract documents.
558 */
559describe("what az devops invoke answers with", () => {
560 it("reads the envelope the extension adds its own continuation token to", () => {
561 // Set on every JSON body it returns, from a response header these routes do not send, so it
562 // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode.
563 expect(
564 expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))),
565 ).toEqual([]);
566
567 expect(
568 expectSuccess(
569 decodeIterationsJson(
570 asJson({
571 count: 1,
572 continuation_token: null,
573 value: [
574 {
575 id: 1,
576 sourceRefCommit: { commitId: "f4031105213c68f197cd46ea303bb5e72acc889a" },
577 commonRefCommit: { commitId: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35" },
578 },
579 ],
580 }),
581 ),
582 ),
583 ).toEqual([
584 {
585 id: 1,
586 headCommit: "f4031105213c68f197cd46ea303bb5e72acc889a",
587 mergeBaseCommit: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35",
588 },
589 ]);
590 });
591
592 it("keeps the files of a change that names neither their object type nor their page after it", () => {
593 // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no
594 // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. Each of those absences
595 // is what the defaults in the decoder are for, and reading any of them as stated would drop
596 // every file of every Azure change request.
597 const page = expectSuccess(
598 decodeIterationChangesJson(
599 asJson({
600 continuation_token: null,
601 changeEntries: [
602 { changeType: "add", item: { path: "/DEMO.md", objectId: "EC005DB24" } },
603 { changeType: "edit", item: { path: "/README.md", objectId: "8F8047A49" } },
604 ],
605 }),
606 ),
607 );
608
609 expect(page.nextSkip).toBeNull();
610 expect(page.changes).toEqual([
611 {
612 path: "DEMO.md",
613 oldPath: "DEMO.md",
614 changeKind: "new",
615 objectId: "EC005DB24",
616 originalObjectId: null,
617 },
618 {
619 path: "README.md",
620 oldPath: "README.md",
621 changeKind: "change",
622 objectId: "8F8047A49",
623 originalObjectId: null,
624 },
625 ]);
626 });
627
628 it("reads a text file whose content type Azure calls a stream", () => {
629 // `contentMetadata` comes back for a markdown file with `application/octet-stream` on it and
630 // no `isBinary` at all, so the content type is not the field to ask, and its absence is the
631 // answer that the text is text.
632 expect(
633 expectSuccess(
634 decodeItemContentJson(
635 asJson({
636 path: "/README.md",
637 objectId: "8f8047a49",
638 gitObjectType: "blob",
639 content: "# T3Demo\n",
640 contentMetadata: {
641 contentType: "application/octet-stream",
642 encoding: 65001,
643 extension: "md",
644 fileName: "README.md",
645 },
646 continuation_token: null,
647 }),
648 ),
649 ),
650 ).toEqual({ contents: "# T3Demo\n", isBinary: false });
651 });
652});
apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts deleted-574
...@@ -1,574 +0,0 @@
1import * as Cause from "effect/Cause";
2import * as Exit from "effect/Exit";
3import * as Result from "effect/Result";
4import * as Schema from "effect/Schema";
5import type {
6 PullRequestActor,
7 PullRequestComment,
8 PullRequestMergeMethod,
9 PullRequestMergeability,
10 PullRequestState,
11} from "@t3tools/contracts";
12import { TrimmedNonEmptyString } from "@t3tools/contracts";
13import { decodeJsonResult } from "@t3tools/shared/schemaJson";
14
15import { azureDevOpsPullRequestWebUrl } from "../sourceControl/azureDevOpsPullRequests.ts";
16
17/**
18 * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as
19 * the other hosts: a new merge status must not fail a whole payload. Every field beyond the
20 * identity is optional, because `az repos pr` returns rather more or less of the REST object
21 * depending on the command.
22 */
23const RawIdentitySchema = Schema.Struct({
24 displayName: Schema.optional(Schema.NullOr(Schema.String)),
25 /** An email or UPN, which is what `az account show` reports for the signed-in user. */
26 uniqueName: Schema.optional(Schema.NullOr(Schema.String)),
27 imageUrl: Schema.optional(Schema.NullOr(Schema.String)),
28});
29
30const RawPullRequestSchema = Schema.Struct({
31 pullRequestId: Schema.Int,
32 title: Schema.String,
33 description: Schema.optional(Schema.NullOr(Schema.String)),
34 status: Schema.optional(Schema.NullOr(Schema.String)),
35 isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)),
36 /**
37 * Who armed auto-complete, which is the only thing Azure says about it: the field carries an
38 * identity while the pull request is set to complete on its own, and Azure leaves it out
39 * entirely once nobody has. So its presence is the answer, and there is no third state.
40 */
41 autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)),
42 completionOptions: Schema.optional(
43 Schema.NullOr(
44 Schema.Struct({
45 mergeStrategy: Schema.optional(Schema.NullOr(Schema.String)),
46 squashMerge: Schema.optional(Schema.NullOr(Schema.Boolean)),
47 }),
48 ),
49 ),
50 mergeStatus: Schema.optional(Schema.NullOr(Schema.String)),
51 createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)),
52 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))),
53 // Required, and required to be non-empty: the wire contract will not carry a change request
54 // without a branch or a created time, so a row missing one is skipped rather than breaking the
55 // response it travels in.
56 sourceRefName: TrimmedNonEmptyString,
57 targetRefName: TrimmedNonEmptyString,
58 creationDate: TrimmedNonEmptyString,
59 closedDate: Schema.optional(Schema.NullOr(Schema.String)),
60 url: Schema.optional(Schema.NullOr(Schema.String)),
61 repository: Schema.optional(
62 Schema.NullOr(
63 Schema.Struct({
64 name: Schema.optional(Schema.NullOr(Schema.String)),
65 webUrl: Schema.optional(Schema.NullOr(Schema.String)),
66 project: Schema.optional(
67 Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })),
68 ),
69 }),
70 ),
71 ),
72 _links: Schema.optional(
73 Schema.NullOr(
74 Schema.Struct({
75 web: Schema.optional(
76 Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })),
77 ),
78 }),
79 ),
80 ),
81});
82
83/** A pull request thread, which is how Azure keeps its conversation. */
84const RawThreadSchema = Schema.Struct({
85 id: Schema.Int,
86 isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)),
87 threadContext: Schema.optional(
88 Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })),
89 ),
90 comments: Schema.optional(
91 Schema.NullOr(
92 Schema.Array(
93 Schema.Struct({
94 id: Schema.optional(Schema.NullOr(Schema.Int)),
95 content: Schema.optional(Schema.NullOr(Schema.String)),
96 author: Schema.optional(Schema.NullOr(RawIdentitySchema)),
97 publishedDate: Schema.optional(Schema.NullOr(Schema.String)),
98 isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)),
99 /** `system` marks the notes Azure writes itself, which are events, not comments. */
100 commentType: Schema.optional(Schema.NullOr(Schema.String)),
101 }),
102 ),
103 ),
104 ),
105});
106
107const RawThreadPageSchema = Schema.Struct({
108 value: Schema.Array(Schema.Unknown),
109});
110
111const RawViewerSchema = Schema.Struct({
112 user: Schema.optional(
113 Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })),
114 ),
115});
116
117/**
118 * Where a repository lives, in the terms Azure's REST routes address it by. They take the project
119 * and the repository as separate route parameters rather than as one path, so the pair travels
120 * together rather than as a URL that would have to be taken apart again to use.
121 */
122export interface AzureDevOpsRepositoryLocation {
123 readonly project: string;
124 readonly repository: string;
125}
126
127export interface AzureDevOpsPullRequest {
128 readonly number: number;
129 readonly title: string;
130 readonly url: string;
131 readonly author: PullRequestActor | null;
132 readonly headBranch: string;
133 readonly baseBranch: string;
134 readonly state: PullRequestState;
135 readonly isDraft: boolean;
136 readonly mergeability: PullRequestMergeability;
137 readonly createdAt: string;
138 /**
139 * Azure records no last-touched time on a pull request, so the closing time stands in where
140 * there is one and the creation time otherwise. The same fallback the rest of the app uses.
141 */
142 readonly updatedAt: string;
143 readonly closedAt: string | null;
144 readonly body: string;
145 readonly reviewRequestLogins: ReadonlyArray<string>;
146 readonly reviewers: ReadonlyArray<PullRequestActor>;
147 /** Where this pull request lives, when Azure said enough to work it out. */
148 readonly location: AzureDevOpsRepositoryLocation | null;
149 /** Whether Azure is set to complete this on its own once its policies pass. */
150 readonly autoMergeEnabled: boolean;
151 /** The completion strategy Azure stored with auto-complete, where it reported one. */
152 readonly autoMergeMethod?: PullRequestMergeMethod;
153}
154
155function trimmed(value: string | null | undefined): string | null {
156 const text = value?.trim() ?? "";
157 return text.length > 0 ? text : null;
158}
159
160function normalizeRefName(refName: string): string {
161 return refName.trim().replace(/^refs\/heads\//, "");
162}
163
164/** A login has to compare against `az account show`, which reports an email. */
165function toActor(raw: Schema.Schema.Type<typeof RawIdentitySchema> | null | undefined) {
166 const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName);
167 return login === null
168 ? null
169 : { login, name: trimmed(raw?.displayName), avatarUrl: trimmed(raw?.imageUrl) };
170}
171
172function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState {
173 switch (raw.status?.trim().toLowerCase()) {
174 case "completed":
175 return "merged";
176 case "abandoned":
177 return "closed";
178 default:
179 return "open";
180 }
181}
182
183function toMergeability(value: string | null | undefined): PullRequestMergeability {
184 switch (value?.trim().toLowerCase()) {
185 case "succeeded":
186 return "mergeable";
187 case "conflicts":
188 case "failure":
189 case "rejectedbypolicy":
190 return "conflicting";
191 default:
192 // `queued` and `notSet` mean Azure has not finished checking.
193 return "unknown";
194 }
195}
196
197/**
198 * Where a pull request's own repository sits. Taken from what Azure returned rather than from the
199 * local remote, whose shape differs between the modern, legacy and SSH forms.
200 */
201function toLocation(
202 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
203): AzureDevOpsRepositoryLocation | null {
204 const project = trimmed(raw.repository?.project?.name);
205 const repository = trimmed(raw.repository?.name);
206 if (project === null || repository === null) return null;
207 return { project, repository };
208}
209
210function toAutoMergeMethod(
211 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
212): PullRequestMergeMethod | undefined {
213 if (raw.autoCompleteSetBy == null) return undefined;
214 switch (raw.completionOptions?.mergeStrategy?.trim().toLowerCase()) {
215 case "squash":
216 return "squash";
217 case "rebase":
218 case "rebasemerge":
219 return "rebase";
220 case "nofastforward":
221 return "merge";
222 default:
223 return raw.completionOptions?.squashMerge === true ? "squash" : undefined;
224 }
225}
226
227/**
228 * Null when Azure said too little to place the pull request: a row with no browser url and no
229 * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract
230 * refuses to carry it either.
231 */
232function toPullRequest(
233 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
234): AzureDevOpsPullRequest | null {
235 const autoMergeMethod = toAutoMergeMethod(raw);
236 const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => {
237 const actor = toActor(reviewer);
238 return actor === null ? [] : [actor];
239 });
240 const closedAt = trimmed(raw.closedDate);
241 const url = trimmed(
242 azureDevOpsPullRequestWebUrl({
243 pullRequestId: raw.pullRequestId,
244 webLink: raw._links?.web?.href,
245 repositoryWebUrl: raw.repository?.webUrl,
246 restApiUrl: raw.url,
247 projectName: raw.repository?.project?.name,
248 repositoryName: raw.repository?.name,
249 }),
250 );
251 const headBranch = trimmed(normalizeRefName(raw.sourceRefName));
252 const baseBranch = trimmed(normalizeRefName(raw.targetRefName));
253 if (url === null || headBranch === null || baseBranch === null) return null;
254 return {
255 number: raw.pullRequestId,
256 title: raw.title,
257 url,
258 author: toActor(raw.createdBy),
259 headBranch,
260 baseBranch,
261 state: toState(raw),
262 isDraft: raw.isDraft ?? false,
263 mergeability: toMergeability(raw.mergeStatus),
264 createdAt: raw.creationDate,
265 updatedAt: closedAt ?? raw.creationDate,
266 closedAt,
267 body: raw.description ?? "",
268 reviewRequestLogins: reviewers.map((reviewer) => reviewer.login),
269 reviewers,
270 location: toLocation(raw),
271 autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null,
272 ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }),
273 };
274}
275
276const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));
277const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema);
278const decodePullRequest = decodeJsonResult(RawPullRequestSchema);
279const decodeThreadPage = decodeJsonResult(RawThreadPageSchema);
280const decodeThreadEntry = Schema.decodeUnknownExit(RawThreadSchema);
281const decodeViewer = decodeJsonResult(RawViewerSchema);
282
283type DecodeFailure = Cause.Cause<Schema.SchemaError>;
284
285export interface AzureDevOpsPullRequestBatch {
286 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
287 /** Zero-based positions of the decoded items in Azure's raw page. */
288 readonly rawIndexes: ReadonlyArray<number>;
289 /** Rows Azure returned, counted before decoding, so a skipped row cannot hide a next page. */
290 readonly rawCount: number;
291}
292
293/** Malformed entries are skipped rather than failing the batch, as on the other hosts. */
294export function decodePullRequestListJson(
295 raw: string,
296): Result.Result<AzureDevOpsPullRequestBatch, DecodeFailure> {
297 const decoded = decodeUnknownList(raw);
298 if (!Result.isSuccess(decoded)) {
299 return Result.fail(decoded.failure);
300 }
301 const items: AzureDevOpsPullRequest[] = [];
302 const rawIndexes: number[] = [];
303 for (const [rawIndex, entry] of decoded.success.entries()) {
304 const item = decodePullRequestEntry(entry);
305 if (Exit.isFailure(item)) continue;
306 const pullRequest = toPullRequest(item.value);
307 if (pullRequest !== null) {
308 items.push(pullRequest);
309 rawIndexes.push(rawIndex);
310 }
311 }
312 return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length });
313}
314
315/** Null carries "Azure answered, but with too little to use", which the caller reports. */
316export function decodePullRequestJson(
317 raw: string,
318): Result.Result<AzureDevOpsPullRequest | null, DecodeFailure> {
319 const decoded = decodePullRequest(raw);
320 return Result.isSuccess(decoded)
321 ? Result.succeed(toPullRequest(decoded.success))
322 : Result.fail(decoded.failure);
323}
324
325/** `az account show --query user` reports the signed-in account, whose name is an email. */
326export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> {
327 const decoded = decodeViewer(raw);
328 return Result.isSuccess(decoded)
329 ? Result.succeed(trimmed(decoded.success.user?.name))
330 : Result.fail(decoded.failure);
331}
332
333/**
334 * Azure keeps its conversation as threads of comments, and every one of them is a remark
335 * somebody wrote: a reply under a thread is as much of the conversation as the line that opened
336 * it. A thread pinned to a file is a line-level review comment.
337 *
338 * Azure answers the whole thread collection in one response, with no cursor and no page to
339 * follow, so what this returns is everything the host has.
340 */
341export function decodeThreadsJson(
342 raw: string,
343): Result.Result<ReadonlyArray<PullRequestComment>, DecodeFailure> {
344 const decoded = decodeThreadPage(raw);
345 if (!Result.isSuccess(decoded)) {
346 return Result.fail(decoded.failure);
347 }
348 const comments: PullRequestComment[] = [];
349 for (const entry of decoded.success.value) {
350 const decodedThread = decodeThreadEntry(entry);
351 if (Exit.isFailure(decodedThread)) continue;
352 const thread = decodedThread.value;
353 if (thread.isDeleted === true) continue;
354 const path = trimmed(thread.threadContext?.filePath);
355 for (const comment of thread.comments ?? []) {
356 const publishedDate = trimmed(comment.publishedDate);
357 if (
358 comment.isDeleted === true ||
359 comment.commentType?.trim().toLowerCase() === "system" ||
360 (comment.content ?? "").trim().length === 0 ||
361 publishedDate === null
362 ) {
363 continue;
364 }
365 comments.push({
366 id: `${thread.id}:${comment.id ?? 0}`,
367 kind: path === null ? "issue-comment" : "review-comment",
368 author: toActor(comment.author),
369 body: comment.content ?? "",
370 createdAt: publishedDate,
371 url: null,
372 path,
373 reviewState: null,
374 });
375 }
376 }
377 return Result.succeed(
378 comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)),
379 );
380}
381
382/**
383 * One push's worth of a pull request. Azure records every push as an iteration and keys the whole
384 * review off them: the changed files, and the marks a reader leaves on those files, both hang
385 * from an iteration rather than from the pull request.
386 */
387const RawIterationSchema = Schema.Struct({
388 id: Schema.Int,
389 sourceRefCommit: Schema.optional(
390 Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })),
391 ),
392 commonRefCommit: Schema.optional(
393 Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })),
394 ),
395});
396
397const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) });
398
399const RawChangeEntrySchema = Schema.Struct({
400 changeType: Schema.optional(Schema.NullOr(Schema.String)),
401 sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)),
402 /** Where a renamed file came from. Azure states it here on an iteration's changes. */
403 originalPath: Schema.optional(Schema.NullOr(Schema.String)),
404 item: Schema.optional(
405 Schema.NullOr(
406 Schema.Struct({
407 path: Schema.optional(Schema.NullOr(Schema.String)),
408 objectId: Schema.optional(Schema.NullOr(Schema.String)),
409 originalObjectId: Schema.optional(Schema.NullOr(Schema.String)),
410 /** Azure marks a directory this way; a review has nothing to show for one. */
411 isFolder: Schema.optional(Schema.NullOr(Schema.Boolean)),
412 gitObjectType: Schema.optional(Schema.NullOr(Schema.String)),
413 }),
414 ),
415 ),
416});
417
418const RawChangePageSchema = Schema.Struct({
419 changeEntries: Schema.Array(Schema.Unknown),
420 /** Where the page after this one starts. Azure leaves it out on the last page. */
421 nextSkip: Schema.optional(Schema.NullOr(Schema.Number)),
422});
423
424const RawItemContentSchema = Schema.Struct({
425 content: Schema.optional(Schema.NullOr(Schema.String)),
426 /** What Azure makes of the file it is handing over, which is where it says it is not text. */
427 contentMetadata: Schema.optional(
428 Schema.NullOr(Schema.Struct({ isBinary: Schema.optional(Schema.NullOr(Schema.Boolean)) })),
429 ),
430});
431
432/** The head and the merge base of one iteration, which is the range its patch is taken over. */
433export interface AzureDevOpsIteration {
434 readonly id: number;
435 readonly headCommit: string;
436 readonly mergeBaseCommit: string;
437}
438
439/**
440 * What one file did across an iteration. `oldPath` differs from `path` only for a rename, which
441 * Azure reports by naming the file's previous home rather than as a delete and an add.
442 */
443export interface AzureDevOpsChangeEntry {
444 readonly path: string;
445 readonly oldPath: string;
446 readonly changeKind: "new" | "deleted" | "change" | "rename-pure" | "rename-changed";
447 readonly objectId: string | null;
448 readonly originalObjectId: string | null;
449}
450
451/**
452 * One page of what an iteration changed, and where the next one starts. Azure pages this route
453 * rather than answering with the whole change, so a review large enough to be paged is followed
454 * to its end instead of being cut off at the first page's worth.
455 */
456export interface AzureDevOpsChangePage {
457 readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>;
458 readonly nextSkip: number | null;
459}
460
461/** One file's text at one commit, and whether Azure says the text is text at all. */
462export interface AzureDevOpsItemContent {
463 readonly contents: string;
464 readonly isBinary: boolean;
465}
466
467const decodeIterationPage = decodeJsonResult(RawIterationPageSchema);
468const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema);
469const decodeChangePage = decodeJsonResult(RawChangePageSchema);
470const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema);
471const decodeItemContent = decodeJsonResult(RawItemContentSchema);
472
473/**
474 * Azure leads a path with a slash that every other host and every patch omits. Not trimmed like
475 * the rest of this payload, since a leading/trailing space is a legal part of a file's name and
476 * the patch and viewed mark are keyed by the untrimmed path.
477 */
478function toRepositoryPath(value: string | null | undefined): string | null {
479 if (value === undefined || value === null) return null;
480 const path = value.replace(/^\/+/, "");
481 return path.length === 0 ? null : path;
482}
483
484/**
485 * Azure names a change with one word or two, and a rename arrives either alone or alongside the
486 * edit that came with it. Anything it has added since reads as a plain change, which shows the
487 * file rather than dropping it from the review.
488 */
489function toChangeKind(
490 raw: string | null | undefined,
491 renamed: boolean,
492): AzureDevOpsChangeEntry["changeKind"] {
493 const parts = new Set(
494 (raw ?? "")
495 .toLowerCase()
496 .split(",")
497 .map((part) => part.trim())
498 .filter((part) => part.length > 0),
499 );
500 if (parts.has("delete")) return "deleted";
501 if (renamed || parts.has("rename")) return parts.has("edit") ? "rename-changed" : "rename-pure";
502 if (parts.has("add")) return "new";
503 return "change";
504}
505
506export function decodeIterationsJson(
507 raw: string,
508): Result.Result<ReadonlyArray<AzureDevOpsIteration>, DecodeFailure> {
509 const decoded = decodeIterationPage(raw);
510 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
511 const iterations: AzureDevOpsIteration[] = [];
512 for (const entry of decoded.success.value) {
513 const decodedIteration = decodeIterationEntry(entry);
514 if (Exit.isFailure(decodedIteration)) continue;
515 const iteration = decodedIteration.value;
516 const headCommit = trimmed(iteration.sourceRefCommit?.commitId);
517 const mergeBaseCommit = trimmed(iteration.commonRefCommit?.commitId);
518 // An iteration Azure cannot place both ends of names no range, and a patch needs both.
519 if (headCommit === null || mergeBaseCommit === null) continue;
520 iterations.push({ id: iteration.id, headCommit, mergeBaseCommit });
521 }
522 return Result.succeed(iterations.toSorted((left, right) => left.id - right.id));
523}
524
525export function decodeIterationChangesJson(
526 raw: string,
527): Result.Result<AzureDevOpsChangePage, DecodeFailure> {
528 const decoded = decodeChangePage(raw);
529 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
530 const changes: AzureDevOpsChangeEntry[] = [];
531 for (const entry of decoded.success.changeEntries) {
532 const decodedChange = decodeChangeEntry(entry);
533 if (Exit.isFailure(decodedChange)) continue;
534 const change = decodedChange.value;
535 const path = toRepositoryPath(change.item?.path);
536 if (path === null) continue;
537 // Azure lists the folders a change touched alongside the files themselves. A review shows
538 // files, and a folder has no content to show for either side of one.
539 if (change.item?.isFolder === true) continue;
540 if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue;
541 // Azure names where a renamed file came from in either of two places depending on the route
542 // and the version, so both are read and the current path stands in when neither is there.
543 const oldPath =
544 toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path;
545 changes.push({
546 path,
547 oldPath,
548 changeKind: toChangeKind(change.changeType, oldPath !== path),
549 objectId: trimmed(change.item?.objectId),
550 originalObjectId: trimmed(change.item?.originalObjectId),
551 });
552 }
553 const nextSkip = decoded.success.nextSkip ?? null;
554 return Result.succeed({
555 changes,
556 nextSkip: nextSkip !== null && Number.isSafeInteger(nextSkip) && nextSkip > 0 ? nextSkip : null,
557 });
558}
559
560/**
561 * Azure answers an absent file with an empty body rather than an error, which reads as empty.
562 * Whether the bytes are text is Azure's own call, not this decoder's to guess from content.
563 */
564export function decodeItemContentJson(
565 raw: string,
566): Result.Result<AzureDevOpsItemContent, DecodeFailure> {
567 const decoded = decodeItemContent(raw);
568 return Result.isSuccess(decoded)
569 ? Result.succeed({
570 contents: decoded.success.content ?? "",
571 isBinary: decoded.success.contentMetadata?.isBinary === true,
572 })
573 : Result.fail(decoded.failure);
574}
apps/server/src/server.ts-2
...@@ -52,7 +52,6 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts";...@@ -52,7 +52,6 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts";
52import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";52import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
53import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";53import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
54import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";54import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
55import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
56import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";55import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
57import * as GitHubApi from "./sourceControl/GitHubApi.ts";56import * as GitHubApi from "./sourceControl/GitHubApi.ts";
58import * as GitLabCli from "./sourceControl/GitLabCli.ts";57import * as GitLabCli from "./sourceControl/GitLabCli.ts";
...@@ -301,7 +300,6 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(Layer.provide(VcsPro...@@ -301,7 +300,6 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(Layer.provide(VcsPro
301const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.pipe(300const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.pipe(
302 Layer.provide(301 Layer.provide(
303 Layer.mergeAll(302 Layer.mergeAll(
304 AzureDevOpsCli.layer,
305 BitbucketApi.layer,303 BitbucketApi.layer,
306 GitHubApi.layerWithDependencies,304 GitHubApi.layerWithDependencies,
307 GitLabCli.layer,305 GitLabCli.layer,
apps/server/src/sourceControl/AzureDevOpsCli.test.ts deleted-448
...@@ -1,448 +0,0 @@
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 * as VcsProcess from "../vcs/VcsProcess.ts";
12import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
13
14const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
15 exitCode: ChildProcessSpawner.ExitCode(0),
16 stdout,
17 stderr: "",
18 stdoutTruncated: false,
19 stderrTruncated: false,
20});
21
22const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();
23
24const layerSupport = Layer.mergeAll(
25 Layer.mock(VcsProcess.VcsProcess)({
26 run: mockRun,
27 }),
28 NodeServices.layer,
29);
30const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(layerSupport)), layerSupport);
31
32afterEach(() => {
33 mockRun.mockReset();
34});
35
36describe("AzureDevOpsCli.layer", () => {
37 it.effect("parses pull request view output", () =>
38 Effect.gen(function* () {
39 mockRun.mockReturnValueOnce(
40 Effect.succeed(
41 processOutput(
42 JSON.stringify({
43 pullRequestId: 42,
44 title: "Add Azure provider",
45 sourceRefName: "refs/heads/feature/source-control",
46 targetRefName: "refs/heads/main",
47 status: "active",
48 creationDate: "2026-01-02T00:00:00.000Z",
49 closedDate: null,
50 _links: {
51 web: {
52 href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
53 },
54 },
55 }),
56 ),
57 ),
58 );
59
60 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
61 const result = yield* az.getPullRequest({
62 cwd: "/repo",
63 reference: "#42",
64 });
65
66 assert.strictEqual(result.number, 42);
67 assert.strictEqual(result.title, "Add Azure provider");
68 assert.strictEqual(result.url, "https://dev.azure.com/acme/project/_git/repo/pullrequest/42");
69 assert.strictEqual(result.baseRefName, "main");
70 assert.strictEqual(result.headRefName, "feature/source-control");
71 assert.strictEqual(result.state, "open");
72 assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag);
73 assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], {
74 operation: "AzureDevOpsCli.execute",
75 command: "az",
76 args: [
77 "repos",
78 "pr",
79 "show",
80 "--detect",
81 "true",
82 "--id",
83 "42",
84 "--only-show-errors",
85 "--output",
86 "json",
87 ],
88 cwd: "/repo",
89 timeoutMs: 30_000,
90 });
91 }).pipe(Effect.provide(layer)),
92 );
93
94 it.effect("builds a web URL when Azure returns only the pull request REST URL", () =>
95 Effect.gen(function* () {
96 mockRun.mockReturnValueOnce(
97 Effect.succeed(
98 processOutput(
99 JSON.stringify({
100 pullRequestId: 863,
101 title: "Fix Azure link",
102 url: "https://dev.azure.com/saplyai/a8fe4088-ad76-4eda-aaaa-e3329713a097/_apis/git/repositories/16108a25-93a3-4eff-b77d-85e894ee0fe4/pullRequests/863",
103 repository: {
104 name: "CV-engine",
105 project: {
106 name: "CV-engine",
107 },
108 },
109 sourceRefName: "refs/heads/feature/azure-pr-link",
110 targetRefName: "refs/heads/main",
111 status: "active",
112 }),
113 ),
114 ),
115 );
116
117 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
118 const result = yield* az.getPullRequest({
119 cwd: "/repo",
120 reference: "863",
121 });
122
123 assert.strictEqual(
124 result.url,
125 "https://dev.azure.com/saplyai/CV-engine/_git/CV-engine/pullrequest/863",
126 );
127 }).pipe(Effect.provide(layer)),
128 );
129
130 it.effect("lists pull requests with Azure status and source branch arguments", () =>
131 Effect.gen(function* () {
132 mockRun.mockReturnValueOnce(
133 Effect.succeed(
134 processOutput(
135 JSON.stringify([
136 {
137 pullRequestId: 7,
138 title: "Merged work",
139 sourceRefName: "refs/heads/feature/merged",
140 targetRefName: "refs/heads/main",
141 status: "completed",
142 closedDate: "2026-01-03T00:00:00.000Z",
143 _links: {
144 web: {
145 href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7",
146 },
147 },
148 },
149 ]),
150 ),
151 ),
152 );
153
154 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
155 const result = yield* az.listPullRequests({
156 cwd: "/repo",
157 headSelector: "origin:feature/merged",
158 state: "merged",
159 limit: 10,
160 });
161
162 assert.strictEqual(result[0]?.state, "merged");
163 assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z");
164 assert.strictEqual(result[0]?.closedAt, null);
165 expect(mockRun).toHaveBeenCalledWith({
166 operation: "AzureDevOpsCli.execute",
167 command: "az",
168 args: [
169 "repos",
170 "pr",
171 "list",
172 "--detect",
173 "true",
174 "--source-branch",
175 "feature/merged",
176 "--status",
177 "completed",
178 "--top",
179 "10",
180 "--only-show-errors",
181 "--output",
182 "json",
183 ],
184 cwd: "/repo",
185 timeoutMs: 30_000,
186 });
187 }).pipe(Effect.provide(layer)),
188 );
189
190 it.effect("reads repository clone URLs", () =>
191 Effect.gen(function* () {
192 mockRun.mockReturnValueOnce(
193 Effect.succeed(
194 processOutput(
195 JSON.stringify({
196 name: "repo",
197 webUrl: "https://dev.azure.com/acme/project/_git/repo",
198 remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
199 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
200 project: {
201 name: "project",
202 },
203 }),
204 ),
205 ),
206 );
207
208 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
209 const result = yield* az.getRepositoryCloneUrls({
210 cwd: "/repo",
211 repository: "repo",
212 });
213
214 assert.deepStrictEqual(result, {
215 nameWithOwner: "project/repo",
216 url: "https://dev.azure.com/acme/project/_git/repo",
217 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
218 });
219 }).pipe(Effect.provide(layer)),
220 );
221
222 it.effect("creates repositories through Azure Repos", () =>
223 Effect.gen(function* () {
224 mockRun.mockReturnValueOnce(
225 Effect.succeed(
226 processOutput(
227 JSON.stringify({
228 name: "repo",
229 webUrl: "https://dev.azure.com/acme/project/_git/repo",
230 remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
231 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
232 project: {
233 name: "project",
234 },
235 }),
236 ),
237 ),
238 );
239
240 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
241 const result = yield* az.createRepository({
242 cwd: "/repo",
243 repository: "project/repo",
244 visibility: "private",
245 });
246
247 assert.deepStrictEqual(result, {
248 nameWithOwner: "project/repo",
249 url: "https://dev.azure.com/acme/project/_git/repo",
250 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
251 });
252 expect(mockRun).toHaveBeenCalledWith({
253 operation: "AzureDevOpsCli.execute",
254 command: "az",
255 args: [
256 "repos",
257 "create",
258 "--detect",
259 "true",
260 "--name",
261 "repo",
262 "--project",
263 "project",
264 "--only-show-errors",
265 "--output",
266 "json",
267 ],
268 cwd: "/repo",
269 timeoutMs: 30_000,
270 });
271 }).pipe(Effect.provide(layer)),
272 );
273
274 it.effect("creates pull requests using the body file as the Azure description", () =>
275 Effect.gen(function* () {
276 const fileSystem = yield* FileSystem.FileSystem;
277 const bodyFile = `/tmp/t3code-azure-devops-cli-.md`;
278 yield* fileSystem.writeFileString(bodyFile, "Generated body");
279 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}")));
280
281 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
282 yield* az.createPullRequest({
283 cwd: "/repo",
284 baseBranch: "main",
285 headSelector: "feature/provider",
286 title: "Provider PR",
287 bodyFile,
288 });
289
290 expect(mockRun).toHaveBeenCalledWith(
291 expect.objectContaining({
292 command: "az",
293 cwd: "/repo",
294 args: expect.arrayContaining(["--description", `@${bodyFile}`]),
295 }),
296 );
297 expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output");
298 }).pipe(Effect.provide(layer)),
299 );
300
301 it.effect("does not force JSON output on checkout side-effect commands", () =>
302 Effect.gen(function* () {
303 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
304
305 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
306 yield* az.checkoutPullRequest({
307 cwd: "/repo",
308 reference: "42",
309 });
310
311 expect(mockRun).toHaveBeenCalledWith({
312 operation: "AzureDevOpsCli.execute",
313 command: "az",
314 args: [
315 "repos",
316 "pr",
317 "checkout",
318 "--only-show-errors",
319 "--detect",
320 "true",
321 "--id",
322 "42",
323 "--remote-name",
324 "origin",
325 ],
326 cwd: "/repo",
327 timeoutMs: 30_000,
328 });
329 }).pipe(Effect.provide(layer)),
330 );
331
332 it.effect("forwards explicit output limits to the process boundary", () =>
333 Effect.gen(function* () {
334 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
335
336 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
337 yield* az.execute({
338 cwd: "/repo",
339 args: ["repos", "pr", "list"],
340 maxOutputBytes: 16 * 1024 * 1024,
341 });
342
343 expect(mockRun).toHaveBeenCalledWith({
344 operation: "AzureDevOpsCli.execute",
345 command: "az",
346 args: ["repos", "pr", "list"],
347 cwd: "/repo",
348 timeoutMs: 30_000,
349 maxOutputBytes: 16 * 1024 * 1024,
350 });
351 }).pipe(Effect.provide(layer)),
352 );
353
354 it.effect("preserves VCS causes without copying upstream details into messages", () =>
355 Effect.gen(function* () {
356 const cause = new VcsProcessExitError({
357 operation: "AzureDevOpsCli.execute",
358 command: "az repos list --organization sensitive-upstream-detail",
359 cwd: "/repo",
360 exitCode: 1,
361 detail: "sensitive-upstream-detail",
362 });
363 mockRun.mockReturnValueOnce(Effect.fail(cause));
364
365 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
366 const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip);
367
368 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError);
369 assert.strictEqual(error.operation, "execute");
370 assert.strictEqual(error.command, "az");
371 assert.strictEqual(error.cwd, "/repo");
372 assert.strictEqual(error.argumentCount, 2);
373 assert.strictEqual(error.detail, "Azure DevOps CLI command failed.");
374 assert.strictEqual(error.cause, cause);
375 assert.equal(error.message.includes("sensitive-upstream-detail"), false);
376 }).pipe(Effect.provide(layer)),
377 );
378
379 it.effect("does not report a missing working directory as a missing Azure CLI", () =>
380 Effect.gen(function* () {
381 const cwd = "/missing/repo";
382 const platformCause = PlatformError.systemError({
383 _tag: "NotFound",
384 module: "ChildProcess",
385 method: "spawn",
386 syscall: "chdir",
387 pathOrDescriptor: cwd,
388 });
389 const cause = new VcsProcessSpawnError({
390 operation: "AzureDevOpsCli.execute",
391 command: "az",
392 cwd,
393 argumentCount: 2,
394 cause: platformCause,
395 });
396 mockRun.mockReturnValueOnce(Effect.fail(cause));
397
398 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
399 const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip);
400
401 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError);
402 assert.strictEqual(error.cwd, cwd);
403 assert.strictEqual(error.cause, cause);
404 }).pipe(Effect.provide(layer)),
405 );
406
407 it.effect("keeps invalid pull request output diagnostics structured", () =>
408 Effect.gen(function* () {
409 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json")));
410
411 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
412 const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip);
413
414 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError);
415 assert.strictEqual(error.operation, "getPullRequest");
416 assert.strictEqual(error.command, "az");
417 assert.strictEqual(error.cwd, "/repo");
418 assert.strictEqual(error.outputLength, 8);
419 assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON.");
420 assert.exists(error.cause);
421 assert.strictEqual(
422 error.message,
423 "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.",
424 );
425 }).pipe(Effect.provide(layer)),
426 );
427
428 it.effect("preserves rate-limit failures as a distinct error", () =>
429 Effect.gen(function* () {
430 const cause = new VcsProcessExitError({
431 operation: "AzureDevOpsCli.execute",
432 command: "az",
433 cwd: "/repo",
434 argumentCount: 2,
435 exitCode: 1,
436 detail: "API rate limit exceeded.",
437 failureKind: "rate-limited",
438 });
439 mockRun.mockReturnValueOnce(Effect.fail(cause));
440
441 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
442 const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip);
443
444 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCliRateLimitError);
445 assert.strictEqual(error.cause, cause);
446 }).pipe(Effect.provide(layer)),
447 );
448});
apps/server/src/sourceControl/AzureDevOpsCli.ts deleted-554
...@@ -1,554 +0,0 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as PlatformError from "effect/PlatformError";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import {
8 NonNegativeInt,
9 TrimmedNonEmptyString,
10 type SourceControlRepositoryVisibility,
11 type VcsError,
12} from "@t3tools/contracts";
13
14import * as VcsProcess from "../vcs/VcsProcess.ts";
15import {
16 decodeAzureDevOpsPullRequestJson,
17 decodeAzureDevOpsPullRequestListJson,
18 type NormalizedAzureDevOpsPullRequestRecord,
19} from "./azureDevOpsPullRequests.ts";
20import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
21
22const DEFAULT_TIMEOUT_MS = 30_000;
23
24const azureDevOpsCommandErrorFields = {
25 operation: Schema.Literal("execute"),
26 command: Schema.Literal("az"),
27 cwd: Schema.String,
28 argumentCount: NonNegativeInt,
29 cause: Schema.Defect(),
30};
31
32export class AzureDevOpsCliUnavailableError extends Schema.TaggedError<AzureDevOpsCliUnavailableError>()(
33 "AzureDevOpsCliUnavailableError",
34 azureDevOpsCommandErrorFields,
35) {
36 get detail(): string {
37 return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.";
38 }
39
40 override get message(): string {
41 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
42 }
43}
44
45export class AzureDevOpsCliAuthenticationError extends Schema.TaggedError<AzureDevOpsCliAuthenticationError>()(
46 "AzureDevOpsCliAuthenticationError",
47 azureDevOpsCommandErrorFields,
48) {
49 get detail(): string {
50 return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.";
51 }
52
53 override get message(): string {
54 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
55 }
56}
57
58export class AzureDevOpsCliRateLimitError extends Schema.TaggedError<AzureDevOpsCliRateLimitError>()(
59 "AzureDevOpsCliRateLimitError",
60 azureDevOpsCommandErrorFields,
61) {
62 get detail(): string {
63 return "Azure DevOps API rate limit exceeded.";
64 }
65
66 override get message(): string {
67 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
68 }
69}
70
71export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedError<AzureDevOpsPullRequestNotFoundError>()(
72 "AzureDevOpsPullRequestNotFoundError",
73 azureDevOpsCommandErrorFields,
74) {
75 get detail(): string {
76 return "Pull request not found. Check the PR number or URL and try again.";
77 }
78
79 override get message(): string {
80 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
81 }
82}
83
84export class AzureDevOpsCommandFailedError extends Schema.TaggedError<AzureDevOpsCommandFailedError>()(
85 "AzureDevOpsCommandFailedError",
86 azureDevOpsCommandErrorFields,
87) {
88 get detail(): string {
89 return "Azure DevOps CLI command failed.";
90 }
91
92 override get message(): string {
93 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
94 }
95
96 static fromVcsError(
97 context: {
98 readonly operation: "execute";
99 readonly command: "az";
100 readonly cwd: string;
101 readonly argumentCount: number;
102 },
103 cause: VcsError,
104 ): AzureDevOpsCliError {
105 const fields = { ...context, cause };
106
107 if (
108 cause._tag === "VcsProcessSpawnError" &&
109 cause.cause instanceof PlatformError.PlatformError &&
110 cause.cause.reason._tag === "NotFound" &&
111 cause.cause.reason.pathOrDescriptor !== context.cwd &&
112 cause.cause.reason.syscall !== "chdir"
113 ) {
114 return new AzureDevOpsCliUnavailableError(fields);
115 }
116
117 if (cause._tag === "VcsProcessExitError") {
118 if (cause.failureKind === "authentication") {
119 return new AzureDevOpsCliAuthenticationError(fields);
120 }
121 if (cause.failureKind === "rate-limited") {
122 return new AzureDevOpsCliRateLimitError(fields);
123 }
124 if (cause.failureKind === "not-found") {
125 return new AzureDevOpsPullRequestNotFoundError(fields);
126 }
127 }
128
129 return new AzureDevOpsCommandFailedError(fields);
130 }
131}
132
133const azureDevOpsDecodeErrorFields = {
134 command: Schema.Literal("az"),
135 cwd: Schema.String,
136 outputLength: NonNegativeInt,
137 cause: Schema.Defect(),
138};
139
140export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedError<AzureDevOpsPullRequestListDecodeError>()(
141 "AzureDevOpsPullRequestListDecodeError",
142 {
143 operation: Schema.Literal("listPullRequests"),
144 ...azureDevOpsDecodeErrorFields,
145 },
146) {
147 get detail(): string {
148 return "Azure DevOps CLI returned invalid PR list JSON.";
149 }
150
151 override get message(): string {
152 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
153 }
154}
155
156export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedError<AzureDevOpsPullRequestDecodeError>()(
157 "AzureDevOpsPullRequestDecodeError",
158 {
159 operation: Schema.Literal("getPullRequest"),
160 ...azureDevOpsDecodeErrorFields,
161 },
162) {
163 get detail(): string {
164 return "Azure DevOps CLI returned invalid pull request JSON.";
165 }
166
167 override get message(): string {
168 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
169 }
170}
171
172const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([
173 "getRepositoryCloneUrls",
174 "getDefaultBranch",
175 "createRepository",
176]);
177
178export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedError<AzureDevOpsRepositoryDecodeError>()(
179 "AzureDevOpsRepositoryDecodeError",
180 {
181 operation: AzureDevOpsRepositoryDecodeOperation,
182 ...azureDevOpsDecodeErrorFields,
183 },
184) {
185 get detail(): string {
186 return "Azure DevOps CLI returned invalid repository JSON.";
187 }
188
189 override get message(): string {
190 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
191 }
192}
193
194export const AzureDevOpsCliError = Schema.Union([
195 AzureDevOpsCliUnavailableError,
196 AzureDevOpsCliAuthenticationError,
197 AzureDevOpsCliRateLimitError,
198 AzureDevOpsPullRequestNotFoundError,
199 AzureDevOpsCommandFailedError,
200 AzureDevOpsPullRequestListDecodeError,
201 AzureDevOpsPullRequestDecodeError,
202 AzureDevOpsRepositoryDecodeError,
203]);
204export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type;
205
206export interface AzureDevOpsRepositoryCloneUrls {
207 readonly nameWithOwner: string;
208 readonly url: string;
209 readonly sshUrl: string;
210}
211
212export class AzureDevOpsCli extends Context.Service<
213 AzureDevOpsCli,
214 {
215 readonly execute: (input: {
216 readonly cwd: string;
217 readonly args: ReadonlyArray<string>;
218 readonly timeoutMs?: number;
219 readonly maxOutputBytes?: number;
220 }) => Effect.Effect<VcsProcess.VcsProcessOutput, AzureDevOpsCliError>;
221
222 readonly listPullRequests: (input: {
223 readonly cwd: string;
224 readonly headSelector: string;
225 readonly source?: SourceControlProvider.SourceControlRefSelector;
226 readonly state: "open" | "closed" | "merged" | "all";
227 readonly limit?: number;
228 }) => Effect.Effect<ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, AzureDevOpsCliError>;
229
230 readonly getPullRequest: (input: {
231 readonly cwd: string;
232 readonly reference: string;
233 }) => Effect.Effect<NormalizedAzureDevOpsPullRequestRecord, AzureDevOpsCliError>;
234
235 readonly getRepositoryCloneUrls: (input: {
236 readonly cwd: string;
237 readonly repository: string;
238 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
239
240 readonly createRepository: (input: {
241 readonly cwd: string;
242 readonly repository: string;
243 readonly visibility: SourceControlRepositoryVisibility;
244 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
245
246 readonly createPullRequest: (input: {
247 readonly cwd: string;
248 readonly baseBranch: string;
249 readonly headSelector: string;
250 readonly source?: SourceControlProvider.SourceControlRefSelector;
251 readonly target?: SourceControlProvider.SourceControlRefSelector;
252 readonly title: string;
253 readonly bodyFile: string;
254 }) => Effect.Effect<void, AzureDevOpsCliError>;
255
256 readonly getDefaultBranch: (input: {
257 readonly cwd: string;
258 }) => Effect.Effect<string | null, AzureDevOpsCliError>;
259
260 readonly checkoutPullRequest: (input: {
261 readonly cwd: string;
262 readonly reference: string;
263 readonly remoteName?: string;
264 }) => Effect.Effect<void, AzureDevOpsCliError>;
265 }
266>()("t3/sourceControl/AzureDevOpsCli") {}
267
268function normalizeChangeRequestId(reference: string): string {
269 const trimmed = reference.trim().replace(/^#/, "");
270 const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed);
271 return urlMatch?.[1] ?? trimmed;
272}
273
274function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string {
275 switch (state) {
276 case "open":
277 return "active";
278 case "closed":
279 return "abandoned";
280 case "merged":
281 return "completed";
282 case "all":
283 return "all";
284 }
285}
286
287const RawAzureDevOpsRepositorySchema = Schema.Struct({
288 name: TrimmedNonEmptyString,
289 webUrl: TrimmedNonEmptyString,
290 remoteUrl: TrimmedNonEmptyString,
291 sshUrl: TrimmedNonEmptyString,
292 project: Schema.optional(
293 Schema.Struct({
294 name: TrimmedNonEmptyString,
295 }),
296 ),
297 defaultBranch: Schema.optional(Schema.NullOr(Schema.String)),
298});
299
300function normalizeDefaultBranch(value: string | null | undefined): string | null {
301 const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? "";
302 return trimmed.length > 0 ? trimmed : null;
303}
304
305function normalizeRepositoryCloneUrls(
306 raw: Schema.Schema.Type<typeof RawAzureDevOpsRepositorySchema>,
307): AzureDevOpsRepositoryCloneUrls {
308 const projectName = raw.project?.name.trim();
309 return {
310 nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name,
311 url: raw.remoteUrl,
312 sshUrl: raw.sshUrl,
313 };
314}
315
316function parseRepositorySpecifier(repository: string): {
317 readonly project: string | null;
318 readonly name: string;
319} {
320 const parts: Array<string> = [];
321 for (const part of repository.split("/")) {
322 const trimmed = part.trim();
323 if (trimmed.length > 0) {
324 parts.push(trimmed);
325 }
326 }
327 return {
328 project: parts.length > 1 ? (parts.at(-2) ?? null) : null,
329 name: parts.at(-1) ?? repository.trim(),
330 };
331}
332
333function decodeAzureDevOpsJson<S extends Schema.Top>(
334 raw: string,
335 schema: S,
336 operation: typeof AzureDevOpsRepositoryDecodeOperation.Type,
337 cwd: string,
338): Effect.Effect<S["Type"], AzureDevOpsRepositoryDecodeError, S["DecodingServices"]> {
339 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
340 Effect.mapError(
341 (cause) =>
342 new AzureDevOpsRepositoryDecodeError({
343 operation,
344 command: "az",
345 cwd,
346 outputLength: raw.length,
347 cause,
348 }),
349 ),
350 );
351}
352
353/** @public Service construction is part of the canonical Effect module API. */
354export const make = Effect.gen(function* () {
355 const process = yield* VcsProcess.VcsProcess;
356
357 const execute: AzureDevOpsCli["Service"]["execute"] = (input) =>
358 process
359 .run({
360 operation: "AzureDevOpsCli.execute",
361 command: "az",
362 args: input.args,
363 cwd: input.cwd,
364 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
365 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
366 })
367 .pipe(
368 Effect.mapError((error) =>
369 AzureDevOpsCommandFailedError.fromVcsError(
370 {
371 operation: "execute",
372 command: "az",
373 cwd: input.cwd,
374 argumentCount: input.args.length,
375 },
376 error,
377 ),
378 ),
379 );
380
381 const executeJson = (input: Parameters<AzureDevOpsCli["Service"]["execute"]>[0]) =>
382 execute({
383 ...input,
384 args: [...input.args, "--only-show-errors", "--output", "json"],
385 });
386
387 return AzureDevOpsCli.of({
388 execute,
389 listPullRequests: (input) =>
390 executeJson({
391 cwd: input.cwd,
392 args: [
393 "repos",
394 "pr",
395 "list",
396 "--detect",
397 "true",
398 "--source-branch",
399 SourceControlProvider.sourceBranch(input),
400 "--status",
401 toAzureStatus(input.state),
402 "--top",
403 String(input.limit ?? 20),
404 ],
405 }).pipe(
406 Effect.map((result) => result.stdout.trim()),
407 Effect.flatMap((raw) =>
408 raw.length === 0
409 ? Effect.succeed([])
410 : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe(
411 Effect.flatMap((decoded) => {
412 if (!Result.isSuccess(decoded)) {
413 return Effect.fail(
414 new AzureDevOpsPullRequestListDecodeError({
415 operation: "listPullRequests",
416 command: "az",
417 cwd: input.cwd,
418 outputLength: raw.length,
419 cause: decoded.failure,
420 }),
421 );
422 }
423
424 return Effect.succeed(decoded.success);
425 }),
426 ),
427 ),
428 ),
429 getPullRequest: (input) =>
430 executeJson({
431 cwd: input.cwd,
432 args: [
433 "repos",
434 "pr",
435 "show",
436 "--detect",
437 "true",
438 "--id",
439 normalizeChangeRequestId(input.reference),
440 ],
441 }).pipe(
442 Effect.map((result) => result.stdout.trim()),
443 Effect.flatMap((raw) =>
444 Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe(
445 Effect.flatMap((decoded) => {
446 if (!Result.isSuccess(decoded)) {
447 return Effect.fail(
448 new AzureDevOpsPullRequestDecodeError({
449 operation: "getPullRequest",
450 command: "az",
451 cwd: input.cwd,
452 outputLength: raw.length,
453 cause: decoded.failure,
454 }),
455 );
456 }
457
458 return Effect.succeed(decoded.success);
459 }),
460 ),
461 ),
462 ),
463 getRepositoryCloneUrls: (input) =>
464 executeJson({
465 cwd: input.cwd,
466 args: ["repos", "show", "--detect", "true", "--repository", input.repository],
467 }).pipe(
468 Effect.map((result) => result.stdout.trim()),
469 Effect.flatMap((raw) =>
470 decodeAzureDevOpsJson(
471 raw,
472 RawAzureDevOpsRepositorySchema,
473 "getRepositoryCloneUrls",
474 input.cwd,
475 ),
476 ),
477 Effect.map(normalizeRepositoryCloneUrls),
478 ),
479 createRepository: (input) => {
480 const repository = parseRepositorySpecifier(input.repository);
481 // Azure Repos access is governed by project/organization permissions.
482 // `az repos create` does not expose a per-repository visibility flag, so
483 // the generic source-control visibility input is intentionally not
484 // translated into CLI args for this provider.
485 return executeJson({
486 cwd: input.cwd,
487 args: [
488 "repos",
489 "create",
490 "--detect",
491 "true",
492 "--name",
493 repository.name,
494 ...(repository.project ? ["--project", repository.project] : []),
495 ],
496 }).pipe(
497 Effect.map((result) => result.stdout.trim()),
498 Effect.flatMap((raw) =>
499 decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd),
500 ),
501 Effect.map(normalizeRepositoryCloneUrls),
502 );
503 },
504 createPullRequest: (input) =>
505 execute({
506 cwd: input.cwd,
507 args: [
508 "repos",
509 "pr",
510 "create",
511 "--only-show-errors",
512 "--detect",
513 "true",
514 "--target-branch",
515 input.target?.refName ?? input.baseBranch,
516 "--source-branch",
517 SourceControlProvider.sourceBranch(input),
518 "--title",
519 input.title,
520 "--description",
521 `@${input.bodyFile}`,
522 ],
523 }).pipe(Effect.asVoid),
524 getDefaultBranch: (input) =>
525 executeJson({
526 cwd: input.cwd,
527 args: ["repos", "show", "--detect", "true"],
528 }).pipe(
529 Effect.map((result) => result.stdout.trim()),
530 Effect.flatMap((raw) =>
531 decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd),
532 ),
533 Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)),
534 ),
535 checkoutPullRequest: (input) =>
536 execute({
537 cwd: input.cwd,
538 args: [
539 "repos",
540 "pr",
541 "checkout",
542 "--only-show-errors",
543 "--detect",
544 "true",
545 "--id",
546 normalizeChangeRequestId(input.reference),
547 "--remote-name",
548 input.remoteName ?? "origin",
549 ],
550 }).pipe(Effect.asVoid),
551 });
552});
553
554export const layer = Layer.effect(AzureDevOpsCli, make);
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts deleted-120
...@@ -1,120 +0,0 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5
6import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
7import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
8
9function makeProvider(azure: Partial<AzureDevOpsCli.AzureDevOpsCli["Service"]>) {
10 return AzureDevOpsSourceControlProvider.make.pipe(
11 Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)),
12 );
13}
14
15it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () =>
16 Effect.gen(function* () {
17 const provider = yield* makeProvider({
18 getPullRequest: () =>
19 Effect.succeed({
20 number: 42,
21 title: "Add Azure provider",
22 url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
23 baseRefName: "main",
24 headRefName: "feature/source-control",
25 state: "closed",
26 closedAt: "2026-08-23T10:00:00Z",
27 updatedAt: Option.none(),
28 }),
29 });
30
31 const changeRequest = yield* provider.getChangeRequest({
32 cwd: "/repo",
33 reference: "42",
34 });
35
36 assert.deepStrictEqual(changeRequest, {
37 provider: "azure-devops",
38 number: 42,
39 title: "Add Azure provider",
40 url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
41 baseRefName: "main",
42 headRefName: "feature/source-control",
43 state: "closed",
44 closedAt: "2026-08-23T10:00:00Z",
45 mergedAt: null,
46 updatedAt: Option.none(),
47 isCrossRepository: false,
48 });
49 }),
50);
51
52it.effect("adds change-request context while retaining Azure CLI causes", () =>
53 Effect.gen(function* () {
54 const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({
55 operation: "execute",
56 command: "az",
57 cwd: "/repo",
58 argumentCount: 2,
59 cause: new Error("raw upstream detail that should remain in the cause"),
60 });
61 const provider = yield* makeProvider({
62 checkoutPullRequest: () => Effect.fail(cause),
63 });
64
65 const error = yield* provider
66 .checkoutChangeRequest({ cwd: "/repo", reference: "#42" })
67 .pipe(Effect.flip);
68
69 assert.deepStrictEqual(
70 {
71 provider: error.provider,
72 operation: error.operation,
73 command: error.command,
74 cwd: error.cwd,
75 reference: error.reference,
76 detail: error.detail,
77 },
78 {
79 provider: "azure-devops",
80 operation: "checkoutChangeRequest",
81 command: "az",
82 cwd: "/repo",
83 reference: "#42",
84 detail: "Azure DevOps CLI command failed.",
85 },
86 );
87 assert.strictEqual(error.cause, cause);
88 assert.equal(error.message.includes("raw upstream detail"), false);
89 }),
90);
91
92it.effect("creates Azure DevOps PRs through provider-neutral input names", () =>
93 Effect.gen(function* () {
94 let createInput:
95 | Parameters<AzureDevOpsCli.AzureDevOpsCli["Service"]["createPullRequest"]>[0]
96 | null = null;
97 const provider = yield* makeProvider({
98 createPullRequest: (input) => {
99 createInput = input;
100 return Effect.void;
101 },
102 });
103
104 yield* provider.createChangeRequest({
105 cwd: "/repo",
106 baseRefName: "main",
107 headSelector: "feature/provider",
108 title: "Provider PR",
109 bodyFile: "/tmp/body.md",
110 });
111
112 assert.deepStrictEqual(createInput, {
113 cwd: "/repo",
114 baseBranch: "main",
115 headSelector: "feature/provider",
116 title: "Provider PR",
117 bodyFile: "/tmp/body.md",
118 });
119 }),
120);
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts deleted-236
...@@ -1,236 +0,0 @@
1import * as Effect from "effect/Effect";
2import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
3
4import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
5import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
6import {
7 combinedAuthOutput,
8 firstSafeAuthLine,
9 providerAuth,
10 type SourceControlAuthProbeInput,
11 type SourceControlCliDiscoverySpec,
12} from "@t3tools/source-control-core/server/discovery";
13
14function parseAzureAuth(input: SourceControlAuthProbeInput) {
15 const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
16
17 if (input.exitCode !== 0) {
18 return providerAuth({
19 status: "unauthenticated",
20 detail:
21 firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.",
22 });
23 }
24
25 if (account !== undefined && account.length > 0) {
26 return providerAuth({
27 status: "authenticated",
28 account,
29 host: "dev.azure.com",
30 });
31 }
32
33 return providerAuth({
34 status: "unknown",
35 host: "dev.azure.com",
36 detail: "Azure CLI account status could not be parsed.",
37 });
38}
39
40export const discovery = {
41 type: "cli",
42 kind: "azure-devops",
43 label: "Azure DevOps",
44 executable: "az",
45 versionArgs: ["--version"],
46 authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"],
47 // `az` boots a fresh Python interpreter on every invocation, so even `az --version`
48 // takes ~6s on Windows and overruns the default budget, leaving the provider reported
49 // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s.
50 probeTimeoutMs: 20_000,
51 parseAuth: parseAzureAuth,
52 installHint:
53 "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.",
54} satisfies SourceControlCliDiscoverySpec;
55
56function toChangeRequest(summary: {
57 readonly number: number;
58 readonly title: string;
59 readonly url: string;
60 readonly baseRefName: string;
61 readonly headRefName: string;
62 readonly state: "open" | "closed" | "merged";
63 readonly isDraft?: boolean;
64 readonly closedAt?: string | null;
65 readonly mergedAt?: string | null;
66 readonly updatedAt: ChangeRequest["updatedAt"];
67}): ChangeRequest {
68 return {
69 provider: "azure-devops",
70 number: summary.number,
71 title: summary.title,
72 url: summary.url,
73 baseRefName: summary.baseRefName,
74 headRefName: summary.headRefName,
75 state: summary.state,
76 ...(summary.isDraft === true ? { isDraft: true } : {}),
77 closedAt: summary.closedAt ?? null,
78 mergedAt: summary.mergedAt ?? null,
79 updatedAt: summary.updatedAt,
80 isCrossRepository: false,
81 };
82}
83
84export const make = Effect.gen(function* () {
85 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
86
87 return SourceControlProvider.SourceControlProvider.of({
88 kind: "azure-devops",
89 listChangeRequests: (input) => {
90 const source = SourceControlProvider.sourceControlRefFromInput(input);
91 return azure
92 .listPullRequests({
93 cwd: input.cwd,
94 headSelector: input.headSelector,
95 ...(source !== undefined ? { source } : {}),
96 state: input.state,
97 ...(input.limit !== undefined ? { limit: input.limit } : {}),
98 })
99 .pipe(
100 Effect.map((items) => items.map(toChangeRequest)),
101 Effect.mapError(
102 (error) =>
103 new SourceControlProviderError({
104 provider: "azure-devops",
105 operation: "listChangeRequests",
106 command: error.command,
107 cwd: input.cwd,
108 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
109 input.headSelector,
110 ),
111 detail: error.detail,
112 cause: error,
113 }),
114 ),
115 );
116 },
117 getChangeRequest: (input) =>
118 azure.getPullRequest(input).pipe(
119 Effect.map(toChangeRequest),
120 Effect.mapError(
121 (error) =>
122 new SourceControlProviderError({
123 provider: "azure-devops",
124 operation: "getChangeRequest",
125 command: error.command,
126 cwd: input.cwd,
127 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
128 input.reference,
129 ),
130 detail: error.detail,
131 cause: error,
132 }),
133 ),
134 ),
135 createChangeRequest: (input) => {
136 const source = SourceControlProvider.sourceControlRefFromInput(input);
137 return azure
138 .createPullRequest({
139 cwd: input.cwd,
140 baseBranch: input.baseRefName,
141 headSelector: input.headSelector,
142 ...(source !== undefined ? { source } : {}),
143 ...(input.target !== undefined ? { target: input.target } : {}),
144 title: input.title,
145 bodyFile: input.bodyFile,
146 })
147 .pipe(
148 Effect.mapError(
149 (error) =>
150 new SourceControlProviderError({
151 provider: "azure-devops",
152 operation: "createChangeRequest",
153 command: error.command,
154 cwd: input.cwd,
155 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
156 input.headSelector,
157 ),
158 detail: error.detail,
159 cause: error,
160 }),
161 ),
162 );
163 },
164 getRepositoryCloneUrls: (input) =>
165 azure.getRepositoryCloneUrls(input).pipe(
166 Effect.mapError(
167 (error) =>
168 new SourceControlProviderError({
169 provider: "azure-devops",
170 operation: "getRepositoryCloneUrls",
171 command: error.command,
172 cwd: input.cwd,
173 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
174 input.repository,
175 ),
176 detail: error.detail,
177 cause: error,
178 }),
179 ),
180 ),
181 createRepository: (input) =>
182 azure.createRepository(input).pipe(
183 Effect.mapError(
184 (error) =>
185 new SourceControlProviderError({
186 provider: "azure-devops",
187 operation: "createRepository",
188 command: error.command,
189 cwd: input.cwd,
190 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
191 input.repository,
192 ),
193 detail: error.detail,
194 cause: error,
195 }),
196 ),
197 ),
198 getDefaultBranch: (input) =>
199 azure.getDefaultBranch({ cwd: input.cwd }).pipe(
200 Effect.mapError(
201 (error) =>
202 new SourceControlProviderError({
203 provider: "azure-devops",
204 operation: "getDefaultBranch",
205 command: error.command,
206 cwd: input.cwd,
207 detail: error.detail,
208 cause: error,
209 }),
210 ),
211 ),
212 checkoutChangeRequest: (input) =>
213 azure
214 .checkoutPullRequest({
215 cwd: input.cwd,
216 reference: input.reference,
217 ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}),
218 })
219 .pipe(
220 Effect.mapError(
221 (error) =>
222 new SourceControlProviderError({
223 provider: "azure-devops",
224 operation: "checkoutChangeRequest",
225 command: error.command,
226 cwd: input.cwd,
227 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
228 input.reference,
229 ),
230 detail: error.detail,
231 cause: error,
232 }),
233 ),
234 ),
235 });
236});
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+3-1
...@@ -16,7 +16,8 @@ import * as ServerConfig from "../config.ts";...@@ -16,7 +16,8 @@ import * as ServerConfig from "../config.ts";
16import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";16import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
17import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";17import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
18import * as VcsProcess from "../vcs/VcsProcess.ts";18import * as VcsProcess from "../vcs/VcsProcess.ts";
19import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";19import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
20import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";
20import * as BitbucketApi from "./BitbucketApi.ts";21import * as BitbucketApi from "./BitbucketApi.ts";
21import * as GitHubApi from "./GitHubApi.ts";22import * as GitHubApi from "./GitHubApi.ts";
22import * as GitLabCli from "./GitLabCli.ts";23import * as GitLabCli from "./GitLabCli.ts";
...@@ -39,6 +40,7 @@ const layerSourceControlProviderRegistryTest = (input: {...@@ -39,6 +40,7 @@ const layerSourceControlProviderRegistryTest = (input: {
39 prefix: "t3-source-control-registry-test-",40 prefix: "t3-source-control-registry-test-",
40 }).pipe(Layer.provide(NodeServices.layer)),41 }).pipe(Layer.provide(NodeServices.layer)),
41 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),42 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
43 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}),
42 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),44 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
43 ServerSettings.ServerSettingsService.layerTest(),45 ServerSettings.ServerSettingsService.layerTest(),
44 Layer.mock(GitHubApi.GitHubApi)({}),46 Layer.mock(GitHubApi.GitHubApi)({}),
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+3-1
...@@ -13,7 +13,8 @@ import * as ServerConfig from "../config.ts";...@@ -13,7 +13,8 @@ import * as ServerConfig from "../config.ts";
13import type * as VcsDriver from "../vcs/VcsDriver.ts";13import type * as VcsDriver from "../vcs/VcsDriver.ts";
14import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";14import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
15import * as VcsProcess from "../vcs/VcsProcess.ts";15import * as VcsProcess from "../vcs/VcsProcess.ts";
16import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";16import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
17import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";
17import * as BitbucketApi from "./BitbucketApi.ts";18import * as BitbucketApi from "./BitbucketApi.ts";
18import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";19import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
19import * as GitHubApi from "./GitHubApi.ts";20import * as GitHubApi from "./GitHubApi.ts";
...@@ -103,6 +104,7 @@ function makeRegistry(input: {...@@ -103,6 +104,7 @@ function makeRegistry(input: {
103 ),104 ),
104 ),105 ),
105 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),106 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
107 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}),
106 Layer.mock(BitbucketApi.BitbucketApi)({}),108 Layer.mock(BitbucketApi.BitbucketApi)({}),
107 ServerSettings.ServerSettingsService.layerTest(),109 ServerSettings.ServerSettingsService.layerTest(),
108 Layer.mock(GitHubApi.GitHubApi)({110 Layer.mock(GitHubApi.GitHubApi)({
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+7-9
...@@ -11,7 +11,6 @@ import {...@@ -11,7 +11,6 @@ import {
11import type { SourceControlProviderKind } from "@t3tools/contracts";11import type { SourceControlProviderKind } from "@t3tools/contracts";
12import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";12import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
1313
14import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
15import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";14import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
16import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";15import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
17import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";16import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
...@@ -310,7 +309,6 @@ export const make = Effect.gen(function* () {...@@ -310,7 +309,6 @@ export const make = Effect.gen(function* () {
310 const gitlab = yield* GitLabSourceControlProvider.make;309 const gitlab = yield* GitLabSourceControlProvider.make;
311 const bitbucket = yield* BitbucketSourceControlProvider.make;310 const bitbucket = yield* BitbucketSourceControlProvider.make;
312 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;311 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;
313 const azureDevOps = yield* AzureDevOpsSourceControlProvider.make;
314 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>312 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
315 driver.make.pipe(313 driver.make.pipe(
316 Effect.map((instance): SourceControlProviderRegistration => ({314 Effect.map((instance): SourceControlProviderRegistration => ({
...@@ -320,7 +318,9 @@ export const make = Effect.gen(function* () {...@@ -320,7 +318,9 @@ export const make = Effect.gen(function* () {
320 })),318 })),
321 ),319 ),
322 );320 );
323 return yield* makeWithProviders([321 // Discovery lists hosts in this order, so it is kept while hosts move into packages.
322 const order = ["github", "gitlab", "azure-devops", "bitbucket", "forgejo"];
323 const registrations: ReadonlyArray<SourceControlProviderRegistration> = [
324 {324 {
325 kind: "github",325 kind: "github",
326 provider: github,326 provider: github,
...@@ -331,18 +331,16 @@ export const make = Effect.gen(function* () {...@@ -331,18 +331,16 @@ export const make = Effect.gen(function* () {
331 provider: gitlab,331 provider: gitlab,
332 discovery: GitLabSourceControlProvider.discovery,332 discovery: GitLabSourceControlProvider.discovery,
333 },333 },
334 {
335 kind: "azure-devops",
336 provider: azureDevOps,
337 discovery: AzureDevOpsSourceControlProvider.discovery,
338 },
339 {334 {
340 kind: "bitbucket",335 kind: "bitbucket",
341 provider: bitbucket,336 provider: bitbucket,
342 discovery: bitbucketDiscovery,337 discovery: bitbucketDiscovery,
343 },338 },
344 ...drivers,339 ...drivers,
345 ]);340 ];
341 return yield* makeWithProviders(
342 registrations.toSorted((left, right) => order.indexOf(left.kind) - order.indexOf(right.kind)),
343 );
346});344});
347345
348export const layer = Layer.effect(SourceControlProviderRegistry, make);346export const layer = Layer.effect(SourceControlProviderRegistry, make);
apps/server/src/sourceControl/azureDevOpsPullRequests.ts deleted-222
...@@ -1,222 +0,0 @@
1import * as Cause from "effect/Cause";
2import * as DateTime from "effect/DateTime";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
8import { decodeJsonResult } from "@t3tools/shared/schemaJson";
9
10export interface NormalizedAzureDevOpsPullRequestRecord {
11 readonly number: number;
12 readonly title: string;
13 readonly url: string;
14 readonly baseRefName: string;
15 readonly headRefName: string;
16 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;
18 readonly closedAt?: string | null;
19 readonly mergedAt?: string | null;
20 readonly updatedAt: Option.Option<DateTime.Utc>;
21}
22
23const AzureDevOpsPullRequestSchema = Schema.Struct({
24 pullRequestId: PositiveInt,
25 title: TrimmedNonEmptyString,
26 url: Schema.optional(Schema.String),
27 repository: Schema.optional(
28 Schema.Struct({
29 name: Schema.optional(Schema.String),
30 webUrl: Schema.optional(Schema.String),
31 project: Schema.optional(
32 Schema.Struct({
33 name: Schema.optional(Schema.String),
34 }),
35 ),
36 }),
37 ),
38 sourceRefName: TrimmedNonEmptyString,
39 targetRefName: TrimmedNonEmptyString,
40 status: Schema.String,
41 isDraft: Schema.optional(Schema.Boolean),
42 creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
43 closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
44 _links: Schema.optional(
45 Schema.Struct({
46 web: Schema.optional(
47 Schema.Struct({
48 href: Schema.String,
49 }),
50 ),
51 }),
52 ),
53});
54
55function trimOptionalString(value: string | null | undefined): string | null {
56 const trimmed = value?.trim() ?? "";
57 return trimmed.length > 0 ? trimmed : null;
58}
59
60function normalizeRefName(refName: string): string {
61 return refName.trim().replace(/^refs\/heads\//, "");
62}
63
64function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" {
65 switch (status.trim().toLowerCase()) {
66 case "completed":
67 return "merged";
68 case "abandoned":
69 return "closed";
70 default:
71 return "open";
72 }
73}
74
75function encodeAzureDevOpsPathSegment(segment: string): string {
76 return encodeURIComponent(segment);
77}
78
79/**
80 * The organization root a REST url belongs to, which is where a browser url has to be hung when
81 * Azure answered with neither a web link nor a repository url. Read from what Azure returned
82 * rather than from the local remote, whose shape varies.
83 */
84function azureDevOpsOrganizationBaseFromRestApiUrl(
85 value: string | null | undefined,
86): string | null {
87 const rawUrl = trimOptionalString(value);
88 if (!rawUrl) {
89 return null;
90 }
91
92 try {
93 const url = new URL(rawUrl);
94 const hostname = url.hostname.toLowerCase();
95 const pathSegments = url.pathname.split("/").filter((segment) => segment.length > 0);
96 const isAzureRestUrl = pathSegments.some((segment) => segment.toLowerCase() === "_apis");
97 if (!isAzureRestUrl) {
98 return null;
99 }
100
101 if (hostname === "dev.azure.com") {
102 const organization = pathSegments[0];
103 return organization ? `${url.origin}/${organization}` : null;
104 }
105
106 if (hostname.endsWith(".visualstudio.com")) {
107 return url.origin;
108 }
109
110 return null;
111 } catch {
112 return null;
113 }
114}
115
116/**
117 * Where a pull request lives in a browser. Azure answers with a web link when asked for one and
118 * otherwise leaves it to be assembled, so all three routes are tried in the order they can be
119 * trusted. Takes plain fields so both the source control provider and the pull requests page
120 * can share it.
121 */
122export function azureDevOpsPullRequestWebUrl(input: {
123 readonly pullRequestId: number;
124 readonly webLink?: string | null | undefined;
125 readonly repositoryWebUrl?: string | null | undefined;
126 readonly restApiUrl?: string | null | undefined;
127 readonly projectName?: string | null | undefined;
128 readonly repositoryName?: string | null | undefined;
129}): string {
130 const webLink = trimOptionalString(input.webLink);
131 if (webLink) {
132 return webLink;
133 }
134
135 const repositoryWebUrl = trimOptionalString(input.repositoryWebUrl);
136 if (repositoryWebUrl) {
137 return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${input.pullRequestId}`;
138 }
139
140 const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(input.restApiUrl);
141 const projectName = trimOptionalString(input.projectName);
142 const repositoryName = trimOptionalString(input.repositoryName);
143 if (organizationBase && projectName && repositoryName) {
144 const encodedProjectName = encodeAzureDevOpsPathSegment(projectName);
145 const encodedRepositoryName = encodeAzureDevOpsPathSegment(repositoryName);
146 return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${input.pullRequestId}`;
147 }
148
149 return trimOptionalString(input.restApiUrl) ?? "";
150}
151
152function normalizeAzureDevOpsPullRequestUrl(
153 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
154): string {
155 return azureDevOpsPullRequestWebUrl({
156 pullRequestId: raw.pullRequestId,
157 webLink: raw._links?.web?.href,
158 repositoryWebUrl: raw.repository?.webUrl,
159 restApiUrl: raw.url,
160 projectName: raw.repository?.project?.name,
161 repositoryName: raw.repository?.name,
162 });
163}
164
165function normalizeAzureDevOpsPullRequestRecord(
166 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
167): NormalizedAzureDevOpsPullRequestRecord {
168 const state = normalizeAzureDevOpsPullRequestState(raw.status);
169 const terminalAt = Option.match(raw.closedDate ?? Option.none(), {
170 onNone: () => null,
171 onSome: DateTime.formatIso,
172 });
173 return {
174 number: raw.pullRequestId,
175 title: raw.title,
176 url: normalizeAzureDevOpsPullRequestUrl(raw),
177 baseRefName: normalizeRefName(raw.targetRefName),
178 headRefName: normalizeRefName(raw.sourceRefName),
179 state,
180 ...(raw.isDraft === true ? { isDraft: true } : {}),
181 closedAt: state === "closed" ? terminalAt : null,
182 mergedAt: state === "merged" ? terminalAt : null,
183 updatedAt: (raw.closedDate ?? Option.none()).pipe(
184 Option.orElse(() => raw.creationDate ?? Option.none()),
185 ),
186 };
187}
188
189const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
190const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema);
191const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema);
192
193export function decodeAzureDevOpsPullRequestListJson(
194 raw: string,
195): Result.Result<
196 ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>,
197 Cause.Cause<Schema.SchemaError>
198> {
199 const result = decodeAzureDevOpsPullRequestList(raw);
200 if (Result.isSuccess(result)) {
201 const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = [];
202 for (const entry of result.success) {
203 const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry);
204 if (Exit.isFailure(decodedEntry)) {
205 continue;
206 }
207 pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value));
208 }
209 return Result.succeed(pullRequests);
210 }
211 return Result.fail(result.failure);
212}
213
214export function decodeAzureDevOpsPullRequestJson(
215 raw: string,
216): Result.Result<NormalizedAzureDevOpsPullRequestRecord, Cause.Cause<Schema.SchemaError>> {
217 const result = decodeAzureDevOpsPullRequest(raw);
218 if (Result.isSuccess(result)) {
219 return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success));
220 }
221 return Result.fail(result.failure);
222}
apps/server/src/sourceControl/builtInDrivers.ts+17-3
...@@ -7,14 +7,28 @@...@@ -7,14 +7,28 @@
7 *7 *
8 * @module sourceControl/builtInDrivers8 * @module sourceControl/builtInDrivers
9 */9 */
10import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
11import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";
12import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver";
10import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";13import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
11import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver";14import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver";
15import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver";
12import * as Layer from "effect/Layer";16import * as Layer from "effect/Layer";
1317
14import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";18import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
1519
16/** Ordered as the hosts appear in discovery, after the ones still built in the server. */20const drivers = [AzureDevOpsDriver.driver, ForgejoDriver.driver];
17export const BUILT_IN_SOURCE_CONTROL_DRIVERS = [ForgejoDriver.driver] as const;21
22/** Every service a built-in driver's `make` needs; the server's layers must provide them all. */
23export type BuiltInSourceControlDriversEnv =
24 (typeof drivers)[number] extends SourceControlDriver<infer R> ? R : never;
25
26export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray<
27 SourceControlDriver<BuiltInSourceControlDriversEnv>
28> = drivers;
1829
19/** The services the built-in drivers' packages own, plus the host port they all run against. */30/** The services the built-in drivers' packages own, plus the host port they all run against. */
20export const layer = ForgejoCli.layer.pipe(Layer.provideMerge(ServerSourceControlHost.layer));31export const layer = Layer.mergeAll(
32 AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)),
33 ForgejoCli.layer,
34).pipe(Layer.provideMerge(ServerSourceControlHost.layer));
apps/server/src/ws.ts-2
...@@ -229,7 +229,6 @@ import * as SqlClient from "effect/sql/SqlClient";...@@ -229,7 +229,6 @@ import * as SqlClient from "effect/sql/SqlClient";
229import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts";229import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts";
230import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";230import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
231import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";231import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
232import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
233import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";232import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
234import * as GitHubApi from "./sourceControl/GitHubApi.ts";233import * as GitHubApi from "./sourceControl/GitHubApi.ts";
235import * as GitLabCli from "./sourceControl/GitLabCli.ts";234import * as GitLabCli from "./sourceControl/GitLabCli.ts";
...@@ -3204,7 +3203,6 @@ export const layer = Layer.unwrap(...@@ -3204,7 +3203,6 @@ export const layer = Layer.unwrap(
3204 SourceControlProviderRegistry.layer.pipe(3203 SourceControlProviderRegistry.layer.pipe(
3205 Layer.provide(3204 Layer.provide(
3206 Layer.mergeAll(3205 Layer.mergeAll(
3207 AzureDevOpsCli.layer,
3208 BitbucketApi.layer,3206 BitbucketApi.layer,
3209 GitHubApi.layerWithDependencies,3207 GitHubApi.layerWithDependencies,
3210 GitLabCli.layer,3208 GitLabCli.layer,
docs/internals/pull-request-file-revisions.md+1-1
...@@ -24,7 +24,7 @@ the provider never got to look at. Absence below the boundary does not survive i...@@ -24,7 +24,7 @@ the provider never got to look at. Absence below the boundary does not survive i
2424
25What "never got to look at" is belongs to the host, and the contract cannot know any of them:25What "never got to look at" is belongs to the host, and the contract cannot know any of them:
2626
27- [Azure DevOps](../../apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts) reads every27- [Azure DevOps](../../packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts) reads every
28 version off one iteration listing, so a change too long to follow to its end leaves the paths28 version off one iteration listing, so a change too long to follow to its end leaves the paths
29 past that point out.29 past that point out.
30- [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the30- [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the
package.json+1-1
...@@ -28,7 +28,7 @@...@@ -28,7 +28,7 @@
28 "tc": "vp run -r --concurrency-limit 2 typecheck",28 "tc": "vp run -r --concurrency-limit 2 typecheck",
29 "lint": "vp lint --report-unused-disable-directives",29 "lint": "vp lint --report-unused-disable-directives",
30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-forgejo --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-forgejo --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
33 "lint:mobile": "node scripts/mobile-native-static-check.ts",33 "lint:mobile": "node scripts/mobile-native-static-check.ts",
34 "test": "vp run -r test",34 "test": "vp run -r test",
packages/source-control-azure-devops/package.json created+57
...@@ -0,0 +1,57 @@
1{
2 "name": "@t3tools/source-control-azure-devops",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./server/AzureDevOpsCli": {
7 "types": "./src/server/AzureDevOpsCli.ts",
8 "import": "./src/server/AzureDevOpsCli.ts"
9 },
10 "./server/azureDevOpsDiff": {
11 "types": "./src/server/azureDevOpsDiff.ts",
12 "import": "./src/server/azureDevOpsDiff.ts"
13 },
14 "./server/AzureDevOpsPullRequestCli": {
15 "types": "./src/server/AzureDevOpsPullRequestCli.ts",
16 "import": "./src/server/AzureDevOpsPullRequestCli.ts"
17 },
18 "./server/azureDevOpsPullRequestJson": {
19 "types": "./src/server/azureDevOpsPullRequestJson.ts",
20 "import": "./src/server/azureDevOpsPullRequestJson.ts"
21 },
22 "./server/AzureDevOpsPullRequestProvider": {
23 "types": "./src/server/AzureDevOpsPullRequestProvider.ts",
24 "import": "./src/server/AzureDevOpsPullRequestProvider.ts"
25 },
26 "./server/azureDevOpsPullRequests": {
27 "types": "./src/server/azureDevOpsPullRequests.ts",
28 "import": "./src/server/azureDevOpsPullRequests.ts"
29 },
30 "./server/AzureDevOpsSourceControlProvider": {
31 "types": "./src/server/AzureDevOpsSourceControlProvider.ts",
32 "import": "./src/server/AzureDevOpsSourceControlProvider.ts"
33 },
34 "./server/driver": {
35 "types": "./src/server/driver.ts",
36 "import": "./src/server/driver.ts"
37 }
38 },
39 "scripts": {
40 "typecheck": "tsc --noEmit",
41 "test": "vp test run --config ../../vite.config.ts --dir ."
42 },
43 "dependencies": {
44 "@t3tools/contracts": "workspace:*",
45 "@t3tools/shared": "workspace:*",
46 "@t3tools/source-control-core": "workspace:*",
47 "diff": "8.0.3",
48 "effect": "catalog:"
49 },
50 "devDependencies": {
51 "@effect/platform-node": "catalog:",
52 "@effect/vitest": "catalog:",
53 "@t3tools/source-control-testing": "workspace:*",
54 "@types/node": "catalog:",
55 "vite-plus": "catalog:"
56 }
57}
packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts created+447
...@@ -0,0 +1,447 @@
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 }).pipe(Effect.provide(layer)),
91 );
92
93 it.effect("builds a web URL when Azure returns only the pull request REST URL", () =>
94 Effect.gen(function* () {
95 mockRun.mockReturnValueOnce(
96 Effect.succeed(
97 processOutput(
98 JSON.stringify({
99 pullRequestId: 863,
100 title: "Fix Azure link",
101 url: "https://dev.azure.com/saplyai/a8fe4088-ad76-4eda-aaaa-e3329713a097/_apis/git/repositories/16108a25-93a3-4eff-b77d-85e894ee0fe4/pullRequests/863",
102 repository: {
103 name: "CV-engine",
104 project: {
105 name: "CV-engine",
106 },
107 },
108 sourceRefName: "refs/heads/feature/azure-pr-link",
109 targetRefName: "refs/heads/main",
110 status: "active",
111 }),
112 ),
113 ),
114 );
115
116 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
117 const result = yield* az.getPullRequest({
118 cwd: "/repo",
119 reference: "863",
120 });
121
122 assert.strictEqual(
123 result.url,
124 "https://dev.azure.com/saplyai/CV-engine/_git/CV-engine/pullrequest/863",
125 );
126 }).pipe(Effect.provide(layer)),
127 );
128
129 it.effect("lists pull requests with Azure status and source branch arguments", () =>
130 Effect.gen(function* () {
131 mockRun.mockReturnValueOnce(
132 Effect.succeed(
133 processOutput(
134 JSON.stringify([
135 {
136 pullRequestId: 7,
137 title: "Merged work",
138 sourceRefName: "refs/heads/feature/merged",
139 targetRefName: "refs/heads/main",
140 status: "completed",
141 closedDate: "2026-01-03T00:00:00.000Z",
142 _links: {
143 web: {
144 href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7",
145 },
146 },
147 },
148 ]),
149 ),
150 ),
151 );
152
153 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
154 const result = yield* az.listPullRequests({
155 cwd: "/repo",
156 headSelector: "origin:feature/merged",
157 state: "merged",
158 limit: 10,
159 });
160
161 assert.strictEqual(result[0]?.state, "merged");
162 assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z");
163 assert.strictEqual(result[0]?.closedAt, null);
164 expect(mockRun).toHaveBeenCalledWith({
165 operation: "AzureDevOpsCli.execute",
166 command: "az",
167 args: [
168 "repos",
169 "pr",
170 "list",
171 "--detect",
172 "true",
173 "--source-branch",
174 "feature/merged",
175 "--status",
176 "completed",
177 "--top",
178 "10",
179 "--only-show-errors",
180 "--output",
181 "json",
182 ],
183 cwd: "/repo",
184 timeoutMs: 30_000,
185 });
186 }).pipe(Effect.provide(layer)),
187 );
188
189 it.effect("reads repository clone URLs", () =>
190 Effect.gen(function* () {
191 mockRun.mockReturnValueOnce(
192 Effect.succeed(
193 processOutput(
194 JSON.stringify({
195 name: "repo",
196 webUrl: "https://dev.azure.com/acme/project/_git/repo",
197 remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
198 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
199 project: {
200 name: "project",
201 },
202 }),
203 ),
204 ),
205 );
206
207 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
208 const result = yield* az.getRepositoryCloneUrls({
209 cwd: "/repo",
210 repository: "repo",
211 });
212
213 assert.deepStrictEqual(result, {
214 nameWithOwner: "project/repo",
215 url: "https://dev.azure.com/acme/project/_git/repo",
216 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
217 });
218 }).pipe(Effect.provide(layer)),
219 );
220
221 it.effect("creates repositories through Azure Repos", () =>
222 Effect.gen(function* () {
223 mockRun.mockReturnValueOnce(
224 Effect.succeed(
225 processOutput(
226 JSON.stringify({
227 name: "repo",
228 webUrl: "https://dev.azure.com/acme/project/_git/repo",
229 remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
230 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
231 project: {
232 name: "project",
233 },
234 }),
235 ),
236 ),
237 );
238
239 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
240 const result = yield* az.createRepository({
241 cwd: "/repo",
242 repository: "project/repo",
243 visibility: "private",
244 });
245
246 assert.deepStrictEqual(result, {
247 nameWithOwner: "project/repo",
248 url: "https://dev.azure.com/acme/project/_git/repo",
249 sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
250 });
251 expect(mockRun).toHaveBeenCalledWith({
252 operation: "AzureDevOpsCli.execute",
253 command: "az",
254 args: [
255 "repos",
256 "create",
257 "--detect",
258 "true",
259 "--name",
260 "repo",
261 "--project",
262 "project",
263 "--only-show-errors",
264 "--output",
265 "json",
266 ],
267 cwd: "/repo",
268 timeoutMs: 30_000,
269 });
270 }).pipe(Effect.provide(layer)),
271 );
272
273 it.effect("creates pull requests using the body file as the Azure description", () =>
274 Effect.gen(function* () {
275 const fileSystem = yield* FileSystem.FileSystem;
276 const bodyFile = `/tmp/t3code-azure-devops-cli-.md`;
277 yield* fileSystem.writeFileString(bodyFile, "Generated body");
278 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}")));
279
280 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
281 yield* az.createPullRequest({
282 cwd: "/repo",
283 baseBranch: "main",
284 headSelector: "feature/provider",
285 title: "Provider PR",
286 bodyFile,
287 });
288
289 expect(mockRun).toHaveBeenCalledWith(
290 expect.objectContaining({
291 command: "az",
292 cwd: "/repo",
293 args: expect.arrayContaining(["--description", `@${bodyFile}`]),
294 }),
295 );
296 expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output");
297 }).pipe(Effect.provide(layer)),
298 );
299
300 it.effect("does not force JSON output on checkout side-effect commands", () =>
301 Effect.gen(function* () {
302 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
303
304 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
305 yield* az.checkoutPullRequest({
306 cwd: "/repo",
307 reference: "42",
308 });
309
310 expect(mockRun).toHaveBeenCalledWith({
311 operation: "AzureDevOpsCli.execute",
312 command: "az",
313 args: [
314 "repos",
315 "pr",
316 "checkout",
317 "--only-show-errors",
318 "--detect",
319 "true",
320 "--id",
321 "42",
322 "--remote-name",
323 "origin",
324 ],
325 cwd: "/repo",
326 timeoutMs: 30_000,
327 });
328 }).pipe(Effect.provide(layer)),
329 );
330
331 it.effect("forwards explicit output limits to the process boundary", () =>
332 Effect.gen(function* () {
333 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
334
335 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
336 yield* az.execute({
337 cwd: "/repo",
338 args: ["repos", "pr", "list"],
339 maxOutputBytes: 16 * 1024 * 1024,
340 });
341
342 expect(mockRun).toHaveBeenCalledWith({
343 operation: "AzureDevOpsCli.execute",
344 command: "az",
345 args: ["repos", "pr", "list"],
346 cwd: "/repo",
347 timeoutMs: 30_000,
348 maxOutputBytes: 16 * 1024 * 1024,
349 });
350 }).pipe(Effect.provide(layer)),
351 );
352
353 it.effect("preserves VCS causes without copying upstream details into messages", () =>
354 Effect.gen(function* () {
355 const cause = new VcsProcessExitError({
356 operation: "AzureDevOpsCli.execute",
357 command: "az repos list --organization sensitive-upstream-detail",
358 cwd: "/repo",
359 exitCode: 1,
360 detail: "sensitive-upstream-detail",
361 });
362 mockRun.mockReturnValueOnce(Effect.fail(cause));
363
364 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
365 const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip);
366
367 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError);
368 assert.strictEqual(error.operation, "execute");
369 assert.strictEqual(error.command, "az");
370 assert.strictEqual(error.cwd, "/repo");
371 assert.strictEqual(error.argumentCount, 2);
372 assert.strictEqual(error.detail, "Azure DevOps CLI command failed.");
373 assert.strictEqual(error.cause, cause);
374 assert.equal(error.message.includes("sensitive-upstream-detail"), false);
375 }).pipe(Effect.provide(layer)),
376 );
377
378 it.effect("does not report a missing working directory as a missing Azure CLI", () =>
379 Effect.gen(function* () {
380 const cwd = "/missing/repo";
381 const platformCause = PlatformError.systemError({
382 _tag: "NotFound",
383 module: "ChildProcess",
384 method: "spawn",
385 syscall: "chdir",
386 pathOrDescriptor: cwd,
387 });
388 const cause = new VcsProcessSpawnError({
389 operation: "AzureDevOpsCli.execute",
390 command: "az",
391 cwd,
392 argumentCount: 2,
393 cause: platformCause,
394 });
395 mockRun.mockReturnValueOnce(Effect.fail(cause));
396
397 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
398 const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip);
399
400 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError);
401 assert.strictEqual(error.cwd, cwd);
402 assert.strictEqual(error.cause, cause);
403 }).pipe(Effect.provide(layer)),
404 );
405
406 it.effect("keeps invalid pull request output diagnostics structured", () =>
407 Effect.gen(function* () {
408 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json")));
409
410 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
411 const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip);
412
413 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError);
414 assert.strictEqual(error.operation, "getPullRequest");
415 assert.strictEqual(error.command, "az");
416 assert.strictEqual(error.cwd, "/repo");
417 assert.strictEqual(error.outputLength, 8);
418 assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON.");
419 assert.exists(error.cause);
420 assert.strictEqual(
421 error.message,
422 "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.",
423 );
424 }).pipe(Effect.provide(layer)),
425 );
426
427 it.effect("preserves rate-limit failures as a distinct error", () =>
428 Effect.gen(function* () {
429 const cause = new VcsProcessExitError({
430 operation: "AzureDevOpsCli.execute",
431 command: "az",
432 cwd: "/repo",
433 argumentCount: 2,
434 exitCode: 1,
435 detail: "API rate limit exceeded.",
436 failureKind: "rate-limited",
437 });
438 mockRun.mockReturnValueOnce(Effect.fail(cause));
439
440 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
441 const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip);
442
443 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCliRateLimitError);
444 assert.strictEqual(error.cause, cause);
445 }).pipe(Effect.provide(layer)),
446 );
447});
packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts created+554
...@@ -0,0 +1,554 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as PlatformError from "effect/PlatformError";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import {
8 NonNegativeInt,
9 TrimmedNonEmptyString,
10 type SourceControlRepositoryVisibility,
11 type VcsError,
12} from "@t3tools/contracts";
13
14import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
15import {
16 decodeAzureDevOpsPullRequestJson,
17 decodeAzureDevOpsPullRequestListJson,
18 type NormalizedAzureDevOpsPullRequestRecord,
19} from "./azureDevOpsPullRequests.ts";
20import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
21
22const DEFAULT_TIMEOUT_MS = 30_000;
23
24const azureDevOpsCommandErrorFields = {
25 operation: Schema.Literal("execute"),
26 command: Schema.Literal("az"),
27 cwd: Schema.String,
28 argumentCount: NonNegativeInt,
29 cause: Schema.Defect(),
30};
31
32export class AzureDevOpsCliUnavailableError extends Schema.TaggedError<AzureDevOpsCliUnavailableError>()(
33 "AzureDevOpsCliUnavailableError",
34 azureDevOpsCommandErrorFields,
35) {
36 get detail(): string {
37 return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.";
38 }
39
40 override get message(): string {
41 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
42 }
43}
44
45export class AzureDevOpsCliAuthenticationError extends Schema.TaggedError<AzureDevOpsCliAuthenticationError>()(
46 "AzureDevOpsCliAuthenticationError",
47 azureDevOpsCommandErrorFields,
48) {
49 get detail(): string {
50 return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.";
51 }
52
53 override get message(): string {
54 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
55 }
56}
57
58export class AzureDevOpsCliRateLimitError extends Schema.TaggedError<AzureDevOpsCliRateLimitError>()(
59 "AzureDevOpsCliRateLimitError",
60 azureDevOpsCommandErrorFields,
61) {
62 get detail(): string {
63 return "Azure DevOps API rate limit exceeded.";
64 }
65
66 override get message(): string {
67 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
68 }
69}
70
71export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedError<AzureDevOpsPullRequestNotFoundError>()(
72 "AzureDevOpsPullRequestNotFoundError",
73 azureDevOpsCommandErrorFields,
74) {
75 get detail(): string {
76 return "Pull request not found. Check the PR number or URL and try again.";
77 }
78
79 override get message(): string {
80 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
81 }
82}
83
84export class AzureDevOpsCommandFailedError extends Schema.TaggedError<AzureDevOpsCommandFailedError>()(
85 "AzureDevOpsCommandFailedError",
86 azureDevOpsCommandErrorFields,
87) {
88 get detail(): string {
89 return "Azure DevOps CLI command failed.";
90 }
91
92 override get message(): string {
93 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
94 }
95
96 static fromVcsError(
97 context: {
98 readonly operation: "execute";
99 readonly command: "az";
100 readonly cwd: string;
101 readonly argumentCount: number;
102 },
103 cause: VcsError,
104 ): AzureDevOpsCliError {
105 const fields = { ...context, cause };
106
107 if (
108 cause._tag === "VcsProcessSpawnError" &&
109 cause.cause instanceof PlatformError.PlatformError &&
110 cause.cause.reason._tag === "NotFound" &&
111 cause.cause.reason.pathOrDescriptor !== context.cwd &&
112 cause.cause.reason.syscall !== "chdir"
113 ) {
114 return new AzureDevOpsCliUnavailableError(fields);
115 }
116
117 if (cause._tag === "VcsProcessExitError") {
118 if (cause.failureKind === "authentication") {
119 return new AzureDevOpsCliAuthenticationError(fields);
120 }
121 if (cause.failureKind === "rate-limited") {
122 return new AzureDevOpsCliRateLimitError(fields);
123 }
124 if (cause.failureKind === "not-found") {
125 return new AzureDevOpsPullRequestNotFoundError(fields);
126 }
127 }
128
129 return new AzureDevOpsCommandFailedError(fields);
130 }
131}
132
133const azureDevOpsDecodeErrorFields = {
134 command: Schema.Literal("az"),
135 cwd: Schema.String,
136 outputLength: NonNegativeInt,
137 cause: Schema.Defect(),
138};
139
140export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedError<AzureDevOpsPullRequestListDecodeError>()(
141 "AzureDevOpsPullRequestListDecodeError",
142 {
143 operation: Schema.Literal("listPullRequests"),
144 ...azureDevOpsDecodeErrorFields,
145 },
146) {
147 get detail(): string {
148 return "Azure DevOps CLI returned invalid PR list JSON.";
149 }
150
151 override get message(): string {
152 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
153 }
154}
155
156export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedError<AzureDevOpsPullRequestDecodeError>()(
157 "AzureDevOpsPullRequestDecodeError",
158 {
159 operation: Schema.Literal("getPullRequest"),
160 ...azureDevOpsDecodeErrorFields,
161 },
162) {
163 get detail(): string {
164 return "Azure DevOps CLI returned invalid pull request JSON.";
165 }
166
167 override get message(): string {
168 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
169 }
170}
171
172const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([
173 "getRepositoryCloneUrls",
174 "getDefaultBranch",
175 "createRepository",
176]);
177
178export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedError<AzureDevOpsRepositoryDecodeError>()(
179 "AzureDevOpsRepositoryDecodeError",
180 {
181 operation: AzureDevOpsRepositoryDecodeOperation,
182 ...azureDevOpsDecodeErrorFields,
183 },
184) {
185 get detail(): string {
186 return "Azure DevOps CLI returned invalid repository JSON.";
187 }
188
189 override get message(): string {
190 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
191 }
192}
193
194export const AzureDevOpsCliError = Schema.Union([
195 AzureDevOpsCliUnavailableError,
196 AzureDevOpsCliAuthenticationError,
197 AzureDevOpsCliRateLimitError,
198 AzureDevOpsPullRequestNotFoundError,
199 AzureDevOpsCommandFailedError,
200 AzureDevOpsPullRequestListDecodeError,
201 AzureDevOpsPullRequestDecodeError,
202 AzureDevOpsRepositoryDecodeError,
203]);
204export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type;
205
206export interface AzureDevOpsRepositoryCloneUrls {
207 readonly nameWithOwner: string;
208 readonly url: string;
209 readonly sshUrl: string;
210}
211
212export class AzureDevOpsCli extends Context.Service<
213 AzureDevOpsCli,
214 {
215 readonly execute: (input: {
216 readonly cwd: string;
217 readonly args: ReadonlyArray<string>;
218 readonly timeoutMs?: number;
219 readonly maxOutputBytes?: number;
220 }) => Effect.Effect<SourceControlHost.SourceControlProcessOutput, AzureDevOpsCliError>;
221
222 readonly listPullRequests: (input: {
223 readonly cwd: string;
224 readonly headSelector: string;
225 readonly source?: SourceControlProvider.SourceControlRefSelector;
226 readonly state: "open" | "closed" | "merged" | "all";
227 readonly limit?: number;
228 }) => Effect.Effect<ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, AzureDevOpsCliError>;
229
230 readonly getPullRequest: (input: {
231 readonly cwd: string;
232 readonly reference: string;
233 }) => Effect.Effect<NormalizedAzureDevOpsPullRequestRecord, AzureDevOpsCliError>;
234
235 readonly getRepositoryCloneUrls: (input: {
236 readonly cwd: string;
237 readonly repository: string;
238 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
239
240 readonly createRepository: (input: {
241 readonly cwd: string;
242 readonly repository: string;
243 readonly visibility: SourceControlRepositoryVisibility;
244 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
245
246 readonly createPullRequest: (input: {
247 readonly cwd: string;
248 readonly baseBranch: string;
249 readonly headSelector: string;
250 readonly source?: SourceControlProvider.SourceControlRefSelector;
251 readonly target?: SourceControlProvider.SourceControlRefSelector;
252 readonly title: string;
253 readonly bodyFile: string;
254 }) => Effect.Effect<void, AzureDevOpsCliError>;
255
256 readonly getDefaultBranch: (input: {
257 readonly cwd: string;
258 }) => Effect.Effect<string | null, AzureDevOpsCliError>;
259
260 readonly checkoutPullRequest: (input: {
261 readonly cwd: string;
262 readonly reference: string;
263 readonly remoteName?: string;
264 }) => Effect.Effect<void, AzureDevOpsCliError>;
265 }
266>()("@t3tools/source-control-azure-devops/server/AzureDevOpsCli") {}
267
268function normalizeChangeRequestId(reference: string): string {
269 const trimmed = reference.trim().replace(/^#/, "");
270 const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed);
271 return urlMatch?.[1] ?? trimmed;
272}
273
274function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string {
275 switch (state) {
276 case "open":
277 return "active";
278 case "closed":
279 return "abandoned";
280 case "merged":
281 return "completed";
282 case "all":
283 return "all";
284 }
285}
286
287const RawAzureDevOpsRepositorySchema = Schema.Struct({
288 name: TrimmedNonEmptyString,
289 webUrl: TrimmedNonEmptyString,
290 remoteUrl: TrimmedNonEmptyString,
291 sshUrl: TrimmedNonEmptyString,
292 project: Schema.optional(
293 Schema.Struct({
294 name: TrimmedNonEmptyString,
295 }),
296 ),
297 defaultBranch: Schema.optional(Schema.NullOr(Schema.String)),
298});
299
300function normalizeDefaultBranch(value: string | null | undefined): string | null {
301 const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? "";
302 return trimmed.length > 0 ? trimmed : null;
303}
304
305function normalizeRepositoryCloneUrls(
306 raw: Schema.Schema.Type<typeof RawAzureDevOpsRepositorySchema>,
307): AzureDevOpsRepositoryCloneUrls {
308 const projectName = raw.project?.name.trim();
309 return {
310 nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name,
311 url: raw.remoteUrl,
312 sshUrl: raw.sshUrl,
313 };
314}
315
316function parseRepositorySpecifier(repository: string): {
317 readonly project: string | null;
318 readonly name: string;
319} {
320 const parts: Array<string> = [];
321 for (const part of repository.split("/")) {
322 const trimmed = part.trim();
323 if (trimmed.length > 0) {
324 parts.push(trimmed);
325 }
326 }
327 return {
328 project: parts.length > 1 ? (parts.at(-2) ?? null) : null,
329 name: parts.at(-1) ?? repository.trim(),
330 };
331}
332
333function decodeAzureDevOpsJson<S extends Schema.Top>(
334 raw: string,
335 schema: S,
336 operation: typeof AzureDevOpsRepositoryDecodeOperation.Type,
337 cwd: string,
338): Effect.Effect<S["Type"], AzureDevOpsRepositoryDecodeError, S["DecodingServices"]> {
339 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
340 Effect.mapError(
341 (cause) =>
342 new AzureDevOpsRepositoryDecodeError({
343 operation,
344 command: "az",
345 cwd,
346 outputLength: raw.length,
347 cause,
348 }),
349 ),
350 );
351}
352
353/** @public Service construction is part of the canonical Effect module API. */
354export const make = Effect.gen(function* () {
355 const { process } = yield* SourceControlHost.SourceControlHost;
356
357 const execute: AzureDevOpsCli["Service"]["execute"] = (input) =>
358 process
359 .run({
360 operation: "AzureDevOpsCli.execute",
361 command: "az",
362 args: input.args,
363 cwd: input.cwd,
364 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
365 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
366 })
367 .pipe(
368 Effect.mapError((error) =>
369 AzureDevOpsCommandFailedError.fromVcsError(
370 {
371 operation: "execute",
372 command: "az",
373 cwd: input.cwd,
374 argumentCount: input.args.length,
375 },
376 error,
377 ),
378 ),
379 );
380
381 const executeJson = (input: Parameters<AzureDevOpsCli["Service"]["execute"]>[0]) =>
382 execute({
383 ...input,
384 args: [...input.args, "--only-show-errors", "--output", "json"],
385 });
386
387 return AzureDevOpsCli.of({
388 execute,
389 listPullRequests: (input) =>
390 executeJson({
391 cwd: input.cwd,
392 args: [
393 "repos",
394 "pr",
395 "list",
396 "--detect",
397 "true",
398 "--source-branch",
399 SourceControlProvider.sourceBranch(input),
400 "--status",
401 toAzureStatus(input.state),
402 "--top",
403 String(input.limit ?? 20),
404 ],
405 }).pipe(
406 Effect.map((result) => result.stdout.trim()),
407 Effect.flatMap((raw) =>
408 raw.length === 0
409 ? Effect.succeed([])
410 : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe(
411 Effect.flatMap((decoded) => {
412 if (!Result.isSuccess(decoded)) {
413 return Effect.fail(
414 new AzureDevOpsPullRequestListDecodeError({
415 operation: "listPullRequests",
416 command: "az",
417 cwd: input.cwd,
418 outputLength: raw.length,
419 cause: decoded.failure,
420 }),
421 );
422 }
423
424 return Effect.succeed(decoded.success);
425 }),
426 ),
427 ),
428 ),
429 getPullRequest: (input) =>
430 executeJson({
431 cwd: input.cwd,
432 args: [
433 "repos",
434 "pr",
435 "show",
436 "--detect",
437 "true",
438 "--id",
439 normalizeChangeRequestId(input.reference),
440 ],
441 }).pipe(
442 Effect.map((result) => result.stdout.trim()),
443 Effect.flatMap((raw) =>
444 Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe(
445 Effect.flatMap((decoded) => {
446 if (!Result.isSuccess(decoded)) {
447 return Effect.fail(
448 new AzureDevOpsPullRequestDecodeError({
449 operation: "getPullRequest",
450 command: "az",
451 cwd: input.cwd,
452 outputLength: raw.length,
453 cause: decoded.failure,
454 }),
455 );
456 }
457
458 return Effect.succeed(decoded.success);
459 }),
460 ),
461 ),
462 ),
463 getRepositoryCloneUrls: (input) =>
464 executeJson({
465 cwd: input.cwd,
466 args: ["repos", "show", "--detect", "true", "--repository", input.repository],
467 }).pipe(
468 Effect.map((result) => result.stdout.trim()),
469 Effect.flatMap((raw) =>
470 decodeAzureDevOpsJson(
471 raw,
472 RawAzureDevOpsRepositorySchema,
473 "getRepositoryCloneUrls",
474 input.cwd,
475 ),
476 ),
477 Effect.map(normalizeRepositoryCloneUrls),
478 ),
479 createRepository: (input) => {
480 const repository = parseRepositorySpecifier(input.repository);
481 // Azure Repos access is governed by project/organization permissions.
482 // `az repos create` does not expose a per-repository visibility flag, so
483 // the generic source-control visibility input is intentionally not
484 // translated into CLI args for this provider.
485 return executeJson({
486 cwd: input.cwd,
487 args: [
488 "repos",
489 "create",
490 "--detect",
491 "true",
492 "--name",
493 repository.name,
494 ...(repository.project ? ["--project", repository.project] : []),
495 ],
496 }).pipe(
497 Effect.map((result) => result.stdout.trim()),
498 Effect.flatMap((raw) =>
499 decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd),
500 ),
501 Effect.map(normalizeRepositoryCloneUrls),
502 );
503 },
504 createPullRequest: (input) =>
505 execute({
506 cwd: input.cwd,
507 args: [
508 "repos",
509 "pr",
510 "create",
511 "--only-show-errors",
512 "--detect",
513 "true",
514 "--target-branch",
515 input.target?.refName ?? input.baseBranch,
516 "--source-branch",
517 SourceControlProvider.sourceBranch(input),
518 "--title",
519 input.title,
520 "--description",
521 `@${input.bodyFile}`,
522 ],
523 }).pipe(Effect.asVoid),
524 getDefaultBranch: (input) =>
525 executeJson({
526 cwd: input.cwd,
527 args: ["repos", "show", "--detect", "true"],
528 }).pipe(
529 Effect.map((result) => result.stdout.trim()),
530 Effect.flatMap((raw) =>
531 decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd),
532 ),
533 Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)),
534 ),
535 checkoutPullRequest: (input) =>
536 execute({
537 cwd: input.cwd,
538 args: [
539 "repos",
540 "pr",
541 "checkout",
542 "--only-show-errors",
543 "--detect",
544 "true",
545 "--id",
546 normalizeChangeRequestId(input.reference),
547 "--remote-name",
548 input.remoteName ?? "origin",
549 ],
550 }).pipe(Effect.asVoid),
551 });
552});
553
554export const layer = Layer.effect(AzureDevOpsCli, make);
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts created+1462
...@@ -0,0 +1,1462 @@
1import { afterEach, assert, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { ChildProcessSpawner } from "effect/process";
5
6import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
7import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
8import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";
9
10const mockedExecute = vi.fn<AzureDevOpsCli.AzureDevOpsCli["Service"]["execute"]>();
11
12const layer = it.layer(
13 AzureDevOpsPullRequestCli.layer.pipe(
14 Layer.provide(
15 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({
16 execute: mockedExecute,
17 }),
18 ),
19 ),
20);
21
22function output(stdout: string) {
23 return {
24 exitCode: ChildProcessSpawner.ExitCode(0),
25 stdout,
26 stderr: "",
27 stdoutTruncated: false,
28 stderrTruncated: false,
29 };
30}
31
32/** What VcsProcess allows a read that asked for no ceiling of its own. */
33const VCS_DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
34
35/**
36 * The runner as it really behaves: it cuts stdout at the ceiling its caller asked for, and cuts
37 * it at the process default when the caller asked for none. A read whose response is larger than
38 * its ceiling gets JSON that stops mid-string, which is the whole cost of an unset ceiling.
39 */
40function outputWithin(maxOutputBytes: number | undefined, response: string) {
41 const ceiling = maxOutputBytes ?? VCS_DEFAULT_MAX_OUTPUT_BYTES;
42 return ceiling >= Buffer.byteLength(response)
43 ? output(response)
44 : { ...output(response.slice(0, ceiling)), stdoutTruncated: true };
45}
46
47/** A fixture's own shape, spelled the way `az` would answer with it. */
48const json = (value: Record<string, unknown>) => JSON.stringify(value);
49
50const pullRequestRow = {
51 pullRequestId: 42,
52 title: "Add the page",
53 status: "active",
54 sourceRefName: "refs/heads/feat/page",
55 targetRefName: "refs/heads/main",
56 creationDate: "2026-07-01T00:00:00Z",
57 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
58 repository: { name: "web", project: { name: "platform" } },
59};
60
61const oneIteration = {
62 value: [
63 {
64 id: 1,
65 sourceRefCommit: { commitId: "a".repeat(40) },
66 commonRefCommit: { commitId: "b".repeat(40) },
67 },
68 ],
69};
70
71function pullRequestRows(
72 count: number,
73 firstNumber: number,
74): ReadonlyArray<Record<string, unknown>> {
75 return Array.from({ length: count }, (_, index) => ({
76 pullRequestId: firstNumber + index,
77 title: `Pull request ${firstNumber + index}`,
78 status: "active",
79 sourceRefName: "refs/heads/feat/page",
80 targetRefName: "refs/heads/main",
81 creationDate: "2026-07-01T00:00:00Z",
82 repository: { name: "web", project: { name: "platform" } },
83 url: `https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/${firstNumber + index}`,
84 }));
85}
86
87function pullRequests(count: number, firstNumber: number): string {
88 return JSON.stringify(pullRequestRows(count, firstNumber));
89}
90
91/** The arguments of the nth az invocation. */
92function argsOfCall(index: number): ReadonlyArray<string> {
93 const call = mockedExecute.mock.calls[index];
94 assert.isDefined(call);
95 return call[0].args;
96}
97
98/** The output ceiling the nth az invocation asked for, if it asked for one at all. */
99function maxOutputBytesOfCall(index: number) {
100 const call = mockedExecute.mock.calls[index];
101 assert.isDefined(call);
102 return call[0].maxOutputBytes;
103}
104
105/** A page of change entries the size Azure really answers with, url and object ids and all. */
106function changeEntries(count: number): ReadonlyArray<Record<string, unknown>> {
107 const commit = "c".repeat(40);
108 return Array.from({ length: count }, (_, index) => {
109 const path = `/apps/server/src/generated/module-${index}/persisted-projection-${index}.ts`;
110 return {
111 changeType: "edit",
112 item: {
113 path,
114 objectId: "a".repeat(40),
115 originalObjectId: "b".repeat(40),
116 commitId: commit,
117 gitObjectType: "blob",
118 url: `https://dev.azure.com/acme/platform/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/items${path}?versionType=Commit&version=${commit}`,
119 },
120 };
121 });
122}
123
124/** An Azure identity, which rides along with every comment and every push Azure answers with. */
125function identity(name: string) {
126 const id = "6f9c9b7f-0000-0000-0000-000000000000";
127 return {
128 displayName: name,
129 id,
130 uniqueName: `${name.toLowerCase().replace(/ /g, ".")}@acme.com`,
131 descriptor: `aad.${"z".repeat(52)}`,
132 imageUrl: `https://dev.azure.com/acme/_api/_common/identityImage?id=${id}`,
133 url: `https://spsprodweu1.vssps.visualstudio.com/A${id}/_apis/Identities/${id}`,
134 _links: {
135 avatar: {
136 href: `https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.${"z".repeat(52)}`,
137 },
138 },
139 };
140}
141
142/** A review's threads the shape Azure answers with, system threads and identities and all. */
143function threadRows(count: number): ReadonlyArray<Record<string, unknown>> {
144 return Array.from({ length: count }, (_, index) => ({
145 id: index + 1,
146 publishedDate: "2026-07-02T00:00:00Z",
147 lastUpdatedDate: "2026-07-02T00:00:00Z",
148 status: "active",
149 threadContext: { filePath: `/apps/server/src/generated/module-${index}.ts` },
150 identities: { 1: identity("Reviewer One") },
151 isDeleted: false,
152 comments: [
153 {
154 id: 1,
155 parentCommentId: 0,
156 author: identity("Reviewer One"),
157 content: `Comment ${index}: ${"this needs another look. ".repeat(20)}`,
158 publishedDate: "2026-07-02T00:00:00Z",
159 lastUpdatedDate: "2026-07-02T00:00:00Z",
160 commentType: "text",
161 usersLiked: [],
162 },
163 ],
164 }));
165}
166
167/** A review's iterations the shape Azure answers with, one per push. */
168function iterationRows(count: number): ReadonlyArray<Record<string, unknown>> {
169 return Array.from({ length: count }, (_, index) => ({
170 id: index + 1,
171 description: `Pushed ${index} commits`,
172 author: identity("Author One"),
173 createdDate: "2026-07-02T00:00:00Z",
174 updatedDate: "2026-07-02T00:00:00Z",
175 sourceRefCommit: { commitId: `${index}`.padStart(40, "a") },
176 targetRefCommit: { commitId: "b".repeat(40) },
177 commonRefCommit: { commitId: "c".repeat(40) },
178 hasMultipleCommits: true,
179 reason: "push",
180 push: { pushId: index + 1, date: "2026-07-02T00:00:00Z", pushedBy: identity("Author One") },
181 }));
182}
183
184afterEach(() => {
185 mockedExecute.mockReset();
186});
187
188layer("AzureDevOpsPullRequestCli.layer", (it) => {
189 it.effect("asks for one row more than the page, to probe for a next page", () =>
190 Effect.gen(function* () {
191 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
192 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
193
194 const batch = yield* cli.listPullRequests({
195 cwd: "/w",
196 repository: "web",
197 state: "open",
198 involvement: "all",
199 viewer: "bilal@acme.dev",
200 limit: 10,
201 });
202
203 assert.strictEqual(batch.items.length, 3);
204 assert.isFalse(batch.truncated);
205 expect(argsOfCall(0)).toEqual([
206 "repos",
207 "pr",
208 "list",
209 "--detect",
210 "true",
211 "--repository",
212 "web",
213 "--status",
214 "active",
215 "--include-links",
216 "--top",
217 "11",
218 "--only-show-errors",
219 "--output",
220 "json",
221 ]);
222 }),
223 );
224
225 it.effect("reads an Azure pull request page larger than the VCS default output limit", () =>
226 Effect.gen(function* () {
227 const rows = pullRequestRows(100, 1).map((row) => ({
228 ...row,
229 description: "x".repeat(10_000),
230 }));
231 const response = JSON.stringify(rows);
232 expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000);
233
234 mockedExecute.mockImplementationOnce((input) =>
235 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
236 );
237 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
238
239 const batch = yield* cli.listPullRequests({
240 cwd: "/w",
241 repository: "web",
242 state: "merged",
243 involvement: "all",
244 viewer: "bilal@acme.dev",
245 limit: 99,
246 });
247
248 assert.strictEqual(batch.items.length, 99);
249 assert.isTrue(batch.truncated);
250 }),
251 );
252
253 it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () =>
254 Effect.gen(function* () {
255 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
256 const provider = yield* AzureDevOpsPullRequestProvider.make;
257
258 const page = yield* provider.listChangeRequests({
259 cwd: "/w",
260 repository: "web",
261 host: "dev.azure.com",
262 state: "open",
263 involvement: "all",
264 viewer: "bilal@acme.dev",
265 limit: 10,
266 query: "page",
267 });
268
269 // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at
270 // all. The rows come back as they would have without a search, for the caller to narrow;
271 // nothing of the search reaches the command, where it could only mean the wrong thing.
272 assert.strictEqual(page.items.length, 3);
273 expect(argsOfCall(0)).toEqual([
274 "repos",
275 "pr",
276 "list",
277 "--detect",
278 "true",
279 "--repository",
280 "web",
281 "--status",
282 "active",
283 "--include-links",
284 "--top",
285 "11",
286 "--only-show-errors",
287 "--output",
288 "json",
289 ]);
290 }),
291 );
292
293 it.effect("steps over what it has already handed over, which is all Azure can be told", () =>
294 Effect.gen(function* () {
295 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
296 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
297
298 yield* cli.listPullRequests({
299 cwd: "/w",
300 repository: "web",
301 state: "open",
302 involvement: "all",
303 viewer: "bilal@acme.dev",
304 limit: 10,
305 // The instant is the same cursor every other host reads; Azure has no filter for it and
306 // takes the count instead.
307 cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 20 },
308 });
309
310 const args = argsOfCall(0);
311 expect(args).toContain("--skip");
312 assert.strictEqual(args[args.indexOf("--skip") + 1], "20");
313 expect(args).not.toContain("2026-07-02T00:00:00Z");
314 }),
315 );
316
317 it.effect("reports truncation from the extra row", () =>
318 Effect.gen(function* () {
319 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1))));
320 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
321
322 const batch = yield* cli.listPullRequests({
323 cwd: "/w",
324 repository: "web",
325 state: "open",
326 involvement: "all",
327 viewer: "bilal@acme.dev",
328 limit: 10,
329 });
330
331 assert.strictEqual(batch.items.length, 10);
332 assert.isTrue(batch.truncated);
333 assert.strictEqual(batch.cursorAdvance, 10);
334 }),
335 );
336
337 it.effect("advances by malformed raw rows and keeps reading until the page is full", () =>
338 Effect.gen(function* () {
339 mockedExecute
340 .mockReturnValueOnce(
341 Effect.succeed(
342 output(
343 JSON.stringify([
344 { pullRequestId: "malformed" },
345 pullRequestRows(1, 1)[0],
346 { pullRequestId: "also malformed" },
347 ]),
348 ),
349 ),
350 )
351 .mockReturnValueOnce(Effect.succeed(output(pullRequests(2, 2))));
352 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
353
354 const batch = yield* cli.listPullRequests({
355 cwd: "/w",
356 repository: "web",
357 state: "open",
358 involvement: "all",
359 viewer: "bilal@acme.dev",
360 limit: 2,
361 });
362
363 expect(batch.items.map((item) => item.number)).toEqual([1, 2]);
364 assert.isTrue(batch.truncated);
365 // Three raw rows from the first request and one from the second produced this page.
366 assert.strictEqual(batch.cursorAdvance, 4);
367 const secondArgs = argsOfCall(1);
368 assert.strictEqual(secondArgs[secondArgs.indexOf("--skip") + 1], "3");
369 assert.strictEqual(secondArgs[secondArgs.indexOf("--top") + 1], "2");
370 }),
371 );
372
373 it.effect("narrows to the author on the authored tab", () =>
374 Effect.gen(function* () {
375 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
376 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
377
378 yield* cli.listPullRequests({
379 cwd: "/w",
380 repository: "web",
381 state: "closed",
382 involvement: "authored",
383 viewer: "bilal@acme.dev",
384 limit: 10,
385 });
386
387 expect(argsOfCall(0)).toContain("--creator");
388 expect(argsOfCall(0)).toContain("bilal@acme.dev");
389 // Azure calls a closed pull request abandoned.
390 expect(argsOfCall(0)).toContain("abandoned");
391 }),
392 );
393
394 it.effect("asks Azure for every status on the All tab", () =>
395 Effect.gen(function* () {
396 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
397 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
398
399 yield* cli.listPullRequests({
400 cwd: "/w",
401 repository: "web",
402 state: "all",
403 involvement: "all",
404 viewer: "bilal@acme.dev",
405 limit: 10,
406 });
407
408 expect(argsOfCall(0)).toContain("--status");
409 expect(argsOfCall(0)).toContain("all");
410 }),
411 );
412
413 it.effect("narrows to the reviewer on the reviewing tab", () =>
414 Effect.gen(function* () {
415 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
416 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
417
418 yield* cli.listPullRequests({
419 cwd: "/w",
420 repository: "web",
421 state: "open",
422 involvement: "reviewing",
423 viewer: "bilal@acme.dev",
424 limit: 10,
425 });
426
427 expect(argsOfCall(0)).toContain("--reviewer");
428 }),
429 );
430
431 it.effect("reads the signed-in account, which az reports as a bare value", () =>
432 Effect.gen(function* () {
433 // `--query user` unwraps the object, so the wrapper has to put it back.
434 mockedExecute.mockReturnValueOnce(
435 Effect.succeed(output(JSON.stringify({ name: "bilal@acme.dev", type: "user" }))),
436 );
437 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
438
439 const viewer = yield* cli.getViewer({ cwd: "/w" });
440
441 assert.strictEqual(viewer, "bilal@acme.dev");
442 expect(argsOfCall(0)).toEqual([
443 "account",
444 "show",
445 "--query",
446 "user",
447 "--only-show-errors",
448 "--output",
449 "json",
450 ]);
451 }),
452 );
453
454 it.effect("fails when nobody is signed in", () =>
455 Effect.gen(function* () {
456 mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));
457 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
458
459 const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" }));
460
461 assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError");
462 }),
463 );
464
465 it.effect("completes a pull request to merge it, squashing only when asked", () =>
466 Effect.gen(function* () {
467 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
468 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
469
470 yield* cli.runPullRequestAction({
471 cwd: "/w",
472 number: 42,
473 action: "merge",
474 mergeMethod: "squash",
475 });
476
477 expect(argsOfCall(0)).toEqual([
478 "repos",
479 "pr",
480 "update",
481 "--detect",
482 "true",
483 "--id",
484 "42",
485 "--status",
486 "completed",
487 "--squash",
488 "true",
489 "--only-show-errors",
490 "--output",
491 "json",
492 ]);
493 }),
494 );
495
496 it.effect("stores the squash choice with an auto-completion, as a merge now does", () =>
497 Effect.gen(function* () {
498 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
499 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
500
501 yield* cli.runPullRequestAction({
502 cwd: "/w",
503 number: 42,
504 action: "enable-auto-merge",
505 mergeMethod: "squash",
506 });
507
508 expect(argsOfCall(0)).toEqual([
509 "repos",
510 "pr",
511 "update",
512 "--detect",
513 "true",
514 "--id",
515 "42",
516 "--auto-complete",
517 "true",
518 "--squash",
519 "true",
520 "--only-show-errors",
521 "--output",
522 "json",
523 ]);
524 }),
525 );
526
527 it.effect.each([
528 { action: "enable-auto-merge", expected: ["--auto-complete", "true"] },
529 { action: "disable-auto-merge", expected: ["--auto-complete", "false"] },
530 { action: "draft", expected: ["--draft", "true"] },
531 { action: "ready", expected: ["--draft", "false"] },
532 { action: "close", expected: ["--status", "abandoned"] },
533 { action: "reopen", expected: ["--status", "active"] },
534 ] as const)("moves a pull request with $action", ({ action, expected }) =>
535 Effect.gen(function* () {
536 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
537 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
538
539 yield* cli.runPullRequestAction({ cwd: "/w", number: 42, action });
540
541 expect(argsOfCall(0)).toEqual([
542 "repos",
543 "pr",
544 "update",
545 "--detect",
546 "true",
547 "--id",
548 "42",
549 ...expected,
550 "--only-show-errors",
551 "--output",
552 "json",
553 ]);
554 }),
555 );
556
557 it.effect.each([
558 { name: "a title", rewrite: { title: "Add the page" }, expected: ["--title=Add the page"] },
559 {
560 name: "a description",
561 rewrite: { body: "Why the page changed" },
562 expected: ["--description=Why the page changed"],
563 },
564 {
565 name: "both",
566 rewrite: { title: "Add the page", body: "Why the page changed" },
567 expected: ["--title=Add the page", "--description=Why the page changed"],
568 },
569 ] as const)("rewrites $name, sending nothing it was not given", ({ rewrite, expected }) =>
570 Effect.gen(function* () {
571 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
572 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
573
574 yield* cli.updatePullRequest({ cwd: "/w", number: 42, ...rewrite });
575
576 expect(argsOfCall(0)).toEqual([
577 "repos",
578 "pr",
579 "update",
580 "--detect",
581 "true",
582 "--id",
583 "42",
584 ...expected,
585 "--only-show-errors",
586 "--output",
587 "json",
588 ]);
589 }),
590 );
591
592 it.effect("sends a description that starts with a dash as one value, not as a flag", () =>
593 Effect.gen(function* () {
594 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
595 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
596
597 yield* cli.updatePullRequest({
598 cwd: "/w",
599 number: 42,
600 body: "- rewrote the page\n- kept the rest",
601 });
602
603 // One argument, so the leading dash of an ordinary bullet list never reaches az as a flag,
604 // and the whole text stays together where `--description` would otherwise take several.
605 expect(argsOfCall(0)).toContain("--description=- rewrote the page\n- kept the rest");
606 }),
607 );
608
609 it.effect("rewrites through the provider, which says it takes one", () =>
610 Effect.gen(function* () {
611 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
612 const provider = yield* AzureDevOpsPullRequestProvider.make;
613
614 // False for a remark because nothing here can post one, so there is none to rewrite.
615 expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: false });
616 assert.isDefined(provider.updateChangeRequest);
617 yield* provider.updateChangeRequest({
618 cwd: "/w",
619 repository: "web",
620 host: "dev.azure.com",
621 number: 42,
622 title: "Add the page",
623 });
624
625 expect(argsOfCall(0)).toContain("--title=Add the page");
626 expect(argsOfCall(0)).not.toContain("--description");
627 }),
628 );
629
630 it.effect("names the head's blob as what a cleared file was cleared at", () =>
631 Effect.gen(function* () {
632 mockedExecute
633 .mockReturnValueOnce(
634 Effect.succeed(
635 output(
636 JSON.stringify({
637 pullRequestId: 42,
638 title: "Add the page",
639 status: "active",
640 sourceRefName: "refs/heads/feat/page",
641 targetRefName: "refs/heads/main",
642 creationDate: "2026-07-01T00:00:00Z",
643 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
644 repository: { name: "web", project: { name: "platform" } },
645 }),
646 ),
647 ),
648 )
649 .mockReturnValueOnce(
650 Effect.succeed(
651 output(
652 JSON.stringify({
653 value: [
654 {
655 id: 1,
656 sourceRefCommit: { commitId: "a".repeat(40) },
657 commonRefCommit: { commitId: "b".repeat(40) },
658 },
659 {
660 id: 2,
661 sourceRefCommit: { commitId: "c".repeat(40) },
662 commonRefCommit: { commitId: "b".repeat(40) },
663 },
664 ],
665 }),
666 ),
667 ),
668 )
669 .mockReturnValueOnce(
670 Effect.succeed(
671 output(
672 JSON.stringify({
673 changeEntries: [
674 { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } },
675 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
676 ],
677 }),
678 ),
679 ),
680 );
681 const provider = yield* AzureDevOpsPullRequestProvider.make;
682
683 // Kept here rather than on Azure: its own record of what a reader has read is behind an
684 // undocumented endpoint, so the marks belong to this environment and need a revision of
685 // their own to tell a re-push from a file still as it was read.
686 assert.strictEqual(provider.capabilities.viewedFiles, "environment");
687 assert.isDefined(provider.getFileRevisions);
688 const answer = yield* provider.getFileRevisions({
689 cwd: "/w",
690 repository: "web",
691 host: "dev.azure.com",
692 number: 42,
693 paths: ["README.md"],
694 });
695
696 // The latest push, since an iteration's changes are reported against the merge base rather
697 // than against the push before it.
698 expect(argsOfCall(2)).toContain("iterationId=2");
699 // Only what was asked for. DEMO.md changed too, and nobody has marked it.
700 expect([...answer.revisions]).toEqual([["README.md", "8f80"]]);
701 }),
702 );
703
704 it.effect("reads where a pull request lives once, however often it is asked about", () =>
705 Effect.gen(function* () {
706 // A pull request cannot move repositories, and the marks would otherwise pay for a whole
707 // pull request read every time they checked whether a file had been pushed to.
708 const pullRequest = Effect.succeed(
709 output(
710 JSON.stringify({
711 pullRequestId: 42,
712 title: "Add the page",
713 status: "active",
714 sourceRefName: "refs/heads/feat/page",
715 targetRefName: "refs/heads/main",
716 creationDate: "2026-07-01T00:00:00Z",
717 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
718 repository: { name: "web", project: { name: "platform" } },
719 }),
720 ),
721 );
722 const iterations = () =>
723 Effect.succeed(
724 output(
725 JSON.stringify({
726 value: [
727 {
728 id: 1,
729 sourceRefCommit: { commitId: "a".repeat(40) },
730 commonRefCommit: { commitId: "b".repeat(40) },
731 },
732 ],
733 }),
734 ),
735 );
736 const changes = () =>
737 Effect.succeed(
738 output(
739 JSON.stringify({
740 changeEntries: [
741 { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } },
742 ],
743 }),
744 ),
745 );
746 mockedExecute
747 .mockReturnValueOnce(pullRequest)
748 .mockReturnValueOnce(iterations())
749 .mockReturnValueOnce(changes())
750 .mockReturnValueOnce(iterations())
751 .mockReturnValueOnce(changes());
752 const provider = yield* AzureDevOpsPullRequestProvider.make;
753 const read = provider.getFileRevisions;
754 assert.isDefined(read);
755 const ask = () =>
756 read({
757 cwd: "/w",
758 repository: "web",
759 host: "dev.azure.com",
760 number: 42,
761 paths: ["README.md"],
762 });
763
764 yield* ask();
765 const again = yield* ask();
766
767 assert.strictEqual(mockedExecute.mock.calls.length, 5);
768 // The second read goes straight to the pushes, and still answers with the head's blob.
769 expect(argsOfCall(3)).toContain("pullRequestIterations");
770 expect([...again.revisions]).toEqual([["README.md", "8f80"]]);
771 }),
772 );
773
774 it.effect(
775 "answers for a marked file the pull request no longer changes as the empty version",
776 () =>
777 Effect.gen(function* () {
778 // Which is what was stored for it when it was ticked with nothing on the head, so a file
779 // the pull request deletes is cleared once and stays cleared.
780 mockedExecute
781 .mockReturnValueOnce(
782 Effect.succeed(
783 output(
784 json({
785 pullRequestId: 42,
786 title: "Add the page",
787 status: "active",
788 sourceRefName: "refs/heads/feat/page",
789 targetRefName: "refs/heads/main",
790 creationDate: "2026-07-01T00:00:00Z",
791 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
792 repository: { name: "web", project: { name: "platform" } },
793 }),
794 ),
795 ),
796 )
797 .mockReturnValueOnce(
798 Effect.succeed(
799 output(
800 json({
801 value: [
802 {
803 id: 1,
804 sourceRefCommit: { commitId: "a".repeat(40) },
805 commonRefCommit: { commitId: "b".repeat(40) },
806 },
807 ],
808 }),
809 ),
810 ),
811 )
812 .mockReturnValueOnce(Effect.succeed(output('{"changeEntries":[]}')));
813 const provider = yield* AzureDevOpsPullRequestProvider.make;
814 assert.isDefined(provider.getFileRevisions);
815
816 const answer = yield* provider.getFileRevisions({
817 cwd: "/w",
818 repository: "web",
819 host: "dev.azure.com",
820 number: 42,
821 paths: ["GONE.md"],
822 });
823
824 expect([...answer.revisions]).toEqual([["GONE.md", ""]]);
825 }),
826 );
827
828 it.effect("says nothing about the files past the end of a change it gave up following", () =>
829 Effect.gen(function* () {
830 // Every page is an `az` process of its own, so a change past the ceiling stops being
831 // followed. A path nobody looked at must not be answered for as deleted.
832 const entries = (from: number, count: number) =>
833 Array.from({ length: count }, (_, index) => ({
834 changeType: "edit",
835 item: { path: `/src/f${from + index}.ts`, objectId: `blob-${from + index}` },
836 }));
837 mockedExecute
838 .mockReturnValueOnce(
839 Effect.succeed(
840 output(
841 json({
842 pullRequestId: 42,
843 title: "Add the page",
844 status: "active",
845 sourceRefName: "refs/heads/feat/page",
846 targetRefName: "refs/heads/main",
847 creationDate: "2026-07-01T00:00:00Z",
848 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
849 repository: { name: "web", project: { name: "platform" } },
850 }),
851 ),
852 ),
853 )
854 .mockReturnValueOnce(
855 Effect.succeed(
856 output(
857 json({
858 value: [
859 {
860 id: 1,
861 sourceRefCommit: { commitId: "a".repeat(40) },
862 commonRefCommit: { commitId: "b".repeat(40) },
863 },
864 ],
865 }),
866 ),
867 ),
868 )
869 .mockReturnValueOnce(
870 Effect.succeed(output(json({ changeEntries: entries(0, 5_000), nextSkip: 5_000 }))),
871 )
872 .mockReturnValueOnce(
873 Effect.succeed(output(json({ changeEntries: entries(5_000, 5_000), nextSkip: 10_000 }))),
874 );
875 const provider = yield* AzureDevOpsPullRequestProvider.make;
876 assert.isDefined(provider.getFileRevisions);
877
878 const answer = yield* provider.getFileRevisions({
879 cwd: "/w",
880 repository: "web",
881 host: "dev.azure.com",
882 number: 42,
883 paths: ["src/f1.ts", "src/f5001.ts", "src/past-the-cut.ts"],
884 });
885
886 // The second page picks up where the first said it ended.
887 expect(argsOfCall(3)).toContain("$skip=5000");
888 expect([...answer.revisions]).toEqual([
889 ["src/f1.ts", "blob-1"],
890 ["src/f5001.ts", "blob-5001"],
891 ]);
892 }),
893 );
894
895 it.effect("stops following pages by what Azure counts, not by what survives decoding", () =>
896 Effect.gen(function* () {
897 mockedExecute
898 .mockReturnValueOnce(
899 Effect.succeed(
900 output(
901 json({
902 pullRequestId: 42,
903 title: "Add the page",
904 status: "active",
905 sourceRefName: "refs/heads/feat/page",
906 targetRefName: "refs/heads/main",
907 creationDate: "2026-07-01T00:00:00Z",
908 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
909 repository: { name: "web", project: { name: "platform" } },
910 }),
911 ),
912 ),
913 )
914 .mockReturnValueOnce(
915 Effect.succeed(
916 output(
917 json({
918 value: [
919 {
920 id: 1,
921 sourceRefCommit: { commitId: "a".repeat(40) },
922 commonRefCommit: { commitId: "b".repeat(40) },
923 },
924 ],
925 }),
926 ),
927 ),
928 )
929 // Nothing a review can show, so every page decodes to nothing at all and a ceiling counted
930 // in files would never be reached however long the walk went on.
931 .mockImplementation((command) => {
932 const skip = command.args.find((arg) => arg.startsWith("$skip="));
933 const from = Number(skip?.slice("$skip=".length) ?? 0);
934 return Effect.succeed(
935 output(
936 json({
937 changeEntries: [{ changeType: "add", item: { path: "/src", isFolder: true } }],
938 nextSkip: from + 2_000,
939 }),
940 ),
941 );
942 });
943 const provider = yield* AzureDevOpsPullRequestProvider.make;
944 assert.isDefined(provider.getFileRevisions);
945
946 const answer = yield* provider.getFileRevisions({
947 cwd: "/w",
948 repository: "web",
949 host: "dev.azure.com",
950 number: 42,
951 paths: ["src/page.ts"],
952 });
953
954 // The pull request, its pushes, and five pages: the walk gives up on Azure's own offset
955 // rather than spending an `az` process a page for as long as Azure keeps paging.
956 assert.strictEqual(mockedExecute.mock.calls.length, 7);
957 // And it read part of a change, so it says nothing about the file it never saw.
958 assert.strictEqual(answer.revisions.size, 0);
959 }),
960 );
961
962 it.effect("leaves one file the host would not hand over listed without its hunks", () =>
963 Effect.gen(function* () {
964 mockedExecute
965 .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow))))
966 .mockReturnValueOnce(Effect.succeed(output(json(oneIteration))))
967 .mockReturnValueOnce(
968 Effect.succeed(
969 output(
970 json({
971 changeEntries: [
972 { changeType: "add", item: { path: "/huge.bin", objectId: "8f80" } },
973 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
974 ],
975 }),
976 ),
977 ),
978 )
979 .mockReturnValueOnce(
980 Effect.fail(
981 new AzureDevOpsCli.AzureDevOpsCommandFailedError({
982 operation: "execute",
983 command: "az",
984 cwd: "/w",
985 argumentCount: 1,
986 cause: "the blob is past what the route will carry",
987 }),
988 ),
989 )
990 .mockReturnValueOnce(Effect.succeed(output(json({ content: "hello\n" }))));
991 const provider = yield* AzureDevOpsPullRequestProvider.make;
992
993 const slice = yield* provider.getDiff({
994 cwd: "/w",
995 repository: "web",
996 host: "dev.azure.com",
997 number: 42,
998 });
999
1000 assert.isTrue(slice.truncated);
1001 expect(slice.patch).toContain("diff --git a/huge.bin b/huge.bin");
1002 // And the file behind it still renders, which is the point of giving up on one file.
1003 expect(slice.patch).toContain("+hello");
1004 }),
1005 );
1006
1007 it.effect("fails the whole read when it is the connection that would not answer", () =>
1008 Effect.gen(function* () {
1009 // A rate limit is not this file's problem, and answering with a change full of files listed
1010 // without their hunks would read as a change nobody can see rather than as a host to wait
1011 // for.
1012 mockedExecute
1013 .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow))))
1014 .mockReturnValueOnce(Effect.succeed(output(json(oneIteration))))
1015 .mockReturnValueOnce(
1016 Effect.succeed(
1017 output(
1018 json({
1019 changeEntries: [
1020 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
1021 ],
1022 }),
1023 ),
1024 ),
1025 )
1026 .mockReturnValueOnce(
1027 Effect.fail(
1028 new AzureDevOpsCli.AzureDevOpsCliRateLimitError({
1029 operation: "execute",
1030 command: "az",
1031 cwd: "/w",
1032 argumentCount: 1,
1033 cause: "429",
1034 }),
1035 ),
1036 );
1037 const provider = yield* AzureDevOpsPullRequestProvider.make;
1038
1039 const error = yield* Effect.flip(
1040 provider.getDiff({ cwd: "/w", repository: "web", host: "dev.azure.com", number: 42 }),
1041 );
1042
1043 assert.strictEqual(error.reason, "rate-limited");
1044 }),
1045 );
1046
1047 it.effect("takes Azure's own word on a file it will not spell out", () =>
1048 Effect.gen(function* () {
1049 mockedExecute
1050 .mockReturnValueOnce(
1051 Effect.succeed(
1052 output(
1053 json({
1054 pullRequestId: 42,
1055 title: "Add the page",
1056 status: "active",
1057 sourceRefName: "refs/heads/feat/page",
1058 targetRefName: "refs/heads/main",
1059 creationDate: "2026-07-01T00:00:00Z",
1060 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
1061 repository: { name: "web", project: { name: "platform" } },
1062 }),
1063 ),
1064 ),
1065 )
1066 .mockReturnValueOnce(
1067 Effect.succeed(
1068 output(
1069 json({
1070 value: [
1071 {
1072 id: 1,
1073 sourceRefCommit: { commitId: "a".repeat(40) },
1074 commonRefCommit: { commitId: "b".repeat(40) },
1075 },
1076 ],
1077 }),
1078 ),
1079 ),
1080 )
1081 .mockReturnValueOnce(
1082 Effect.succeed(
1083 output(
1084 json({
1085 changeEntries: [
1086 { changeType: "add", item: { path: "/logo.png", objectId: "8f80" } },
1087 ],
1088 }),
1089 ),
1090 ),
1091 )
1092 .mockReturnValueOnce(
1093 Effect.succeed(
1094 output(json({ content: "iVBORw0KGgo=", contentMetadata: { isBinary: true } })),
1095 ),
1096 );
1097 const provider = yield* AzureDevOpsPullRequestProvider.make;
1098
1099 const slice = yield* provider.getDiff({
1100 cwd: "/w",
1101 repository: "web",
1102 host: "dev.azure.com",
1103 number: 42,
1104 });
1105
1106 // Azure leaves the metadata out unless it is asked for, and without it every file reads as
1107 // text however it was stored.
1108 expect(argsOfCall(3)).toContain("includeContentMetadata=true");
1109 expect(slice.patch).toContain("Binary files a/logo.png and b/logo.png differ");
1110 assert.isTrue(slice.truncated);
1111 }),
1112 );
1113
1114 it.effect("stops following pages when one of them does not move the cursor on", () =>
1115 Effect.gen(function* () {
1116 mockedExecute
1117 .mockReturnValueOnce(
1118 Effect.succeed(
1119 output(
1120 json({
1121 pullRequestId: 42,
1122 title: "Add the page",
1123 status: "active",
1124 sourceRefName: "refs/heads/feat/page",
1125 targetRefName: "refs/heads/main",
1126 creationDate: "2026-07-01T00:00:00Z",
1127 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
1128 repository: { name: "web", project: { name: "platform" } },
1129 }),
1130 ),
1131 ),
1132 )
1133 .mockReturnValueOnce(
1134 Effect.succeed(
1135 output(
1136 json({
1137 value: [
1138 {
1139 id: 1,
1140 sourceRefCommit: { commitId: "a".repeat(40) },
1141 commonRefCommit: { commitId: "b".repeat(40) },
1142 },
1143 ],
1144 }),
1145 ),
1146 ),
1147 )
1148 .mockReturnValueOnce(
1149 Effect.succeed(
1150 output(
1151 json({
1152 changeEntries: [{ changeType: "edit", item: { path: "/a.ts", objectId: "8f80" } }],
1153 nextSkip: 2_000,
1154 }),
1155 ),
1156 ),
1157 )
1158 .mockReturnValueOnce(
1159 Effect.succeed(
1160 output(
1161 json({
1162 changeEntries: [{ changeType: "edit", item: { path: "/b.ts", objectId: "0ca4" } }],
1163 nextSkip: 2_000,
1164 }),
1165 ),
1166 ),
1167 );
1168 const provider = yield* AzureDevOpsPullRequestProvider.make;
1169 assert.isDefined(provider.getFileRevisions);
1170
1171 const answer = yield* provider.getFileRevisions({
1172 cwd: "/w",
1173 repository: "web",
1174 host: "dev.azure.com",
1175 number: 42,
1176 paths: ["a.ts", "b.ts", "unlisted.ts"],
1177 });
1178
1179 // Four reads and no more: a page pointing at where it already is would be read forever.
1180 assert.strictEqual(mockedExecute.mock.calls.length, 4);
1181 // And what was read is not the whole change, so the file nobody listed is left unanswered
1182 // rather than reported as gone from the change request.
1183 expect([...answer.revisions]).toEqual([
1184 ["a.ts", "8f80"],
1185 ["b.ts", "0ca4"],
1186 ]);
1187 }),
1188 );
1189
1190 it.effect("asks Azure nothing when no file has been ticked off", () =>
1191 Effect.gen(function* () {
1192 mockedExecute.mockReturnValue(Effect.succeed(output('{"changeEntries":[]}')));
1193 const provider = yield* AzureDevOpsPullRequestProvider.make;
1194 assert.isDefined(provider.getFileRevisions);
1195
1196 const answer = yield* provider.getFileRevisions({
1197 cwd: "/w",
1198 repository: "web",
1199 host: "dev.azure.com",
1200 number: 42,
1201 paths: [],
1202 });
1203
1204 expect(answer.revisions.size).toBe(0);
1205 // Nothing was marked, so Azure was not asked at all.
1206 assert.strictEqual(mockedExecute.mock.calls.length, 0);
1207 }),
1208 );
1209
1210 it.effect("reads the conversation through the REST API, pinned to a version", () =>
1211 Effect.gen(function* () {
1212 mockedExecute.mockReturnValueOnce(
1213 Effect.succeed(
1214 output(
1215 JSON.stringify({
1216 value: [
1217 {
1218 id: 1,
1219 comments: [
1220 { id: 1, content: "Looks good.", publishedDate: "2026-07-02T00:00:00Z" },
1221 ],
1222 },
1223 ],
1224 }),
1225 ),
1226 ),
1227 );
1228 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1229
1230 const comments = yield* cli.listThreads({
1231 cwd: "/w",
1232 location: { project: "platform", repository: "web" },
1233 number: 42,
1234 });
1235
1236 assert.strictEqual(comments.length, 1);
1237 // `az devops invoke` rather than `az rest`: it signs in the way the azure-devops extension
1238 // does, and `az rest` mints its own token against whichever tenant `az` defaults to.
1239 expect(argsOfCall(0)).toContain("invoke");
1240 expect(argsOfCall(0)).toContain("pullRequestThreads");
1241 expect(argsOfCall(0)).toContain("project=platform");
1242 expect(argsOfCall(0)).toContain("repositoryId=web");
1243 expect(argsOfCall(0)).toContain("pullRequestId=42");
1244 // A review's threads grow with how long it ran, and this route does not page, so the read
1245 // asks for more than the process default rather than taking whatever it is given.
1246 expect(maxOutputBytesOfCall(0)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1247 }),
1248 );
1249
1250 it.effect("reads a long review's threads, which are past the default output limit", () =>
1251 Effect.gen(function* () {
1252 const response = json({ value: threadRows(800) });
1253 // Azure opens a thread per vote and per ref update beside the ones people wrote, and every
1254 // comment carries a full identity, so a review argued over for weeks outgrows the default.
1255 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1256 mockedExecute.mockImplementationOnce((input) =>
1257 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1258 );
1259 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1260
1261 const comments = yield* cli.listThreads({
1262 cwd: "/w",
1263 location: { project: "platform", repository: "web" },
1264 number: 42,
1265 });
1266
1267 assert.strictEqual(comments.length, 800);
1268 }),
1269 );
1270
1271 it.effect("reads a long review's iterations, which are past the default output limit", () =>
1272 Effect.gen(function* () {
1273 const response = json({ value: iterationRows(1_200) });
1274 // This route does not page, so the whole history arrives at once. Cut at the default it is
1275 // JSON stopping mid-string, and every diff and file revision read on this host fails with
1276 // it, since each of them starts by asking which iteration is the latest.
1277 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1278 mockedExecute.mockImplementationOnce((input) =>
1279 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1280 );
1281 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1282
1283 const iterations = yield* cli.listIterations({
1284 cwd: "/w",
1285 location: { project: "platform", repository: "web" },
1286 number: 42,
1287 });
1288
1289 assert.strictEqual(iterations.length, 1_200);
1290 assert.strictEqual(iterations.at(-1)?.id, 1_200);
1291 }),
1292 );
1293
1294 it.effect("reads a full page of change entries, which is past the default output limit", () =>
1295 Effect.gen(function* () {
1296 const response = json({ changeEntries: changeEntries(2_000) });
1297 // Azure's own maximum for this route, and every entry carries a path, a url and three
1298 // object ids, so an ordinary page of a large change already outgrows the default.
1299 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1300 mockedExecute.mockImplementationOnce((input) =>
1301 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1302 );
1303 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1304
1305 const page = yield* cli.listIterationChanges({
1306 cwd: "/w",
1307 location: { project: "platform", repository: "web" },
1308 number: 42,
1309 iterationId: 1,
1310 });
1311
1312 // Cut at the default this would arrive as JSON stopping mid-string, and a perfectly
1313 // ordinary page would be reported as a host answering with nonsense.
1314 assert.strictEqual(page.changes.length, 2_000);
1315 assert.isFalse(page.truncated);
1316 }),
1317 );
1318
1319 it.effect("reads a file whose JSON envelope is past the default output limit", () =>
1320 Effect.gen(function* () {
1321 // Under a megabyte as bytes on the host, so this is a file the other hosts hand over.
1322 const file = "const value = 1;\n".repeat(57_000);
1323 const response = json({ content: file });
1324 expect(Buffer.byteLength(file)).toBeLessThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1325 // And past it once Azure wraps it, because there is no route here that serves the bytes.
1326 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1327 mockedExecute.mockImplementationOnce((input) =>
1328 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1329 );
1330 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1331
1332 const item = yield* cli.readItemContent({
1333 cwd: "/w",
1334 location: { project: "platform", repository: "web" },
1335 path: "src/generated/schema.ts",
1336 commit: "a".repeat(40),
1337 });
1338
1339 assert.strictEqual(item.contents, file);
1340 assert.isFalse(item.isBinary);
1341 }),
1342 );
1343
1344 it.effect("asks for a file by Azure's own spelling of its path", () =>
1345 Effect.gen(function* () {
1346 mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "const a = 1;" }))));
1347 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1348
1349 // The path carried around here has had Azure's leading slash taken off so it matches the
1350 // patch and the viewed mark. The items route is documented in Azure's spelling, so it goes
1351 // back on the way out rather than being sent as the shorter name.
1352 yield* cli.readItemContent({
1353 cwd: "/w",
1354 location: { project: "platform", repository: "web" },
1355 path: "src/app.ts",
1356 commit: "a".repeat(40),
1357 });
1358
1359 expect(argsOfCall(0)).toContain("path=/src/app.ts");
1360 }),
1361 );
1362
1363 it.effect("reports a pull request it cannot place as its own outcome", () =>
1364 Effect.gen(function* () {
1365 mockedExecute.mockReturnValueOnce(
1366 Effect.succeed(
1367 output(
1368 // Well-formed, but with nothing to build a link from: not a decode failure.
1369 JSON.stringify({
1370 pullRequestId: 42,
1371 title: "Add the page",
1372 sourceRefName: "refs/heads/feat/page",
1373 targetRefName: "refs/heads/main",
1374 creationDate: "2026-07-01T00:00:00Z",
1375 }),
1376 ),
1377 ),
1378 );
1379 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1380
1381 const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 }));
1382
1383 assert.strictEqual(error._tag, "AzureDevOpsPullRequestIncompleteError");
1384 }),
1385 );
1386
1387 it.effect("fails the read when az returns something unreadable", () =>
1388 Effect.gen(function* () {
1389 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}')));
1390 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1391
1392 const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 }));
1393
1394 assert.strictEqual(error._tag, "AzureDevOpsPullRequestReadError");
1395 }),
1396 );
1397
1398 it.effect("adds reviewers with the one command Azure has for it", () =>
1399 Effect.gen(function* () {
1400 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1401 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1402
1403 yield* cli.setPullRequestReviewers({
1404 cwd: "/w",
1405 number: 42,
1406 reviewers: ["octocat@acme.test", "hubot@acme.test"],
1407 requested: true,
1408 });
1409
1410 expect(argsOfCall(0)).toEqual([
1411 "repos",
1412 "pr",
1413 "reviewer",
1414 "add",
1415 "--detect",
1416 "true",
1417 "--id",
1418 "42",
1419 "--reviewers",
1420 "octocat@acme.test",
1421 "hubot@acme.test",
1422 "--only-show-errors",
1423 "--output",
1424 "json",
1425 ]);
1426 }),
1427 );
1428
1429 it.effect("takes a reviewer off the pull request with the same command's counterpart", () =>
1430 Effect.gen(function* () {
1431 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1432 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1433
1434 yield* cli.setPullRequestReviewers({
1435 cwd: "/w",
1436 number: 42,
1437 reviewers: ["octocat@acme.test"],
1438 requested: false,
1439 });
1440
1441 expect(argsOfCall(0)).toContain("remove");
1442 }),
1443 );
1444
1445 it.effect("refuses a reviewer az would read as a flag, before running anything", () =>
1446 Effect.gen(function* () {
1447 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1448
1449 const error = yield* Effect.flip(
1450 cli.setPullRequestReviewers({
1451 cwd: "/w",
1452 number: 42,
1453 reviewers: ["--query"],
1454 requested: true,
1455 }),
1456 );
1457
1458 assert.strictEqual(error._tag, "AzureDevOpsReviewerNameError");
1459 assert.strictEqual(mockedExecute.mock.calls.length, 0);
1460 }),
1461 );
1462});
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts created+744
...@@ -0,0 +1,744 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";
6import type {
7 PullRequestAction,
8 PullRequestComment,
9 PullRequestInvolvement,
10 PullRequestListState,
11 PullRequestMergeMethod,
12} from "@t3tools/contracts";
13
14import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
15import {
16 decodeItemContentJson,
17 decodeIterationChangesJson,
18 decodeIterationsJson,
19 decodePullRequestJson,
20 decodePullRequestListJson,
21 decodeThreadsJson,
22 decodeViewerJson,
23 type AzureDevOpsChangeEntry,
24 type AzureDevOpsItemContent,
25 type AzureDevOpsIteration,
26 type AzureDevOpsPullRequest,
27 type AzureDevOpsRepositoryLocation,
28} from "./azureDevOpsPullRequestJson.ts";
29import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
30
31/**
32 * Names the read that produced unusable output, so a failure reports the call it came from
33 * rather than borrowing another operation's message.
34 */
35export class AzureDevOpsPullRequestReadError extends Schema.TaggedError<AzureDevOpsPullRequestReadError>()(
36 "AzureDevOpsPullRequestReadError",
37 {
38 command: Schema.Literal("az"),
39 cwd: Schema.String,
40 operation: Schema.String,
41 cause: Schema.Defect(),
42 },
43) {
44 get detail(): string {
45 return `Azure CLI returned an unreadable ${this.operation} response.`;
46 }
47
48 override get message(): string {
49 return `Azure CLI failed in ${this.operation}: ${this.detail}`;
50 }
51}
52
53/** Not a decode failure: az answered, the account it answered for just has no name. */
54export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError<AzureDevOpsViewerUnavailableError>()(
55 "AzureDevOpsViewerUnavailableError",
56 {
57 command: Schema.Literal("az"),
58 cwd: Schema.String,
59 },
60) {
61 get detail(): string {
62 return "Azure CLI returned no account for the current sign-in.";
63 }
64
65 override get message(): string {
66 return `Azure CLI failed in getViewer: ${this.detail}`;
67 }
68}
69
70/**
71 * Not a decode failure either: az answered with a well-formed pull request that simply carries
72 * no branch or link, which is a response this cannot place rather than one it cannot read.
73 */
74export class AzureDevOpsPullRequestIncompleteError extends Schema.TaggedError<AzureDevOpsPullRequestIncompleteError>()(
75 "AzureDevOpsPullRequestIncompleteError",
76 {
77 command: Schema.Literal("az"),
78 cwd: Schema.String,
79 number: Schema.Int,
80 },
81) {
82 get detail(): string {
83 return "Azure DevOps returned no branch or link for the pull request.";
84 }
85
86 override get message(): string {
87 return `Azure CLI failed in getPullRequest: ${this.detail}`;
88 }
89}
90
91/**
92 * Not a decode failure: the reader named a reviewer `az` would read as a flag of its own. The
93 * reviewers travel as argv rather than in a request body — `az repos pr reviewer` takes them no
94 * other way — so anything that could leave the value position is refused rather than sent.
95 */
96export class AzureDevOpsReviewerNameError extends Schema.TaggedError<AzureDevOpsReviewerNameError>()(
97 "AzureDevOpsReviewerNameError",
98 {
99 command: Schema.Literal("az"),
100 cwd: Schema.String,
101 },
102) {
103 get detail(): string {
104 return "A reviewer is named by an email address or an identity id.";
105 }
106
107 override get message(): string {
108 return `Azure CLI failed in setPullRequestReviewers: ${this.detail}`;
109 }
110}
111
112export type AzureDevOpsPullRequestCliError =
113 | AzureDevOpsCli.AzureDevOpsCliError
114 | AzureDevOpsPullRequestReadError
115 | AzureDevOpsPullRequestIncompleteError
116 | AzureDevOpsReviewerNameError
117 | AzureDevOpsViewerUnavailableError;
118
119/** The version every REST call below is pinned to, so a new default cannot reshape a response. */
120const REST_API_VERSION = "7.1";
121const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
122/**
123 * A full page of change entries is two thousand files, each carrying its path, its url and
124 * several object ids, which is past the megabyte a read is given by default. Output cut at that
125 * ceiling arrives here as JSON that will not parse, so a change large enough to be paged would
126 * report itself as a host returning nonsense rather than as the ordinary page it is.
127 */
128const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
129/**
130 * Four times the megabyte of file the other hosts hand over, because Azure has no route that
131 * serves the bytes themselves: the file arrives inside a JSON envelope, escaped if it is text and
132 * base64 if it is not, and both are larger than the file they carry.
133 */
134const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
135/**
136 * What a review's own history is given. Neither route pages, so each answers with the whole of it
137 * at once and grows with how long the review ran rather than with how large the change is. Cut at
138 * the default, both arrive as JSON that stops mid-string, and a long review would report its host
139 * as answering with nonsense.
140 */
141const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
142
143/** Azure's own ceiling for one page of an iteration's changes. */
144const CHANGE_ENTRIES_PER_PAGE = 2000;
145
146/**
147 * Where following the pages stops, counted in the entries Azure was asked to skip rather than in
148 * the files that survived decoding: a page can be entirely folders and other entries a review has
149 * nothing to show for, and bounding on what was kept would follow such a change forever. Every
150 * page is an `az` process of its own, so the read gives up and reports itself incomplete rather
151 * than presenting five pages as the whole change.
152 */
153const MAX_CHANGE_ENTRIES = 10_000;
154
155/** What an iteration changed, and whether following its pages reached the end of it. */
156export interface AzureDevOpsIterationChanges {
157 readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>;
158 readonly truncated: boolean;
159}
160
161export class AzureDevOpsPullRequestCli extends Context.Service<
162 AzureDevOpsPullRequestCli,
163 {
164 readonly getViewer: (input: {
165 readonly cwd: string;
166 }) => Effect.Effect<string, AzureDevOpsPullRequestCliError>;
167
168 readonly listPullRequests: (input: {
169 readonly cwd: string;
170 readonly repository: string;
171 readonly state: PullRequestListState;
172 readonly involvement: PullRequestInvolvement;
173 readonly viewer: string;
174 readonly limit: number;
175 /**
176 * Where to carry on from. Azure has no date filter for a pull request listing, so the only
177 * part of a cursor it can use is how many rows have already been handed over.
178 */
179 readonly cursor?: ProviderListCursor | undefined;
180 }) => Effect.Effect<
181 {
182 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
183 readonly truncated: boolean;
184 /** Raw Azure rows consumed to produce this page, including malformed rows. */
185 readonly cursorAdvance: number;
186 },
187 AzureDevOpsPullRequestCliError
188 >;
189
190 readonly getPullRequest: (input: {
191 readonly cwd: string;
192 readonly number: number;
193 }) => Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError>;
194
195 /** Threads are not reachable through `az repos pr`, so they come from the REST API. */
196 readonly listThreads: (input: {
197 readonly cwd: string;
198 readonly location: AzureDevOpsRepositoryLocation;
199 readonly number: number;
200 }) => Effect.Effect<ReadonlyArray<PullRequestComment>, AzureDevOpsPullRequestCliError>;
201
202 /**
203 * The pushes a pull request has had, oldest first. Azure hangs the changed files off an
204 * iteration rather than off the pull request, so reading a diff starts here.
205 */
206 readonly listIterations: (input: {
207 readonly cwd: string;
208 readonly location: AzureDevOpsRepositoryLocation;
209 readonly number: number;
210 }) => Effect.Effect<ReadonlyArray<AzureDevOpsIteration>, AzureDevOpsPullRequestCliError>;
211
212 /**
213 * What one iteration changed, against the merge base rather than against the previous push,
214 * which is the whole of the pull request rather than the latest slice of it.
215 */
216 readonly listIterationChanges: (input: {
217 readonly cwd: string;
218 readonly location: AzureDevOpsRepositoryLocation;
219 readonly number: number;
220 readonly iterationId: number;
221 }) => Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError>;
222
223 /**
224 * One file's text at one commit. Azure has no diff route that carries content, so both sides
225 * of every changed file are read this way and the patch is made from them here.
226 */
227 readonly readItemContent: (input: {
228 readonly cwd: string;
229 readonly location: AzureDevOpsRepositoryLocation;
230 readonly path: string;
231 readonly commit: string;
232 }) => Effect.Effect<AzureDevOpsItemContent, AzureDevOpsPullRequestCliError>;
233
234 readonly runPullRequestAction: (input: {
235 readonly cwd: string;
236 readonly number: number;
237 readonly action: PullRequestAction;
238 readonly mergeMethod?: PullRequestMergeMethod;
239 }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;
240
241 /** Rewrites the pull request's own words, through the same command that moves it. */
242 readonly updatePullRequest: (input: {
243 readonly cwd: string;
244 readonly number: number;
245 readonly title?: string | undefined;
246 readonly body?: string | undefined;
247 }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;
248
249 /**
250 * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole
251 * of what Azure offers here: it adds and removes named identities, and has no counterpart that
252 * says who could be named.
253 */
254 readonly setPullRequestReviewers: (input: {
255 readonly cwd: string;
256 readonly number: number;
257 readonly reviewers: ReadonlyArray<string>;
258 readonly requested: boolean;
259 }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;
260 }
261>()("@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli") {}
262
263function statusArgs(state: PullRequestListState): ReadonlyArray<string> {
264 switch (state) {
265 case "open":
266 return ["--status", "active"];
267 case "merged":
268 return ["--status", "completed"];
269 case "closed":
270 return ["--status", "abandoned"];
271 case "all":
272 return ["--status", "all"];
273 }
274}
275
276function involvementArgs(input: {
277 readonly involvement: PullRequestInvolvement;
278 readonly viewer: string;
279}): ReadonlyArray<string> {
280 switch (input.involvement) {
281 case "authored":
282 return ["--creator", input.viewer];
283 case "reviewing":
284 return ["--reviewer", input.viewer];
285 case "all":
286 return [];
287 }
288}
289
290/**
291 * Azure moves a pull request by setting its state rather than by named commands: completing it
292 * is the merge, abandoning it is the close, and reactivating it is the reopen. Squashing is a
293 * completion option rather than a strategy of its own.
294 */
295function actionArgs(
296 action: PullRequestAction,
297 mergeMethod: PullRequestMergeMethod | undefined,
298): ReadonlyArray<string> {
299 switch (action) {
300 case "merge":
301 return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"];
302 // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes
303 // it once its policies pass. The squash choice is stored with it, as it is for a merge now.
304 case "enable-auto-merge":
305 return [
306 "--auto-complete",
307 "true",
308 ...(mergeMethod === undefined
309 ? []
310 : ["--squash", mergeMethod === "squash" ? "true" : "false"]),
311 ];
312 case "disable-auto-merge":
313 return ["--auto-complete", "false"];
314 case "ready":
315 return ["--draft", "false"];
316 case "draft":
317 return ["--draft", "true"];
318 case "close":
319 return ["--status", "abandoned"];
320 // Never reached: this host does not declare the action, so nothing offers it.
321 case "update-branch":
322 return [];
323 case "reopen":
324 return ["--status", "active"];
325 // Never reached: this host does not declare the action, so the service refuses it first.
326 case "revert":
327 case "approve-workflows":
328 throw new Error(`Azure DevOps pull request action ${action} is unsupported`);
329 }
330}
331
332/**
333 * A reviewer Azure could be given: an email address, a display name or an identity guid, and
334 * nothing that starts with a dash. The dash is the whole point — these are argv, and a value that
335 * looks like a flag stops being a value.
336 */
337function isReviewerName(value: string): boolean {
338 const name = value.trim();
339 return name.length > 0 && !name.startsWith("-");
340}
341
342/** @public Service construction is part of the canonical Effect module API. */
343export const make = Effect.gen(function* () {
344 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
345
346 // Every command resolves the organization, project and repository from the checkout, which is
347 // what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows
348 // how to read all of them.
349 const detectArgs = ["--detect", "true"] as const;
350
351 const executeJson = (input: {
352 readonly cwd: string;
353 readonly args: ReadonlyArray<string>;
354 readonly maxOutputBytes?: number;
355 }) =>
356 azure.execute({
357 cwd: input.cwd,
358 args: [...input.args, "--only-show-errors", "--output", "json"],
359 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
360 });
361
362 /**
363 * A REST route reached through `az devops invoke`, which addresses it by area, resource and
364 * route parameters rather than by URL. Used in place of `az rest` because it signs in the way
365 * the azure-devops extension does: `az rest` mints its own token against the tenant `az`
366 * defaults to, and an organisation in any other tenant answers that with a sign-in page, which
367 * arrives here as unreadable output rather than as a failure.
368 */
369 const invoke = <A>(input: {
370 readonly cwd: string;
371 readonly operation: string;
372 readonly resource: string;
373 readonly routeParameters: ReadonlyArray<string>;
374 readonly queryParameters?: ReadonlyArray<string>;
375 readonly maxOutputBytes?: number;
376 readonly decode: (raw: string) => Result.Result<A, unknown>;
377 }): Effect.Effect<A, AzureDevOpsPullRequestCliError> =>
378 executeJson({
379 cwd: input.cwd,
380 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
381 args: [
382 "devops",
383 "invoke",
384 ...detectArgs,
385 "--area",
386 "git",
387 "--resource",
388 input.resource,
389 "--api-version",
390 REST_API_VERSION,
391 "--route-parameters",
392 ...input.routeParameters,
393 ...(input.queryParameters === undefined
394 ? []
395 : ["--query-parameters", ...input.queryParameters]),
396 ],
397 }).pipe(
398 Effect.flatMap((result) => {
399 const decoded = input.decode(result.stdout.trim());
400 return Result.isSuccess(decoded)
401 ? Effect.succeed(decoded.success)
402 : Effect.fail(
403 new AzureDevOpsPullRequestReadError({
404 command: "az",
405 cwd: input.cwd,
406 operation: input.operation,
407 cause: decoded.failure,
408 }),
409 );
410 }),
411 );
412
413 /**
414 * Azure names its own items with a leading slash, which the repository paths carried around
415 * here have had taken off so they match the patch and the viewed mark. Put it back on the way
416 * out, because the items route is documented in Azure's own spelling.
417 */
418 const toItemPath = (path: string) => (path.startsWith("/") ? path : `/${path}`);
419
420 const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray<string> => [
421 `project=${location.project}`,
422 `repositoryId=${location.repository}`,
423 ];
424
425 const pullRequestRoute = (input: {
426 readonly location: AzureDevOpsRepositoryLocation;
427 readonly number: number;
428 }): ReadonlyArray<string> => [
429 ...repositoryRoute(input.location),
430 `pullRequestId=${input.number}`,
431 ];
432
433 /**
434 * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and
435 * retain the raw count so the next public cursor skips every row this walk consumed.
436 */
437 const listPullRequestPage = (input: {
438 readonly cwd: string;
439 readonly repository: string;
440 readonly state: PullRequestListState;
441 readonly involvement: PullRequestInvolvement;
442 readonly viewer: string;
443 readonly limit: number;
444 readonly skip: number;
445 readonly cursorAdvance: number;
446 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
447 }): Effect.Effect<
448 {
449 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
450 readonly truncated: boolean;
451 readonly cursorAdvance: number;
452 },
453 AzureDevOpsPullRequestCliError
454 > => {
455 const remaining = input.limit - input.items.length;
456 const top = remaining + 1;
457 return executeJson({
458 cwd: input.cwd,
459 maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES,
460 args: [
461 "repos",
462 "pr",
463 "list",
464 ...detectArgs,
465 "--repository",
466 input.repository,
467 ...statusArgs(input.state),
468 ...involvementArgs(input),
469 // A web link per row, which is the only url that needs no assembling.
470 "--include-links",
471 ...(input.skip === 0 ? [] : ["--skip", String(input.skip)]),
472 "--top",
473 String(top),
474 ],
475 }).pipe(
476 Effect.flatMap((result) => {
477 const raw = result.stdout.trim();
478 if (raw.length === 0) {
479 return Effect.succeed({
480 items: input.items,
481 truncated: false,
482 cursorAdvance: input.cursorAdvance,
483 });
484 }
485 const decoded = decodePullRequestListJson(raw);
486 if (!Result.isSuccess(decoded)) {
487 return Effect.fail(
488 new AzureDevOpsPullRequestReadError({
489 command: "az",
490 cwd: input.cwd,
491 operation: "listPullRequests",
492 cause: decoded.failure,
493 }),
494 );
495 }
496
497 const lastItemIndex = decoded.success.rawIndexes[remaining - 1];
498 if (lastItemIndex !== undefined) {
499 const consumed = lastItemIndex + 1;
500 return Effect.succeed({
501 items: [...input.items, ...decoded.success.items.slice(0, remaining)],
502 // A full raw response may have more rows even when malformed entries used the probe.
503 truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top,
504 cursorAdvance: input.cursorAdvance + consumed,
505 });
506 }
507
508 const items = [...input.items, ...decoded.success.items];
509 if (decoded.success.rawCount < top) {
510 return Effect.succeed({
511 items,
512 truncated: false,
513 cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
514 });
515 }
516 return listPullRequestPage({
517 ...input,
518 skip: input.skip + decoded.success.rawCount,
519 cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
520 items,
521 });
522 }),
523 );
524 };
525
526 return AzureDevOpsPullRequestCli.of({
527 getViewer: (input) =>
528 executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe(
529 Effect.flatMap((result): Effect.Effect<string, AzureDevOpsPullRequestCliError> => {
530 // `--query user` narrows the payload to the account, so it is nested back under the
531 // key the decoder reads to keep one shape for the signed-in user.
532 const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`);
533 if (!Result.isSuccess(decoded)) {
534 return Effect.fail(
535 new AzureDevOpsPullRequestReadError({
536 command: "az",
537 cwd: input.cwd,
538 operation: "getViewer",
539 cause: decoded.failure,
540 }),
541 );
542 }
543 return decoded.success === null
544 ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }))
545 : Effect.succeed(decoded.success);
546 }),
547 ),
548
549 listPullRequests: (input) =>
550 listPullRequestPage({
551 cwd: input.cwd,
552 repository: input.repository,
553 state: input.state,
554 involvement: input.involvement,
555 viewer: input.viewer,
556 limit: input.limit,
557 // Azure counts rather than filters, so a slice carries on by stepping over every raw row
558 // the prior slice consumed. That is an offset into a list that can shift underneath it:
559 // a pull request opened between two slices moves everything down one, and the row on the
560 // seam is the one that pays for it.
561 skip: input.cursor?.delivered ?? 0,
562 cursorAdvance: 0,
563 items: [],
564 }),
565
566 getPullRequest: (input) =>
567 executeJson({
568 cwd: input.cwd,
569 args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)],
570 }).pipe(
571 Effect.flatMap(
572 (result): Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError> => {
573 const decoded = decodePullRequestJson(result.stdout.trim());
574 if (!Result.isSuccess(decoded)) {
575 return Effect.fail(
576 new AzureDevOpsPullRequestReadError({
577 command: "az",
578 cwd: input.cwd,
579 operation: "getPullRequest",
580 cause: decoded.failure,
581 }),
582 );
583 }
584 // Null means Azure answered with too little to place the pull request. Nothing
585 // failed underneath it, so it is its own outcome rather than a decode failure.
586 return decoded.success === null
587 ? Effect.fail(
588 new AzureDevOpsPullRequestIncompleteError({
589 command: "az",
590 cwd: input.cwd,
591 number: input.number,
592 }),
593 )
594 : Effect.succeed(decoded.success);
595 },
596 ),
597 ),
598
599 listThreads: (input) =>
600 invoke({
601 cwd: input.cwd,
602 operation: "listThreads",
603 resource: "pullRequestThreads",
604 routeParameters: pullRequestRoute(input),
605 maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES,
606 decode: decodeThreadsJson,
607 }),
608
609 listIterations: (input) =>
610 invoke({
611 cwd: input.cwd,
612 operation: "listIterations",
613 resource: "pullRequestIterations",
614 routeParameters: pullRequestRoute(input),
615 maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES,
616 decode: decodeIterationsJson,
617 }),
618
619 listIterationChanges: (input) => {
620 const page = (skip: number) =>
621 invoke({
622 cwd: input.cwd,
623 operation: "listIterationChanges",
624 resource: "pullRequestIterationChanges",
625 routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`],
626 // Azure pages this route at 1000 entries by default; this is its own maximum per page,
627 // and it names where the next page starts rather than answering with the whole change.
628 queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`],
629 maxOutputBytes: CHANGE_ENTRIES_MAX_OUTPUT_BYTES,
630 decode: decodeIterationChangesJson,
631 });
632 const from = (
633 skip: number,
634 collected: ReadonlyArray<AzureDevOpsChangeEntry>,
635 ): Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError> =>
636 page(skip).pipe(
637 Effect.flatMap((answer) => {
638 const changes = [...collected, ...answer.changes];
639 // The last page names no page after it, and only that is the end of the change.
640 if (answer.nextSkip === null) return Effect.succeed({ changes, truncated: false });
641 // A page pointing at where the read already is would be followed forever, and one
642 // past the ceiling is a change nobody will read to the end of. Both stop the read
643 // and both say so, rather than presenting part of a change as the whole of it.
644 return answer.nextSkip <= skip || answer.nextSkip >= MAX_CHANGE_ENTRIES
645 ? Effect.succeed({ changes, truncated: true })
646 : from(answer.nextSkip, changes);
647 }),
648 );
649 return from(0, []);
650 },
651
652 readItemContent: (input) =>
653 invoke({
654 cwd: input.cwd,
655 operation: "readItemContent",
656 resource: "items",
657 routeParameters: repositoryRoute(input.location),
658 queryParameters: [
659 `path=${toItemPath(input.path)}`,
660 "versionDescriptor.versionType=commit",
661 `versionDescriptor.version=${input.commit}`,
662 "includeContent=true",
663 // Azure leaves `contentMetadata` out unless this is asked for, and with it goes its own
664 // word on whether the file is binary, which is the only reliable one, since a binary
665 // file arrives encoded rather than as the bytes it is on the host.
666 "includeContentMetadata=true",
667 // Without this Azure answers with the file's own bytes rather than with a JSON
668 // envelope, and `az devops invoke` refuses anything it cannot parse as JSON.
669 "$format=json",
670 ],
671 maxOutputBytes: ITEM_CONTENT_MAX_OUTPUT_BYTES,
672 decode: decodeItemContentJson,
673 }),
674
675 setPullRequestReviewers: (input) =>
676 input.reviewers.some((reviewer) => !isReviewerName(reviewer))
677 ? Effect.fail(new AzureDevOpsReviewerNameError({ command: "az", cwd: input.cwd }))
678 : azure
679 .execute({
680 cwd: input.cwd,
681 args: [
682 "repos",
683 "pr",
684 "reviewer",
685 input.requested ? "add" : "remove",
686 ...detectArgs,
687 "--id",
688 String(input.number),
689 // One `--reviewers` takes them all, because az reads the flag as a list and a
690 // second one would replace the first rather than add to it.
691 "--reviewers",
692 ...input.reviewers,
693 "--only-show-errors",
694 "--output",
695 "json",
696 ],
697 })
698 .pipe(Effect.asVoid),
699
700 runPullRequestAction: (input) =>
701 azure
702 .execute({
703 cwd: input.cwd,
704 args: [
705 "repos",
706 "pr",
707 "update",
708 ...detectArgs,
709 "--id",
710 String(input.number),
711 ...actionArgs(input.action, input.mergeMethod),
712 "--only-show-errors",
713 "--output",
714 "json",
715 ],
716 })
717 .pipe(Effect.asVoid),
718
719 updatePullRequest: (input) =>
720 azure
721 .execute({
722 cwd: input.cwd,
723 args: [
724 "repos",
725 "pr",
726 "update",
727 ...detectArgs,
728 "--id",
729 String(input.number),
730 // One argument rather than a flag and a value beside it: a description usually opens
731 // with a bullet, and az reads a dash in the next argv slot as a flag of its own.
732 // `--description` also takes several strings, and this keeps the whole text as one.
733 ...(input.title === undefined ? [] : [`--title=${input.title}`]),
734 ...(input.body === undefined ? [] : [`--description=${input.body}`]),
735 "--only-show-errors",
736 "--output",
737 "json",
738 ],
739 })
740 .pipe(Effect.asVoid),
741 });
742});
743
744export const layer = Layer.effect(AzureDevOpsPullRequestCli, make);
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts created+420
...@@ -0,0 +1,420 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
6import {
7 LOCATION_CACHE_CAPACITY,
8 make,
9 MAX_DIFF_SPAWNS,
10} from "./AzureDevOpsPullRequestProvider.ts";
11import {
12 byteLength,
13 MAX_DIFF_SLICE_BYTES,
14 MAX_DIFF_SLICE_FILES,
15 MAX_FILE_DIFF_EDITS,
16 parseAzureDevOpsDiffCursor,
17} from "./azureDevOpsDiff.ts";
18import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts";
19
20const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" };
21
22const PULL_REQUEST = {
23 number: 7,
24 title: "Pull request 7",
25 url: "https://dev.azure.com/acme/web/_git/web/pullrequest/7",
26 author: null,
27 headBranch: "feat/page",
28 baseBranch: "main",
29 state: "open" as const,
30 isDraft: false,
31 mergeability: "mergeable" as const,
32 createdAt: "2026-07-01T00:00:00Z",
33 updatedAt: "2026-07-02T00:00:00Z",
34 closedAt: null,
35 body: "",
36 reviewRequestLogins: [],
37 reviewers: [],
38 location: { project: "acme", repository: "web" },
39 autoMergeEnabled: false,
40};
41
42function change(
43 path: string,
44 changeKind: AzureDevOpsChangeEntry["changeKind"] = "change",
45): AzureDevOpsChangeEntry {
46 return { path, oldPath: path, changeKind, objectId: "8f80", originalObjectId: "0ca4" };
47}
48
49/**
50 * A file whose two sides share no line, so its patch is `lines` removals and `lines` additions of
51 * `width` characters each: the diff work and the patch bytes one file costs are both dialled from
52 * here, and they are what a slice is bounded by. Each line carries its own number and a prefix
53 * as well, so `width` is a floor on how long a line is rather than its byte count.
54 */
55function side(prefix: string, lines: number, width: number): string {
56 const pad = "z".repeat(width);
57 return `${Array.from({ length: lines }, (_, line) => `${prefix} ${line} ${pad}`).join("\n")}\n`;
58}
59
60const readSlice = (input: {
61 readonly paths: ReadonlyArray<string>;
62 readonly lines: number;
63 readonly width: number;
64 /** Paths the host refuses, which is one file's problem rather than the read's. */
65 readonly refused?: ReadonlyArray<string>;
66 /** Paths the change creates, so the host has nothing to hand back for their old side. */
67 readonly created?: ReadonlyArray<string>;
68 readonly cursor?: string;
69}) =>
70 Effect.gen(function* () {
71 const reads: string[] = [];
72 const refused = new Set(input.refused ?? []);
73 const created = new Set(input.created ?? []);
74 let inFlight = 0;
75 let peakInFlight = 0;
76
77 const provider = yield* make.pipe(
78 Effect.provide(
79 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
80 getPullRequest: () => Effect.succeed(PULL_REQUEST),
81 listIterations: () => Effect.succeed([ITERATION]),
82 listIterationChanges: () =>
83 Effect.succeed({
84 changes: input.paths.map((path) =>
85 change(path, created.has(path) ? "new" : "change"),
86 ),
87 truncated: false,
88 }),
89 readItemContent: (item) =>
90 Effect.gen(function* () {
91 reads.push(item.path);
92 inFlight += 1;
93 peakInFlight = Math.max(peakInFlight, inFlight);
94 // Every read suspends before it answers, as a subprocess would, so what runs at
95 // once is the scheduler's answer rather than an artefact of resolving inline. The
96 // later a file is listed the sooner it answers, to leave the assembled patch
97 // nothing but the change list to take its order from.
98 const answersAfter = input.paths.length - input.paths.indexOf(item.path);
99 for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow;
100 inFlight -= 1;
101 if (refused.has(item.path)) {
102 return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({
103 command: "az",
104 cwd: "/w",
105 operation: "readItemContent",
106 cause: "refused",
107 });
108 }
109 const isOldSide = item.commit !== ITERATION.headCommit;
110 if (isOldSide && created.has(item.path)) return { contents: "", isBinary: false };
111 return {
112 contents: side(isOldSide ? "old" : "new", input.lines, input.width),
113 isBinary: false,
114 };
115 }),
116 }),
117 ),
118 );
119
120 const slice = yield* provider.getDiff({
121 cwd: "/w",
122 repository: "acme/web",
123 host: "dev.azure.com",
124 number: 7,
125 ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
126 });
127 return { slice, reads, peakInFlight };
128 });
129
130/** Which files the patch carries a section for, in the order it carries them. */
131function patchedPaths(patch: string): ReadonlyArray<string> {
132 return [...patch.matchAll(/^diff --git a\/(?<path>\S+) b\//gmu)].map(
133 (match) => match.groups?.path ?? "",
134 );
135}
136
137describe("getChangeRequestSummary", () => {
138 it.effect("costs the one pull request read, not the iterations changedFiles needs", () =>
139 Effect.gen(function* () {
140 let pullRequestReads = 0;
141
142 const provider = yield* make.pipe(
143 Effect.provide(
144 // listIterations and listIterationChanges are left unimplemented here, so a summary
145 // that reached for either would die with UnimplementedError instead of this passing.
146 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
147 getPullRequest: () => {
148 pullRequestReads += 1;
149 return Effect.succeed(PULL_REQUEST);
150 },
151 }),
152 ),
153 );
154
155 const readSummary = provider.getChangeRequestSummary;
156 if (readSummary === undefined) return yield* Effect.die("summary read was not implemented");
157 const summary = yield* readSummary({
158 cwd: "/w",
159 repository: "acme/web",
160 host: "dev.azure.com",
161 number: 7,
162 });
163
164 expect(pullRequestReads).toBe(1);
165 expect(summary.title).toBe(PULL_REQUEST.title);
166 expect(summary.changedFiles).toBeUndefined();
167 }),
168 );
169});
170
171describe("getChangeRequest", () => {
172 it.effect("still reports the file count the detail panel needs", () =>
173 Effect.gen(function* () {
174 const provider = yield* make.pipe(
175 Effect.provide(
176 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
177 getPullRequest: () => Effect.succeed(PULL_REQUEST),
178 listIterations: () => Effect.succeed([ITERATION]),
179 listIterationChanges: () =>
180 Effect.succeed({ changes: [change("a.ts"), change("b.ts")], truncated: false }),
181 }),
182 ),
183 );
184
185 const detail = yield* provider.getChangeRequest({
186 cwd: "/w",
187 repository: "acme/web",
188 host: "dev.azure.com",
189 number: 7,
190 });
191
192 expect(detail.changedFiles).toBe(2);
193 }),
194 );
195});
196
197describe("getDiff reads", () => {
198 it.effect("holds every reader together to one request's worth of processes", () =>
199 Effect.gen(function* () {
200 // The fan-out inside a read bounds one Code tab. Two people opening two Azure reviews at
201 // once are two reads, so without a ceiling above them both they are twice a request's
202 // processes, each paying a Python interpreter's start-up on the same machine.
203 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
204 let inFlight = 0;
205 let peakInFlight = 0;
206
207 const provider = yield* make.pipe(
208 Effect.provide(
209 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
210 getPullRequest: () => Effect.succeed(PULL_REQUEST),
211 listIterations: () => Effect.succeed([ITERATION]),
212 listIterationChanges: () =>
213 Effect.succeed({ changes: paths.map((path) => change(path)), truncated: false }),
214 readItemContent: () =>
215 Effect.gen(function* () {
216 inFlight += 1;
217 peakInFlight = Math.max(peakInFlight, inFlight);
218 // Suspends before answering, as a subprocess would, so what is out at once is the
219 // scheduler's answer rather than an artefact of resolving inline.
220 yield* Effect.yieldNow;
221 yield* Effect.yieldNow;
222 inFlight -= 1;
223 return { contents: side("new", 2, 4), isBinary: false };
224 }),
225 }),
226 ),
227 );
228
229 const readDiff = (number: number) =>
230 provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number });
231
232 yield* Effect.all([readDiff(7), readDiff(8)], { concurrency: 2 });
233
234 expect(peakInFlight).toBeLessThanOrEqual(MAX_DIFF_SPAWNS);
235 }),
236 );
237
238 it.effect("leaves a run of files it could not diff at all for the next slice", () =>
239 Effect.gen(function* () {
240 // A binary, oversize, purely renamed or unreadable entry is a header apiece, a couple of
241 // hundred bytes with no edits in it, so a change made of them spends neither budget: the
242 // byte one would take well over a thousand of them, and the edit one never fills at all.
243 // A listing holds up to ten thousand entries and each still costs its two reads, which is
244 // what a file count is here to bound.
245 const paths = Array.from({ length: MAX_DIFF_SLICE_FILES + 20 }, (_, at) => `gen/a${at}.bin`);
246 const read = yield* readSlice({ paths, lines: 2, width: 4, refused: paths });
247
248 expect(patchedPaths(read.slice.patch)).toHaveLength(MAX_DIFF_SLICE_FILES);
249 // Well inside the byte budget, so the file count is what stopped it rather than either of
250 // the budgets that were already there.
251 expect(byteLength(read.slice.patch)).toBeLessThan(MAX_DIFF_SLICE_BYTES);
252 expect(parseAzureDevOpsDiffCursor(read.slice.nextCursor)?.fileIndex).toBe(
253 MAX_DIFF_SLICE_FILES,
254 );
255 }),
256 );
257
258 it.effect("asks for both sides of several files at once rather than one side at a time", () =>
259 Effect.gen(function* () {
260 // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a
261 // slice read one side after another is most of what the Code tab waits for.
262 const read = yield* readSlice({
263 paths: ["a.ts", "b.ts", "c.ts", "d.ts"],
264 lines: 2,
265 width: 4,
266 });
267
268 expect(read.peakInFlight).toBeGreaterThan(2);
269 }),
270 );
271
272 it.effect("holds the number of files it reads at once down", () =>
273 Effect.gen(function* () {
274 // The host throttles, and `az` is a process on the same machine the reader runs agents on,
275 // so a long change is read in batches rather than all at once.
276 const paths = Array.from({ length: 24 }, (_, file) => `file-${file}.ts`);
277 const read = yield* readSlice({ paths, lines: 2, width: 4 });
278
279 expect(read.reads).toHaveLength(paths.length * 2);
280 expect(read.peakInFlight).toBeLessThanOrEqual(8);
281 }),
282 );
283
284 it.effect("keeps the patch in the order the change was listed, whoever answered first", () =>
285 Effect.gen(function* () {
286 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"];
287 const read = yield* readSlice({ paths, lines: 2, width: 4 });
288
289 expect(patchedPaths(read.slice.patch)).toEqual(paths);
290 expect(read.slice.nextCursor).toBeNull();
291 }),
292 );
293
294 it.effect("leaves a file the host refused listed without its hunks, in its place", () =>
295 Effect.gen(function* () {
296 const paths = ["a.ts", "b.ts", "c.ts"];
297 const read = yield* readSlice({ paths, lines: 2, width: 4, refused: ["b.ts"] });
298
299 expect(patchedPaths(read.slice.patch)).toEqual(paths);
300 expect(read.slice.truncated).toBe(true);
301 // Its section ends at its header, and the files around it still carry their hunks.
302 expect(read.slice.patch).toContain("+++ b/b.ts\ndiff --git a/c.ts");
303 expect(read.slice.patch.match(/^@@ /gmu)).toHaveLength(2);
304 }),
305 );
306});
307
308describe("what one diff slice spends", () => {
309 it.effect("stops on the byte ceiling without carrying what it read past it", () =>
310 Effect.gen(function* () {
311 // Two of these fill the slice, and the batch they were read in reached two files further.
312 // Those two belong to the next slice: carrying them would put the request past a ceiling
313 // that is there to bound what one answer weighs.
314 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
315 const read = yield* readSlice({ paths, lines: 100, width: 900 });
316
317 expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES);
318 expect(patchedPaths(read.slice.patch)).toEqual(["a.ts", "b.ts"]);
319 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:2`);
320 expect(new Set(read.reads)).toEqual(new Set(["a.ts", "b.ts", "c.ts", "d.ts"]));
321 }),
322 );
323
324 it.effect("narrows what it reads at once as the slice fills", () =>
325 Effect.gen(function* () {
326 // Four files fit inside the budget and the fifth is past half of what is left of it, so
327 // reading four more would throw most of them away and read them again next slice. Every
328 // file is two `az` invocations, so the batch is judged against what the files before it
329 // weighed rather than left at its full width to the last file.
330 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts", "g.ts", "h.ts"];
331 const read = yield* readSlice({ paths, lines: 50, width: 450 });
332
333 expect(read.slice.nextCursor).not.toBeNull();
334 expect(new Set(read.reads)).toEqual(new Set(patchedPaths(read.slice.patch)));
335 }),
336 );
337
338 it.effect("stops once the diff work one request may do is spent", () =>
339 Effect.gen(function* () {
340 // Short lines are cheap on the wire and dear to diff, so the byte ceiling alone would let
341 // one request hold the thread through a dozen of them. Each of these is half of what one
342 // file is allowed, and the slice ends while there is still room for another.
343 const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`);
344 const read = yield* readSlice({ paths, lines: MAX_FILE_DIFF_EDITS / 4, width: 1 });
345
346 expect(read.slice.patch.length).toBeLessThan(MAX_DIFF_SLICE_BYTES);
347 expect(patchedPaths(read.slice.patch)).toEqual(paths.slice(0, 5));
348 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:5`);
349 }),
350 );
351
352 it.effect("carries a whole new file and stops the slice on what it weighed", () =>
353 Effect.gen(function* () {
354 // A creation has no edit distance to search out, so no edit bound applies to it and its
355 // section is the whole file. What keeps a run of them from filling one answer is the bytes
356 // they weighed, which the slice has to be charged for.
357 const paths = ["new.ts", "b.ts", "c.ts", "d.ts"];
358 const read = yield* readSlice({ paths, lines: 8_000, width: 30, created: ["new.ts"] });
359
360 expect(patchedPaths(read.slice.patch)).toEqual(["new.ts"]);
361 expect(read.slice.patch).toContain("--- /dev/null");
362 expect(read.slice.patch).toContain("@@ -0,0 +1,8000 @@");
363 expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES);
364 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:1`);
365 }),
366 );
367
368 it.effect("carries on from where the last slice stopped", () =>
369 Effect.gen(function* () {
370 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
371 const read = yield* readSlice({
372 paths,
373 lines: 100,
374 width: 900,
375 cursor: `${ITERATION.id}:2`,
376 });
377
378 expect(patchedPaths(read.slice.patch)).toEqual(["c.ts", "d.ts"]);
379 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:4`);
380 }),
381 );
382 it.effect("keeps the pull request being read, not the one looked up first", () =>
383 Effect.gen(function* () {
384 // Where a pull request lives is read from the pull request itself, so an evicted entry
385 // costs a whole pull request read before any file can be asked for. Ordered by insertion
386 // alone a hit does not renew its entry, so the review being worked through is the first
387 // thing dropped once a listing has walked a cache's worth of cold pull requests.
388 const HOT = 7;
389 const readsOf = new Map<number, number>();
390
391 const provider = yield* make.pipe(
392 Effect.provide(
393 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
394 getPullRequest: (input) =>
395 Effect.sync(() => {
396 readsOf.set(input.number, (readsOf.get(input.number) ?? 0) + 1);
397 return { ...PULL_REQUEST, number: input.number };
398 }),
399 listIterations: () => Effect.succeed([ITERATION]),
400 listIterationChanges: () =>
401 Effect.succeed({ changes: [change("a.ts")], truncated: false }),
402 readItemContent: () => Effect.succeed({ contents: side("new", 2, 4), isBinary: false }),
403 }),
404 ),
405 );
406
407 const readDiff = (number: number) =>
408 provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number });
409
410 yield* readDiff(HOT);
411 // A cache's worth of cold pull requests, with the open one read in between each of them.
412 for (let filled = 0; filled < LOCATION_CACHE_CAPACITY; filled += 1) {
413 yield* readDiff(HOT + 1 + filled);
414 yield* readDiff(HOT);
415 }
416
417 expect(readsOf.get(HOT)).toBe(1);
418 }),
419 );
420});
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts created+647
...@@ -0,0 +1,647 @@
1import * as Effect from "effect/Effect";
2import * as Semaphore from "effect/Semaphore";
3import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
4
5import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
6import {
7 azureDevOpsFilePatch,
8 azureDevOpsUnreadableFilePatch,
9 formatAzureDevOpsDiffCursor,
10 parseAzureDevOpsDiffCursor,
11 MAX_DIFF_SLICE_BYTES,
12 MAX_DIFF_SLICE_EDITS,
13 MAX_DIFF_SLICE_FILES,
14 byteLength,
15 MAX_FILE_DIFF_EDITS,
16 type AzureDevOpsFileTexts,
17} from "./azureDevOpsDiff.ts";
18import {
19 PullRequestProviderError,
20 type PullRequestProviderFailure,
21 type ProviderChangeRequest,
22 type ProviderChangeRequestActivity,
23 type ProviderChangeRequestDetail,
24 type ProviderChangeRequestSummary,
25 type ProviderDiffSlice,
26 type PullRequestProviderApi,
27} from "@t3tools/source-control-core/server/PullRequestProvider";
28import type {
29 AzureDevOpsChangeEntry,
30 AzureDevOpsItemContent,
31 AzureDevOpsIteration,
32 AzureDevOpsPullRequest,
33 AzureDevOpsRepositoryLocation,
34} from "./azureDevOpsPullRequestJson.ts";
35
36/**
37 * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a
38 * Python interpreter's start-up, so reading them one after another is most of what the Code tab
39 * waits for. Four files means eight processes at once: the fan-out the GitHub CLI reads its
40 * per-file stats with here, and low enough not to swamp the host's throttling or the machine.
41 */
42const DIFF_FILE_CONCURRENCY = 4;
43
44/**
45 * How many `az` processes this build will have out at once, counted across every reader rather
46 * than per request. The fan-out above bounds one Code tab, so two people opening two Azure reviews
47 * had sixteen Python interpreters starting at once and nothing above them. Held at what one
48 * request at full width spends, so a second reader waits behind the first instead of adding to it.
49 */
50export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY;
51
52/** How many pull requests' repository locations one provider remembers at once. */
53export const LOCATION_CACHE_CAPACITY = 128;
54
55const CAPABILITIES: PullRequestCapabilities = {
56 // Azure serves no patch of its own, so the one the Code tab reads is built here out of the
57 // files an iteration changed and both sides of each of them.
58 diff: true,
59 // Reading a conversation is a plain REST read, but posting one is not something this can
60 // claim without having run it, so the composer stays hidden.
61 comment: false,
62 actions: [
63 "merge",
64 "ready",
65 "draft",
66 "close",
67 "reopen",
68 "enable-auto-merge",
69 "disable-auto-merge",
70 ],
71 // Azure squashes as a completion option; it has no rebase strategy of its own.
72 mergeMethods: ["merge", "squash"],
73 // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all.
74 search: false,
75 reactions: false,
76 // The patch has lines to write against, but writing a remark at all is what Azure is not
77 // offered for here, so nothing in a review is either.
78 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
79 // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos`
80 // lists the ones this repository could name — that lives behind the identity and graph APIs, a
81 // different service with its own permissions. So the page takes a name here rather than being
82 // handed a menu built out of a guess.
83 reviewers: { request: true, listCandidates: false },
84 // A new title and description travel on the same `az repos pr update` that moves a pull request.
85 // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on
86 // Azure DevOps at all, so there is nothing here it could rewrite either.
87 edit: { changeRequest: true, comment: false },
88 // Azure does keep a viewed record of its own, but only behind the undocumented contribution
89 // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway.
90 // So they are kept here instead, and the client says whose they are rather than implying the
91 // Azure DevOps page will show them.
92 viewedFiles: "environment",
93};
94
95/**
96 * Everything this host offers, granted to whoever is signed in. Azure DevOps states no permission
97 * anywhere `az repos pr show` or `az repos pr list` reach: the answer lives in the security
98 * namespaces, behind identity descriptors and token paths that would be several calls per pull
99 * request to resolve.
100 *
101 * So the actions stay live and a viewer who may not take one is told so by Azure, at the moment
102 * they try. That is the safer half of an unknown: hiding a control from someone entitled to it
103 * leaves them no way through and no reason given.
104 */
105const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = {
106 actions: CAPABILITIES.actions,
107 comment: CAPABILITIES.comment,
108 resolve: CAPABILITIES.review.resolve,
109 verdicts: CAPABILITIES.review.verdicts,
110 requestReviewers: CAPABILITIES.reviewers.request,
111};
112
113/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */
114export function azureDevOpsProviderFailure(
115 error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError,
116): PullRequestProviderFailure {
117 if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" };
118 if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" };
119 if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" };
120 if (error._tag === "AzureDevOpsPullRequestNotFoundError") return { reason: "not-found" };
121 return { reason: "failed" };
122}
123
124function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeRequest {
125 return {
126 number: pullRequest.number,
127 title: pullRequest.title,
128 url: pullRequest.url,
129 author: pullRequest.author,
130 headBranch: pullRequest.headBranch,
131 baseBranch: pullRequest.baseBranch,
132 state: pullRequest.state,
133 isDraft: pullRequest.isDraft,
134 mergeability: pullRequest.mergeability,
135 // Azure counts a pull request's files but never its lines, and counting them here would mean
136 // reading every file on both sides of every row of a listing.
137 additions: 0,
138 deletions: 0,
139 createdAt: pullRequest.createdAt,
140 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
141 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
142 updatedAt: pullRequest.updatedAt,
143 reviewRequestLogins: pullRequest.reviewRequestLogins,
144 // Azure keeps labels on work items rather than on the pull request.
145 labels: [],
146 };
147}
148
149export const make = Effect.gen(function* () {
150 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
151 // Made once with the provider, which the registry builds once, so this is the whole build's
152 // allowance rather than one request's.
153 const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS);
154 const readItemContent = (input: Parameters<typeof cli.readItemContent>[0]) =>
155 diffSpawns.withPermits(1)(cli.readItemContent(input));
156
157 const fail =
158 (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) =>
159 new PullRequestProviderError({
160 provider: "azure-devops",
161 operation,
162 ...azureDevOpsProviderFailure(error),
163 detail: error.detail,
164 cause: error,
165 });
166
167 /** Refuses what the capabilities already say this host cannot do. */
168 const unsupported = (operation: string) =>
169 Effect.fail(
170 new PullRequestProviderError({
171 provider: "azure-devops",
172 operation,
173 reason: "failed",
174 detail: "Azure DevOps reviews cannot be written from here yet.",
175 }),
176 );
177
178 /** A pull request Azure could not place has no diff to read, which reads as an empty one. */
179 const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null };
180
181 /**
182 * Where a pull request's repository lives, which is the route every other read of it needs and
183 * the one thing only the pull request itself states. A pull request cannot move between
184 * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for
185 * a whole pull request read every time they checked whether a file had been pushed to. Least
186 * recently used, so a listing walking cold pull requests cannot evict the one being read.
187 */
188 const locations = new Map<string, AzureDevOpsRepositoryLocation>();
189
190 const locationOf = (input: { readonly cwd: string; readonly number: number }) => {
191 const key = `${input.cwd} ${input.number}`;
192 const held = locations.get(key);
193 if (held !== undefined) {
194 locations.delete(key);
195 locations.set(key, held);
196 return Effect.succeed(held);
197 }
198 return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
199 Effect.map((pullRequest) => {
200 const location = pullRequest.location;
201 if (location === null) return null;
202 if (locations.size >= LOCATION_CACHE_CAPACITY) {
203 const oldest = locations.keys().next().value;
204 if (oldest !== undefined) locations.delete(oldest);
205 }
206 locations.set(key, location);
207 return location;
208 }),
209 );
210 };
211
212 /**
213 * Everything a diff read needs before it can ask for a file: where the repository lives, and
214 * which pushes the pull request has had. A client names neither, and the iterations are read
215 * afresh every time because the newest one is what a push adds.
216 */
217 const diffScope = (input: { readonly cwd: string; readonly number: number }) =>
218 Effect.gen(function* () {
219 const location = yield* locationOf(input);
220 if (location === null) return null;
221 const iterations = yield* cli.listIterations({
222 cwd: input.cwd,
223 location,
224 number: input.number,
225 });
226 return { location, iterations };
227 });
228
229 const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false };
230
231 /**
232 * Both sides of one changed file, read at once because neither answer depends on the other and
233 * `az` pays a Python interpreter's start-up for each. Only the sides a change actually has are
234 * asked for: Azure answers for a file that is not at a commit with a failure rather than with
235 * nothing.
236 */
237 const readTexts = (input: {
238 readonly cwd: string;
239 readonly location: AzureDevOpsRepositoryLocation;
240 readonly iteration: AzureDevOpsIteration;
241 readonly change: Pick<AzureDevOpsChangeEntry, "changeKind" | "path" | "oldPath">;
242 }) =>
243 Effect.all(
244 [
245 input.change.changeKind === "new"
246 ? Effect.succeed(EMPTY_ITEM)
247 : readItemContent({
248 cwd: input.cwd,
249 location: input.location,
250 path: input.change.oldPath,
251 commit: input.iteration.mergeBaseCommit,
252 }),
253 input.change.changeKind === "deleted"
254 ? Effect.succeed(EMPTY_ITEM)
255 : readItemContent({
256 cwd: input.cwd,
257 location: input.location,
258 path: input.change.path,
259 commit: input.iteration.headCommit,
260 }),
261 ],
262 { concurrency: 2 },
263 ).pipe(
264 Effect.map(([oldItem, newItem]): AzureDevOpsFileTexts => ({
265 oldContents: oldItem.contents,
266 newContents: newItem.contents,
267 // Azure hands a file it calls binary over in an encoding of its own, so its own word on
268 // that is taken rather than looked for in bytes it may never have sent verbatim.
269 binary: oldItem.isBinary || newItem.isBinary,
270 })),
271 );
272
273 /**
274 * What the whole pull request changed, taken from its latest push. An iteration's changes are
275 * reported against the merge base rather than against the push before it, so the newest one is
276 * the whole of the change rather than the last slice of it.
277 */
278 const listLatestChanges = (input: {
279 readonly cwd: string;
280 readonly location: AzureDevOpsRepositoryLocation;
281 readonly number: number;
282 readonly iterations: ReadonlyArray<AzureDevOpsIteration>;
283 }) => {
284 const latest = input.iterations.at(-1);
285 return latest === undefined
286 ? Effect.succeed({
287 changes: [],
288 truncated: false,
289 } as AzureDevOpsPullRequestCli.AzureDevOpsIterationChanges)
290 : cli.listIterationChanges({
291 cwd: input.cwd,
292 location: input.location,
293 number: input.number,
294 iterationId: latest.id,
295 });
296 };
297
298 const provider: PullRequestProviderApi = {
299 kind: "azure-devops",
300 capabilities: CAPABILITIES,
301
302 getViewer: (input) =>
303 cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))),
304
305 // `input.query` is deliberately dropped: `az repos pr list` filters by status, creator,
306 // reviewer and branch, and has nothing that matches text. Sending it as one of those would
307 // narrow by the wrong thing, so the page comes back unnarrowed and the caller filters it.
308 listChangeRequests: (input) =>
309 cli
310 .listPullRequests({
311 cwd: input.cwd,
312 repository: input.repository,
313 state: input.state,
314 involvement: input.involvement,
315 viewer: input.viewer,
316 limit: input.limit,
317 cursor: input.cursor,
318 })
319 .pipe(
320 Effect.mapError(fail("listChangeRequests")),
321 Effect.map((batch) => ({
322 items: batch.items.map(toChangeRequest),
323 truncated: batch.truncated,
324 cursorAdvance: batch.cursorAdvance,
325 // Azure answers in one order whether or not it is being carried on from, so a slice
326 // can always be stepped past — by counting, which is all Azure offers.
327 continues: true,
328 })),
329 ),
330
331 // The polled path a linked thread's row stays live on: one `az` read, no iterations or
332 // changes behind it, since the file count that would cost is not shown here.
333 getChangeRequestSummary: (input) =>
334 cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
335 Effect.mapError(fail("getChangeRequestSummary")),
336 Effect.map((pullRequest): ProviderChangeRequestSummary => ({
337 number: pullRequest.number,
338 title: pullRequest.title,
339 url: pullRequest.url,
340 author: pullRequest.author,
341 headBranch: pullRequest.headBranch,
342 baseBranch: pullRequest.baseBranch,
343 state: pullRequest.state,
344 isDraft: pullRequest.isDraft,
345 mergeability: pullRequest.mergeability,
346 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
347 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
348 updatedAt: pullRequest.updatedAt,
349 })),
350 ),
351
352 getChangeRequest: (input) =>
353 Effect.gen(function* () {
354 const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number });
355 const location = pullRequest.location;
356 // The file count is two reads past the pull request itself, and it is the only thing
357 // riding on them, so a failure leaves it unknown rather than losing the whole detail.
358 const changedFiles =
359 location === null
360 ? 0
361 : yield* cli.listIterations({ cwd: input.cwd, location, number: input.number }).pipe(
362 Effect.flatMap((iterations) =>
363 listLatestChanges({
364 cwd: input.cwd,
365 location,
366 number: input.number,
367 iterations,
368 }),
369 ),
370 Effect.map((listed) => listed.changes.length),
371 Effect.orElseSucceed(() => 0),
372 );
373 const detail: ProviderChangeRequestDetail = {
374 ...toChangeRequest(pullRequest),
375 body: pullRequest.body,
376 changedFiles,
377 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
378 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
379 reviewers: pullRequest.reviewers,
380 checks: [],
381 mergeCapabilities: { merge: true, squash: true, rebase: false },
382 viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS,
383 autoMergeEnabled: pullRequest.autoMergeEnabled,
384 ...(pullRequest.autoMergeMethod === undefined
385 ? {}
386 : { autoMergeMethod: pullRequest.autoMergeMethod }),
387 };
388 return detail;
389 }).pipe(Effect.mapError(fail("getChangeRequest"))),
390
391 getChangeRequestActivity: (input) =>
392 cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
393 Effect.mapError(fail("getChangeRequestActivity")),
394 Effect.flatMap((pullRequest) =>
395 (pullRequest.location === null
396 ? Effect.succeed({ comments: [], truncated: true })
397 : cli
398 .listThreads({
399 cwd: input.cwd,
400 location: pullRequest.location,
401 number: input.number,
402 })
403 .pipe(
404 Effect.map((comments) => ({ comments, truncated: false })),
405 Effect.orElseSucceed(() => ({ comments: [], truncated: true })),
406 )
407 ).pipe(
408 Effect.map((conversation): ProviderChangeRequestActivity => ({
409 comments: conversation.comments,
410 commentCount: conversation.comments.length,
411 commentsTruncated: conversation.truncated,
412 reviewThreads: [],
413 commits: [],
414 })),
415 ),
416 ),
417 ),
418
419 // No request at all: Azure has nothing to say about the viewer that a pull request read can
420 // reach, so the answer is the same constant the detail carries.
421 getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS),
422
423 // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so
424 // the Code tab has nothing to scope itself to and always asks for the whole change.
425 getDiff: (input) =>
426 Effect.gen(function* () {
427 const scope = yield* diffScope(input);
428 if (scope === null) return EMPTY_DIFF_SLICE;
429 const cursor = parseAzureDevOpsDiffCursor(input.cursor);
430 // Reading on stays with the push the first slice was taken against. A push landing
431 // mid-read would otherwise renumber the files and hand the reader one twice, or none.
432 const iteration =
433 cursor === null
434 ? scope.iterations.at(-1)
435 : scope.iterations.find((candidate) => candidate.id === cursor.iterationId);
436 if (iteration === undefined) return EMPTY_DIFF_SLICE;
437 const listed = yield* cli.listIterationChanges({
438 cwd: input.cwd,
439 location: scope.location,
440 number: input.number,
441 iterationId: iteration.id,
442 });
443 const changes = listed.changes;
444
445 const sections: string[] = [];
446 let truncated = listed.truncated;
447 let bytes = 0;
448 let edits = 0;
449 let index = cursor?.fileIndex ?? 0;
450 let full = false;
451 // How many files to read at once. Every file costs a request a side, and everything read
452 // past the point the slice fills is thrown away and read again by the next slice, so the
453 // batch narrows as the budgets do: what is left of each, over what a file has spent of it
454 // on average so far, which is the only estimate there is before a file is read. At least
455 // one, so a file heavier than the whole budget still moves the cursor.
456 const batchWidth = () => {
457 if (sections.length === 0) return DIFF_FILE_CONCURRENCY;
458 const admits = (left: number, spent: number) =>
459 Math.ceil(left / Math.max(1, spent / sections.length));
460 return Math.max(
461 1,
462 Math.min(
463 DIFF_FILE_CONCURRENCY,
464 // The file budget needs no estimate: a file spends exactly one of it.
465 MAX_DIFF_SLICE_FILES - sections.length,
466 admits(MAX_DIFF_SLICE_BYTES - bytes, bytes),
467 admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits),
468 ),
469 );
470 };
471 while (!full && index < changes.length) {
472 const batch = changes.slice(index, index + batchWidth());
473 const read = yield* Effect.forEach(
474 batch,
475 (change) =>
476 // A pair Azure refuses is one file rather than the whole slice: an oversize blob or
477 // a path `az` will not carry through leaves that file listed without its hunks, and
478 // everything around it still renders.
479 readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe(
480 // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az`
481 // at all is the read failing rather than the file, and belongs to the caller,
482 // which pauses the host rather than showing every file as unreadable.
483 Effect.catchTags({
484 AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null),
485 AzureDevOpsCommandFailedError: () => Effect.succeed(null),
486 AzureDevOpsPullRequestReadError: () => Effect.succeed(null),
487 }),
488 Effect.map((texts) => ({ change, texts })),
489 ),
490 { concurrency: DIFF_FILE_CONCURRENCY },
491 );
492 for (const { change, texts } of read) {
493 // The diff is synchronous and the reads no longer stand between one file and the next
494 // to let anything else on the server run, so the thread is handed back here.
495 yield* Effect.yieldNow;
496 const file =
497 texts === null
498 ? azureDevOpsUnreadableFilePatch(change)
499 : azureDevOpsFilePatch({ change, texts });
500 sections.push(file.section);
501 bytes += byteLength(file.section);
502 edits += file.edits;
503 truncated = truncated || file.truncated;
504 index += 1;
505 // A file whose diff was given up on spent the whole of what one file is allowed and
506 // has only a header to show for it, and a file the diff never ran on at all weighs
507 // almost nothing in either budget and still costs its two reads. So the file count
508 // bounds the request alongside the bytes.
509 // Checked after the file is added rather than before it, so every slice carries at
510 // least one: a section heavier than the whole budget would otherwise never be added,
511 // and the read would answer the same slice forever without moving the cursor.
512 if (
513 bytes >= MAX_DIFF_SLICE_BYTES ||
514 edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS ||
515 sections.length >= MAX_DIFF_SLICE_FILES ||
516 file.abandoned
517 ) {
518 full = true;
519 break;
520 }
521 }
522 }
523
524 const slice: ProviderDiffSlice = {
525 patch: sections.join(""),
526 truncated,
527 nextCursor:
528 index >= changes.length
529 ? null
530 : formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }),
531 };
532 return slice;
533 }).pipe(Effect.mapError(fail("getDiff"))),
534
535 // The patch is built from whole files, so opening the lines around a hunk is the same two
536 // reads over again rather than a wider request. Read against the latest iteration: nothing in
537 // the request says which push the reader is looking at, and expansion is stale after a
538 // mid-review push on every host here anyway.
539 getDiffFileContents: (input) =>
540 Effect.gen(function* () {
541 const scope = yield* diffScope(input);
542 const iteration = scope?.iterations.at(-1);
543 if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" };
544 return yield* readTexts({
545 cwd: input.cwd,
546 location: scope.location,
547 iteration,
548 change: {
549 changeKind: input.changeType,
550 path: input.newPath,
551 oldPath: input.oldPath,
552 },
553 });
554 }).pipe(Effect.mapError(fail("getDiffFileContents"))),
555
556 /**
557 * What the head has of each marked file, which is the blob Azure already names on the change
558 * it reports. One read covers every path, since the latest iteration lists the whole change.
559 *
560 * A path the change does not carry is at the empty revision, which is where a file the pull
561 * request deletes sits. When the change was too long to follow to its end, those paths are
562 * left out instead: reporting them as deleted would clear a file nobody has read.
563 */
564 getFileRevisions: (input) =>
565 Effect.gen(function* () {
566 const revisions = new Map<string, string>();
567 if (input.paths.length === 0) return { revisions };
568 const scope = yield* diffScope(input);
569 if (scope === null) return { revisions };
570 const listed = yield* listLatestChanges({
571 ...scope,
572 cwd: input.cwd,
573 number: input.number,
574 });
575 const marked = new Set(input.paths);
576 for (const change of listed.changes) {
577 if (!marked.has(change.path) || change.objectId === null) continue;
578 revisions.set(change.path, change.objectId);
579 }
580 if (!listed.truncated) {
581 for (const path of input.paths) {
582 if (!revisions.has(path)) revisions.set(path, "");
583 }
584 }
585 return { revisions };
586 }).pipe(Effect.mapError(fail("getFileRevisions"))),
587
588 runAction: (input) =>
589 cli
590 .runPullRequestAction({
591 cwd: input.cwd,
592 number: input.number,
593 action: input.action,
594 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
595 })
596 .pipe(Effect.mapError(fail("runAction"))),
597
598 updateChangeRequest: (input) =>
599 cli
600 .updatePullRequest({
601 cwd: input.cwd,
602 number: input.number,
603 title: input.title,
604 body: input.body,
605 })
606 .pipe(Effect.mapError(fail("updateChangeRequest"))),
607
608 // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the
609 // list without it.
610 listReviewerCandidates: () =>
611 Effect.fail(
612 new PullRequestProviderError({
613 provider: "azure-devops",
614 operation: "listReviewerCandidates",
615 reason: "failed",
616 detail: "Azure DevOps cannot say who may review a pull request.",
617 }),
618 ),
619
620 setReviewerRequest: (input) =>
621 cli
622 .setPullRequestReviewers({
623 cwd: input.cwd,
624 number: input.number,
625 // Azure names an identity by an email address or a guid, and has no team to ask, so a
626 // candidate's id is the whole of what it takes.
627 reviewers: input.reviewers.map((reviewer) => reviewer.id),
628 requested: input.requested,
629 })
630 .pipe(Effect.mapError(fail("setReviewerRequest"))),
631
632 // Never called: `capabilities.comment` is false, and the service refuses a comment without it.
633 comment: () => unsupported("comment"),
634
635 // Declared unsupported above, so the service refuses these before a provider is reached.
636 // They exist because every provider answers the whole port.
637 submitReview: () => unsupported("submitReview"),
638
639 replyToThread: () => unsupported("replyToThread"),
640
641 setThreadResolution: () => unsupported("setThreadResolution"),
642
643 setReaction: () => unsupported("setReaction"),
644 };
645
646 return provider;
647});
packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts created+120
...@@ -0,0 +1,120 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5
6import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
7import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
8
9function makeProvider(azure: Partial<AzureDevOpsCli.AzureDevOpsCli["Service"]>) {
10 return AzureDevOpsSourceControlProvider.make.pipe(
11 Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)),
12 );
13}
14
15it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () =>
16 Effect.gen(function* () {
17 const provider = yield* makeProvider({
18 getPullRequest: () =>
19 Effect.succeed({
20 number: 42,
21 title: "Add Azure provider",
22 url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
23 baseRefName: "main",
24 headRefName: "feature/source-control",
25 state: "closed",
26 closedAt: "2026-08-23T10:00:00Z",
27 updatedAt: Option.none(),
28 }),
29 });
30
31 const changeRequest = yield* provider.getChangeRequest({
32 cwd: "/repo",
33 reference: "42",
34 });
35
36 assert.deepStrictEqual(changeRequest, {
37 provider: "azure-devops",
38 number: 42,
39 title: "Add Azure provider",
40 url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
41 baseRefName: "main",
42 headRefName: "feature/source-control",
43 state: "closed",
44 closedAt: "2026-08-23T10:00:00Z",
45 mergedAt: null,
46 updatedAt: Option.none(),
47 isCrossRepository: false,
48 });
49 }),
50);
51
52it.effect("adds change-request context while retaining Azure CLI causes", () =>
53 Effect.gen(function* () {
54 const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({
55 operation: "execute",
56 command: "az",
57 cwd: "/repo",
58 argumentCount: 2,
59 cause: new Error("raw upstream detail that should remain in the cause"),
60 });
61 const provider = yield* makeProvider({
62 checkoutPullRequest: () => Effect.fail(cause),
63 });
64
65 const error = yield* provider
66 .checkoutChangeRequest({ cwd: "/repo", reference: "#42" })
67 .pipe(Effect.flip);
68
69 assert.deepStrictEqual(
70 {
71 provider: error.provider,
72 operation: error.operation,
73 command: error.command,
74 cwd: error.cwd,
75 reference: error.reference,
76 detail: error.detail,
77 },
78 {
79 provider: "azure-devops",
80 operation: "checkoutChangeRequest",
81 command: "az",
82 cwd: "/repo",
83 reference: "#42",
84 detail: "Azure DevOps CLI command failed.",
85 },
86 );
87 assert.strictEqual(error.cause, cause);
88 assert.equal(error.message.includes("raw upstream detail"), false);
89 }),
90);
91
92it.effect("creates Azure DevOps PRs through provider-neutral input names", () =>
93 Effect.gen(function* () {
94 let createInput:
95 | Parameters<AzureDevOpsCli.AzureDevOpsCli["Service"]["createPullRequest"]>[0]
96 | null = null;
97 const provider = yield* makeProvider({
98 createPullRequest: (input) => {
99 createInput = input;
100 return Effect.void;
101 },
102 });
103
104 yield* provider.createChangeRequest({
105 cwd: "/repo",
106 baseRefName: "main",
107 headSelector: "feature/provider",
108 title: "Provider PR",
109 bodyFile: "/tmp/body.md",
110 });
111
112 assert.deepStrictEqual(createInput, {
113 cwd: "/repo",
114 baseBranch: "main",
115 headSelector: "feature/provider",
116 title: "Provider PR",
117 bodyFile: "/tmp/body.md",
118 });
119 }),
120);
packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts created+236
...@@ -0,0 +1,236 @@
1import * as Effect from "effect/Effect";
2import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
3
4import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
5import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
6import {
7 combinedAuthOutput,
8 firstSafeAuthLine,
9 providerAuth,
10 type SourceControlAuthProbeInput,
11 type SourceControlCliDiscoverySpec,
12} from "@t3tools/source-control-core/server/discovery";
13
14function parseAzureAuth(input: SourceControlAuthProbeInput) {
15 const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
16
17 if (input.exitCode !== 0) {
18 return providerAuth({
19 status: "unauthenticated",
20 detail:
21 firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.",
22 });
23 }
24
25 if (account !== undefined && account.length > 0) {
26 return providerAuth({
27 status: "authenticated",
28 account,
29 host: "dev.azure.com",
30 });
31 }
32
33 return providerAuth({
34 status: "unknown",
35 host: "dev.azure.com",
36 detail: "Azure CLI account status could not be parsed.",
37 });
38}
39
40export const discovery = {
41 type: "cli",
42 kind: "azure-devops",
43 label: "Azure DevOps",
44 executable: "az",
45 versionArgs: ["--version"],
46 authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"],
47 // `az` boots a fresh Python interpreter on every invocation, so even `az --version`
48 // takes ~6s on Windows and overruns the default budget, leaving the provider reported
49 // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s.
50 probeTimeoutMs: 20_000,
51 parseAuth: parseAzureAuth,
52 installHint:
53 "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.",
54} satisfies SourceControlCliDiscoverySpec;
55
56function toChangeRequest(summary: {
57 readonly number: number;
58 readonly title: string;
59 readonly url: string;
60 readonly baseRefName: string;
61 readonly headRefName: string;
62 readonly state: "open" | "closed" | "merged";
63 readonly isDraft?: boolean;
64 readonly closedAt?: string | null;
65 readonly mergedAt?: string | null;
66 readonly updatedAt: ChangeRequest["updatedAt"];
67}): ChangeRequest {
68 return {
69 provider: "azure-devops",
70 number: summary.number,
71 title: summary.title,
72 url: summary.url,
73 baseRefName: summary.baseRefName,
74 headRefName: summary.headRefName,
75 state: summary.state,
76 ...(summary.isDraft === true ? { isDraft: true } : {}),
77 closedAt: summary.closedAt ?? null,
78 mergedAt: summary.mergedAt ?? null,
79 updatedAt: summary.updatedAt,
80 isCrossRepository: false,
81 };
82}
83
84export const make = Effect.gen(function* () {
85 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
86
87 return SourceControlProvider.SourceControlProvider.of({
88 kind: "azure-devops",
89 listChangeRequests: (input) => {
90 const source = SourceControlProvider.sourceControlRefFromInput(input);
91 return azure
92 .listPullRequests({
93 cwd: input.cwd,
94 headSelector: input.headSelector,
95 ...(source !== undefined ? { source } : {}),
96 state: input.state,
97 ...(input.limit !== undefined ? { limit: input.limit } : {}),
98 })
99 .pipe(
100 Effect.map((items) => items.map(toChangeRequest)),
101 Effect.mapError(
102 (error) =>
103 new SourceControlProviderError({
104 provider: "azure-devops",
105 operation: "listChangeRequests",
106 command: error.command,
107 cwd: input.cwd,
108 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
109 input.headSelector,
110 ),
111 detail: error.detail,
112 cause: error,
113 }),
114 ),
115 );
116 },
117 getChangeRequest: (input) =>
118 azure.getPullRequest(input).pipe(
119 Effect.map(toChangeRequest),
120 Effect.mapError(
121 (error) =>
122 new SourceControlProviderError({
123 provider: "azure-devops",
124 operation: "getChangeRequest",
125 command: error.command,
126 cwd: input.cwd,
127 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
128 input.reference,
129 ),
130 detail: error.detail,
131 cause: error,
132 }),
133 ),
134 ),
135 createChangeRequest: (input) => {
136 const source = SourceControlProvider.sourceControlRefFromInput(input);
137 return azure
138 .createPullRequest({
139 cwd: input.cwd,
140 baseBranch: input.baseRefName,
141 headSelector: input.headSelector,
142 ...(source !== undefined ? { source } : {}),
143 ...(input.target !== undefined ? { target: input.target } : {}),
144 title: input.title,
145 bodyFile: input.bodyFile,
146 })
147 .pipe(
148 Effect.mapError(
149 (error) =>
150 new SourceControlProviderError({
151 provider: "azure-devops",
152 operation: "createChangeRequest",
153 command: error.command,
154 cwd: input.cwd,
155 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
156 input.headSelector,
157 ),
158 detail: error.detail,
159 cause: error,
160 }),
161 ),
162 );
163 },
164 getRepositoryCloneUrls: (input) =>
165 azure.getRepositoryCloneUrls(input).pipe(
166 Effect.mapError(
167 (error) =>
168 new SourceControlProviderError({
169 provider: "azure-devops",
170 operation: "getRepositoryCloneUrls",
171 command: error.command,
172 cwd: input.cwd,
173 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
174 input.repository,
175 ),
176 detail: error.detail,
177 cause: error,
178 }),
179 ),
180 ),
181 createRepository: (input) =>
182 azure.createRepository(input).pipe(
183 Effect.mapError(
184 (error) =>
185 new SourceControlProviderError({
186 provider: "azure-devops",
187 operation: "createRepository",
188 command: error.command,
189 cwd: input.cwd,
190 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
191 input.repository,
192 ),
193 detail: error.detail,
194 cause: error,
195 }),
196 ),
197 ),
198 getDefaultBranch: (input) =>
199 azure.getDefaultBranch({ cwd: input.cwd }).pipe(
200 Effect.mapError(
201 (error) =>
202 new SourceControlProviderError({
203 provider: "azure-devops",
204 operation: "getDefaultBranch",
205 command: error.command,
206 cwd: input.cwd,
207 detail: error.detail,
208 cause: error,
209 }),
210 ),
211 ),
212 checkoutChangeRequest: (input) =>
213 azure
214 .checkoutPullRequest({
215 cwd: input.cwd,
216 reference: input.reference,
217 ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}),
218 })
219 .pipe(
220 Effect.mapError(
221 (error) =>
222 new SourceControlProviderError({
223 provider: "azure-devops",
224 operation: "checkoutChangeRequest",
225 command: error.command,
226 cwd: input.cwd,
227 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
228 input.reference,
229 ),
230 detail: error.detail,
231 cause: error,
232 }),
233 ),
234 ),
235 });
236});
packages/source-control-azure-devops/src/server/azureDevOpsDiff.test.ts created+459
...@@ -0,0 +1,459 @@
1import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 azureDevOpsFilePatch,
6 azureDevOpsUnreadableFilePatch,
7 byteLength,
8 formatAzureDevOpsDiffCursor,
9 MAX_FILE_DIFF_EDITS,
10 parseAzureDevOpsDiffCursor,
11} from "./azureDevOpsDiff.ts";
12import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts";
13
14function change(overrides: Partial<AzureDevOpsChangeEntry> = {}): AzureDevOpsChangeEntry {
15 return {
16 path: "README.md",
17 oldPath: "README.md",
18 changeKind: "change",
19 objectId: "8f80",
20 originalObjectId: "0ca4",
21 ...overrides,
22 };
23}
24
25function texts(oldContents: string, newContents: string, binary = false) {
26 return { oldContents, newContents, binary };
27}
28
29describe("azureDevOpsFilePatch", () => {
30 it("writes a changed file as the unified patch every diff viewer already reads", () => {
31 const patch = azureDevOpsFilePatch({
32 change: change(),
33 texts: texts("one\ntwo\nthree\n", "one\ntwo again\nthree\n"),
34 });
35
36 expect(patch.truncated).toBe(false);
37 expect(patch.section).toBe(
38 [
39 "diff --git a/README.md b/README.md",
40 "--- a/README.md",
41 "+++ b/README.md",
42 "@@ -1,3 +1,3 @@",
43 " one",
44 "-two",
45 "+two again",
46 " three",
47 "",
48 ].join("\n"),
49 );
50 });
51
52 it("names the side a new file does not have as /dev/null", () => {
53 const patch = azureDevOpsFilePatch({
54 change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }),
55 texts: texts("", "hello\n"),
56 });
57
58 expect(patch.section).toContain("new file mode 100644");
59 expect(patch.section).toContain("--- /dev/null");
60 expect(patch.section).toContain("+++ b/DEMO.md");
61 // Git points the range a new file does not have at line zero, not at line one.
62 expect(patch.section).toContain("@@ -0,0 +1 @@");
63 expect(patch.section).toContain("+hello");
64 });
65
66 it("names the side a deleted file no longer has as /dev/null", () => {
67 const patch = azureDevOpsFilePatch({
68 change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }),
69 texts: texts("gone\n", ""),
70 });
71
72 expect(patch.section).toContain("deleted file mode 100644");
73 expect(patch.section).toContain("--- a/OLD.md");
74 expect(patch.section).toContain("+++ /dev/null");
75 expect(patch.section).toContain("@@ -1 +0,0 @@");
76 expect(patch.section).toContain("-gone");
77 });
78
79 it("keeps the carriage returns of a file with Windows line endings", () => {
80 // They are part of the line rather than around it, so a patch that dropped them would ask
81 // the reader to look at a change that is not the one on the host.
82 const patch = azureDevOpsFilePatch({
83 change: change(),
84 texts: texts("one\r\ntwo\r\n", "one\r\ntwo again\r\n"),
85 });
86
87 expect(patch.section).toContain("-two\r");
88 expect(patch.section).toContain("+two again\r");
89 });
90
91 it("keeps a file that only moved, which has no hunks to give", () => {
92 const patch = azureDevOpsFilePatch({
93 change: change({ path: "docs/new.md", oldPath: "docs/old.md", changeKind: "rename-pure" }),
94 texts: texts("same\n", "same\n"),
95 });
96
97 expect(patch.truncated).toBe(false);
98 expect(patch.section).toBe(
99 [
100 "diff --git a/docs/old.md b/docs/new.md",
101 "rename from docs/old.md",
102 "rename to docs/new.md",
103 "--- a/docs/old.md",
104 "+++ b/docs/new.md",
105 "",
106 ].join("\n"),
107 );
108 });
109
110 it("reports a binary file as changed rather than spelling it out", () => {
111 const patch = azureDevOpsFilePatch({
112 change: change({ path: "logo.png", oldPath: "logo.png" }),
113 texts: texts("PNG\u0000old", "PNG\u0000new"),
114 });
115
116 expect(patch.truncated).toBe(true);
117 expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ");
118 });
119
120 it("shows an overlong file as changed without its hunks", () => {
121 const patch = azureDevOpsFilePatch({
122 change: change({ path: "bundle.js", oldPath: "bundle.js" }),
123 texts: texts("a\n".repeat(400_000), "b\n".repeat(400_000)),
124 });
125
126 expect(patch.truncated).toBe(true);
127 expect(patch.section).toBe(
128 ["diff --git a/bundle.js b/bundle.js", "--- a/bundle.js", "+++ b/bundle.js", ""].join("\n"),
129 );
130 });
131
132 it("takes the host's word that a file is binary, whatever its bytes look like", () => {
133 // Azure hands such a file over base64-encoded, so nothing in the text it sent gives it away.
134 const patch = azureDevOpsFilePatch({
135 change: change({ path: "logo.png", oldPath: "logo.png" }),
136 texts: texts("b2xk", "bmV3", true),
137 });
138
139 expect(patch.truncated).toBe(true);
140 expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ");
141 });
142
143 it("counts an overlong file in bytes rather than in characters", () => {
144 // Three bytes each, so a ceiling counted in code units would let three times the size through.
145 const patch = azureDevOpsFilePatch({
146 change: change({ path: "notes.md", oldPath: "notes.md" }),
147 texts: texts("\u4e00".repeat(200_000), "\u4e8c".repeat(200_000)),
148 });
149
150 expect(patch.truncated).toBe(true);
151 expect(patch.section).toBe(
152 ["diff --git a/notes.md b/notes.md", "--- a/notes.md", "+++ b/notes.md", ""].join("\n"),
153 );
154 });
155
156 const lineRange = (count: number, prefix: string) =>
157 Array.from({ length: count }, (_, line) => `${prefix} ${line}`).join("\n");
158
159 it("lists a file too far apart to diff without its hunks", () => {
160 // Sharing no line at all costs one edit per line on each side, so this pair is twice the
161 // ceiling apart. Left to itself the search costs about the square of that and would hold the
162 // whole server, every websocket client with it, while it worked out a patch nobody reads.
163 // Writing both sides out instead would read as a genuine rewrite: the ceiling is a distance
164 // rather than a proportion, so a long file reaches it having changed in one corner, and that
165 // corner would be buried in a wall of red and green.
166 const lines = (prefix: string) =>
167 Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n");
168 const patch = azureDevOpsFilePatch({
169 change: change({ path: "generated.ts", oldPath: "generated.ts" }),
170 texts: texts(`${lines("old")}\n`, `${lines("new")}\n`),
171 });
172
173 expect(patch.truncated).toBe(true);
174 // And it says the search was given up on, because the reader of a run of files is meant to
175 // stop rather than spend that work again on each of the ones behind it.
176 expect(patch.abandoned).toBe(true);
177 expect(patch.section).toBe(
178 [
179 "diff --git a/generated.ts b/generated.ts",
180 "--- a/generated.ts",
181 "+++ b/generated.ts",
182 "",
183 ].join("\n"),
184 );
185 });
186
187 it("writes out a wholly new file however many lines it has", () => {
188 // Nothing on the old side means there was no edit distance to search out, so this is the
189 // minimal patch and not a stand-in for one. Fifteen thousand lines of thirty bytes is the
190 // shape this used to lose: inside the byte ceiling that gates every file, and well past every
191 // bound a two-sided file answers to, none of which is protecting against anything here.
192 const contents = `${Array.from({ length: 15_000 }, () => "x".repeat(29)).join("\n")}\n`;
193 const patch = azureDevOpsFilePatch({
194 change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }),
195 texts: texts("", contents),
196 });
197
198 expect(byteLength(contents)).toBeLessThan(512 * 1024);
199 expect(patch.truncated).toBe(false);
200 expect(patch.abandoned).toBe(false);
201 expect(patch.edits).toBe(15_000);
202 expect(patch.section).toContain("@@ -0,0 +1,15000 @@");
203 // Only the `+++` of the header on top of the file's own lines, so nothing was dropped out of
204 // the middle.
205 expect(patch.section.match(/^\+/gmu)).toHaveLength(15_001);
206 });
207
208 it("keeps a wholly new file too heavy to write out listed without its hunks", () => {
209 // Every line carries a marker, so a side of very short lines answers with up to twice its own
210 // bytes: these 200,000 one-character lines fit the ceiling each side is read under and weigh
211 // about 600KB written out, more than twice what a whole slice may carry. There is no smaller
212 // true patch for a creation to fall back to, so it is listed without its hunks, the same as a
213 // side too big to read at all, rather than sent at a size the slice budget exists to prevent.
214 const contents = `${Array.from({ length: 200_000 }, () => "x").join("\n")}\n`;
215 const patch = azureDevOpsFilePatch({
216 change: change({ path: "bundle.min.js", oldPath: "bundle.min.js", changeKind: "new" }),
217 texts: texts("", contents),
218 });
219
220 expect(byteLength(contents)).toBeLessThan(512 * 1024);
221 expect(patch.truncated).toBe(true);
222 expect(patch.abandoned).toBe(false);
223 // It still cost the walk over its lines, which is what the slice is charged for.
224 expect(patch.edits).toBe(200_000);
225 expect(patch.section).toBe(
226 [
227 "diff --git a/bundle.min.js b/bundle.min.js",
228 "new file mode 100644",
229 "--- /dev/null",
230 "+++ b/bundle.min.js",
231 "",
232 ].join("\n"),
233 );
234 });
235
236 it("writes out a wholly deleted file however many lines it had", () => {
237 const contents = `${lineRange(20_000, "line")}\n`;
238 const patch = azureDevOpsFilePatch({
239 change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }),
240 texts: texts(contents, ""),
241 });
242
243 expect(patch.truncated).toBe(false);
244 expect(patch.abandoned).toBe(false);
245 expect(patch.edits).toBe(20_000);
246 expect(patch.section).toContain("@@ -1,20000 +0,0 @@");
247 expect(patch.section.match(/^-line /gmu)).toHaveLength(20_000);
248 expect(patch.section.match(/^-/gmu)).toHaveLength(20_001);
249 });
250
251 it("gives an empty new file no hunk to read", () => {
252 // A file with nothing on either side has no lines to claim were replaced, and git writes it
253 // as a header alone.
254 const patch = azureDevOpsFilePatch({
255 change: change({ path: "EMPTY.md", oldPath: "EMPTY.md", changeKind: "new" }),
256 texts: texts("", ""),
257 });
258
259 expect(patch.edits).toBe(0);
260 expect(patch.section).not.toContain("@@");
261 });
262
263 it("marks a wholly new file whose last line has no newline after it", () => {
264 const patch = azureDevOpsFilePatch({
265 change: change({ path: "NOTES.md", oldPath: "NOTES.md", changeKind: "new" }),
266 texts: texts("", "one\ntwo"),
267 });
268
269 expect(patch.section).toBe(
270 [
271 "diff --git a/NOTES.md b/NOTES.md",
272 "new file mode 100644",
273 "--- /dev/null",
274 "+++ b/NOTES.md",
275 "@@ -0,0 +1,2 @@",
276 "+one",
277 "+two",
278 "\\ No newline at end of file",
279 "",
280 ].join("\n"),
281 );
282 });
283
284 it("keeps a file it did diff out of the giving up", () => {
285 const patch = azureDevOpsFilePatch({
286 change: change(),
287 texts: texts("one\ntwo\n", "one\ntwo again\n"),
288 });
289
290 expect(patch.abandoned).toBe(false);
291 });
292
293 it("counts what the diff worked out, which is what the file cost to diff", () => {
294 // The caller spends a budget of these across a slice, so they have to be the edits the search
295 // actually made: one line replaced is a removal and an addition, and the three lines of
296 // context around them cost nothing.
297 const patch = azureDevOpsFilePatch({
298 change: change(),
299 texts: texts("one\ntwo\nthree\nfour\n", "one\ntwo again\nthree\nfour\n"),
300 });
301
302 expect(patch.edits).toBe(2);
303 });
304
305 it("counts nothing for a file it never diffed", () => {
306 const patch = azureDevOpsFilePatch({
307 change: change({ path: "logo.png", oldPath: "logo.png" }),
308 texts: texts("PNG\u0000old", "PNG\u0000new"),
309 });
310
311 expect(patch.edits).toBe(0);
312 });
313
314 it("lists a file whose hunks outweigh its sides without them", () => {
315 // A handful of very long lines is a few edits and nowhere near the edit ceiling, and the
316 // patch carries both sides in full with three lines of context around each hunk, so the
317 // section comes out heavier than either side was. What one file weighs is what a slice's
318 // budget is spent in, so the edit ceiling alone does not bound this.
319 const line = `${"a".repeat(400 * 1024)}\n`;
320 const patch = azureDevOpsFilePatch({
321 change: change({ path: "min.js", oldPath: "min.js" }),
322 texts: texts(line, `${"b".repeat(400 * 1024)}\n`),
323 });
324
325 expect(patch.edits).toBeLessThan(MAX_FILE_DIFF_EDITS);
326 expect(patch.truncated).toBe(true);
327 expect(patch.section).toBe(
328 ["diff --git a/min.js b/min.js", "--- a/min.js", "+++ b/min.js", ""].join("\n"),
329 );
330 expect(byteLength(patch.section)).toBeLessThan(byteLength(line));
331 });
332
333 it("marks a file that does not end in a newline, as git does", () => {
334 const patch = azureDevOpsFilePatch({
335 change: change(),
336 texts: texts("one\n", "two"),
337 });
338
339 expect(patch.section).toContain("\\ No newline at end of file");
340 });
341});
342
343describe("azureDevOpsUnreadableFilePatch", () => {
344 it("keeps a file the host would not hand over, listed without its hunks", () => {
345 const patch = azureDevOpsUnreadableFilePatch(change({ path: "huge.bin", oldPath: "huge.bin" }));
346
347 expect(patch.truncated).toBe(true);
348 expect(patch.section).toBe(
349 ["diff --git a/huge.bin b/huge.bin", "--- a/huge.bin", "+++ b/huge.bin", ""].join("\n"),
350 );
351 });
352});
353
354describe("a file Azure names something a patch header cannot carry plainly", () => {
355 it("writes each side as git's quoted form, the side letter inside the quotes", () => {
356 const patch = azureDevOpsFilePatch({
357 change: change({ path: "notes\treadme.md", oldPath: "notes\treadme.md" }),
358 texts: texts("one\n", "two\n"),
359 });
360
361 expect(patch.section).toBe(
362 [
363 'diff --git "a/notes\\treadme.md" "b/notes\\treadme.md"',
364 '--- "a/notes\\treadme.md"',
365 '+++ "b/notes\\treadme.md"',
366 "@@ -1 +1 @@",
367 "-one",
368 "+two",
369 "",
370 ].join("\n"),
371 );
372 });
373
374 it("keeps a name holding a newline on the one header line it belongs to", () => {
375 const patch = azureDevOpsFilePatch({
376 change: change({ path: "line\nfile.txt", oldPath: "line\nfile.txt" }),
377 texts: texts("one\n", "two\n"),
378 });
379
380 // Written as itself the name would start a line of its own, and a reader would take what
381 // followed for a header the patch never had.
382 expect(patch.section.split("\n").slice(0, 3)).toEqual([
383 'diff --git "a/line\\nfile.txt" "b/line\\nfile.txt"',
384 '--- "a/line\\nfile.txt"',
385 '+++ "b/line\\nfile.txt"',
386 ]);
387 });
388
389 it("quotes the names a rename states, which carry no side letter", () => {
390 const patch = azureDevOpsFilePatch({
391 change: change({
392 path: "docs/new\tname.md",
393 oldPath: "docs/old\tname.md",
394 changeKind: "rename-pure",
395 }),
396 texts: texts("same\n", "same\n"),
397 });
398
399 expect(patch.section).toBe(
400 [
401 'diff --git "a/docs/old\\tname.md" "b/docs/new\\tname.md"',
402 'rename from "docs/old\\tname.md"',
403 'rename to "docs/new\\tname.md"',
404 '--- "a/docs/old\\tname.md"',
405 '+++ "b/docs/new\\tname.md"',
406 "",
407 ].join("\n"),
408 );
409 });
410
411 it("quotes the sides of the one line a binary file gets", () => {
412 const patch = azureDevOpsFilePatch({
413 change: change({ path: "logo\tmark.png", oldPath: "logo\tmark.png" }),
414 texts: texts("PNG\u0000old", "PNG\u0000new"),
415 });
416
417 expect(patch.section).toContain(
418 'Binary files "a/logo\\tmark.png" and "b/logo\\tmark.png" differ',
419 );
420 });
421
422 it("hands a reader of the header back the name Azure gave", () => {
423 const path = 'every\t\n"kind"\\of.md';
424 const patch = azureDevOpsFilePatch({
425 change: change({ path, oldPath: path }),
426 texts: texts("one\n", "two\n"),
427 });
428 const [header, oldLine, newLine] = patch.section.split("\n");
429
430 expect(header).not.toContain("\t");
431 expect(unquoteGitPatchPath(oldLine?.slice(4) ?? "")).toBe(`a/${path}`);
432 expect(unquoteGitPatchPath(newLine?.slice(4) ?? "")).toBe(`b/${path}`);
433 });
434});
435
436describe("a diff cursor", () => {
437 it("carries the push it was taken against back to the next slice", () => {
438 const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 });
439
440 expect(parseAzureDevOpsDiffCursor(cursor)).toEqual({ iterationId: 3, fileIndex: 12 });
441 });
442
443 it("reads anything it did not write as no position at all", () => {
444 // Which starts the read from the top rather than failing it: a cursor is the client's to
445 // hand back, and nothing downstream is worth refusing a whole diff over.
446 for (const raw of [undefined, null, "", "abc", "1", "0:4", "1:-2", "1:2:3"]) {
447 expect(parseAzureDevOpsDiffCursor(raw)).toBeNull();
448 }
449 });
450
451 it("refuses a half it did not write rather than reading it as the first file", () => {
452 // `Number` is wider than the cursor: an empty, padded or hex half would otherwise pass as a
453 // position, and the read would resume against an iteration the client never saw instead of
454 // starting again from the latest one.
455 for (const raw of ["1:", ":4", "1: ", " 1:4", "1:0x2", "0x1:2", "1e2:0", "1:4.0"]) {
456 expect(parseAzureDevOpsDiffCursor(raw)).toBeNull();
457 }
458 });
459});
packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts created+273
...@@ -0,0 +1,273 @@
1import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2import { structuredPatch } from "diff";
3
4import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts";
5
6/**
7 * How far a diff read got, and which push it was reading. Azure hangs a pull request's changed
8 * files off an iteration, so a push landing mid-read would renumber the list under a cursor that
9 * did not also pin the iteration.
10 */
11export interface AzureDevOpsDiffCursor {
12 readonly iterationId: number;
13 readonly fileIndex: number;
14}
15
16const CURSOR_SEPARATOR = ":";
17
18/**
19 * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as
20 * three.
21 */
22const CURSOR_COMPONENT = /^\d+$/;
23
24export function formatAzureDevOpsDiffCursor(cursor: AzureDevOpsDiffCursor): string {
25 return `${cursor.iterationId}${CURSOR_SEPARATOR}${cursor.fileIndex}`;
26}
27
28/** Null for anything this did not write, which starts the read from the top rather than failing. */
29export function parseAzureDevOpsDiffCursor(
30 raw: string | null | undefined,
31): AzureDevOpsDiffCursor | null {
32 if (raw === null || raw === undefined) return null;
33 const [iteration, file, ...rest] = raw.split(CURSOR_SEPARATOR);
34 if (rest.length > 0) return null;
35 if (iteration === undefined || file === undefined) return null;
36 if (!CURSOR_COMPONENT.test(iteration) || !CURSOR_COMPONENT.test(file)) return null;
37 const iterationId = Number(iteration);
38 const fileIndex = Number(file);
39 if (!Number.isSafeInteger(iterationId) || iterationId <= 0) return null;
40 if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return null;
41 return { iterationId, fileIndex };
42}
43
44/** The two texts of one changed file, empty on whichever side the change does not have. */
45export interface AzureDevOpsFileTexts {
46 readonly oldContents: string;
47 readonly newContents: string;
48 /** Azure's own flag for a file it hands back base64-encoded instead of as text. */
49 readonly binary: boolean;
50}
51
52export interface AzureDevOpsFilePatch {
53 readonly section: string;
54 /** The file changed but its hunks are not in the section, so the patch has a hole in it. */
55 readonly truncated: boolean;
56 /**
57 * The diff was given up on partway, having spent the whole edit budget, so a caller reading a
58 * run of files stops here rather than paying that again for each one behind it.
59 */
60 readonly abandoned: boolean;
61 /**
62 * Lines added or removed, which is the edit distance the diff had to search out, and what a
63 * slice's budget is spent in: a file of short lines is cheap on the wire and dear to diff.
64 */
65 readonly edits: number;
66}
67
68/**
69 * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather
70 * than a patch, so an oversize file is paid for twice over before anything can be diffed.
71 */
72const MAX_FILE_BYTES = 512 * 1024;
73
74/** Git's own default, and what the hunks from this repo's other hosts are already cut to. */
75const PATCH_CONTEXT_LINES = 3;
76
77/**
78 * How far apart one file's two sides may be before it is listed without its hunks. The line diff
79 * costs about the square of the edit distance, so an unbounded pair sharing almost nothing could
80 * hold the whole server. Bounded in edits rather than milliseconds so a change slices the same
81 * way on every machine.
82 */
83export const MAX_FILE_DIFF_EDITS = 2_000;
84
85/**
86 * A backstop for a machine slower than the edit ceiling was tuned for. The ceiling rather than
87 * this is what decides a patch's shape: a timeout that fires would slice the same change one way
88 * here and another on a busier host.
89 */
90const MAX_FILE_DIFF_MILLIS = 2_000;
91
92/** How much diff work one slice does before the rest is left for the next request. */
93export const MAX_DIFF_SLICE_EDITS = 6_000;
94
95/** How much patch one slice carries before the rest is left for the next request. */
96export const MAX_DIFF_SLICE_BYTES = 256 * 1024;
97
98/**
99 * How many files one slice carries however little each one weighs. A binary, oversize, or
100 * unreadable entry is just a header, so neither budget above stops a run of thousands of them.
101 */
102export const MAX_DIFF_SLICE_FILES = 300;
103
104/** Git's own note for a side whose last line has no newline after it. */
105const NO_NEWLINE_MARKER = "\\ No newline at end of file";
106
107/** A text's lines, without the empty one that a trailing newline leaves behind a split. */
108function contentLines(contents: string): ReadonlyArray<string> {
109 if (contents === "") return [];
110 const lines = contents.split("\n");
111 if (lines.at(-1) === "") lines.pop();
112 return lines;
113}
114
115/** A NUL byte is git's own test for it, and it survives Azure's JSON envelope intact. */
116function isBinary(contents: string): boolean {
117 return contents.includes("\u0000");
118}
119
120/**
121 * What a file costs on the wire, which is its bytes rather than its code units: a ceiling counted
122 * in characters lets a file of three-byte glyphs through at three times the intended size.
123 */
124export const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8");
125
126/**
127 * Git points an empty range at the line before it, which is line zero for a file that is wholly
128 * new or wholly gone, and writes a single line as its number alone.
129 */
130function hunkRange(start: number, lines: number): string {
131 if (lines === 0) return `${start - 1},0`;
132 return lines === 1 ? String(start) : `${start},${lines}`;
133}
134
135/**
136 * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file
137 * mode, so the ordinary one stands in. Names are quoted the way git quotes them, prefix inside the
138 * quoting: a header reader stops a bare name at its first tab or newline, so a path holding either
139 * would be truncated and its viewed mark would land on the wrong file.
140 */
141function patchHeader(change: AzureDevOpsChangeEntry): string {
142 const oldSide = quoteGitPatchPath(`a/${change.oldPath}`);
143 const newSide = quoteGitPatchPath(`b/${change.path}`);
144 const lines = [`diff --git ${oldSide} ${newSide}`];
145 if (change.changeKind === "new") lines.push("new file mode 100644");
146 if (change.changeKind === "deleted") lines.push("deleted file mode 100644");
147 if (change.changeKind === "rename-pure" || change.changeKind === "rename-changed") {
148 lines.push(
149 `rename from ${quoteGitPatchPath(change.oldPath)}`,
150 `rename to ${quoteGitPatchPath(change.path)}`,
151 );
152 }
153 lines.push(
154 `--- ${change.changeKind === "new" ? "/dev/null" : oldSide}`,
155 `+++ ${change.changeKind === "deleted" ? "/dev/null" : newSide}`,
156 );
157 return lines.join("\n");
158}
159
160/**
161 * A file written out as wholly replaced: every old line gone, every new line arrived, in one
162 * hunk, which needs no edit-distance search at all.
163 */
164function replacementSection(header: string, texts: AzureDevOpsFileTexts): string {
165 const oldLines = contentLines(texts.oldContents);
166 const newLines = contentLines(texts.newContents);
167 const noNewline = (contents: string, lines: ReadonlyArray<string>) =>
168 lines.length > 0 && !contents.endsWith("\n") ? [NO_NEWLINE_MARKER] : [];
169 return [
170 header,
171 `@@ -${hunkRange(1, oldLines.length)} +${hunkRange(1, newLines.length)} @@`,
172 ...oldLines.map((line) => `-${line}`),
173 ...noNewline(texts.oldContents, oldLines),
174 ...newLines.map((line) => `+${line}`),
175 ...noNewline(texts.newContents, newLines),
176 "",
177 ].join("\n");
178}
179
180/**
181 * One file's section of a unified patch, built here because Azure has no route that returns one:
182 * its diff routes name the files that changed, and the contents are a separate read per side.
183 */
184export function azureDevOpsFilePatch(input: {
185 readonly change: AzureDevOpsChangeEntry;
186 readonly texts: AzureDevOpsFileTexts;
187}): AzureDevOpsFilePatch {
188 const header = patchHeader(input.change);
189 const { oldContents, newContents } = input.texts;
190
191 if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) {
192 // Git's own wording for a file it will not spell out.
193 const oldSide = quoteGitPatchPath(`a/${input.change.oldPath}`);
194 const newSide = quoteGitPatchPath(`b/${input.change.path}`);
195 const binary = `Binary files ${oldSide} and ${newSide} differ`;
196 return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false, edits: 0 };
197 }
198 if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) {
199 return { section: `${header}\n`, truncated: true, abandoned: false, edits: 0 };
200 }
201
202 // A creation or deletion has nothing on one side, so writing both sides in full is already the
203 // minimal patch and needs no diff search.
204 const created = oldContents === "" && newContents !== "";
205 const deleted = newContents === "" && oldContents !== "";
206 if (created || deleted) {
207 const contents = created ? newContents : oldContents;
208 const lines = contentLines(contents);
209 // A `+`/`-` marker on every line can put a side that just fits the size ceiling over it.
210 // Checked against bytes-plus-marker-count rather than building the section, since that upper
211 // bound is cheaper and this is exactly the file shape that would cost the most to build.
212 if (byteLength(contents) + lines.length > MAX_FILE_BYTES) {
213 return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length };
214 }
215 const section = replacementSection(header, input.texts);
216 if (byteLength(section) > MAX_FILE_BYTES) {
217 return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length };
218 }
219 return { section, truncated: false, abandoned: false, edits: lines.length };
220 }
221
222 const patch = structuredPatch(
223 `a/${input.change.oldPath}`,
224 `b/${input.change.path}`,
225 oldContents,
226 newContents,
227 undefined,
228 undefined,
229 {
230 context: PATCH_CONTEXT_LINES,
231 maxEditLength: MAX_FILE_DIFF_EDITS,
232 timeout: MAX_FILE_DIFF_MILLIS,
233 },
234 );
235 // Hitting the edit ceiling lists the file without hunks rather than falling back to a full
236 // replacement: the ceiling is a distance, not a proportion, so a long file can reach it having
237 // changed in one small corner, and both sides in full would bury that corner in a wall of text.
238 if (patch === undefined) {
239 return {
240 section: `${header}\n`,
241 truncated: true,
242 abandoned: true,
243 edits: MAX_FILE_DIFF_EDITS,
244 };
245 }
246
247 let edits = 0;
248 const hunks = patch.hunks.map((hunk) => {
249 for (const line of hunk.lines) {
250 if (line.startsWith("+") || line.startsWith("-")) edits += 1;
251 }
252 return [
253 `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`,
254 ...hunk.lines,
255 ].join("\n");
256 });
257 // A pure rename has no hunks to give but is still listed, since dropping it would take the file
258 // out of the change altogether.
259 const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`;
260 // The edit ceiling bounds edit distance, not the hunks' size: a handful of very long lines plus
261 // context can still exceed the size ceiling even well inside the edit ceiling.
262 if (byteLength(section) > MAX_FILE_BYTES) {
263 return { section: `${header}\n`, truncated: true, abandoned: false, edits };
264 }
265 return { section, truncated: false, abandoned: false, edits };
266}
267
268/** A file listed without its hunks, for when the host would not hand one of its two sides over. */
269export function azureDevOpsUnreadableFilePatch(
270 change: AzureDevOpsChangeEntry,
271): AzureDevOpsFilePatch {
272 return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false, edits: 0 };
273}
packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts created+652
...@@ -0,0 +1,652 @@
1import * as Result from "effect/Result";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 decodeItemContentJson,
6 decodeIterationChangesJson,
7 decodeIterationsJson,
8 decodePullRequestJson,
9 decodePullRequestListJson,
10 decodeThreadsJson,
11 decodeViewerJson,
12} from "./azureDevOpsPullRequestJson.ts";
13
14const REST_URL =
15 "https://dev.azure.com/acme/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/pullRequests/42";
16
17/** Shaped after Azure's `GitPullRequest`, trimmed to the fields that are read. */
18function pullRequest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
19 return {
20 pullRequestId: 42,
21 title: "Add the change requests page",
22 description: "Ships the page.",
23 status: "active",
24 isDraft: false,
25 mergeStatus: "succeeded",
26 createdBy: { displayName: "Bilal Hassan", uniqueName: "bilal@acme.dev" },
27 sourceRefName: "refs/heads/feat/page",
28 targetRefName: "refs/heads/main",
29 creationDate: "2026-07-01T00:00:00Z",
30 url: REST_URL,
31 repository: { name: "web", project: { name: "platform" } },
32 ...overrides,
33 };
34}
35
36function expectSuccess<A>(result: Result.Result<A, unknown>): A {
37 expect(Result.isSuccess(result)).toBe(true);
38 if (!Result.isSuccess(result)) throw new Error("expected a successful decode");
39 return result.success;
40}
41
42const asJson = (value: unknown) => JSON.stringify(value);
43
44describe("decodePullRequestListJson", () => {
45 it("reads a pull request as a change request", () => {
46 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()])));
47
48 expect(batch.items).toHaveLength(1);
49 expect(batch.items[0]).toMatchObject({
50 number: 42,
51 title: "Add the change requests page",
52 // The login is an email, because that is what `az account show` reports to compare with.
53 author: { login: "bilal@acme.dev", name: "Bilal Hassan" },
54 // Azure prefixes its refs, which no other host does.
55 headBranch: "feat/page",
56 baseBranch: "main",
57 state: "open",
58 isDraft: false,
59 mergeability: "mergeable",
60 });
61 });
62
63 it("assembles a browser url when Azure reports no web link", () => {
64 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()])));
65
66 expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42");
67 });
68
69 it("prefers the web link Azure sends when asked for one", () => {
70 const batch = expectSuccess(
71 decodePullRequestListJson(
72 asJson([
73 pullRequest({
74 _links: {
75 web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" },
76 },
77 }),
78 ]),
79 ),
80 );
81
82 expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42");
83 });
84
85 it.each([
86 ["active", "open"],
87 ["completed", "merged"],
88 ["abandoned", "closed"],
89 ["something new", "open"],
90 ])("reads the %s status as %s", (status, expected) => {
91 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ status })])));
92
93 expect(batch.items[0]?.state).toBe(expected);
94 });
95
96 it.each([
97 ["succeeded", "mergeable"],
98 ["conflicts", "conflicting"],
99 ["rejectedByPolicy", "conflicting"],
100 ["queued", "unknown"],
101 ["notSet", "unknown"],
102 ])("reads the %s merge status as %s", (mergeStatus, expected) => {
103 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ mergeStatus })])));
104
105 expect(batch.items[0]?.mergeability).toBe(expected);
106 });
107
108 it("stands the closing time in for a last-touched time Azure does not keep", () => {
109 const batch = expectSuccess(
110 decodePullRequestListJson(
111 asJson([pullRequest({ status: "completed", closedDate: "2026-07-05T00:00:00Z" })]),
112 ),
113 );
114
115 expect(batch.items[0]).toMatchObject({
116 createdAt: "2026-07-01T00:00:00Z",
117 updatedAt: "2026-07-05T00:00:00Z",
118 });
119 });
120
121 it("skips a malformed row but still counts it, so paging does not stop early", () => {
122 const batch = expectSuccess(
123 decodePullRequestListJson(asJson([{ pullRequestId: "nope" }, pullRequest()])),
124 );
125
126 expect(batch.items).toHaveLength(1);
127 expect(batch.rawCount).toBe(2);
128 expect(batch.rawIndexes).toEqual([1]);
129 });
130});
131
132describe("decodePullRequestJson", () => {
133 it("reads reviewers as review requests", () => {
134 const detail = expectSuccess(
135 decodePullRequestJson(
136 asJson(
137 pullRequest({
138 reviewers: [{ displayName: "Julius", uniqueName: "julius@acme.dev", vote: 10 }],
139 }),
140 ),
141 ),
142 );
143
144 expect(detail?.reviewRequestLogins).toEqual(["julius@acme.dev"]);
145 expect(detail?.reviewers).toEqual([
146 { login: "julius@acme.dev", name: "Julius", avatarUrl: null },
147 ]);
148 });
149
150 it("reads auto-complete from whoever armed it, and its absence as nobody", () => {
151 const armed = expectSuccess(
152 decodePullRequestJson(
153 asJson(pullRequest({ autoCompleteSetBy: { displayName: "Bilal Hassan" } })),
154 ),
155 );
156 expect(armed?.autoMergeEnabled).toBe(true);
157
158 // Azure leaves the field out entirely rather than sending it empty, so its absence is the
159 // whole of what it says about auto-complete being off.
160 expect(expectSuccess(decodePullRequestJson(asJson(pullRequest())))?.autoMergeEnabled).toBe(
161 false,
162 );
163 });
164
165 it("keeps the strategy stored with auto-complete", () => {
166 const armed = expectSuccess(
167 decodePullRequestJson(
168 asJson(
169 pullRequest({
170 autoCompleteSetBy: { displayName: "Bilal Hassan" },
171 completionOptions: { mergeStrategy: "squash" },
172 }),
173 ),
174 ),
175 );
176
177 expect(armed).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" });
178
179 const unspecified = expectSuccess(
180 decodePullRequestJson(
181 asJson(
182 pullRequest({
183 autoCompleteSetBy: { displayName: "Bilal Hassan" },
184 completionOptions: { squashMerge: false },
185 }),
186 ),
187 ),
188 );
189 expect(unspecified?.autoMergeEnabled).toBe(true);
190 expect(unspecified?.autoMergeMethod).toBeUndefined();
191 });
192
193 it("works out where the repository lives from what Azure returned", () => {
194 const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest())));
195
196 expect(detail?.location).toEqual({ project: "platform", repository: "web" });
197 });
198
199 it("reports no repository location when Azure said too little to name one", () => {
200 // A web link places the pull request, but with no repository named there is nothing to
201 // address the routes that read its files and its conversation.
202 const detail = expectSuccess(
203 decodePullRequestJson(
204 asJson(
205 pullRequest({
206 url: null,
207 repository: null,
208 _links: {
209 web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" },
210 },
211 }),
212 ),
213 ),
214 );
215
216 expect(detail?.location).toBeNull();
217 });
218
219 it("returns nothing when Azure gave no way to place the pull request at all", () => {
220 const detail = expectSuccess(
221 decodePullRequestJson(asJson(pullRequest({ url: null, repository: null }))),
222 );
223
224 expect(detail).toBeNull();
225 });
226});
227
228describe("decodeViewerJson", () => {
229 it("reads the signed-in account name", () => {
230 expect(expectSuccess(decodeViewerJson(asJson({ user: { name: "bilal@acme.dev" } })))).toBe(
231 "bilal@acme.dev",
232 );
233 });
234
235 it("returns nothing when nobody is signed in", () => {
236 expect(expectSuccess(decodeViewerJson(asJson({ user: null })))).toBeNull();
237 });
238});
239
240describe("decodeThreadsJson", () => {
241 it("takes every real comment of every thread, oldest first", () => {
242 const comments = expectSuccess(
243 decodeThreadsJson(
244 asJson({
245 value: [
246 {
247 id: 2,
248 comments: [
249 {
250 id: 1,
251 content: "Second remark.",
252 author: { displayName: "Julius", uniqueName: "julius@acme.dev" },
253 publishedDate: "2026-07-03T00:00:00Z",
254 },
255 ],
256 },
257 {
258 id: 1,
259 comments: [
260 // Azure's own activity notes are events rather than remarks.
261 { id: 1, content: "Bilal voted", commentType: "system", publishedDate: "x" },
262 {
263 id: 2,
264 content: "First remark.",
265 author: { displayName: "Bilal", uniqueName: "bilal@acme.dev" },
266 publishedDate: "2026-07-02T00:00:00Z",
267 },
268 ],
269 },
270 ],
271 }),
272 ),
273 );
274
275 expect(comments.map((comment) => comment.body)).toEqual(["First remark.", "Second remark."]);
276 expect(comments[0]).toMatchObject({
277 kind: "issue-comment",
278 author: { login: "bilal@acme.dev" },
279 });
280 });
281
282 it("reads a thread pinned to a file as a review comment", () => {
283 const comments = expectSuccess(
284 decodeThreadsJson(
285 asJson({
286 value: [
287 {
288 id: 3,
289 threadContext: { filePath: "/src/app.ts" },
290 comments: [{ id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }],
291 },
292 ],
293 }),
294 ),
295 );
296
297 expect(comments[0]).toMatchObject({ kind: "review-comment", path: "/src/app.ts" });
298 });
299
300 it("keeps the replies under a thread, which are as much of the conversation", () => {
301 const comments = expectSuccess(
302 decodeThreadsJson(
303 asJson({
304 value: [
305 {
306 id: 4,
307 threadContext: { filePath: "/src/app.ts" },
308 comments: [
309 { id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" },
310 { id: 2, content: "Renamed.", publishedDate: "2026-07-02T01:00:00Z" },
311 { id: 3, content: "Thanks.", publishedDate: "2026-07-02T02:00:00Z" },
312 ],
313 },
314 ],
315 }),
316 ),
317 );
318
319 expect(comments.map((comment) => comment.id)).toEqual(["4:1", "4:2", "4:3"]);
320 });
321
322 it("drops deleted threads and threads with nothing to show", () => {
323 const comments = expectSuccess(
324 decodeThreadsJson(
325 asJson({
326 value: [
327 {
328 id: 1,
329 isDeleted: true,
330 comments: [{ id: 1, content: "gone", publishedDate: "2026-07-02T00:00:00Z" }],
331 },
332 { id: 2, comments: [] },
333 {
334 id: 3,
335 comments: [{ id: 1, content: " ", publishedDate: "2026-07-02T00:00:00Z" }],
336 },
337 ],
338 }),
339 ),
340 );
341
342 expect(comments).toEqual([]);
343 });
344});
345
346describe("decodeIterationsJson", () => {
347 const iteration = (id: number, head: string, base: string) => ({
348 id,
349 sourceRefCommit: { commitId: head },
350 commonRefCommit: { commitId: base },
351 targetRefCommit: { commitId: base },
352 });
353
354 it("reads every push in order, oldest first", () => {
355 const iterations = expectSuccess(
356 decodeIterationsJson(
357 asJson({ value: [iteration(2, "bbb", "base"), iteration(1, "aaa", "base")] }),
358 ),
359 );
360
361 expect(iterations.map((entry) => entry.id)).toEqual([1, 2]);
362 expect(iterations.at(-1)).toEqual({ id: 2, headCommit: "bbb", mergeBaseCommit: "base" });
363 });
364
365 it("skips a push Azure could not place both ends of", () => {
366 // A patch is taken over a range, and an iteration missing either end names no range at all.
367 const iterations = expectSuccess(
368 decodeIterationsJson(
369 asJson({
370 value: [{ id: 1, sourceRefCommit: { commitId: "aaa" } }, iteration(2, "bbb", "base")],
371 }),
372 ),
373 );
374
375 expect(iterations.map((entry) => entry.id)).toEqual([2]);
376 });
377});
378
379describe("decodeIterationChangesJson", () => {
380 it("names each changed file without the slash Azure leads its paths with", () => {
381 const page = expectSuccess(
382 decodeIterationChangesJson(
383 asJson({
384 changeEntries: [
385 { changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } },
386 {
387 changeType: "edit",
388 item: { path: "/README.md", objectId: "8f80", originalObjectId: "0ca4" },
389 },
390 { changeType: "delete", item: { path: "/OLD.md", originalObjectId: "1111" } },
391 ],
392 }),
393 ),
394 );
395
396 expect(page.changes.map((change) => [change.path, change.changeKind])).toEqual([
397 ["DEMO.md", "new"],
398 ["README.md", "change"],
399 ["OLD.md", "deleted"],
400 ]);
401 });
402
403 it("keeps a space at the end of a file's name, which belongs to the name", () => {
404 // Git will carry a name that ends in a space, and the patch and the viewed mark are both
405 // keyed by it. Tidying it here files the change under a name nothing else uses.
406 const page = expectSuccess(
407 decodeIterationChangesJson(
408 asJson({
409 changeEntries: [
410 { changeType: "edit", item: { path: "/docs/readme.md ", objectId: "ec00" } },
411 {
412 changeType: "rename",
413 sourceServerItem: "/docs/old.md ",
414 item: { path: "/docs/moved.md", objectId: "aaaa", originalObjectId: "aaaa" },
415 },
416 ],
417 }),
418 ),
419 );
420
421 expect(page.changes.map((change) => [change.path, change.oldPath])).toEqual([
422 ["docs/readme.md ", "docs/readme.md "],
423 ["docs/moved.md", "docs/old.md "],
424 ]);
425 });
426
427 it("reads a rename as one file that moved, and says whether it also changed", () => {
428 const page = expectSuccess(
429 decodeIterationChangesJson(
430 asJson({
431 changeEntries: [
432 {
433 changeType: "rename",
434 sourceServerItem: "/docs/old.md",
435 item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" },
436 },
437 {
438 changeType: "edit, rename",
439 sourceServerItem: "/src/old.ts",
440 item: { path: "/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" },
441 },
442 ],
443 }),
444 ),
445 );
446
447 expect(page.changes).toEqual([
448 {
449 path: "docs/new.md",
450 oldPath: "docs/old.md",
451 changeKind: "rename-pure",
452 objectId: "aaaa",
453 originalObjectId: "aaaa",
454 },
455 {
456 path: "src/new.ts",
457 oldPath: "src/old.ts",
458 changeKind: "rename-changed",
459 objectId: "bbbb",
460 originalObjectId: "cccc",
461 },
462 ]);
463 });
464
465 it("drops the folders Azure lists alongside the files that changed", () => {
466 // A review shows files, and a folder has no content on either side to show for one.
467 const page = expectSuccess(
468 decodeIterationChangesJson(
469 asJson({
470 changeEntries: [
471 { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } },
472 { changeType: "add", item: { path: "/docs/page.md", objectId: "dddd" } },
473 ],
474 }),
475 ),
476 );
477
478 expect(page.changes.map((change) => change.path)).toEqual(["docs/page.md"]);
479 });
480
481 it("carries where the next page of a long change starts", () => {
482 const page = expectSuccess(
483 decodeIterationChangesJson(
484 asJson({
485 changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }],
486 nextSkip: 2000,
487 }),
488 ),
489 );
490
491 expect(page.nextSkip).toBe(2000);
492 });
493
494 it("reads the last page, which names no page after it, as the end of the change", () => {
495 const page = expectSuccess(
496 decodeIterationChangesJson(
497 asJson({
498 changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }],
499 }),
500 ),
501 );
502
503 expect(page.nextSkip).toBeNull();
504 });
505
506 it("reads where a rename came from out of either of the two places Azure names it", () => {
507 // The iteration-changes route answers with `originalPath`; the commit routes answer with
508 // `sourceServerItem`, and both are the same fact under two names.
509 const page = expectSuccess(
510 decodeIterationChangesJson(
511 asJson({
512 changeEntries: [
513 {
514 changeType: "rename",
515 originalPath: "/docs/old.md",
516 item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" },
517 },
518 ],
519 }),
520 ),
521 );
522
523 expect(page.changes.at(0)?.oldPath).toBe("docs/old.md");
524 });
525});
526
527describe("decodeItemContentJson", () => {
528 it("reads the file's text out of the envelope Azure wraps it in", () => {
529 expect(
530 expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))),
531 ).toEqual({ contents: "one\ntwo", isBinary: false });
532 });
533
534 it("reads an empty file as empty rather than as a failure to look", () => {
535 expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toEqual({
536 contents: "",
537 isBinary: false,
538 });
539 });
540
541 it("keeps Azure's own word that a file is binary", () => {
542 // Which it answers base64-encoded, so nothing in the text it sent would give it away.
543 expect(
544 expectSuccess(
545 decodeItemContentJson(
546 asJson({ path: "/logo.png", content: "b2xk", contentMetadata: { isBinary: true } }),
547 ),
548 ),
549 ).toEqual({ contents: "b2xk", isBinary: true });
550 });
551});
552
553/**
554 * Every other fixture in this file is written from Azure's published contract. These are the
555 * shapes a real organisation answered with, read back through `az devops invoke` rather than
556 * `az rest`: the extension hands over the route's own body with one key of its own added, and a
557 * live repository leaves out fields the contract documents.
558 */
559describe("what az devops invoke answers with", () => {
560 it("reads the envelope the extension adds its own continuation token to", () => {
561 // Set on every JSON body it returns, from a response header these routes do not send, so it
562 // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode.
563 expect(
564 expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))),
565 ).toEqual([]);
566
567 expect(
568 expectSuccess(
569 decodeIterationsJson(
570 asJson({
571 count: 1,
572 continuation_token: null,
573 value: [
574 {
575 id: 1,
576 sourceRefCommit: { commitId: "f4031105213c68f197cd46ea303bb5e72acc889a" },
577 commonRefCommit: { commitId: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35" },
578 },
579 ],
580 }),
581 ),
582 ),
583 ).toEqual([
584 {
585 id: 1,
586 headCommit: "f4031105213c68f197cd46ea303bb5e72acc889a",
587 mergeBaseCommit: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35",
588 },
589 ]);
590 });
591
592 it("keeps the files of a change that names neither their object type nor their page after it", () => {
593 // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no
594 // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. Each of those absences
595 // is what the defaults in the decoder are for, and reading any of them as stated would drop
596 // every file of every Azure change request.
597 const page = expectSuccess(
598 decodeIterationChangesJson(
599 asJson({
600 continuation_token: null,
601 changeEntries: [
602 { changeType: "add", item: { path: "/DEMO.md", objectId: "EC005DB24" } },
603 { changeType: "edit", item: { path: "/README.md", objectId: "8F8047A49" } },
604 ],
605 }),
606 ),
607 );
608
609 expect(page.nextSkip).toBeNull();
610 expect(page.changes).toEqual([
611 {
612 path: "DEMO.md",
613 oldPath: "DEMO.md",
614 changeKind: "new",
615 objectId: "EC005DB24",
616 originalObjectId: null,
617 },
618 {
619 path: "README.md",
620 oldPath: "README.md",
621 changeKind: "change",
622 objectId: "8F8047A49",
623 originalObjectId: null,
624 },
625 ]);
626 });
627
628 it("reads a text file whose content type Azure calls a stream", () => {
629 // `contentMetadata` comes back for a markdown file with `application/octet-stream` on it and
630 // no `isBinary` at all, so the content type is not the field to ask, and its absence is the
631 // answer that the text is text.
632 expect(
633 expectSuccess(
634 decodeItemContentJson(
635 asJson({
636 path: "/README.md",
637 objectId: "8f8047a49",
638 gitObjectType: "blob",
639 content: "# T3Demo\n",
640 contentMetadata: {
641 contentType: "application/octet-stream",
642 encoding: 65001,
643 extension: "md",
644 fileName: "README.md",
645 },
646 continuation_token: null,
647 }),
648 ),
649 ),
650 ).toEqual({ contents: "# T3Demo\n", isBinary: false });
651 });
652});
packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts created+574
...@@ -0,0 +1,574 @@
1import * as Cause from "effect/Cause";
2import * as Exit from "effect/Exit";
3import * as Result from "effect/Result";
4import * as Schema from "effect/Schema";
5import type {
6 PullRequestActor,
7 PullRequestComment,
8 PullRequestMergeMethod,
9 PullRequestMergeability,
10 PullRequestState,
11} from "@t3tools/contracts";
12import { TrimmedNonEmptyString } from "@t3tools/contracts";
13import { decodeJsonResult } from "@t3tools/shared/schemaJson";
14
15import { azureDevOpsPullRequestWebUrl } from "./azureDevOpsPullRequests.ts";
16
17/**
18 * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as
19 * the other hosts: a new merge status must not fail a whole payload. Every field beyond the
20 * identity is optional, because `az repos pr` returns rather more or less of the REST object
21 * depending on the command.
22 */
23const RawIdentitySchema = Schema.Struct({
24 displayName: Schema.optional(Schema.NullOr(Schema.String)),
25 /** An email or UPN, which is what `az account show` reports for the signed-in user. */
26 uniqueName: Schema.optional(Schema.NullOr(Schema.String)),
27 imageUrl: Schema.optional(Schema.NullOr(Schema.String)),
28});
29
30const RawPullRequestSchema = Schema.Struct({
31 pullRequestId: Schema.Int,
32 title: Schema.String,
33 description: Schema.optional(Schema.NullOr(Schema.String)),
34 status: Schema.optional(Schema.NullOr(Schema.String)),
35 isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)),
36 /**
37 * Who armed auto-complete, which is the only thing Azure says about it: the field carries an
38 * identity while the pull request is set to complete on its own, and Azure leaves it out
39 * entirely once nobody has. So its presence is the answer, and there is no third state.
40 */
41 autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)),
42 completionOptions: Schema.optional(
43 Schema.NullOr(
44 Schema.Struct({
45 mergeStrategy: Schema.optional(Schema.NullOr(Schema.String)),
46 squashMerge: Schema.optional(Schema.NullOr(Schema.Boolean)),
47 }),
48 ),
49 ),
50 mergeStatus: Schema.optional(Schema.NullOr(Schema.String)),
51 createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)),
52 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))),
53 // Required, and required to be non-empty: the wire contract will not carry a change request
54 // without a branch or a created time, so a row missing one is skipped rather than breaking the
55 // response it travels in.
56 sourceRefName: TrimmedNonEmptyString,
57 targetRefName: TrimmedNonEmptyString,
58 creationDate: TrimmedNonEmptyString,
59 closedDate: Schema.optional(Schema.NullOr(Schema.String)),
60 url: Schema.optional(Schema.NullOr(Schema.String)),
61 repository: Schema.optional(
62 Schema.NullOr(
63 Schema.Struct({
64 name: Schema.optional(Schema.NullOr(Schema.String)),
65 webUrl: Schema.optional(Schema.NullOr(Schema.String)),
66 project: Schema.optional(
67 Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })),
68 ),
69 }),
70 ),
71 ),
72 _links: Schema.optional(
73 Schema.NullOr(
74 Schema.Struct({
75 web: Schema.optional(
76 Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })),
77 ),
78 }),
79 ),
80 ),
81});
82
83/** A pull request thread, which is how Azure keeps its conversation. */
84const RawThreadSchema = Schema.Struct({
85 id: Schema.Int,
86 isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)),
87 threadContext: Schema.optional(
88 Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })),
89 ),
90 comments: Schema.optional(
91 Schema.NullOr(
92 Schema.Array(
93 Schema.Struct({
94 id: Schema.optional(Schema.NullOr(Schema.Int)),
95 content: Schema.optional(Schema.NullOr(Schema.String)),
96 author: Schema.optional(Schema.NullOr(RawIdentitySchema)),
97 publishedDate: Schema.optional(Schema.NullOr(Schema.String)),
98 isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)),
99 /** `system` marks the notes Azure writes itself, which are events, not comments. */
100 commentType: Schema.optional(Schema.NullOr(Schema.String)),
101 }),
102 ),
103 ),
104 ),
105});
106
107const RawThreadPageSchema = Schema.Struct({
108 value: Schema.Array(Schema.Unknown),
109});
110
111const RawViewerSchema = Schema.Struct({
112 user: Schema.optional(
113 Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })),
114 ),
115});
116
117/**
118 * Where a repository lives, in the terms Azure's REST routes address it by. They take the project
119 * and the repository as separate route parameters rather than as one path, so the pair travels
120 * together rather than as a URL that would have to be taken apart again to use.
121 */
122export interface AzureDevOpsRepositoryLocation {
123 readonly project: string;
124 readonly repository: string;
125}
126
127export interface AzureDevOpsPullRequest {
128 readonly number: number;
129 readonly title: string;
130 readonly url: string;
131 readonly author: PullRequestActor | null;
132 readonly headBranch: string;
133 readonly baseBranch: string;
134 readonly state: PullRequestState;
135 readonly isDraft: boolean;
136 readonly mergeability: PullRequestMergeability;
137 readonly createdAt: string;
138 /**
139 * Azure records no last-touched time on a pull request, so the closing time stands in where
140 * there is one and the creation time otherwise. The same fallback the rest of the app uses.
141 */
142 readonly updatedAt: string;
143 readonly closedAt: string | null;
144 readonly body: string;
145 readonly reviewRequestLogins: ReadonlyArray<string>;
146 readonly reviewers: ReadonlyArray<PullRequestActor>;
147 /** Where this pull request lives, when Azure said enough to work it out. */
148 readonly location: AzureDevOpsRepositoryLocation | null;
149 /** Whether Azure is set to complete this on its own once its policies pass. */
150 readonly autoMergeEnabled: boolean;
151 /** The completion strategy Azure stored with auto-complete, where it reported one. */
152 readonly autoMergeMethod?: PullRequestMergeMethod;
153}
154
155function trimmed(value: string | null | undefined): string | null {
156 const text = value?.trim() ?? "";
157 return text.length > 0 ? text : null;
158}
159
160function normalizeRefName(refName: string): string {
161 return refName.trim().replace(/^refs\/heads\//, "");
162}
163
164/** A login has to compare against `az account show`, which reports an email. */
165function toActor(raw: Schema.Schema.Type<typeof RawIdentitySchema> | null | undefined) {
166 const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName);
167 return login === null
168 ? null
169 : { login, name: trimmed(raw?.displayName), avatarUrl: trimmed(raw?.imageUrl) };
170}
171
172function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState {
173 switch (raw.status?.trim().toLowerCase()) {
174 case "completed":
175 return "merged";
176 case "abandoned":
177 return "closed";
178 default:
179 return "open";
180 }
181}
182
183function toMergeability(value: string | null | undefined): PullRequestMergeability {
184 switch (value?.trim().toLowerCase()) {
185 case "succeeded":
186 return "mergeable";
187 case "conflicts":
188 case "failure":
189 case "rejectedbypolicy":
190 return "conflicting";
191 default:
192 // `queued` and `notSet` mean Azure has not finished checking.
193 return "unknown";
194 }
195}
196
197/**
198 * Where a pull request's own repository sits. Taken from what Azure returned rather than from the
199 * local remote, whose shape differs between the modern, legacy and SSH forms.
200 */
201function toLocation(
202 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
203): AzureDevOpsRepositoryLocation | null {
204 const project = trimmed(raw.repository?.project?.name);
205 const repository = trimmed(raw.repository?.name);
206 if (project === null || repository === null) return null;
207 return { project, repository };
208}
209
210function toAutoMergeMethod(
211 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
212): PullRequestMergeMethod | undefined {
213 if (raw.autoCompleteSetBy == null) return undefined;
214 switch (raw.completionOptions?.mergeStrategy?.trim().toLowerCase()) {
215 case "squash":
216 return "squash";
217 case "rebase":
218 case "rebasemerge":
219 return "rebase";
220 case "nofastforward":
221 return "merge";
222 default:
223 return raw.completionOptions?.squashMerge === true ? "squash" : undefined;
224 }
225}
226
227/**
228 * Null when Azure said too little to place the pull request: a row with no browser url and no
229 * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract
230 * refuses to carry it either.
231 */
232function toPullRequest(
233 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
234): AzureDevOpsPullRequest | null {
235 const autoMergeMethod = toAutoMergeMethod(raw);
236 const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => {
237 const actor = toActor(reviewer);
238 return actor === null ? [] : [actor];
239 });
240 const closedAt = trimmed(raw.closedDate);
241 const url = trimmed(
242 azureDevOpsPullRequestWebUrl({
243 pullRequestId: raw.pullRequestId,
244 webLink: raw._links?.web?.href,
245 repositoryWebUrl: raw.repository?.webUrl,
246 restApiUrl: raw.url,
247 projectName: raw.repository?.project?.name,
248 repositoryName: raw.repository?.name,
249 }),
250 );
251 const headBranch = trimmed(normalizeRefName(raw.sourceRefName));
252 const baseBranch = trimmed(normalizeRefName(raw.targetRefName));
253 if (url === null || headBranch === null || baseBranch === null) return null;
254 return {
255 number: raw.pullRequestId,
256 title: raw.title,
257 url,
258 author: toActor(raw.createdBy),
259 headBranch,
260 baseBranch,
261 state: toState(raw),
262 isDraft: raw.isDraft ?? false,
263 mergeability: toMergeability(raw.mergeStatus),
264 createdAt: raw.creationDate,
265 updatedAt: closedAt ?? raw.creationDate,
266 closedAt,
267 body: raw.description ?? "",
268 reviewRequestLogins: reviewers.map((reviewer) => reviewer.login),
269 reviewers,
270 location: toLocation(raw),
271 autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null,
272 ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }),
273 };
274}
275
276const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));
277const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema);
278const decodePullRequest = decodeJsonResult(RawPullRequestSchema);
279const decodeThreadPage = decodeJsonResult(RawThreadPageSchema);
280const decodeThreadEntry = Schema.decodeUnknownExit(RawThreadSchema);
281const decodeViewer = decodeJsonResult(RawViewerSchema);
282
283type DecodeFailure = Cause.Cause<Schema.SchemaError>;
284
285export interface AzureDevOpsPullRequestBatch {
286 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
287 /** Zero-based positions of the decoded items in Azure's raw page. */
288 readonly rawIndexes: ReadonlyArray<number>;
289 /** Rows Azure returned, counted before decoding, so a skipped row cannot hide a next page. */
290 readonly rawCount: number;
291}
292
293/** Malformed entries are skipped rather than failing the batch, as on the other hosts. */
294export function decodePullRequestListJson(
295 raw: string,
296): Result.Result<AzureDevOpsPullRequestBatch, DecodeFailure> {
297 const decoded = decodeUnknownList(raw);
298 if (!Result.isSuccess(decoded)) {
299 return Result.fail(decoded.failure);
300 }
301 const items: AzureDevOpsPullRequest[] = [];
302 const rawIndexes: number[] = [];
303 for (const [rawIndex, entry] of decoded.success.entries()) {
304 const item = decodePullRequestEntry(entry);
305 if (Exit.isFailure(item)) continue;
306 const pullRequest = toPullRequest(item.value);
307 if (pullRequest !== null) {
308 items.push(pullRequest);
309 rawIndexes.push(rawIndex);
310 }
311 }
312 return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length });
313}
314
315/** Null carries "Azure answered, but with too little to use", which the caller reports. */
316export function decodePullRequestJson(
317 raw: string,
318): Result.Result<AzureDevOpsPullRequest | null, DecodeFailure> {
319 const decoded = decodePullRequest(raw);
320 return Result.isSuccess(decoded)
321 ? Result.succeed(toPullRequest(decoded.success))
322 : Result.fail(decoded.failure);
323}
324
325/** `az account show --query user` reports the signed-in account, whose name is an email. */
326export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> {
327 const decoded = decodeViewer(raw);
328 return Result.isSuccess(decoded)
329 ? Result.succeed(trimmed(decoded.success.user?.name))
330 : Result.fail(decoded.failure);
331}
332
333/**
334 * Azure keeps its conversation as threads of comments, and every one of them is a remark
335 * somebody wrote: a reply under a thread is as much of the conversation as the line that opened
336 * it. A thread pinned to a file is a line-level review comment.
337 *
338 * Azure answers the whole thread collection in one response, with no cursor and no page to
339 * follow, so what this returns is everything the host has.
340 */
341export function decodeThreadsJson(
342 raw: string,
343): Result.Result<ReadonlyArray<PullRequestComment>, DecodeFailure> {
344 const decoded = decodeThreadPage(raw);
345 if (!Result.isSuccess(decoded)) {
346 return Result.fail(decoded.failure);
347 }
348 const comments: PullRequestComment[] = [];
349 for (const entry of decoded.success.value) {
350 const decodedThread = decodeThreadEntry(entry);
351 if (Exit.isFailure(decodedThread)) continue;
352 const thread = decodedThread.value;
353 if (thread.isDeleted === true) continue;
354 const path = trimmed(thread.threadContext?.filePath);
355 for (const comment of thread.comments ?? []) {
356 const publishedDate = trimmed(comment.publishedDate);
357 if (
358 comment.isDeleted === true ||
359 comment.commentType?.trim().toLowerCase() === "system" ||
360 (comment.content ?? "").trim().length === 0 ||
361 publishedDate === null
362 ) {
363 continue;
364 }
365 comments.push({
366 id: `${thread.id}:${comment.id ?? 0}`,
367 kind: path === null ? "issue-comment" : "review-comment",
368 author: toActor(comment.author),
369 body: comment.content ?? "",
370 createdAt: publishedDate,
371 url: null,
372 path,
373 reviewState: null,
374 });
375 }
376 }
377 return Result.succeed(
378 comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)),
379 );
380}
381
382/**
383 * One push's worth of a pull request. Azure records every push as an iteration and keys the whole
384 * review off them: the changed files, and the marks a reader leaves on those files, both hang
385 * from an iteration rather than from the pull request.
386 */
387const RawIterationSchema = Schema.Struct({
388 id: Schema.Int,
389 sourceRefCommit: Schema.optional(
390 Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })),
391 ),
392 commonRefCommit: Schema.optional(
393 Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })),
394 ),
395});
396
397const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) });
398
399const RawChangeEntrySchema = Schema.Struct({
400 changeType: Schema.optional(Schema.NullOr(Schema.String)),
401 sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)),
402 /** Where a renamed file came from. Azure states it here on an iteration's changes. */
403 originalPath: Schema.optional(Schema.NullOr(Schema.String)),
404 item: Schema.optional(
405 Schema.NullOr(
406 Schema.Struct({
407 path: Schema.optional(Schema.NullOr(Schema.String)),
408 objectId: Schema.optional(Schema.NullOr(Schema.String)),
409 originalObjectId: Schema.optional(Schema.NullOr(Schema.String)),
410 /** Azure marks a directory this way; a review has nothing to show for one. */
411 isFolder: Schema.optional(Schema.NullOr(Schema.Boolean)),
412 gitObjectType: Schema.optional(Schema.NullOr(Schema.String)),
413 }),
414 ),
415 ),
416});
417
418const RawChangePageSchema = Schema.Struct({
419 changeEntries: Schema.Array(Schema.Unknown),
420 /** Where the page after this one starts. Azure leaves it out on the last page. */
421 nextSkip: Schema.optional(Schema.NullOr(Schema.Number)),
422});
423
424const RawItemContentSchema = Schema.Struct({
425 content: Schema.optional(Schema.NullOr(Schema.String)),
426 /** What Azure makes of the file it is handing over, which is where it says it is not text. */
427 contentMetadata: Schema.optional(
428 Schema.NullOr(Schema.Struct({ isBinary: Schema.optional(Schema.NullOr(Schema.Boolean)) })),
429 ),
430});
431
432/** The head and the merge base of one iteration, which is the range its patch is taken over. */
433export interface AzureDevOpsIteration {
434 readonly id: number;
435 readonly headCommit: string;
436 readonly mergeBaseCommit: string;
437}
438
439/**
440 * What one file did across an iteration. `oldPath` differs from `path` only for a rename, which
441 * Azure reports by naming the file's previous home rather than as a delete and an add.
442 */
443export interface AzureDevOpsChangeEntry {
444 readonly path: string;
445 readonly oldPath: string;
446 readonly changeKind: "new" | "deleted" | "change" | "rename-pure" | "rename-changed";
447 readonly objectId: string | null;
448 readonly originalObjectId: string | null;
449}
450
451/**
452 * One page of what an iteration changed, and where the next one starts. Azure pages this route
453 * rather than answering with the whole change, so a review large enough to be paged is followed
454 * to its end instead of being cut off at the first page's worth.
455 */
456export interface AzureDevOpsChangePage {
457 readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>;
458 readonly nextSkip: number | null;
459}
460
461/** One file's text at one commit, and whether Azure says the text is text at all. */
462export interface AzureDevOpsItemContent {
463 readonly contents: string;
464 readonly isBinary: boolean;
465}
466
467const decodeIterationPage = decodeJsonResult(RawIterationPageSchema);
468const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema);
469const decodeChangePage = decodeJsonResult(RawChangePageSchema);
470const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema);
471const decodeItemContent = decodeJsonResult(RawItemContentSchema);
472
473/**
474 * Azure leads a path with a slash that every other host and every patch omits. Not trimmed like
475 * the rest of this payload, since a leading/trailing space is a legal part of a file's name and
476 * the patch and viewed mark are keyed by the untrimmed path.
477 */
478function toRepositoryPath(value: string | null | undefined): string | null {
479 if (value === undefined || value === null) return null;
480 const path = value.replace(/^\/+/, "");
481 return path.length === 0 ? null : path;
482}
483
484/**
485 * Azure names a change with one word or two, and a rename arrives either alone or alongside the
486 * edit that came with it. Anything it has added since reads as a plain change, which shows the
487 * file rather than dropping it from the review.
488 */
489function toChangeKind(
490 raw: string | null | undefined,
491 renamed: boolean,
492): AzureDevOpsChangeEntry["changeKind"] {
493 const parts = new Set(
494 (raw ?? "")
495 .toLowerCase()
496 .split(",")
497 .map((part) => part.trim())
498 .filter((part) => part.length > 0),
499 );
500 if (parts.has("delete")) return "deleted";
501 if (renamed || parts.has("rename")) return parts.has("edit") ? "rename-changed" : "rename-pure";
502 if (parts.has("add")) return "new";
503 return "change";
504}
505
506export function decodeIterationsJson(
507 raw: string,
508): Result.Result<ReadonlyArray<AzureDevOpsIteration>, DecodeFailure> {
509 const decoded = decodeIterationPage(raw);
510 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
511 const iterations: AzureDevOpsIteration[] = [];
512 for (const entry of decoded.success.value) {
513 const decodedIteration = decodeIterationEntry(entry);
514 if (Exit.isFailure(decodedIteration)) continue;
515 const iteration = decodedIteration.value;
516 const headCommit = trimmed(iteration.sourceRefCommit?.commitId);
517 const mergeBaseCommit = trimmed(iteration.commonRefCommit?.commitId);
518 // An iteration Azure cannot place both ends of names no range, and a patch needs both.
519 if (headCommit === null || mergeBaseCommit === null) continue;
520 iterations.push({ id: iteration.id, headCommit, mergeBaseCommit });
521 }
522 return Result.succeed(iterations.toSorted((left, right) => left.id - right.id));
523}
524
525export function decodeIterationChangesJson(
526 raw: string,
527): Result.Result<AzureDevOpsChangePage, DecodeFailure> {
528 const decoded = decodeChangePage(raw);
529 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
530 const changes: AzureDevOpsChangeEntry[] = [];
531 for (const entry of decoded.success.changeEntries) {
532 const decodedChange = decodeChangeEntry(entry);
533 if (Exit.isFailure(decodedChange)) continue;
534 const change = decodedChange.value;
535 const path = toRepositoryPath(change.item?.path);
536 if (path === null) continue;
537 // Azure lists the folders a change touched alongside the files themselves. A review shows
538 // files, and a folder has no content to show for either side of one.
539 if (change.item?.isFolder === true) continue;
540 if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue;
541 // Azure names where a renamed file came from in either of two places depending on the route
542 // and the version, so both are read and the current path stands in when neither is there.
543 const oldPath =
544 toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path;
545 changes.push({
546 path,
547 oldPath,
548 changeKind: toChangeKind(change.changeType, oldPath !== path),
549 objectId: trimmed(change.item?.objectId),
550 originalObjectId: trimmed(change.item?.originalObjectId),
551 });
552 }
553 const nextSkip = decoded.success.nextSkip ?? null;
554 return Result.succeed({
555 changes,
556 nextSkip: nextSkip !== null && Number.isSafeInteger(nextSkip) && nextSkip > 0 ? nextSkip : null,
557 });
558}
559
560/**
561 * Azure answers an absent file with an empty body rather than an error, which reads as empty.
562 * Whether the bytes are text is Azure's own call, not this decoder's to guess from content.
563 */
564export function decodeItemContentJson(
565 raw: string,
566): Result.Result<AzureDevOpsItemContent, DecodeFailure> {
567 const decoded = decodeItemContent(raw);
568 return Result.isSuccess(decoded)
569 ? Result.succeed({
570 contents: decoded.success.content ?? "",
571 isBinary: decoded.success.contentMetadata?.isBinary === true,
572 })
573 : Result.fail(decoded.failure);
574}
packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts created+222
...@@ -0,0 +1,222 @@
1import * as Cause from "effect/Cause";
2import * as DateTime from "effect/DateTime";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
8import { decodeJsonResult } from "@t3tools/shared/schemaJson";
9
10export interface NormalizedAzureDevOpsPullRequestRecord {
11 readonly number: number;
12 readonly title: string;
13 readonly url: string;
14 readonly baseRefName: string;
15 readonly headRefName: string;
16 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;
18 readonly closedAt?: string | null;
19 readonly mergedAt?: string | null;
20 readonly updatedAt: Option.Option<DateTime.Utc>;
21}
22
23const AzureDevOpsPullRequestSchema = Schema.Struct({
24 pullRequestId: PositiveInt,
25 title: TrimmedNonEmptyString,
26 url: Schema.optional(Schema.String),
27 repository: Schema.optional(
28 Schema.Struct({
29 name: Schema.optional(Schema.String),
30 webUrl: Schema.optional(Schema.String),
31 project: Schema.optional(
32 Schema.Struct({
33 name: Schema.optional(Schema.String),
34 }),
35 ),
36 }),
37 ),
38 sourceRefName: TrimmedNonEmptyString,
39 targetRefName: TrimmedNonEmptyString,
40 status: Schema.String,
41 isDraft: Schema.optional(Schema.Boolean),
42 creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
43 closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
44 _links: Schema.optional(
45 Schema.Struct({
46 web: Schema.optional(
47 Schema.Struct({
48 href: Schema.String,
49 }),
50 ),
51 }),
52 ),
53});
54
55function trimOptionalString(value: string | null | undefined): string | null {
56 const trimmed = value?.trim() ?? "";
57 return trimmed.length > 0 ? trimmed : null;
58}
59
60function normalizeRefName(refName: string): string {
61 return refName.trim().replace(/^refs\/heads\//, "");
62}
63
64function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" {
65 switch (status.trim().toLowerCase()) {
66 case "completed":
67 return "merged";
68 case "abandoned":
69 return "closed";
70 default:
71 return "open";
72 }
73}
74
75function encodeAzureDevOpsPathSegment(segment: string): string {
76 return encodeURIComponent(segment);
77}
78
79/**
80 * The organization root a REST url belongs to, which is where a browser url has to be hung when
81 * Azure answered with neither a web link nor a repository url. Read from what Azure returned
82 * rather than from the local remote, whose shape varies.
83 */
84function azureDevOpsOrganizationBaseFromRestApiUrl(
85 value: string | null | undefined,
86): string | null {
87 const rawUrl = trimOptionalString(value);
88 if (!rawUrl) {
89 return null;
90 }
91
92 try {
93 const url = new URL(rawUrl);
94 const hostname = url.hostname.toLowerCase();
95 const pathSegments = url.pathname.split("/").filter((segment) => segment.length > 0);
96 const isAzureRestUrl = pathSegments.some((segment) => segment.toLowerCase() === "_apis");
97 if (!isAzureRestUrl) {
98 return null;
99 }
100
101 if (hostname === "dev.azure.com") {
102 const organization = pathSegments[0];
103 return organization ? `${url.origin}/${organization}` : null;
104 }
105
106 if (hostname.endsWith(".visualstudio.com")) {
107 return url.origin;
108 }
109
110 return null;
111 } catch {
112 return null;
113 }
114}
115
116/**
117 * Where a pull request lives in a browser. Azure answers with a web link when asked for one and
118 * otherwise leaves it to be assembled, so all three routes are tried in the order they can be
119 * trusted. Takes plain fields so both the source control provider and the pull requests page
120 * can share it.
121 */
122export function azureDevOpsPullRequestWebUrl(input: {
123 readonly pullRequestId: number;
124 readonly webLink?: string | null | undefined;
125 readonly repositoryWebUrl?: string | null | undefined;
126 readonly restApiUrl?: string | null | undefined;
127 readonly projectName?: string | null | undefined;
128 readonly repositoryName?: string | null | undefined;
129}): string {
130 const webLink = trimOptionalString(input.webLink);
131 if (webLink) {
132 return webLink;
133 }
134
135 const repositoryWebUrl = trimOptionalString(input.repositoryWebUrl);
136 if (repositoryWebUrl) {
137 return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${input.pullRequestId}`;
138 }
139
140 const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(input.restApiUrl);
141 const projectName = trimOptionalString(input.projectName);
142 const repositoryName = trimOptionalString(input.repositoryName);
143 if (organizationBase && projectName && repositoryName) {
144 const encodedProjectName = encodeAzureDevOpsPathSegment(projectName);
145 const encodedRepositoryName = encodeAzureDevOpsPathSegment(repositoryName);
146 return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${input.pullRequestId}`;
147 }
148
149 return trimOptionalString(input.restApiUrl) ?? "";
150}
151
152function normalizeAzureDevOpsPullRequestUrl(
153 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
154): string {
155 return azureDevOpsPullRequestWebUrl({
156 pullRequestId: raw.pullRequestId,
157 webLink: raw._links?.web?.href,
158 repositoryWebUrl: raw.repository?.webUrl,
159 restApiUrl: raw.url,
160 projectName: raw.repository?.project?.name,
161 repositoryName: raw.repository?.name,
162 });
163}
164
165function normalizeAzureDevOpsPullRequestRecord(
166 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
167): NormalizedAzureDevOpsPullRequestRecord {
168 const state = normalizeAzureDevOpsPullRequestState(raw.status);
169 const terminalAt = Option.match(raw.closedDate ?? Option.none(), {
170 onNone: () => null,
171 onSome: DateTime.formatIso,
172 });
173 return {
174 number: raw.pullRequestId,
175 title: raw.title,
176 url: normalizeAzureDevOpsPullRequestUrl(raw),
177 baseRefName: normalizeRefName(raw.targetRefName),
178 headRefName: normalizeRefName(raw.sourceRefName),
179 state,
180 ...(raw.isDraft === true ? { isDraft: true } : {}),
181 closedAt: state === "closed" ? terminalAt : null,
182 mergedAt: state === "merged" ? terminalAt : null,
183 updatedAt: (raw.closedDate ?? Option.none()).pipe(
184 Option.orElse(() => raw.creationDate ?? Option.none()),
185 ),
186 };
187}
188
189const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
190const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema);
191const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema);
192
193export function decodeAzureDevOpsPullRequestListJson(
194 raw: string,
195): Result.Result<
196 ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>,
197 Cause.Cause<Schema.SchemaError>
198> {
199 const result = decodeAzureDevOpsPullRequestList(raw);
200 if (Result.isSuccess(result)) {
201 const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = [];
202 for (const entry of result.success) {
203 const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry);
204 if (Exit.isFailure(decodedEntry)) {
205 continue;
206 }
207 pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value));
208 }
209 return Result.succeed(pullRequests);
210 }
211 return Result.fail(result.failure);
212}
213
214export function decodeAzureDevOpsPullRequestJson(
215 raw: string,
216): Result.Result<NormalizedAzureDevOpsPullRequestRecord, Cause.Cause<Schema.SchemaError>> {
217 const result = decodeAzureDevOpsPullRequest(raw);
218 if (Result.isSuccess(result)) {
219 return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success));
220 }
221 return Result.fail(result.failure);
222}
packages/source-control-azure-devops/src/server/driver.ts created+19
...@@ -0,0 +1,19 @@
1/**
2 * The Azure DevOps driver: repository operations, discovery, and pull requests through `az`.
3 *
4 * @module source-control-azure-devops/server/driver
5 */
6import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver";
7import * as Effect from "effect/Effect";
8
9import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";
10import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
11
12export const driver = defineSourceControlDriver({
13 kind: "azure-devops",
14 make: Effect.all({
15 sourceControl: AzureDevOpsSourceControlProvider.make,
16 discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery),
17 pullRequests: AzureDevOpsPullRequestProvider.make,
18 }),
19});
packages/source-control-azure-devops/tsconfig.json created+7
...@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["src"]
7}
pnpm-lock.yaml+37-3
...@@ -648,15 +648,15 @@ importers:...@@ -648,15 +648,15 @@ importers:
648 '@t3tools/provider-pi':648 '@t3tools/provider-pi':
649 specifier: workspace:*649 specifier: workspace:*
650 version: link:../../packages/provider-pi650 version: link:../../packages/provider-pi
651 '@t3tools/source-control-azure-devops':
652 specifier: workspace:*
653 version: link:../../packages/source-control-azure-devops
651 '@t3tools/source-control-core':654 '@t3tools/source-control-core':
652 specifier: workspace:*655 specifier: workspace:*
653 version: link:../../packages/source-control-core656 version: link:../../packages/source-control-core
654 '@t3tools/source-control-forgejo':657 '@t3tools/source-control-forgejo':
655 specifier: workspace:*658 specifier: workspace:*
656 version: link:../../packages/source-control-forgejo659 version: link:../../packages/source-control-forgejo
657 diff:
658 specifier: 8.0.3
659 version: 8.0.3
660 effect:660 effect:
661 specifier: 4.0.2661 specifier: 4.0.2
662 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)662 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
...@@ -1516,6 +1516,40 @@ importers:...@@ -1516,6 +1516,40 @@ importers:
1516 specifier: 1.0.01516 specifier: 1.0.0
1517 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)1517 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
15181518
1519 packages/source-control-azure-devops:
1520 dependencies:
1521 '@t3tools/contracts':
1522 specifier: workspace:*
1523 version: link:../contracts
1524 '@t3tools/shared':
1525 specifier: workspace:*
1526 version: link:../shared
1527 '@t3tools/source-control-core':
1528 specifier: workspace:*
1529 version: link:../source-control-core
1530 diff:
1531 specifier: 8.0.3
1532 version: 8.0.3
1533 effect:
1534 specifier: 4.0.2
1535 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
1536 devDependencies:
1537 '@effect/platform-node':
1538 specifier: 4.0.2
1539 version: 4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(redis@6.2.1)(utf-8-validate@6.0.6)
1540 '@effect/vitest':
1541 specifier: 4.0.2
1542 version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1543 '@t3tools/source-control-testing':
1544 specifier: workspace:*
1545 version: link:../source-control-testing
1546 '@types/node':
1547 specifier: 24.12.4
1548 version: 24.12.4
1549 vite-plus:
1550 specifier: 1.0.0
1551 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1552
1519 packages/source-control-core:1553 packages/source-control-core:
1520 dependencies:1554 dependencies:
1521 '@t3tools/contracts':1555 '@t3tools/contracts':
scripts/release-smoke.ts+1
...@@ -37,6 +37,7 @@ const workspaceFiles = [...@@ -37,6 +37,7 @@ const workspaceFiles = [
37 "packages/provider-grok/package.json",37 "packages/provider-grok/package.json",
38 "packages/provider-muse/package.json",38 "packages/provider-muse/package.json",
39 "packages/provider-opencode/package.json",39 "packages/provider-opencode/package.json",
40 "packages/source-control-azure-devops/package.json",
40 "packages/source-control-core/package.json",41 "packages/source-control-core/package.json",
41 "packages/source-control-forgejo/package.json",42 "packages/source-control-forgejo/package.json",
42 "packages/source-control-testing/package.json",43 "packages/source-control-testing/package.json",