Skip to content
Source
Sign inSign up
Open History to see the last commit

AzureDevOpsPullRequestCli.ts

860 lines29.3 KB

1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import type {
8 PullRequestAction,
9 PullRequestCheck,
10 PullRequestComment,
11 PullRequestInvolvement,
12 PullRequestListState,
13 PullRequestMergeMethod,
14} from "@t3tools/contracts";
15​
16import { sortChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
17​
18import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
19import { blobContent, readBlobZip } from "./azureDevOpsBlobZip.ts";
20import {
21 decodeItemContentJson,
22 decodeIterationChangesJson,
23 decodeIterationsJson,
24 decodePolicyChecksJson,
25 decodePullRequestJson,
26 decodePullRequestListJson,
27 decodeThreadsJson,
28 decodeViewerJson,
29 type AzureDevOpsChangeEntry,
30 type AzureDevOpsItemContent,
31 type AzureDevOpsIteration,
32 type AzureDevOpsPullRequest,
33 type AzureDevOpsRepositoryLocation,
34} from "./azureDevOpsPullRequestJson.ts";
35import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
36​
37export class AzureDevOpsPullRequestReadError extends Schema.TaggedError<AzureDevOpsPullRequestReadError>()(
38 "AzureDevOpsPullRequestReadError",
39 {
40 command: Schema.Literal("az"),
41 cwd: Schema.String,
42 operation: Schema.String,
43 cause: Schema.Defect(),
44 },
45) {
46 get detail(): string {
47 return `Azure CLI returned an unreadable ${this.operation} response.`;
48 }
49​
50 override get message(): string {
51 return `Azure CLI failed in ${this.operation}: ${this.detail}`;
52 }
54​
55export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError<AzureDevOpsViewerUnavailableError>()(
56 "AzureDevOpsViewerUnavailableError",
57 {
58 command: Schema.Literal("az"),
59 cwd: Schema.String,
60 },
61) {
62 get detail(): string {
63 return "Azure CLI could not name the signed-in account. Run `az login` and retry.";
64 }
65​
66 override get message(): string {
67 return `Azure CLI failed in getViewer: ${this.detail}`;
68 }
70​
71export class AzureDevOpsPullRequestIncompleteError extends Schema.TaggedError<AzureDevOpsPullRequestIncompleteError>()(
72 "AzureDevOpsPullRequestIncompleteError",
73 {
74 command: Schema.Literal("az"),
75 cwd: Schema.String,
76 number: Schema.Int,
77 },
78) {
79 get detail(): string {
80 return "Azure DevOps returned no branch or link for the pull request.";
81 }
82​
83 override get message(): string {
84 return `Azure CLI failed in getPullRequest: ${this.detail}`;
85 }
87​
88export class AzureDevOpsReviewerNameError extends Schema.TaggedError<AzureDevOpsReviewerNameError>()(
89 "AzureDevOpsReviewerNameError",
90 {
91 command: Schema.Literal("az"),
92 cwd: Schema.String,
93 },
94) {
95 get detail(): string {
96 return "A reviewer is named by an email address or an identity id.";
97 }
98​
99 override get message(): string {
100 return `Azure CLI failed in setPullRequestReviewers: ${this.detail}`;
101 }
103​
104export type AzureDevOpsPullRequestCliError =
105 | AzureDevOpsCli.AzureDevOpsCliError
106 | AzureDevOpsPullRequestReadError
107 | AzureDevOpsPullRequestIncompleteError
108 | AzureDevOpsReviewerNameError
109 | AzureDevOpsViewerUnavailableError;
110​
111const REST_API_VERSION = "7.1";
112const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
113/** A 2,000-file page exceeds the default 1 MiB output budget. */
114const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
115/** JSON escaping and base64 can exceed the decoded file's size. */
116const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
117/** A larger batch is left to per-file reads rather than held in memory at once. */
118const BLOB_ARCHIVE_MAX_BYTES = 64 * 1024 * 1024;
119/** Matches what a per-file read could carry. */
120const BLOB_MAX_BYTES = ITEM_CONTENT_MAX_OUTPUT_BYTES;
121/** Blobs past this, together, are left to per-file reads, which hold a few at a time. */
122const BLOB_BATCH_MAX_BYTES = 16 * 1024 * 1024;
123const BLOB_ID = /^[0-9a-f]{40}$/i;
124/** Review history is unpaged and independent of diff size. */
125const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
126​
127const POLICY_EVALUATIONS_PER_PAGE = 100;
128const MAX_POLICY_EVALUATIONS = 1000;
129​
130/** Azure's iteration-change page limit. */
131const CHANGE_ENTRIES_PER_PAGE = 2000;
132​
133/** Count raw entries, including folders and malformed rows. */
134const MAX_CHANGE_ENTRIES = 10_000;
135​
136export interface AzureDevOpsIterationChanges {
137 readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>;
138 readonly truncated: boolean;
140​
141export class AzureDevOpsPullRequestCli extends Context.Service<
142 AzureDevOpsPullRequestCli,
143 {
144 readonly getViewer: (input: {
145 readonly cwd: string;
146 }) => Effect.Effect<string, AzureDevOpsPullRequestCliError>;
147​
148 readonly listPullRequests: (input: {
149 readonly cwd: string;
150 readonly repository: string;
151 readonly state: PullRequestListState;
152 readonly involvement: PullRequestInvolvement;
153 readonly viewer: string;
154 readonly limit: number;
155 /** Matched by `--creator`, which takes the email a row shows as its login. */
156 readonly author?: string | undefined;
157 /**
158 * Where to carry on from. Azure has no date filter for a pull request listing, so the only
159 * part of a cursor it can use is how many rows have already been handed over.
160 */
161 readonly cursor?: ProviderListCursor | undefined;
162 }) => Effect.Effect<
163 {
164 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
165 readonly truncated: boolean;
166 readonly cursorAdvance: number;
167 },
168 AzureDevOpsPullRequestCliError
169 >;
170​
171 readonly getPullRequest: (input: {
172 readonly cwd: string;
173 readonly number: number;
174 }) => Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError>;
175​
176 readonly listPolicyChecks: (input: {
177 readonly cwd: string;
178 readonly number: number;
179 /** Azure holds builds back on a draft, which reads the same as a build about to start. */
180 readonly isDraft: boolean;
181 }) => Effect.Effect<ReadonlyArray<PullRequestCheck>, AzureDevOpsPullRequestCliError>;
182​
183 readonly listThreads: (input: {
184 readonly cwd: string;
185 readonly location: AzureDevOpsRepositoryLocation;
186 readonly number: number;
187 }) => Effect.Effect<ReadonlyArray<PullRequestComment>, AzureDevOpsPullRequestCliError>;
188​
189 readonly listIterations: (input: {
190 readonly cwd: string;
191 readonly location: AzureDevOpsRepositoryLocation;
192 readonly number: number;
193 }) => Effect.Effect<ReadonlyArray<AzureDevOpsIteration>, AzureDevOpsPullRequestCliError>;
194​
195 /** Changes cover the whole PR from its merge base. */
196 readonly listIterationChanges: (input: {
197 readonly cwd: string;
198 readonly location: AzureDevOpsRepositoryLocation;
199 readonly number: number;
200 readonly iterationId: number;
201 }) => Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError>;
202​
203 readonly readItemContent: (input: {
204 readonly cwd: string;
205 readonly location: AzureDevOpsRepositoryLocation;
206 readonly path: string;
207 readonly commit: string;
208 }) => Effect.Effect<AzureDevOpsItemContent, AzureDevOpsPullRequestCliError>;
209​
210 /**
211 * Many files in one `az` run, by blob id, keyed by lowercase id. A blob only Azure's item read
212 * decodes faithfully, or one too large to carry, is left out.
213 */
214 readonly readBlobs: (input: {
215 readonly cwd: string;
216 readonly location: AzureDevOpsRepositoryLocation;
217 readonly objectIds: ReadonlyArray<string>;
218 }) => Effect.Effect<
219 ReadonlyMap<string, AzureDevOpsItemContent>,
220 AzureDevOpsPullRequestCliError
221 >;
222​
223 readonly runPullRequestAction: (input: {
224 readonly cwd: string;
225 readonly number: number;
226 readonly action: PullRequestAction;
227 readonly mergeMethod?: PullRequestMergeMethod;
228 }) => Effect.Effect<AzureDevOpsPullRequest | void, AzureDevOpsPullRequestCliError>;
229​
230 readonly updatePullRequest: (input: {
231 readonly cwd: string;
232 readonly number: number;
233 readonly title?: string | undefined;
234 readonly body?: string | undefined;
235 }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;
236​
237 readonly setPullRequestReviewers: (input: {
238 readonly cwd: string;
239 readonly number: number;
240 readonly reviewers: ReadonlyArray<string>;
241 readonly requested: boolean;
242 }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;
243 }
244>()("@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli") {}
245​
246function statusArgs(state: PullRequestListState): ReadonlyArray<string> {
247 switch (state) {
248 case "open":
249 return ["--status", "active"];
250 case "merged":
251 return ["--status", "completed"];
252 case "closed":
253 return ["--status", "abandoned"];
254 case "all":
255 return ["--status", "all"];
256 }
258​
259function involvementArgs(input: {
260 readonly involvement: PullRequestInvolvement;
261 readonly viewer: string;
262}): ReadonlyArray<string> {
263 switch (input.involvement) {
264 case "authored":
265 return ["--creator", input.viewer];
266 case "reviewing":
267 return ["--reviewer", input.viewer];
268 case "all":
269 return [];
270 }
272​
273function actionArgs(
274 action: PullRequestAction,
275 mergeMethod: PullRequestMergeMethod | undefined,
276): ReadonlyArray<string> {
277 switch (action) {
278 case "merge":
279 return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"];
280 case "enable-auto-merge":
281 return [
282 "--auto-complete",
283 "true",
284 ...(mergeMethod === undefined
285 ? []
286 : ["--squash", mergeMethod === "squash" ? "true" : "false"]),
287 ];
288 case "disable-auto-merge":
289 return ["--auto-complete", "false"];
290 case "ready":
291 return ["--draft", "false"];
292 case "draft":
293 return ["--draft", "true"];
294 case "close":
295 return ["--status", "abandoned"];
296 case "update-branch":
297 return [];
298 case "reopen":
299 return ["--status", "active"];
300 case "revert":
301 case "approve-workflows":
302 case "delete-branch":
303 throw new Error(`Azure DevOps pull request action ${action} is unsupported`);
304 }
306​
307function isReviewerName(value: string): boolean {
308 const name = value.trim();
309 return name.length > 0 && !name.startsWith("-");
311​
312/** @public */
313export const make = Effect.gen(function* () {
314 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
315 const fileSystem = yield* FileSystem.FileSystem;
316​
317 const detectArgs = ["--detect", "true"] as const;
318​
319 const executeJson = (input: {
320 readonly cwd: string;
321 readonly args: ReadonlyArray<string>;
322 readonly maxOutputBytes?: number;
323 }) =>
324 azure.execute({
325 cwd: input.cwd,
326 args: [...input.args, "--only-show-errors", "--output", "json"],
327 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
328 });
329​
330 /** Use the extension's credentials; `az rest` uses the default tenant. */
331 const invoke = <A>(input: {
332 readonly cwd: string;
333 readonly operation: string;
334 readonly resource: string;
335 readonly routeParameters: ReadonlyArray<string>;
336 readonly queryParameters?: ReadonlyArray<string>;
337 readonly maxOutputBytes?: number;
338 readonly decode: (raw: string) => Result.Result<A, unknown>;
339 }): Effect.Effect<A, AzureDevOpsPullRequestCliError> =>
340 executeJson({
341 cwd: input.cwd,
342 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
343 args: [
344 "devops",
345 "invoke",
346 ...detectArgs,
347 "--area",
348 "git",
349 "--resource",
350 input.resource,
351 "--api-version",
352 REST_API_VERSION,
353 "--route-parameters",
354 ...input.routeParameters,
355 ...(input.queryParameters === undefined
356 ? []
357 : ["--query-parameters", ...input.queryParameters]),
358 ],
359 }).pipe(
360 Effect.flatMap((result) => {
361 const decoded = input.decode(result.stdout.trim());
362 return Result.isSuccess(decoded)
363 ? Effect.succeed(decoded.success)
364 : Effect.fail(
365 new AzureDevOpsPullRequestReadError({
366 command: "az",
367 cwd: input.cwd,
368 operation: input.operation,
369 cause: decoded.failure,
370 }),
371 );
372 }),
373 );
374​
375 /** Azure's items route requires a leading slash. */
376 const toItemPath = (path: string) => (path.startsWith("/") ? path : `/${path}`);
377​
378 const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray<string> => [
379 `project=${location.project}`,
380 `repositoryId=${location.repository}`,
381 ];
382​
383 const pullRequestRoute = (input: {
384 readonly location: AzureDevOpsRepositoryLocation;
385 readonly number: number;
386 }): ReadonlyArray<string> => [
387 ...repositoryRoute(input.location),
388 `pullRequestId=${input.number}`,
389 ];
390​
391 /** Decoding can shorten a non-final page; advance by raw rows. */
392 const listPullRequestPage = (input: {
393 readonly cwd: string;
394 readonly repository: string;
395 readonly state: PullRequestListState;
396 readonly involvement: PullRequestInvolvement;
397 readonly viewer: string;
398 readonly author?: string | undefined;
399 readonly limit: number;
400 readonly skip: number;
401 readonly cursorAdvance: number;
402 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
403 }): Effect.Effect<
404 {
405 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
406 readonly truncated: boolean;
407 readonly cursorAdvance: number;
408 },
409 AzureDevOpsPullRequestCliError
410 > => {
411 const remaining = input.limit - input.items.length;
412 const top = remaining + 1;
413 return executeJson({
414 cwd: input.cwd,
415 maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES,
416 args: [
417 "repos",
418 "pr",
419 "list",
420 ...detectArgs,
421 "--repository",
422 input.repository,
423 ...statusArgs(input.state),
424 ...involvementArgs(input),
425 ...(input.author === undefined || input.involvement === "authored"
426 ? []
427 : ["--creator", input.author]),
428 "--include-links",
429 ...(input.skip === 0 ? [] : ["--skip", String(input.skip)]),
430 "--top",
431 String(top),
432 ],
433 }).pipe(
434 Effect.flatMap((result) => {
435 const raw = result.stdout.trim();
436 if (raw.length === 0) {
437 return Effect.succeed({
438 items: input.items,
439 truncated: false,
440 cursorAdvance: input.cursorAdvance,
441 });
442 }
443 const decoded = decodePullRequestListJson(raw);
444 if (!Result.isSuccess(decoded)) {
445 return Effect.fail(
446 new AzureDevOpsPullRequestReadError({
447 command: "az",
448 cwd: input.cwd,
449 operation: "listPullRequests",
450 cause: decoded.failure,
451 }),
452 );
453 }
454​
455 const lastItemIndex = decoded.success.rawIndexes[remaining - 1];
456 if (lastItemIndex !== undefined) {
457 const consumed = lastItemIndex + 1;
458 return Effect.succeed({
459 items: [...input.items, ...decoded.success.items.slice(0, remaining)],
460 truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top,
461 cursorAdvance: input.cursorAdvance + consumed,
462 });
463 }
464​
465 const items = [...input.items, ...decoded.success.items];
466 if (decoded.success.rawCount < top) {
467 return Effect.succeed({
468 items,
469 truncated: false,
470 cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
471 });
472 }
473 return listPullRequestPage({
474 ...input,
475 skip: input.skip + decoded.success.rawCount,
476 cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
477 items,
478 });
479 }),
480 );
481 };
482​
483 return AzureDevOpsPullRequestCli.of({
484 getViewer: Effect.fn("AzureDevOpsPullRequestCli.getViewer")(function (input) {
485 return executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe(
486 Effect.flatMap((result): Effect.Effect<string, AzureDevOpsPullRequestCliError> => {
487 const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`);
488 if (!Result.isSuccess(decoded)) {
489 return Effect.fail(
490 new AzureDevOpsPullRequestReadError({
491 command: "az",
492 cwd: input.cwd,
493 operation: "getViewer",
494 cause: decoded.failure,
495 }),
496 );
497 }
498 return decoded.success === null
499 ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }))
500 : Effect.succeed(decoded.success);
501 }),
502 // `az account show` cannot identify PAT logins; the extension resolves `--creator me`.
503 Effect.catchIf(
504 (error) => error._tag !== "AzureDevOpsCliUnavailableError",
505 () =>
506 executeJson({
507 cwd: input.cwd,
508 args: [
509 "repos",
510 "pr",
511 "list",
512 ...detectArgs,
513 "--creator",
514 "me",
515 "--status",
516 "all",
517 "--top",
518 "1",
519 ],
520 }).pipe(
521 Effect.flatMap((result): Effect.Effect<string, AzureDevOpsPullRequestCliError> => {
522 const decoded = decodePullRequestListJson(result.stdout.trim() || "[]");
523 const login = Result.isSuccess(decoded)
524 ? decoded.success.items[0]?.author?.login
525 : undefined;
526 return login === undefined
527 ? Effect.fail(
528 new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }),
529 )
530 : Effect.succeed(login);
531 }),
532 ),
533 ),
534 );
535 }),
536​
537 listPullRequests: Effect.fn("AzureDevOpsPullRequestCli.listPullRequests")(function (input) {
538 return listPullRequestPage({
539 cwd: input.cwd,
540 repository: input.repository,
541 state: input.state,
542 involvement: input.involvement,
543 viewer: input.viewer,
544 author: input.author,
545 limit: input.limit,
546 skip: input.cursor?.delivered ?? 0,
547 cursorAdvance: 0,
548 items: [],
549 });
550 }),
551​
552 getPullRequest: Effect.fn("AzureDevOpsPullRequestCli.getPullRequest")(function (input) {
553 return executeJson({
554 cwd: input.cwd,
555 args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)],
556 }).pipe(
557 Effect.flatMap(
558 (result): Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError> => {
559 const decoded = decodePullRequestJson(result.stdout.trim());
560 if (!Result.isSuccess(decoded)) {
561 return Effect.fail(
562 new AzureDevOpsPullRequestReadError({
563 command: "az",
564 cwd: input.cwd,
565 operation: "getPullRequest",
566 cause: decoded.failure,
567 }),
568 );
569 }
570 return decoded.success === null
571 ? Effect.fail(
572 new AzureDevOpsPullRequestIncompleteError({
573 command: "az",
574 cwd: input.cwd,
575 number: input.number,
576 }),
577 )
578 : Effect.succeed(decoded.success);
579 },
580 ),
581 );
582 }),
583​
584 listPolicyChecks: Effect.fn("AzureDevOpsPullRequestCli.listPolicyChecks")(function* (input) {
585 const unreadable = (cause: unknown) =>
586 new AzureDevOpsPullRequestReadError({
587 command: "az",
588 cwd: input.cwd,
589 operation: "listPolicyChecks",
590 cause,
591 });
592 const checks: PullRequestCheck[] = [];
593 for (let skip = 0; skip < MAX_POLICY_EVALUATIONS; skip += POLICY_EVALUATIONS_PER_PAGE) {
594 const result = yield* executeJson({
595 cwd: input.cwd,
596 args: [
597 "repos",
598 "pr",
599 "policy",
600 "list",
601 ...detectArgs,
602 "--id",
603 String(input.number),
604 "--top",
605 String(POLICY_EVALUATIONS_PER_PAGE),
606 "--skip",
607 String(skip),
608 ],
609 });
610 if (result.stdoutTruncated) return yield* unreadable("Policy output was truncated");
611 const page = decodePolicyChecksJson(result.stdout.trim(), { isDraft: input.isDraft });
612 if (!Result.isSuccess(page)) return yield* unreadable(page.failure);
613 checks.push(...page.success.checks);
614 if (page.success.rawCount < POLICY_EVALUATIONS_PER_PAGE) return sortChecks(checks);
615 }
616 return yield* unreadable("Policy evaluations exceeded the read limit");
617 }),
618​
619 listThreads: Effect.fn("AzureDevOpsPullRequestCli.listThreads")(function (input) {
620 return invoke({
621 cwd: input.cwd,
622 operation: "listThreads",
623 resource: "pullRequestThreads",
624 routeParameters: pullRequestRoute(input),
625 maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES,
626 decode: decodeThreadsJson,
627 });
628 }),
629​
630 listIterations: Effect.fn("AzureDevOpsPullRequestCli.listIterations")(function (input) {
631 return invoke({
632 cwd: input.cwd,
633 operation: "listIterations",
634 resource: "pullRequestIterations",
635 routeParameters: pullRequestRoute(input),
636 maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES,
637 decode: decodeIterationsJson,
638 });
639 }),
640​
641 listIterationChanges: Effect.fn("AzureDevOpsPullRequestCli.listIterationChanges")(
642 function (input) {
643 const page = (skip: number) =>
644 invoke({
645 cwd: input.cwd,
646 operation: "listIterationChanges",
647 resource: "pullRequestIterationChanges",
648 routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`],
649 queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`],
650 maxOutputBytes: CHANGE_ENTRIES_MAX_OUTPUT_BYTES,
651 decode: decodeIterationChangesJson,
652 });
653 const from = (
654 skip: number,
655 collected: ReadonlyArray<AzureDevOpsChangeEntry>,
656 ): Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError> =>
657 page(skip).pipe(
658 Effect.flatMap((answer) => {
659 const changes = [...collected, ...answer.changes];
660 if (answer.nextSkip === null) return Effect.succeed({ changes, truncated: false });
661 return answer.nextSkip <= skip || answer.nextSkip >= MAX_CHANGE_ENTRIES
662 ? Effect.succeed({ changes, truncated: true })
663 : from(answer.nextSkip, changes);
664 }),
665 );
666 return from(0, []);
667 },
668 ),
669​
670 readBlobs: Effect.fn("AzureDevOpsPullRequestCli.readBlobs")(function* (input) {
671 const requested = new Map<string, string>();
672 // Azure states ids in uppercase and names entries in lowercase.
673 for (const id of input.objectIds) {
674 const key = id.toLowerCase();
675 if (BLOB_ID.test(id) && !requested.has(key)) requested.set(key, id);
676 }
677 if (requested.size === 0) return new Map<string, AzureDevOpsItemContent>();
678 const readFailure = (cause: unknown) =>
679 new AzureDevOpsPullRequestReadError({
680 command: "az",
681 cwd: input.cwd,
682 operation: "readBlobs",
683 cause,
684 });
685 return yield* Effect.scoped(
686 Effect.gen(function* () {
687 const directory = yield* fileSystem
688 .makeTempDirectoryScoped({ prefix: "t3-azure-blobs-" })
689 .pipe(Effect.mapError(readFailure));
690 const request = `${directory}/ids.json`;
691 // invoke refuses to overwrite its output, so the archive gets a name nothing holds yet.
692 const archive = `${directory}/blobs.zip`;
693 yield* fileSystem
694 .writeFileString(
695 request,
696 `[${[...requested.values()].map((id) => `"${id}"`).join(",")}]`,
697 )
698 .pipe(Effect.mapError(readFailure));
699 yield* azure.execute({
700 cwd: input.cwd,
701 args: [
702 "devops",
703 "invoke",
704 ...detectArgs,
705 "--area",
706 "git",
707 "--resource",
708 "blobs",
709 "--api-version",
710 REST_API_VERSION,
711 "--route-parameters",
712 ...repositoryRoute(input.location),
713 "--http-method",
714 "POST",
715 "--in-file",
716 request,
717 "--accept-media-type",
718 "application/zip",
719 "--out-file",
720 archive,
721 "--only-show-errors",
722 ],
723 });
724 const size = yield* fileSystem.stat(archive).pipe(
725 Effect.map((info) => Number(info.size)),
726 Effect.mapError(readFailure),
727 );
728 const contents = new Map<string, AzureDevOpsItemContent>();
729 if (size > BLOB_ARCHIVE_MAX_BYTES) return contents;
730 const zip = yield* fileSystem.readFile(archive).pipe(Effect.mapError(readFailure));
731 const entries = readBlobZip(zip, BLOB_MAX_BYTES, BLOB_BATCH_MAX_BYTES);
732 if (entries === null) return yield* readFailure("Azure returned an unreadable archive.");
733 for (const [id, bytes] of entries) {
734 const content = requested.has(id) ? blobContent(bytes) : null;
735 if (content !== null) contents.set(id, content);
736 }
737 return contents;
738 }),
739 );
740 }),
741​
742 readItemContent: Effect.fn("AzureDevOpsPullRequestCli.readItemContent")(function (input) {
743 return invoke({
744 cwd: input.cwd,
745 operation: "readItemContent",
746 resource: "items",
747 routeParameters: repositoryRoute(input.location),
748 queryParameters: [
749 // `az devops invoke` does not escape query values, so `#` or `&` in a path would truncate it.
750 `path=${encodeURIComponent(toItemPath(input.path))}`,
751 "versionDescriptor.versionType=commit",
752 `versionDescriptor.version=${input.commit}`,
753 "includeContent=true",
754 // Encoded content cannot identify binaries; request Azure's binary flag.
755 "includeContentMetadata=true",
756 // `az devops invoke` requires JSON; otherwise Azure returns raw file bytes.
757 "$format=json",
758 ],
759 maxOutputBytes: ITEM_CONTENT_MAX_OUTPUT_BYTES,
760 decode: decodeItemContentJson,
761 });
762 }),
763​
764 setPullRequestReviewers: Effect.fn("AzureDevOpsPullRequestCli.setPullRequestReviewers")(
765 function* (input) {
766 if (input.reviewers.some((reviewer) => !isReviewerName(reviewer))) {
767 return yield* new AzureDevOpsReviewerNameError({ command: "az", cwd: input.cwd });
768 }
769 return yield* azure
770 .execute({
771 cwd: input.cwd,
772 args: [
773 "repos",
774 "pr",
775 "reviewer",
776 input.requested ? "add" : "remove",
777 ...detectArgs,
778 "--id",
779 String(input.number),
780 // Repeating `--reviewers` replaces the earlier list.
781 "--reviewers",
782 ...input.reviewers,
783 "--only-show-errors",
784 "--output",
785 "json",
786 ],
787 })
788 .pipe(Effect.asVoid);
789 },
790 ),
791​
792 runPullRequestAction: Effect.fn("AzureDevOpsPullRequestCli.runPullRequestAction")(
793 function (input) {
794 return azure
795 .execute({
796 cwd: input.cwd,
797 args: [
798 "repos",
799 "pr",
800 "update",
801 ...detectArgs,
802 "--id",
803 String(input.number),
804 ...actionArgs(input.action, input.mergeMethod),
805 "--only-show-errors",
806 "--output",
807 "json",
808 ],
809 })
810 .pipe(
811 // `pr update` prints the pull request it left, in the shape `pr show` prints.
812 Effect.map((result) => {
813 const decoded = decodePullRequestJson(result.stdout.trim());
814 return Result.isSuccess(decoded) ? (decoded.success ?? undefined) : undefined;
815 }),
816 );
817 },
818 ),
819​
820 updatePullRequest: Effect.fn("AzureDevOpsPullRequestCli.updatePullRequest")(function (input) {
821 // One argv entry preserves leading dashes and multi-string descriptions.
822 const fields = [
823 ...(input.title === undefined ? [] : [AzureDevOpsCli.textOption("--title", input.title)]),
824 ...(input.body === undefined
825 ? []
826 : [AzureDevOpsCli.textOption("--description", input.body)]),
827 ];
828 // az has one stdin, so each value read from it needs its own call.
829 const batches =
830 fields.filter((field) => field.stdin !== undefined).length > 1
831 ? fields.map((field) => [field])
832 : [fields];
833 return Effect.forEach(
834 batches,
835 (batch) => {
836 const stdin = batch.find((field) => field.stdin !== undefined)?.stdin;
837 return azure.execute({
838 cwd: input.cwd,
839 args: [
840 "repos",
841 "pr",
842 "update",
843 ...detectArgs,
844 "--id",
845 String(input.number),
846 ...batch.map((field) => field.arg),
847 "--only-show-errors",
848 "--output",
849 "json",
850 ],
851 ...(stdin === undefined ? {} : { stdin }),
852 });
853 },
854 { discard: true },
855 );
856 }),
857 });
858});
859​
860export const layer = Layer.effect(AzureDevOpsPullRequestCli, make);