372 lines · 13.8 KB · TypeScript
RawDownload
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<string>;  readonly reviewers: ReadonlyArray<PullRequestActor>;  /** 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<typeof RawIdentitySchema> | 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<typeof RawPullRequestSchema>): 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<typeof RawPullRequestSchema>): 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<typeof RawPullRequestSchema>,): 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<typeof RawPullRequestSchema>,): 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<Schema.SchemaError>; export interface AzureDevOpsPullRequestBatch {  readonly items: ReadonlyArray<AzureDevOpsPullRequest>;  /** Zero-based positions of the decoded items in Azure's raw page. */  readonly rawIndexes: ReadonlyArray<number>;  /** 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<AzureDevOpsPullRequestBatch, DecodeFailure> {  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<AzureDevOpsPullRequest | null, DecodeFailure> {  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<string | null, DecodeFailure> {  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<ReadonlyArray<PullRequestComment>, 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)),  );}