Skip to content
Source
Sign inSign up

azureDevOpsPullRequestJson.ts

485 lines16.7 KB

1import {
2 trimOptionalString as trimmed,
3 normalizeRefName,
4} from "@t3tools/source-control-core/server/pullRequestUtils";
5import * as Cause from "effect/Cause";
6import * as Exit from "effect/Exit";
7import * as Result from "effect/Result";
8import * as Schema from "effect/Schema";
9import type {
10 PullRequestActor,
11 PullRequestComment,
12 PullRequestMergeMethod,
13 PullRequestMergeability,
14 PullRequestState,
15} from "@t3tools/contracts";
16import { TrimmedNonEmptyString } from "@t3tools/contracts";
17import { decodeJsonResult } from "@t3tools/shared/schemaJson";
18​
19import { azureDevOpsPullRequestWebUrl } from "./azureDevOpsPullRequests.ts";
20​
21// CLI fields vary by command; unknown enum values must not fail the payload.
22const RawIdentitySchema = Schema.Struct({
23 displayName: Schema.optional(Schema.NullOr(Schema.String)),
24 uniqueName: Schema.optional(Schema.NullOr(Schema.String)),
25 imageUrl: Schema.optional(Schema.NullOr(Schema.String)),
26});
27​
28const RawPullRequestSchema = Schema.Struct({
29 pullRequestId: Schema.Int,
30 title: Schema.String,
31 description: Schema.optional(Schema.NullOr(Schema.String)),
32 status: Schema.optional(Schema.NullOr(Schema.String)),
33 isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)),
34 /** This identity's presence indicates auto-complete. */
35 autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)),
36 completionOptions: Schema.optional(
37 Schema.NullOr(
38 Schema.Struct({
39 mergeStrategy: Schema.optional(Schema.NullOr(Schema.String)),
40 squashMerge: Schema.optional(Schema.NullOr(Schema.Boolean)),
41 }),
42 ),
43 ),
44 mergeStatus: Schema.optional(Schema.NullOr(Schema.String)),
45 createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)),
46 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))),
47 sourceRefName: TrimmedNonEmptyString,
48 targetRefName: TrimmedNonEmptyString,
49 creationDate: TrimmedNonEmptyString,
50 closedDate: Schema.optional(Schema.NullOr(Schema.String)),
51 url: Schema.optional(Schema.NullOr(Schema.String)),
52 repository: Schema.optional(
53 Schema.NullOr(
54 Schema.Struct({
55 name: Schema.optional(Schema.NullOr(Schema.String)),
56 webUrl: Schema.optional(Schema.NullOr(Schema.String)),
57 project: Schema.optional(
58 Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })),
59 ),
60 }),
61 ),
62 ),
63 _links: Schema.optional(
64 Schema.NullOr(
65 Schema.Struct({
66 web: Schema.optional(
67 Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })),
68 ),
69 }),
70 ),
71 ),
72});
73​
74const RawThreadSchema = Schema.Struct({
75 id: Schema.Int,
76 isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)),
77 threadContext: Schema.optional(
78 Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })),
79 ),
80 comments: Schema.optional(
81 Schema.NullOr(
82 Schema.Array(
83 Schema.Struct({
84 id: Schema.optional(Schema.NullOr(Schema.Int)),
85 content: Schema.optional(Schema.NullOr(Schema.String)),
86 author: Schema.optional(Schema.NullOr(RawIdentitySchema)),
87 publishedDate: Schema.optional(Schema.NullOr(Schema.String)),
88 isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)),
89 commentType: Schema.optional(Schema.NullOr(Schema.String)),
90 }),
91 ),
92 ),
93 ),
94});
95​
96const RawThreadPageSchema = Schema.Struct({
97 value: Schema.Array(Schema.Unknown),
98});
99​
100const RawViewerSchema = Schema.Struct({
101 user: Schema.optional(
102 Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })),
103 ),
104});
105​
106export interface AzureDevOpsRepositoryLocation {
107 readonly project: string;
108 readonly repository: string;
110​
111export interface AzureDevOpsPullRequest {
112 readonly number: number;
113 readonly title: string;
114 readonly url: string;
115 readonly author: PullRequestActor | null;
116 readonly headBranch: string;
117 readonly baseBranch: string;
118 readonly state: PullRequestState;
119 readonly isDraft: boolean;
120 readonly mergeability: PullRequestMergeability;
121 readonly createdAt: string;
122 readonly updatedAt: string;
123 readonly closedAt: string | null;
124 readonly body: string;
125 readonly reviewRequestLogins: ReadonlyArray<string>;
126 readonly reviewers: ReadonlyArray<PullRequestActor>;
127 readonly location: AzureDevOpsRepositoryLocation | null;
128 readonly autoMergeEnabled: boolean;
129 readonly autoMergeMethod?: PullRequestMergeMethod;
131​
132/** Logins must match the email from `az account show`. */
133function toActor(raw: Schema.Schema.Type<typeof RawIdentitySchema> | null | undefined) {
134 const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName);
135 return login === null
136 ? null
137 : { login, name: trimmed(raw?.displayName), avatarUrl: trimmed(raw?.imageUrl) };
139​
140function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState {
141 switch (raw.status?.trim().toLowerCase()) {
142 case "completed":
143 return "merged";
144 case "abandoned":
145 return "closed";
146 default:
147 return "open";
148 }
150​
151function toMergeability(value: string | null | undefined): PullRequestMergeability {
152 switch (value?.trim().toLowerCase()) {
153 case "succeeded":
154 return "mergeable";
155 case "conflicts":
156 case "failure":
157 case "rejectedbypolicy":
158 return "conflicting";
159 default:
160 return "unknown";
161 }
163​
164function toLocation(
165 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
166): AzureDevOpsRepositoryLocation | null {
167 const project = trimmed(raw.repository?.project?.name);
168 const repository = trimmed(raw.repository?.name);
169 if (project === null || repository === null) return null;
170 return { project, repository };
172​
173function toAutoMergeMethod(
174 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
175): PullRequestMergeMethod | undefined {
176 if (raw.autoCompleteSetBy == null) return undefined;
177 switch (raw.completionOptions?.mergeStrategy?.trim().toLowerCase()) {
178 case "squash":
179 return "squash";
180 case "rebase":
181 case "rebasemerge":
182 return "rebase";
183 case "nofastforward":
184 return "merge";
185 default:
186 return raw.completionOptions?.squashMerge === true ? "squash" : undefined;
187 }
189​
190function toPullRequest(
191 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
192): AzureDevOpsPullRequest | null {
193 const autoMergeMethod = toAutoMergeMethod(raw);
194 const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => {
195 const actor = toActor(reviewer);
196 return actor === null ? [] : [actor];
197 });
198 const closedAt = trimmed(raw.closedDate);
199 const url = trimmed(
200 azureDevOpsPullRequestWebUrl({
201 pullRequestId: raw.pullRequestId,
202 webLink: raw._links?.web?.href,
203 repositoryWebUrl: raw.repository?.webUrl,
204 restApiUrl: raw.url,
205 projectName: raw.repository?.project?.name,
206 repositoryName: raw.repository?.name,
207 }),
208 );
209 const headBranch = trimmed(normalizeRefName(raw.sourceRefName));
210 const baseBranch = trimmed(normalizeRefName(raw.targetRefName));
211 if (url === null || headBranch === null || baseBranch === null) return null;
212 return {
213 number: raw.pullRequestId,
214 title: raw.title,
215 url,
216 author: toActor(raw.createdBy),
217 headBranch,
218 baseBranch,
219 state: toState(raw),
220 isDraft: raw.isDraft ?? false,
221 mergeability: toMergeability(raw.mergeStatus),
222 createdAt: raw.creationDate,
223 updatedAt: closedAt ?? raw.creationDate,
224 closedAt,
225 body: raw.description ?? "",
226 reviewRequestLogins: reviewers.map((reviewer) => reviewer.login),
227 reviewers,
228 location: toLocation(raw),
229 autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null,
230 ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }),
231 };
233​
234const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));
235const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema);
236const decodePullRequest = decodeJsonResult(RawPullRequestSchema);
237const decodeThreadPage = decodeJsonResult(RawThreadPageSchema);
238const decodeThreadEntry = Schema.decodeUnknownExit(RawThreadSchema);
239const decodeViewer = decodeJsonResult(RawViewerSchema);
240​
241type DecodeFailure = Cause.Cause<Schema.SchemaError>;
242​
243export interface AzureDevOpsPullRequestBatch {
244 readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
245 readonly rawIndexes: ReadonlyArray<number>;
246 /** Count before decoding to avoid ending pagination on skipped rows. */
247 readonly rawCount: number;
249​
250export function decodePullRequestListJson(
251 raw: string,
252): Result.Result<AzureDevOpsPullRequestBatch, DecodeFailure> {
253 const decoded = decodeUnknownList(raw);
254 if (!Result.isSuccess(decoded)) {
255 return Result.fail(decoded.failure);
256 }
257 const items: AzureDevOpsPullRequest[] = [];
258 const rawIndexes: number[] = [];
259 for (const [rawIndex, entry] of decoded.success.entries()) {
260 const item = decodePullRequestEntry(entry);
261 if (Exit.isFailure(item)) continue;
262 const pullRequest = toPullRequest(item.value);
263 if (pullRequest !== null) {
264 items.push(pullRequest);
265 rawIndexes.push(rawIndex);
266 }
267 }
268 return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length });
270​
271/** Null means Azure returned insufficient data. */
272export function decodePullRequestJson(
273 raw: string,
274): Result.Result<AzureDevOpsPullRequest | null, DecodeFailure> {
275 const decoded = decodePullRequest(raw);
276 return Result.isSuccess(decoded)
277 ? Result.succeed(toPullRequest(decoded.success))
278 : Result.fail(decoded.failure);
280​
281export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> {
282 const decoded = decodeViewer(raw);
283 return Result.isSuccess(decoded)
284 ? Result.succeed(trimmed(decoded.success.user?.name))
285 : Result.fail(decoded.failure);
287​
288export function decodeThreadsJson(
289 raw: string,
290): Result.Result<ReadonlyArray<PullRequestComment>, DecodeFailure> {
291 const decoded = decodeThreadPage(raw);
292 if (!Result.isSuccess(decoded)) {
293 return Result.fail(decoded.failure);
294 }
295 const comments: PullRequestComment[] = [];
296 for (const entry of decoded.success.value) {
297 const decodedThread = decodeThreadEntry(entry);
298 if (Exit.isFailure(decodedThread)) continue;
299 const thread = decodedThread.value;
300 if (thread.isDeleted === true) continue;
301 const path = toRepositoryPath(thread.threadContext?.filePath);
302 for (const comment of thread.comments ?? []) {
303 const publishedDate = trimmed(comment.publishedDate);
304 if (
305 comment.isDeleted === true ||
306 comment.commentType?.trim().toLowerCase() === "system" ||
307 (comment.content ?? "").trim().length === 0 ||
308 publishedDate === null
309 ) {
310 continue;
311 }
312 comments.push({
313 id: `${thread.id}:${comment.id ?? 0}`,
314 kind: path === null ? "issue-comment" : "review-comment",
315 author: toActor(comment.author),
316 body: comment.content ?? "",
317 createdAt: publishedDate,
318 url: null,
319 path,
320 reviewState: null,
321 });
322 }
323 }
324 return Result.succeed(
325 comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)),
326 );
328​
329const RawIterationSchema = Schema.Struct({
330 id: Schema.Int,
331 sourceRefCommit: Schema.optional(
332 Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })),
333 ),
334 commonRefCommit: Schema.optional(
335 Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })),
336 ),
337});
338​
339const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) });
340​
341const RawChangeEntrySchema = Schema.Struct({
342 changeType: Schema.optional(Schema.NullOr(Schema.String)),
343 sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)),
344 originalPath: Schema.optional(Schema.NullOr(Schema.String)),
345 item: Schema.optional(
346 Schema.NullOr(
347 Schema.Struct({
348 path: Schema.optional(Schema.NullOr(Schema.String)),
349 objectId: Schema.optional(Schema.NullOr(Schema.String)),
350 originalObjectId: Schema.optional(Schema.NullOr(Schema.String)),
351 isFolder: Schema.optional(Schema.NullOr(Schema.Boolean)),
352 gitObjectType: Schema.optional(Schema.NullOr(Schema.String)),
353 }),
354 ),
355 ),
356});
357​
358const RawChangePageSchema = Schema.Struct({
359 changeEntries: Schema.Array(Schema.Unknown),
360 nextSkip: Schema.optional(Schema.NullOr(Schema.Number)),
361});
362​
363const RawItemContentSchema = Schema.Struct({
364 content: Schema.optional(Schema.NullOr(Schema.String)),
365 contentMetadata: Schema.optional(
366 Schema.NullOr(Schema.Struct({ isBinary: Schema.optional(Schema.NullOr(Schema.Boolean)) })),
367 ),
368});
369​
370export interface AzureDevOpsIteration {
371 readonly id: number;
372 readonly headCommit: string;
373 readonly mergeBaseCommit: string;
375​
376export interface AzureDevOpsChangeEntry {
377 readonly path: string;
378 readonly oldPath: string;
379 readonly changeKind: "new" | "deleted" | "change" | "rename-pure" | "rename-changed";
380 readonly objectId: string | null;
381 readonly originalObjectId: string | null;
383​
384export interface AzureDevOpsChangePage {
385 readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>;
386 readonly nextSkip: number | null;
388​
389export interface AzureDevOpsItemContent {
390 readonly contents: string;
391 readonly isBinary: boolean;
393​
394const decodeIterationPage = decodeJsonResult(RawIterationPageSchema);
395const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema);
396const decodeChangePage = decodeJsonResult(RawChangePageSchema);
397const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema);
398const decodeItemContent = decodeJsonResult(RawItemContentSchema);
399​
400/** Preserve whitespace to match patch paths and viewed marks. */
401function toRepositoryPath(value: string | null | undefined): string | null {
402 if (value === undefined || value === null) return null;
403 const path = value.replace(/^\/+/, "");
404 return path.length === 0 ? null : path;
406​
407function toChangeKind(
408 raw: string | null | undefined,
409 renamed: boolean,
410): AzureDevOpsChangeEntry["changeKind"] {
411 const parts = new Set(
412 (raw ?? "")
413 .toLowerCase()
414 .split(",")
415 .map((part) => part.trim())
416 .filter((part) => part.length > 0),
417 );
418 if (parts.has("delete")) return "deleted";
419 if (renamed || parts.has("rename")) return parts.has("edit") ? "rename-changed" : "rename-pure";
420 if (parts.has("add")) return "new";
421 return "change";
423​
424export function decodeIterationsJson(
425 raw: string,
426): Result.Result<ReadonlyArray<AzureDevOpsIteration>, DecodeFailure> {
427 const decoded = decodeIterationPage(raw);
428 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
429 const iterations: AzureDevOpsIteration[] = [];
430 for (const entry of decoded.success.value) {
431 const decodedIteration = decodeIterationEntry(entry);
432 if (Exit.isFailure(decodedIteration)) continue;
433 const iteration = decodedIteration.value;
434 const headCommit = trimmed(iteration.sourceRefCommit?.commitId);
435 const mergeBaseCommit = trimmed(iteration.commonRefCommit?.commitId);
436 if (headCommit === null || mergeBaseCommit === null) continue;
437 iterations.push({ id: iteration.id, headCommit, mergeBaseCommit });
438 }
439 return Result.succeed(iterations.toSorted((left, right) => left.id - right.id));
441​
442export function decodeIterationChangesJson(
443 raw: string,
444): Result.Result<AzureDevOpsChangePage, DecodeFailure> {
445 const decoded = decodeChangePage(raw);
446 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
447 const changes: AzureDevOpsChangeEntry[] = [];
448 for (const entry of decoded.success.changeEntries) {
449 const decodedChange = decodeChangeEntry(entry);
450 if (Exit.isFailure(decodedChange)) continue;
451 const change = decodedChange.value;
452 const path = toRepositoryPath(change.item?.path);
453 if (path === null) continue;
454 if (change.item?.isFolder === true) continue;
455 if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue;
456 // Azure uses either field for renames, depending on route/version.
457 const oldPath =
458 toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path;
459 changes.push({
460 path,
461 oldPath,
462 changeKind: toChangeKind(change.changeType, oldPath !== path),
463 objectId: trimmed(change.item?.objectId),
464 originalObjectId: trimmed(change.item?.originalObjectId),
465 });
466 }
467 const nextSkip = decoded.success.nextSkip ?? null;
468 return Result.succeed({
469 changes,
470 nextSkip: nextSkip !== null && Number.isSafeInteger(nextSkip) && nextSkip > 0 ? nextSkip : null,
471 });
473​
474/** Azure returns an empty body for absent files. Binary status comes from its metadata. */
475export function decodeItemContentJson(
476 raw: string,
477): Result.Result<AzureDevOpsItemContent, DecodeFailure> {
478 const decoded = decodeItemContent(raw);
479 return Result.isSuccess(decoded)
480 ? Result.succeed({
481 contents: decoded.success.content ?? "",
482 isBinary: decoded.success.contentMetadata?.isBinary === true,
483 })
484 : Result.fail(decoded.failure);