1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Semaphore from "effect/Semaphore";
4import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
5
6import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
7import {
8 azureDevOpsFilePatch,
9 azureDevOpsUnreadableFilePatch,
10 formatAzureDevOpsDiffCursor,
11 parseAzureDevOpsDiffCursor,
12 MAX_DIFF_SLICE_BYTES,
13 MAX_DIFF_SLICE_EDITS,
14 MAX_DIFF_SLICE_FILES,
15 byteLength,
16 MAX_FILE_DIFF_EDITS,
17 type AzureDevOpsFileTexts,
18} from "./azureDevOpsDiff.ts";
19import {
20 PullRequestProviderError,
21 type PullRequestProviderFailure,
22 type ProviderChangeRequest,
23 type ProviderChangeRequestActivity,
24 type ProviderChangeRequestDetail,
25 type ProviderChangeRequestSummary,
26 type ProviderDiffSlice,
27 type PullRequestProviderApi,
28} from "@t3tools/source-control-core/server/PullRequestProvider";
29import type {
30 AzureDevOpsChangeEntry,
31 AzureDevOpsItemContent,
32 AzureDevOpsIteration,
33 AzureDevOpsPullRequest,
34 AzureDevOpsRepositoryLocation,
35} from "./azureDevOpsPullRequestJson.ts";
36import { definition } from "../client/definition.ts";
37
38/**
39 * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a
40 * Python interpreter's start-up, so reading them one after another is most of what the Code tab
41 * waits for. Four files means eight processes at once: the fan-out the GitHub CLI reads its
42 * per-file stats with here, and low enough not to swamp the host's throttling or the machine.
43 */
44const DIFF_FILE_CONCURRENCY = 4;
45
46/**
47 * How many `az` processes this build will have out at once, counted across every reader rather
48 * than per request. The fan-out above bounds one Code tab, so two people opening two Azure reviews
49 * had sixteen Python interpreters starting at once and nothing above them. Held at what one
50 * request at full width spends, so a second reader waits behind the first instead of adding to it.
51 */
52export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY;
53
54/** How many pull requests' repository locations one provider remembers at once. */
55export const LOCATION_CACHE_CAPACITY = 128;
56
57const CAPABILITIES: PullRequestCapabilities = {
58 // Azure serves no patch of its own, so the one the Code tab reads is built here out of the
59 // files an iteration changed and both sides of each of them.
60 diff: true,
61 // Reading a conversation is a plain REST read, but posting one is not something this can
62 // claim without having run it, so the composer stays hidden.
63 comment: false,
64 actions: [...definition.changeRequestActions],
65 // Azure squashes as a completion option; it has no rebase strategy of its own.
66 mergeMethods: ["merge", "squash"],
67 // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all.
68 search: false,
69 reactions: false,
70 // The patch has lines to write against, but writing a remark at all is what Azure is not
71 // offered for here, so nothing in a review is either.
72 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
73 // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos`
74 // lists the ones this repository could name — that lives behind the identity and graph APIs, a
75 // different service with its own permissions. So the page takes a name here rather than being
76 // handed a menu built out of a guess.
77 reviewers: { request: true, listCandidates: false },
78 // A new title and description travel on the same `az repos pr update` that moves a pull request.
79 // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on
80 // Azure DevOps at all, so there is nothing here it could rewrite either.
81 edit: { changeRequest: true, comment: false },
82 // Azure does keep a viewed record of its own, but only behind the undocumented contribution
83 // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway.
84 // So they are kept here instead, and the client says whose they are rather than implying the
85 // Azure DevOps page will show them.
86 viewedFiles: "environment",
87};
88
89/**
90 * Everything this host offers, granted to whoever is signed in. Azure DevOps states no permission
91 * anywhere `az repos pr show` or `az repos pr list` reach: the answer lives in the security
92 * namespaces, behind identity descriptors and token paths that would be several calls per pull
93 * request to resolve.
94 *
95 * So the actions stay live and a viewer who may not take one is told so by Azure, at the moment
96 * they try. That is the safer half of an unknown: hiding a control from someone entitled to it
97 * leaves them no way through and no reason given.
98 */
99const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = {
100 actions: CAPABILITIES.actions,
101 comment: CAPABILITIES.comment,
102 resolve: CAPABILITIES.review.resolve,
103 verdicts: CAPABILITIES.review.verdicts,
104 requestReviewers: CAPABILITIES.reviewers.request,
105};
106
107/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */
108export function azureDevOpsProviderFailure(
109 error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError,
110): PullRequestProviderFailure {
111 if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" };
112 if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" };
113 if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" };
114 if (error._tag === "AzureDevOpsPullRequestNotFoundError") return { reason: "not-found" };
115 return { reason: "failed" };
116}
117
118function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeRequest {
119 return {
120 number: pullRequest.number,
121 title: pullRequest.title,
122 url: pullRequest.url,
123 author: pullRequest.author,
124 headBranch: pullRequest.headBranch,
125 baseBranch: pullRequest.baseBranch,
126 state: pullRequest.state,
127 isDraft: pullRequest.isDraft,
128 mergeability: pullRequest.mergeability,
129 // Azure counts a pull request's files but never its lines, and counting them here would mean
130 // reading every file on both sides of every row of a listing.
131 additions: 0,
132 deletions: 0,
133 createdAt: pullRequest.createdAt,
134 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
135 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
136 updatedAt: pullRequest.updatedAt,
137 reviewRequestLogins: pullRequest.reviewRequestLogins,
138 // Azure keeps labels on work items rather than on the pull request.
139 labels: [],
140 };
141}
142
143export const make = Effect.gen(function* () {
144 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
145 // Made once with the provider, which the registry builds once, so this is the whole build's
146 // allowance rather than one request's.
147 const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS);
148 const readItemContent = (input: Parameters<typeof cli.readItemContent>[0]) =>
149 diffSpawns.withPermits(1)(cli.readItemContent(input));
150
151 const fail =
152 (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) =>
153 new PullRequestProviderError({
154 provider: SourceControlProviderKind.make("azure-devops"),
155 operation,
156 ...azureDevOpsProviderFailure(error),
157 detail: error.detail,
158 cause: error,
159 });
160
161 /** Refuses what the capabilities already say this host cannot do. */
162 const unsupported = (operation: string) =>
163 Effect.fail(
164 new PullRequestProviderError({
165 provider: SourceControlProviderKind.make("azure-devops"),
166 operation,
167 reason: "failed",
168 detail: "Azure DevOps reviews cannot be written from here yet.",
169 }),
170 );
171
172 /** A pull request Azure could not place has no diff to read, which reads as an empty one. */
173 const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null };
174
175 /**
176 * Where a pull request's repository lives, which is the route every other read of it needs and
177 * the one thing only the pull request itself states. A pull request cannot move between
178 * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for
179 * a whole pull request read every time they checked whether a file had been pushed to. Least
180 * recently used, so a listing walking cold pull requests cannot evict the one being read.
181 */
182 const locations = new Map<string, AzureDevOpsRepositoryLocation>();
183
184 const locationOf = (input: { readonly cwd: string; readonly number: number }) => {
185 const key = `${input.cwd} ${input.number}`;
186 const held = locations.get(key);
187 if (held !== undefined) {
188 locations.delete(key);
189 locations.set(key, held);
190 return Effect.succeed(held);
191 }
192 return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
193 Effect.map((pullRequest) => {
194 const location = pullRequest.location;
195 if (location === null) return null;
196 if (locations.size >= LOCATION_CACHE_CAPACITY) {
197 const oldest = locations.keys().next().value;
198 if (oldest !== undefined) locations.delete(oldest);
199 }
200 locations.set(key, location);
201 return location;
202 }),
203 );
204 };
205
206 /**
207 * Everything a diff read needs before it can ask for a file: where the repository lives, and
208 * which pushes the pull request has had. A client names neither, and the iterations are read
209 * afresh every time because the newest one is what a push adds.
210 */
211 const diffScope = (input: { readonly cwd: string; readonly number: number }) =>
212 Effect.gen(function* () {
213 const location = yield* locationOf(input);
214 if (location === null) return null;
215 const iterations = yield* cli.listIterations({
216 cwd: input.cwd,
217 location,
218 number: input.number,
219 });
220 return { location, iterations };
221 });
222
223 const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false };
224
225 /**
226 * Both sides of one changed file, read at once because neither answer depends on the other and
227 * `az` pays a Python interpreter's start-up for each. Only the sides a change actually has are
228 * asked for: Azure answers for a file that is not at a commit with a failure rather than with
229 * nothing.
230 */
231 const readTexts = (input: {
232 readonly cwd: string;
233 readonly location: AzureDevOpsRepositoryLocation;
234 readonly iteration: AzureDevOpsIteration;
235 readonly change: Pick<AzureDevOpsChangeEntry, "changeKind" | "path" | "oldPath">;
236 }) =>
237 Effect.all(
238 [
239 input.change.changeKind === "new"
240 ? Effect.succeed(EMPTY_ITEM)
241 : readItemContent({
242 cwd: input.cwd,
243 location: input.location,
244 path: input.change.oldPath,
245 commit: input.iteration.mergeBaseCommit,
246 }),
247 input.change.changeKind === "deleted"
248 ? Effect.succeed(EMPTY_ITEM)
249 : readItemContent({
250 cwd: input.cwd,
251 location: input.location,
252 path: input.change.path,
253 commit: input.iteration.headCommit,
254 }),
255 ],
256 { concurrency: 2 },
257 ).pipe(
258 Effect.map(([oldItem, newItem]): AzureDevOpsFileTexts => ({
259 oldContents: oldItem.contents,
260 newContents: newItem.contents,
261 // Azure hands a file it calls binary over in an encoding of its own, so its own word on
262 // that is taken rather than looked for in bytes it may never have sent verbatim.
263 binary: oldItem.isBinary || newItem.isBinary,
264 })),
265 );
266
267 /**
268 * What the whole pull request changed, taken from its latest push. An iteration's changes are
269 * reported against the merge base rather than against the push before it, so the newest one is
270 * the whole of the change rather than the last slice of it.
271 */
272 const listLatestChanges = (input: {
273 readonly cwd: string;
274 readonly location: AzureDevOpsRepositoryLocation;
275 readonly number: number;
276 readonly iterations: ReadonlyArray<AzureDevOpsIteration>;
277 }) => {
278 const latest = input.iterations.at(-1);
279 return latest === undefined
280 ? Effect.succeed({
281 changes: [],
282 truncated: false,
283 } as AzureDevOpsPullRequestCli.AzureDevOpsIterationChanges)
284 : cli.listIterationChanges({
285 cwd: input.cwd,
286 location: input.location,
287 number: input.number,
288 iterationId: latest.id,
289 });
290 };
291
292 const provider: PullRequestProviderApi = {
293 kind: SourceControlProviderKind.make("azure-devops"),
294 capabilities: CAPABILITIES,
295 // The bare repository name repeats across an organization; the project path does not.
296 repositoryKey: ({ canonicalKey }) => canonicalKey,
297
298 getViewer: (input) =>
299 cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))),
300
301 // `input.query` is deliberately dropped: `az repos pr list` filters by status, creator,
302 // reviewer and branch, and has nothing that matches text. Sending it as one of those would
303 // narrow by the wrong thing, so the page comes back unnarrowed and the caller filters it.
304 listChangeRequests: (input) =>
305 cli
306 .listPullRequests({
307 cwd: input.cwd,
308 repository: input.repository,
309 state: input.state,
310 involvement: input.involvement,
311 viewer: input.viewer,
312 limit: input.limit,
313 cursor: input.cursor,
314 })
315 .pipe(
316 Effect.mapError(fail("listChangeRequests")),
317 Effect.map((batch) => ({
318 items: batch.items.map(toChangeRequest),
319 truncated: batch.truncated,
320 cursorAdvance: batch.cursorAdvance,
321 // Azure answers in one order whether or not it is being carried on from, so a slice
322 // can always be stepped past — by counting, which is all Azure offers.
323 continues: true,
324 })),
325 ),
326
327 // The polled path a linked thread's row stays live on: one `az` read, no iterations or
328 // changes behind it, since the file count that would cost is not shown here.
329 getChangeRequestSummary: (input) =>
330 cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
331 Effect.mapError(fail("getChangeRequestSummary")),
332 Effect.map((pullRequest): ProviderChangeRequestSummary => ({
333 number: pullRequest.number,
334 title: pullRequest.title,
335 url: pullRequest.url,
336 author: pullRequest.author,
337 headBranch: pullRequest.headBranch,
338 baseBranch: pullRequest.baseBranch,
339 state: pullRequest.state,
340 isDraft: pullRequest.isDraft,
341 mergeability: pullRequest.mergeability,
342 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
343 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
344 updatedAt: pullRequest.updatedAt,
345 })),
346 ),
347
348 getChangeRequest: (input) =>
349 Effect.gen(function* () {
350 const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number });
351 const location = pullRequest.location;
352 // The file count is two reads past the pull request itself, and it is the only thing
353 // riding on them, so a failure leaves it unknown rather than losing the whole detail.
354 const changedFiles =
355 location === null
356 ? 0
357 : yield* cli.listIterations({ cwd: input.cwd, location, number: input.number }).pipe(
358 Effect.flatMap((iterations) =>
359 listLatestChanges({
360 cwd: input.cwd,
361 location,
362 number: input.number,
363 iterations,
364 }),
365 ),
366 Effect.map((listed) => listed.changes.length),
367 Effect.orElseSucceed(() => 0),
368 );
369 const detail: ProviderChangeRequestDetail = {
370 ...toChangeRequest(pullRequest),
371 body: pullRequest.body,
372 changedFiles,
373 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
374 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
375 reviewers: pullRequest.reviewers,
376 checks: [],
377 mergeCapabilities: { merge: true, squash: true, rebase: false },
378 viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS,
379 autoMergeEnabled: pullRequest.autoMergeEnabled,
380 ...(pullRequest.autoMergeMethod === undefined
381 ? {}
382 : { autoMergeMethod: pullRequest.autoMergeMethod }),
383 };
384 return detail;
385 }).pipe(Effect.mapError(fail("getChangeRequest"))),
386
387 getChangeRequestActivity: (input) =>
388 cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
389 Effect.mapError(fail("getChangeRequestActivity")),
390 Effect.flatMap((pullRequest) =>
391 (pullRequest.location === null
392 ? Effect.succeed({ comments: [], truncated: true })
393 : cli
394 .listThreads({
395 cwd: input.cwd,
396 location: pullRequest.location,
397 number: input.number,
398 })
399 .pipe(
400 Effect.map((comments) => ({ comments, truncated: false })),
401 Effect.orElseSucceed(() => ({ comments: [], truncated: true })),
402 )
403 ).pipe(
404 Effect.map((conversation): ProviderChangeRequestActivity => ({
405 comments: conversation.comments,
406 commentCount: conversation.comments.length,
407 commentsTruncated: conversation.truncated,
408 reviewThreads: [],
409 commits: [],
410 })),
411 ),
412 ),
413 ),
414
415 // No request at all: Azure has nothing to say about the viewer that a pull request read can
416 // reach, so the answer is the same constant the detail carries.
417 getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS),
418
419 // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so
420 // the Code tab has nothing to scope itself to and always asks for the whole change.
421 getDiff: (input) =>
422 Effect.gen(function* () {
423 const scope = yield* diffScope(input);
424 if (scope === null) return EMPTY_DIFF_SLICE;
425 const cursor = parseAzureDevOpsDiffCursor(input.cursor);
426 // Reading on stays with the push the first slice was taken against. A push landing
427 // mid-read would otherwise renumber the files and hand the reader one twice, or none.
428 const iteration =
429 cursor === null
430 ? scope.iterations.at(-1)
431 : scope.iterations.find((candidate) => candidate.id === cursor.iterationId);
432 if (iteration === undefined) return EMPTY_DIFF_SLICE;
433 const listed = yield* cli.listIterationChanges({
434 cwd: input.cwd,
435 location: scope.location,
436 number: input.number,
437 iterationId: iteration.id,
438 });
439 const changes = listed.changes;
440
441 const sections: string[] = [];
442 let truncated = listed.truncated;
443 let bytes = 0;
444 let edits = 0;
445 let index = cursor?.fileIndex ?? 0;
446 let full = false;
447 // How many files to read at once. Every file costs a request a side, and everything read
448 // past the point the slice fills is thrown away and read again by the next slice, so the
449 // batch narrows as the budgets do: what is left of each, over what a file has spent of it
450 // on average so far, which is the only estimate there is before a file is read. At least
451 // one, so a file heavier than the whole budget still moves the cursor.
452 const batchWidth = () => {
453 if (sections.length === 0) return DIFF_FILE_CONCURRENCY;
454 const admits = (left: number, spent: number) =>
455 Math.ceil(left / Math.max(1, spent / sections.length));
456 return Math.max(
457 1,
458 Math.min(
459 DIFF_FILE_CONCURRENCY,
460 // The file budget needs no estimate: a file spends exactly one of it.
461 MAX_DIFF_SLICE_FILES - sections.length,
462 admits(MAX_DIFF_SLICE_BYTES - bytes, bytes),
463 admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits),
464 ),
465 );
466 };
467 while (!full && index < changes.length) {
468 const batch = changes.slice(index, index + batchWidth());
469 const read = yield* Effect.forEach(
470 batch,
471 (change) =>
472 // A pair Azure refuses is one file rather than the whole slice: an oversize blob or
473 // a path `az` will not carry through leaves that file listed without its hunks, and
474 // everything around it still renders.
475 readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe(
476 // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az`
477 // at all is the read failing rather than the file, and belongs to the caller,
478 // which pauses the host rather than showing every file as unreadable.
479 Effect.catchTags({
480 AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null),
481 AzureDevOpsCommandFailedError: () => Effect.succeed(null),
482 AzureDevOpsPullRequestReadError: () => Effect.succeed(null),
483 }),
484 Effect.map((texts) => ({ change, texts })),
485 ),
486 { concurrency: DIFF_FILE_CONCURRENCY },
487 );
488 for (const { change, texts } of read) {
489 // The diff is synchronous and the reads no longer stand between one file and the next
490 // to let anything else on the server run, so the thread is handed back here.
491 yield* Effect.yieldNow;
492 const file =
493 texts === null
494 ? azureDevOpsUnreadableFilePatch(change)
495 : azureDevOpsFilePatch({ change, texts });
496 sections.push(file.section);
497 bytes += byteLength(file.section);
498 edits += file.edits;
499 truncated = truncated || file.truncated;
500 index += 1;
501 // A file whose diff was given up on spent the whole of what one file is allowed and
502 // has only a header to show for it, and a file the diff never ran on at all weighs
503 // almost nothing in either budget and still costs its two reads. So the file count
504 // bounds the request alongside the bytes.
505 // Checked after the file is added rather than before it, so every slice carries at
506 // least one: a section heavier than the whole budget would otherwise never be added,
507 // and the read would answer the same slice forever without moving the cursor.
508 if (
509 bytes >= MAX_DIFF_SLICE_BYTES ||
510 edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS ||
511 sections.length >= MAX_DIFF_SLICE_FILES ||
512 file.abandoned
513 ) {
514 full = true;
515 break;
516 }
517 }
518 }
519
520 const slice: ProviderDiffSlice = {
521 patch: sections.join(""),
522 truncated,
523 nextCursor:
524 index >= changes.length
525 ? null
526 : formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }),
527 };
528 return slice;
529 }).pipe(Effect.mapError(fail("getDiff"))),
530
531 // The patch is built from whole files, so opening the lines around a hunk is the same two
532 // reads over again rather than a wider request. Read against the latest iteration: nothing in
533 // the request says which push the reader is looking at, and expansion is stale after a
534 // mid-review push on every host here anyway.
535 getDiffFileContents: (input) =>
536 Effect.gen(function* () {
537 const scope = yield* diffScope(input);
538 const iteration = scope?.iterations.at(-1);
539 if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" };
540 return yield* readTexts({
541 cwd: input.cwd,
542 location: scope.location,
543 iteration,
544 change: {
545 changeKind: input.changeType,
546 path: input.newPath,
547 oldPath: input.oldPath,
548 },
549 });
550 }).pipe(Effect.mapError(fail("getDiffFileContents"))),
551
552 /**
553 * What the head has of each marked file, which is the blob Azure already names on the change
554 * it reports. One read covers every path, since the latest iteration lists the whole change.
555 *
556 * A path the change does not carry is at the empty revision, which is where a file the pull
557 * request deletes sits. When the change was too long to follow to its end, those paths are
558 * left out instead: reporting them as deleted would clear a file nobody has read.
559 */
560 getFileRevisions: (input) =>
561 Effect.gen(function* () {
562 const revisions = new Map<string, string>();
563 if (input.paths.length === 0) return { revisions };
564 const scope = yield* diffScope(input);
565 if (scope === null) return { revisions };
566 const listed = yield* listLatestChanges({
567 ...scope,
568 cwd: input.cwd,
569 number: input.number,
570 });
571 const marked = new Set(input.paths);
572 for (const change of listed.changes) {
573 if (!marked.has(change.path) || change.objectId === null) continue;
574 revisions.set(change.path, change.objectId);
575 }
576 if (!listed.truncated) {
577 for (const path of input.paths) {
578 if (!revisions.has(path)) revisions.set(path, "");
579 }
580 }
581 return { revisions };
582 }).pipe(Effect.mapError(fail("getFileRevisions"))),
583
584 runAction: (input) =>
585 cli
586 .runPullRequestAction({
587 cwd: input.cwd,
588 number: input.number,
589 action: input.action,
590 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
591 })
592 .pipe(Effect.mapError(fail("runAction"))),
593
594 updateChangeRequest: (input) =>
595 cli
596 .updatePullRequest({
597 cwd: input.cwd,
598 number: input.number,
599 title: input.title,
600 body: input.body,
601 })
602 .pipe(Effect.mapError(fail("updateChangeRequest"))),
603
604 // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the
605 // list without it.
606 listReviewerCandidates: () =>
607 Effect.fail(
608 new PullRequestProviderError({
609 provider: SourceControlProviderKind.make("azure-devops"),
610 operation: "listReviewerCandidates",
611 reason: "failed",
612 detail: "Azure DevOps cannot say who may review a pull request.",
613 }),
614 ),
615
616 setReviewerRequest: (input) =>
617 cli
618 .setPullRequestReviewers({
619 cwd: input.cwd,
620 number: input.number,
621 // Azure names an identity by an email address or a guid, and has no team to ask, so a
622 // candidate's id is the whole of what it takes.
623 reviewers: input.reviewers.map((reviewer) => reviewer.id),
624 requested: input.requested,
625 })
626 .pipe(Effect.mapError(fail("setReviewerRequest"))),
627
628 // Never called: `capabilities.comment` is false, and the service refuses a comment without it.
629 comment: () => unsupported("comment"),
630
631 // Declared unsupported above, so the service refuses these before a provider is reached.
632 // They exist because every provider answers the whole port.
633 submitReview: () => unsupported("submitReview"),
634
635 replyToThread: () => unsupported("replyToThread"),
636
637 setThreadResolution: () => unsupported("setThreadResolution"),
638
639 setReaction: () => unsupported("setReaction"),
640 };
641
642 return provider;
643});