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 could not name the signed-in account. Run `az login` and retry.";
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 case "delete-branch":
329 throw new Error(`Azure DevOps pull request action ${action} is unsupported`);
330 }
331}
332
333/**
334 * A reviewer Azure could be given: an email address, a display name or an identity guid, and
335 * nothing that starts with a dash. The dash is the whole point — these are argv, and a value that
336 * looks like a flag stops being a value.
337 */
338function isReviewerName(value: string): boolean {
339 const name = value.trim();
340 return name.length > 0 && !name.startsWith("-");
341}
342
343/** @public Service construction is part of the canonical Effect module API. */
344export const make = Effect.gen(function* () {
345 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
346
347 // Every command resolves the organization, project and repository from the checkout, which is
348 // what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows
349 // how to read all of them.
350 const detectArgs = ["--detect", "true"] as const;
351
352 const executeJson = (input: {
353 readonly cwd: string;
354 readonly args: ReadonlyArray<string>;
355 readonly maxOutputBytes?: number;
356 }) =>
357 azure.execute({
358 cwd: input.cwd,
359 args: [...input.args, "--only-show-errors", "--output", "json"],
360 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
361 });
362
363 /**
364 * A REST route reached through `az devops invoke`, which addresses it by area, resource and
365 * route parameters rather than by URL. Used in place of `az rest` because it signs in the way
366 * the azure-devops extension does: `az rest` mints its own token against the tenant `az`
367 * defaults to, and an organisation in any other tenant answers that with a sign-in page, which
368 * arrives here as unreadable output rather than as a failure.
369 */
370 const invoke = <A>(input: {
371 readonly cwd: string;
372 readonly operation: string;
373 readonly resource: string;
374 readonly routeParameters: ReadonlyArray<string>;
375 readonly queryParameters?: ReadonlyArray<string>;
376 readonly maxOutputBytes?: number;
377 readonly decode: (raw: string) => Result.Result<A, unknown>;
378 }): Effect.Effect<A, AzureDevOpsPullRequestCliError> =>
379 executeJson({
380 cwd: input.cwd,
381 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
382 args: [
383 "devops",
384 "invoke",
385 ...detectArgs,
386 "--area",
387 "git",
388 "--resource",
389 input.resource,
390 "--api-version",
391 REST_API_VERSION,
392 "--route-parameters",
393 ...input.routeParameters,
394 ...(input.queryParameters === undefined
395 ? []
396 : ["--query-parameters", ...input.queryParameters]),
397 ],
398 }).pipe(
399 Effect.flatMap((result) => {
400 const decoded = input.decode(result.stdout.trim());
401 return Result.isSuccess(decoded)
402 ? Effect.succeed(decoded.success)
403 : Effect.fail(
404 new AzureDevOpsPullRequestReadError({
405 command: "az",
406 cwd: input.cwd,
407 operation: input.operation,
408 cause: decoded.failure,
409 }),
410 );
411 }),
412 );
413
414 /**
415 * Azure names its own items with a leading slash, which the repository paths carried around
416 * here have had taken off so they match the patch and the viewed mark. Put it back on the way
417 * out, because the items route is documented in Azure's own spelling.
418 */
419 const toItemPath = (path: string) => (path.startsWith("/") ? path : `/${path}`);
420
421 const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray<string> => [
422 `project=${location.project}`,
423 `repositoryId=${location.repository}`,
424 ];
425
426 const pullRequestRoute = (input: {
427 readonly location: AzureDevOpsRepositoryLocation;
428 readonly number: number;
429 }): ReadonlyArray<string> => [
430 ...repositoryRoute(input.location),
431 `pullRequestId=${input.number}`,
432 ];
433
434 /**
435 * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and
436 * retain the raw count so the next public cursor skips every row this walk consumed.
437 */
438 const listPullRequestPage = (input: {
439 readonly cwd: string;
440 readonly repository: string;
441 readonly state: PullRequestListState;
442 readonly involvement: PullRequestInvolvement;
443 readonly viewer: string;
444 readonly limit: number;
445 readonly skip: number;
446 readonly cursorAdvance: number;
447 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
448 }): Effect.Effect<
449 {
450 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
451 readonly truncated: boolean;
452 readonly cursorAdvance: number;
453 },
454 AzureDevOpsPullRequestCliError
455 > => {
456 const remaining = input.limit - input.items.length;
457 const top = remaining + 1;
458 return executeJson({
459 cwd: input.cwd,
460 maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES,
461 args: [
462 "repos",
463 "pr",
464 "list",
465 ...detectArgs,
466 "--repository",
467 input.repository,
468 ...statusArgs(input.state),
469 ...involvementArgs(input),
470 // A web link per row, which is the only url that needs no assembling.
471 "--include-links",
472 ...(input.skip === 0 ? [] : ["--skip", String(input.skip)]),
473 "--top",
474 String(top),
475 ],
476 }).pipe(
477 Effect.flatMap((result) => {
478 const raw = result.stdout.trim();
479 if (raw.length === 0) {
480 return Effect.succeed({
481 items: input.items,
482 truncated: false,
483 cursorAdvance: input.cursorAdvance,
484 });
485 }
486 const decoded = decodePullRequestListJson(raw);
487 if (!Result.isSuccess(decoded)) {
488 return Effect.fail(
489 new AzureDevOpsPullRequestReadError({
490 command: "az",
491 cwd: input.cwd,
492 operation: "listPullRequests",
493 cause: decoded.failure,
494 }),
495 );
496 }
497
498 const lastItemIndex = decoded.success.rawIndexes[remaining - 1];
499 if (lastItemIndex !== undefined) {
500 const consumed = lastItemIndex + 1;
501 return Effect.succeed({
502 items: [...input.items, ...decoded.success.items.slice(0, remaining)],
503 // A full raw response may have more rows even when malformed entries used the probe.
504 truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top,
505 cursorAdvance: input.cursorAdvance + consumed,
506 });
507 }
508
509 const items = [...input.items, ...decoded.success.items];
510 if (decoded.success.rawCount < top) {
511 return Effect.succeed({
512 items,
513 truncated: false,
514 cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
515 });
516 }
517 return listPullRequestPage({
518 ...input,
519 skip: input.skip + decoded.success.rawCount,
520 cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
521 items,
522 });
523 }),
524 );
525 };
526
527 return AzureDevOpsPullRequestCli.of({
528 getViewer: (input) =>
529 executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe(
530 Effect.flatMap((result): Effect.Effect<string, AzureDevOpsPullRequestCliError> => {
531 // `--query user` narrows the payload to the account, so it is nested back under the
532 // key the decoder reads to keep one shape for the signed-in user.
533 const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`);
534 if (!Result.isSuccess(decoded)) {
535 return Effect.fail(
536 new AzureDevOpsPullRequestReadError({
537 command: "az",
538 cwd: input.cwd,
539 operation: "getViewer",
540 cause: decoded.failure,
541 }),
542 );
543 }
544 return decoded.success === null
545 ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }))
546 : Effect.succeed(decoded.success);
547 }),
548 // `az devops login` signs in with a personal access token, which `az account show` knows
549 // nothing about, and nothing in `az` prints whose token it is. `--creator me` is resolved
550 // by the extension itself, so the viewer's own pull request names them. Someone with none
551 // stays unnamed: `me` would read as nobody everywhere a row is compared with the viewer.
552 Effect.catchIf(
553 (error) => error._tag !== "AzureDevOpsCliUnavailableError",
554 () =>
555 executeJson({
556 cwd: input.cwd,
557 args: [
558 "repos",
559 "pr",
560 "list",
561 ...detectArgs,
562 "--creator",
563 "me",
564 "--status",
565 "all",
566 "--top",
567 "1",
568 ],
569 }).pipe(
570 Effect.flatMap((result): Effect.Effect<string, AzureDevOpsPullRequestCliError> => {
571 const decoded = decodePullRequestListJson(result.stdout.trim() || "[]");
572 const login = Result.isSuccess(decoded)
573 ? decoded.success.items[0]?.author?.login
574 : undefined;
575 return login === undefined
576 ? Effect.fail(
577 new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }),
578 )
579 : Effect.succeed(login);
580 }),
581 ),
582 ),
583 ),
584
585 listPullRequests: (input) =>
586 listPullRequestPage({
587 cwd: input.cwd,
588 repository: input.repository,
589 state: input.state,
590 involvement: input.involvement,
591 viewer: input.viewer,
592 limit: input.limit,
593 // Azure counts rather than filters, so a slice carries on by stepping over every raw row
594 // the prior slice consumed. That is an offset into a list that can shift underneath it:
595 // a pull request opened between two slices moves everything down one, and the row on the
596 // seam is the one that pays for it.
597 skip: input.cursor?.delivered ?? 0,
598 cursorAdvance: 0,
599 items: [],
600 }),
601
602 getPullRequest: (input) =>
603 executeJson({
604 cwd: input.cwd,
605 args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)],
606 }).pipe(
607 Effect.flatMap(
608 (result): Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError> => {
609 const decoded = decodePullRequestJson(result.stdout.trim());
610 if (!Result.isSuccess(decoded)) {
611 return Effect.fail(
612 new AzureDevOpsPullRequestReadError({
613 command: "az",
614 cwd: input.cwd,
615 operation: "getPullRequest",
616 cause: decoded.failure,
617 }),
618 );
619 }
620 // Null means Azure answered with too little to place the pull request. Nothing
621 // failed underneath it, so it is its own outcome rather than a decode failure.
622 return decoded.success === null
623 ? Effect.fail(
624 new AzureDevOpsPullRequestIncompleteError({
625 command: "az",
626 cwd: input.cwd,
627 number: input.number,
628 }),
629 )
630 : Effect.succeed(decoded.success);
631 },
632 ),
633 ),
634
635 listThreads: (input) =>
636 invoke({
637 cwd: input.cwd,
638 operation: "listThreads",
639 resource: "pullRequestThreads",
640 routeParameters: pullRequestRoute(input),
641 maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES,
642 decode: decodeThreadsJson,
643 }),
644
645 listIterations: (input) =>
646 invoke({
647 cwd: input.cwd,
648 operation: "listIterations",
649 resource: "pullRequestIterations",
650 routeParameters: pullRequestRoute(input),
651 maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES,
652 decode: decodeIterationsJson,
653 }),
654
655 listIterationChanges: (input) => {
656 const page = (skip: number) =>
657 invoke({
658 cwd: input.cwd,
659 operation: "listIterationChanges",
660 resource: "pullRequestIterationChanges",
661 routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`],
662 // Azure pages this route at 1000 entries by default; this is its own maximum per page,
663 // and it names where the next page starts rather than answering with the whole change.
664 queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`],
665 maxOutputBytes: CHANGE_ENTRIES_MAX_OUTPUT_BYTES,
666 decode: decodeIterationChangesJson,
667 });
668 const from = (
669 skip: number,
670 collected: ReadonlyArray<AzureDevOpsChangeEntry>,
671 ): Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError> =>
672 page(skip).pipe(
673 Effect.flatMap((answer) => {
674 const changes = [...collected, ...answer.changes];
675 // The last page names no page after it, and only that is the end of the change.
676 if (answer.nextSkip === null) return Effect.succeed({ changes, truncated: false });
677 // A page pointing at where the read already is would be followed forever, and one
678 // past the ceiling is a change nobody will read to the end of. Both stop the read
679 // and both say so, rather than presenting part of a change as the whole of it.
680 return answer.nextSkip <= skip || answer.nextSkip >= MAX_CHANGE_ENTRIES
681 ? Effect.succeed({ changes, truncated: true })
682 : from(answer.nextSkip, changes);
683 }),
684 );
685 return from(0, []);
686 },
687
688 readItemContent: (input) =>
689 invoke({
690 cwd: input.cwd,
691 operation: "readItemContent",
692 resource: "items",
693 routeParameters: repositoryRoute(input.location),
694 queryParameters: [
695 `path=${toItemPath(input.path)}`,
696 "versionDescriptor.versionType=commit",
697 `versionDescriptor.version=${input.commit}`,
698 "includeContent=true",
699 // Azure leaves `contentMetadata` out unless this is asked for, and with it goes its own
700 // word on whether the file is binary, which is the only reliable one, since a binary
701 // file arrives encoded rather than as the bytes it is on the host.
702 "includeContentMetadata=true",
703 // Without this Azure answers with the file's own bytes rather than with a JSON
704 // envelope, and `az devops invoke` refuses anything it cannot parse as JSON.
705 "$format=json",
706 ],
707 maxOutputBytes: ITEM_CONTENT_MAX_OUTPUT_BYTES,
708 decode: decodeItemContentJson,
709 }),
710
711 setPullRequestReviewers: (input) =>
712 input.reviewers.some((reviewer) => !isReviewerName(reviewer))
713 ? Effect.fail(new AzureDevOpsReviewerNameError({ command: "az", cwd: input.cwd }))
714 : azure
715 .execute({
716 cwd: input.cwd,
717 args: [
718 "repos",
719 "pr",
720 "reviewer",
721 input.requested ? "add" : "remove",
722 ...detectArgs,
723 "--id",
724 String(input.number),
725 // One `--reviewers` takes them all, because az reads the flag as a list and a
726 // second one would replace the first rather than add to it.
727 "--reviewers",
728 ...input.reviewers,
729 "--only-show-errors",
730 "--output",
731 "json",
732 ],
733 })
734 .pipe(Effect.asVoid),
735
736 runPullRequestAction: (input) =>
737 azure
738 .execute({
739 cwd: input.cwd,
740 args: [
741 "repos",
742 "pr",
743 "update",
744 ...detectArgs,
745 "--id",
746 String(input.number),
747 ...actionArgs(input.action, input.mergeMethod),
748 "--only-show-errors",
749 "--output",
750 "json",
751 ],
752 })
753 .pipe(Effect.asVoid),
754
755 updatePullRequest: (input) =>
756 azure
757 .execute({
758 cwd: input.cwd,
759 args: [
760 "repos",
761 "pr",
762 "update",
763 ...detectArgs,
764 "--id",
765 String(input.number),
766 // One argument rather than a flag and a value beside it: a description usually opens
767 // with a bullet, and az reads a dash in the next argv slot as a flag of its own.
768 // `--description` also takes several strings, and this keeps the whole text as one.
769 ...(input.title === undefined ? [] : [`--title=${input.title}`]),
770 ...(input.body === undefined ? [] : [`--description=${input.body}`]),
771 "--only-show-errors",
772 "--output",
773 "json",
774 ],
775 })
776 .pipe(Effect.asVoid),
777 });
778});
779
780export const layer = Layer.effect(AzureDevOpsPullRequestCli, make);