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}