import * as Cause from "effect/Cause"; import * as Exit from "effect/Exit"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { PullRequestActor, PullRequestComment, PullRequestMergeMethod, PullRequestMergeability, PullRequestState, } from "@t3tools/contracts"; import { TrimmedNonEmptyString } from "@t3tools/contracts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { azureDevOpsOrganizationBaseFromRestApiUrl, azureDevOpsPullRequestWebUrl, } from "../sourceControl/azureDevOpsPullRequests.ts"; /** * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as * the other hosts: a new merge status must not fail a whole payload. Every field beyond the * identity is optional, because `az repos pr` returns rather more or less of the REST object * depending on the command. */ const RawIdentitySchema = Schema.Struct({ displayName: Schema.optional(Schema.NullOr(Schema.String)), /** An email or UPN, which is what `az account show` reports for the signed-in user. */ uniqueName: Schema.optional(Schema.NullOr(Schema.String)), imageUrl: Schema.optional(Schema.NullOr(Schema.String)), }); const RawPullRequestSchema = Schema.Struct({ pullRequestId: Schema.Int, title: Schema.String, description: Schema.optional(Schema.NullOr(Schema.String)), status: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), /** * Who armed auto-complete, which is the only thing Azure says about it: the field carries an * identity while the pull request is set to complete on its own, and Azure leaves it out * entirely once nobody has. So its presence is the answer, and there is no third state. */ autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), completionOptions: Schema.optional( Schema.NullOr( Schema.Struct({ mergeStrategy: Schema.optional(Schema.NullOr(Schema.String)), squashMerge: Schema.optional(Schema.NullOr(Schema.Boolean)), }), ), ), mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))), // Required, and required to be non-empty: the wire contract will not carry a change request // without a branch or a created time, so a row missing one is skipped rather than breaking the // response it travels in. sourceRefName: TrimmedNonEmptyString, targetRefName: TrimmedNonEmptyString, creationDate: TrimmedNonEmptyString, closedDate: Schema.optional(Schema.NullOr(Schema.String)), url: Schema.optional(Schema.NullOr(Schema.String)), repository: Schema.optional( Schema.NullOr( Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)), webUrl: Schema.optional(Schema.NullOr(Schema.String)), project: Schema.optional( Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), ), }), ), ), _links: Schema.optional( Schema.NullOr( Schema.Struct({ web: Schema.optional( Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), ), }), ), ), }); /** A pull request thread, which is how Azure keeps its conversation. */ const RawThreadSchema = Schema.Struct({ id: Schema.Int, isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), threadContext: Schema.optional( Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })), ), comments: Schema.optional( Schema.NullOr( Schema.Array( Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.Int)), content: Schema.optional(Schema.NullOr(Schema.String)), author: Schema.optional(Schema.NullOr(RawIdentitySchema)), publishedDate: Schema.optional(Schema.NullOr(Schema.String)), isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), /** `system` marks the notes Azure writes itself, which are events, not comments. */ commentType: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), ), }); const RawThreadPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown), }); const RawViewerSchema = Schema.Struct({ user: Schema.optional( Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), ), }); export interface AzureDevOpsPullRequest { readonly number: number; readonly title: string; readonly url: string; readonly author: PullRequestActor | null; readonly headBranch: string; readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; readonly mergeability: PullRequestMergeability; readonly createdAt: string; /** * Azure records no last-touched time on a pull request, so the closing time stands in where * there is one and the creation time otherwise. The same fallback the rest of the app uses. */ readonly updatedAt: string; readonly closedAt: string | null; readonly body: string; readonly reviewRequestLogins: ReadonlyArray; readonly reviewers: ReadonlyArray; /** Where this pull request's threads live, when Azure said enough to work it out. */ readonly threadsUrl: string | null; /** Whether Azure is set to complete this on its own once its policies pass. */ readonly autoMergeEnabled: boolean; /** The completion strategy Azure stored with auto-complete, where it reported one. */ readonly autoMergeMethod?: PullRequestMergeMethod; } function trimmed(value: string | null | undefined): string | null { const text = value?.trim() ?? ""; return text.length > 0 ? text : null; } function normalizeRefName(refName: string): string { return refName.trim().replace(/^refs\/heads\//, ""); } /** A login has to compare against `az account show`, which reports an email. */ function toActor(raw: Schema.Schema.Type | null | undefined) { const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName); return login === null ? null : { login, name: trimmed(raw?.displayName), avatarUrl: trimmed(raw?.imageUrl) }; } function toState(raw: Schema.Schema.Type): PullRequestState { switch (raw.status?.trim().toLowerCase()) { case "completed": return "merged"; case "abandoned": return "closed"; default: return "open"; } } function toMergeability(value: string | null | undefined): PullRequestMergeability { switch (value?.trim().toLowerCase()) { case "succeeded": return "mergeable"; case "conflicts": case "failure": case "rejectedbypolicy": return "conflicting"; default: // `queued` and `notSet` mean Azure has not finished checking. return "unknown"; } } /** * The REST collection a pull request's threads hang from. Built from what Azure returned rather * than from the local remote, whose shape differs between the modern, legacy and SSH forms. */ function toThreadsUrl(raw: Schema.Schema.Type): string | null { const base = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url); const project = trimmed(raw.repository?.project?.name); const repository = trimmed(raw.repository?.name); if (base === null || project === null || repository === null) return null; return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repository)}/pullRequests/${raw.pullRequestId}/threads`; } function toAutoMergeMethod( raw: Schema.Schema.Type, ): PullRequestMergeMethod | undefined { if (raw.autoCompleteSetBy == null) return undefined; switch (raw.completionOptions?.mergeStrategy?.trim().toLowerCase()) { case "squash": return "squash"; case "rebase": case "rebasemerge": return "rebase"; case "nofastforward": return "merge"; default: return raw.completionOptions?.squashMerge === true ? "squash" : undefined; } } /** * Null when Azure said too little to place the pull request: a row with no browser url and no * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract * refuses to carry it either. */ function toPullRequest( raw: Schema.Schema.Type, ): AzureDevOpsPullRequest | null { const autoMergeMethod = toAutoMergeMethod(raw); const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { const actor = toActor(reviewer); return actor === null ? [] : [actor]; }); const closedAt = trimmed(raw.closedDate); const url = trimmed( azureDevOpsPullRequestWebUrl({ pullRequestId: raw.pullRequestId, webLink: raw._links?.web?.href, repositoryWebUrl: raw.repository?.webUrl, restApiUrl: raw.url, projectName: raw.repository?.project?.name, repositoryName: raw.repository?.name, }), ); const headBranch = trimmed(normalizeRefName(raw.sourceRefName)); const baseBranch = trimmed(normalizeRefName(raw.targetRefName)); if (url === null || headBranch === null || baseBranch === null) return null; return { number: raw.pullRequestId, title: raw.title, url, author: toActor(raw.createdBy), headBranch, baseBranch, state: toState(raw), isDraft: raw.isDraft ?? false, mergeability: toMergeability(raw.mergeStatus), createdAt: raw.creationDate, updatedAt: closedAt ?? raw.creationDate, closedAt, body: raw.description ?? "", reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), reviewers, threadsUrl: toThreadsUrl(raw), autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), }; } const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); const decodePullRequest = decodeJsonResult(RawPullRequestSchema); const decodeThreadPage = decodeJsonResult(RawThreadPageSchema); const decodeThreadEntry = Schema.decodeUnknownExit(RawThreadSchema); const decodeViewer = decodeJsonResult(RawViewerSchema); type DecodeFailure = Cause.Cause; export interface AzureDevOpsPullRequestBatch { readonly items: ReadonlyArray; /** Zero-based positions of the decoded items in Azure's raw page. */ readonly rawIndexes: ReadonlyArray; /** Rows Azure returned, counted before decoding, so a skipped row cannot hide a next page. */ readonly rawCount: number; } /** Malformed entries are skipped rather than failing the batch, as on the other hosts. */ export function decodePullRequestListJson( raw: string, ): Result.Result { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const items: AzureDevOpsPullRequest[] = []; const rawIndexes: number[] = []; for (const [rawIndex, entry] of decoded.success.entries()) { const item = decodePullRequestEntry(entry); if (Exit.isFailure(item)) continue; const pullRequest = toPullRequest(item.value); if (pullRequest !== null) { items.push(pullRequest); rawIndexes.push(rawIndex); } } return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); } /** Null carries "Azure answered, but with too little to use", which the caller reports. */ export function decodePullRequestJson( raw: string, ): Result.Result { const decoded = decodePullRequest(raw); return Result.isSuccess(decoded) ? Result.succeed(toPullRequest(decoded.success)) : Result.fail(decoded.failure); } /** `az account show --query user` reports the signed-in account, whose name is an email. */ export function decodeViewerJson(raw: string): Result.Result { const decoded = decodeViewer(raw); return Result.isSuccess(decoded) ? Result.succeed(trimmed(decoded.success.user?.name)) : Result.fail(decoded.failure); } /** * Azure keeps its conversation as threads of comments, and every one of them is a remark * somebody wrote: a reply under a thread is as much of the conversation as the line that opened * it. A thread pinned to a file is a line-level review comment. * * Azure answers the whole thread collection in one response, with no cursor and no page to * follow, so what this returns is everything the host has. */ export function decodeThreadsJson( raw: string, ): Result.Result, DecodeFailure> { const decoded = decodeThreadPage(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const comments: PullRequestComment[] = []; for (const entry of decoded.success.value) { const decodedThread = decodeThreadEntry(entry); if (Exit.isFailure(decodedThread)) continue; const thread = decodedThread.value; if (thread.isDeleted === true) continue; const path = trimmed(thread.threadContext?.filePath); for (const comment of thread.comments ?? []) { const publishedDate = trimmed(comment.publishedDate); if ( comment.isDeleted === true || comment.commentType?.trim().toLowerCase() === "system" || (comment.content ?? "").trim().length === 0 || publishedDate === null ) { continue; } comments.push({ id: `${thread.id}:${comment.id ?? 0}`, kind: path === null ? "issue-comment" : "review-comment", author: toActor(comment.author), body: comment.content ?? "", createdAt: publishedDate, url: null, path, reviewState: null, }); } } return Result.succeed( comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), ); }