940 lines · 34.7 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,  PullRequestCheck,  PullRequestCheckStatus,  PullRequestComment,  PullRequestCommit,  PullRequestLabel,  PullRequestMergeability,  PullRequestMergeCapabilities,  PullRequestReaction,  PullRequestReactionContent,  PullRequestReviewThread,  PullRequestReviewerCandidate,  PullRequestState,} from "@t3tools/contracts";import { TrimmedNonEmptyString } from "@t3tools/contracts";import { decodeJsonResult } from "@t3tools/shared/schemaJson"; /** * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that * adds a pipeline status or a merge status must not fail the whole payload. */const RawUserSchema = Schema.Struct({  /**   * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the   * id is carried alongside the handle rather than looked up again when a review is asked for.   */  id: Schema.optional(Schema.Int),  username: Schema.String,  name: Schema.optional(Schema.NullOr(Schema.String)),  avatar_url: Schema.optional(Schema.NullOr(Schema.String)),}); const RawPipelineSchema = Schema.Struct({  status: Schema.optional(Schema.NullOr(Schema.String)),  web_url: Schema.optional(Schema.NullOr(Schema.String)),  source: Schema.optional(Schema.NullOr(Schema.String)),}); const RawMergeRequestSchema = Schema.Struct({  iid: Schema.Int,  title: Schema.String,  web_url: Schema.String,  description: Schema.optional(Schema.NullOr(Schema.String)),  author: Schema.optional(Schema.NullOr(RawUserSchema)),  source_branch: Schema.String,  target_branch: Schema.String,  state: Schema.optional(Schema.NullOr(Schema.String)),  draft: Schema.optional(Schema.Boolean),  work_in_progress: Schema.optional(Schema.Boolean),  merge_status: Schema.optional(Schema.NullOr(Schema.String)),  has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)),  created_at: Schema.String,  updated_at: Schema.String,  merged_at: Schema.optional(Schema.NullOr(Schema.String)),  closed_at: Schema.optional(Schema.NullOr(Schema.String)),  reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))),  labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),  // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded.  changes_count: Schema.optional(Schema.NullOr(Schema.String)),  head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)),  /**   * What the requesting account may do, which only the single-merge-request endpoint carries.   * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts   * for the role, the approval rules and a protected target branch — none of which a project's   * access level on its own would tell apart.   */  user: Schema.optional(    Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })),  ),  /**   * Whether GitLab is holding this merge request to merge it once its pipeline goes green.   * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also   * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so   * either one saying yes is a yes.   */  merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)),  auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)),  /**   * How far the target branch has moved on since this one left it, which is the same number   * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab   * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a   * single merge request — a list never carries it, however it is asked for.   */  diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)),}); const RawNoteSchema = Schema.Struct({  id: Schema.Int,  body: Schema.optional(Schema.NullOr(Schema.String)),  author: Schema.optional(Schema.NullOr(RawUserSchema)),  created_at: Schema.String,  /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */  system: Schema.optional(Schema.Boolean),  type: Schema.optional(Schema.NullOr(Schema.String)),  position: Schema.optional(    Schema.NullOr(      Schema.Struct({        new_path: Schema.optional(Schema.NullOr(Schema.String)),        old_path: Schema.optional(Schema.NullOr(Schema.String)),      }),    ),  ),}); /** * A discussion note carrying its place in the diff, which is the shape the whole thread view * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a * discussion resolved once every resolvable note in it is. */const RawDiscussionNoteSchema = Schema.Struct({  id: Schema.Int,  body: Schema.optional(Schema.NullOr(Schema.String)),  author: Schema.optional(Schema.NullOr(RawUserSchema)),  created_at: Schema.String,  system: Schema.optional(Schema.Boolean),  resolvable: Schema.optional(Schema.Boolean),  resolved: Schema.optional(Schema.NullOr(Schema.Boolean)),  position: Schema.optional(    Schema.NullOr(      Schema.Struct({        position_type: Schema.optional(Schema.NullOr(Schema.String)),        new_path: Schema.optional(Schema.NullOr(Schema.String)),        old_path: Schema.optional(Schema.NullOr(Schema.String)),        new_line: Schema.optional(Schema.NullOr(Schema.Int)),        old_line: Schema.optional(Schema.NullOr(Schema.Int)),      }),    ),  ),}); const RawDiscussionSchema = Schema.Struct({  id: Schema.String,  notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))),}); const RawDiffRefsSchema = Schema.Struct({  diff_refs: Schema.optional(    Schema.NullOr(      Schema.Struct({        base_sha: Schema.String,        head_sha: Schema.String,        start_sha: Schema.String,      }),    ),  ),}); const RawCommitSchema = Schema.Struct({  id: TrimmedNonEmptyString,  title: Schema.optional(Schema.NullOr(Schema.String)),  committed_date: Schema.optional(Schema.NullOr(Schema.String)),  created_at: Schema.optional(Schema.NullOr(Schema.String)),  parent_ids: Schema.optional(Schema.Array(Schema.String)),  author_name: Schema.optional(Schema.NullOr(Schema.String)),  author_email: Schema.optional(Schema.NullOr(Schema.String)),  stats: Schema.optional(    Schema.NullOr(      Schema.Struct({        additions: Schema.optional(Schema.Int),        deletions: Schema.optional(Schema.Int),      }),    ),  ),}); const RawDiffSchema = Schema.Struct({  old_path: Schema.String,  new_path: Schema.String,  a_mode: Schema.optional(Schema.NullOr(Schema.String)),  b_mode: Schema.optional(Schema.NullOr(Schema.String)),  new_file: Schema.optional(Schema.Boolean),  renamed_file: Schema.optional(Schema.Boolean),  deleted_file: Schema.optional(Schema.Boolean),  diff: Schema.optional(Schema.NullOr(Schema.String)),  /** GitLab omits the hunks for a file it considers too large to inline. */  too_large: Schema.optional(Schema.NullOr(Schema.Boolean)),  /** And for one it collapsed, which withholds them the same way. */  collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)),}); const RawViewerSchema = Schema.Struct({  username: Schema.optional(Schema.NullOr(Schema.String)),}); /** A GitLab project settles on one merge strategy plus an optional squash. */const RawProjectMergeSettingsSchema = Schema.Struct({  merge_method: Schema.optional(Schema.NullOr(Schema.String)),  squash_option: Schema.optional(Schema.NullOr(Schema.String)),}); export interface GitLabMergeRequestListItem {  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;  /**   * GitLab reports neither added nor removed lines on a merge request, so both stay zero and   * the surface omits the stat. The Code tab counts them from the patch it already fetched.   */  readonly additions: number;  readonly deletions: number;  readonly createdAt: string;  readonly updatedAt: string;  readonly reviewRequestLogins: ReadonlyArray<string>;  readonly labels: ReadonlyArray<PullRequestLabel>;} export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem {  readonly body: string;  readonly changedFiles: number;  readonly mergedAt: string | null;  readonly closedAt: string | null;  readonly reviewers: ReadonlyArray<PullRequestActor>;  readonly checks: ReadonlyArray<PullRequestCheck>;  /** False only where GitLab said so; an answer without the field leaves merging permitted. */  readonly viewerCanMerge: boolean;  /** The reviewers as GitLab addresses them, which is what writing the set back takes. */  readonly reviewerIds: ReadonlyArray<number>;  /** Absent where GitLab named neither auto-merge field, which is not the same as off. */  readonly autoMergeEnabled?: boolean;  /**   * Absent where GitLab did not count, which is not the same as a branch that has nothing behind   * it: an install too old to answer must not be read as saying the branch is current.   */  readonly divergedCommits?: number;} function trimmed(value: string | null | undefined): string | null {  const text = value?.trim() ?? "";  return text.length > 0 ? text : null;} function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) {  const login = trimmed(raw?.username);  return login === null    ? null    : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) };} function toState(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): PullRequestState {  if (trimmed(raw.merged_at) !== null) return "merged";  switch (raw.state?.trim().toLowerCase()) {    case "merged":      return "merged";    case "closed":      return "closed";    default:      // `locked` is an open merge request whose discussion is locked.      return "open";  }} function toMergeability(  raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,): PullRequestMergeability {  if (raw.has_conflicts === true) return "conflicting";  switch (raw.merge_status?.trim().toLowerCase()) {    case "can_be_merged":      return "mergeable";    case "cannot_be_merged":      return "conflicting";    default:      // `unchecked` and `checking` mean GitLab has not finished the merge check yet.      return "unknown";  }} function toLabels(raw: ReadonlyArray<string> | null | undefined): ReadonlyArray<PullRequestLabel> {  // GitLab returns label names only, so there is no colour to carry.  return (raw ?? []).flatMap((label) => {    const name = trimmed(label);    return name === null ? [] : [{ name, color: null }];  });} /** * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is * the floor either way, which reads better than dropping an uncounted change set to nothing. */function toChangedFiles(value: string | null | undefined): number {  const parsed = Number.parseInt(value?.trim() ?? "", 10);  return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;} function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus {  switch (value?.trim().toLowerCase()) {    case "success":      return "success";    case "failed":      return "failure";    case "canceled":    case "cancelling":      return "cancelled";    case "skipped":      return "skipped";    // A pipeline waiting on a person is not progress, and it is not a failure either.    case "manual":    case "scheduled":      return "neutral";    default:      return "pending";  }} /** * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one * check. The jobs behind it stay one click away through the pipeline URL. */function toChecks(  raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,): ReadonlyArray<PullRequestCheck> {  const pipeline = raw.head_pipeline;  if (!pipeline) return [];  return [    {      name: "Pipeline",      status: toPipelineStatus(pipeline.status),      description: trimmed(pipeline.source),      url: trimmed(pipeline.web_url),    },  ];} function toListItem(  raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,): GitLabMergeRequestListItem {  return {    number: raw.iid,    title: raw.title,    url: raw.web_url,    author: toActor(raw.author),    headBranch: raw.source_branch,    baseBranch: raw.target_branch,    state: toState(raw),    isDraft: raw.draft ?? raw.work_in_progress ?? false,    mergeability: toMergeability(raw),    additions: 0,    deletions: 0,    createdAt: raw.created_at,    updatedAt: raw.updated_at,    reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => {      const login = trimmed(reviewer.username);      return login === null ? [] : [login];    }),    labels: toLabels(raw.labels),  };} function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLabMergeRequestDetail {  const listItem = toListItem(raw);  const autoMerge =    raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null      ? undefined      : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true;  return {    ...listItem,    body: raw.description ?? "",    changedFiles: toChangedFiles(raw.changes_count),    mergedAt: trimmed(raw.merged_at),    closedAt: trimmed(raw.closed_at),    // Built from the reviewers themselves rather than from their logins, so the avatars survive.    reviewers: (raw.reviewers ?? []).flatMap((reviewer) => {      const actor = toActor(reviewer);      return actor === null ? [] : [actor];    }),    checks: toChecks(raw),    viewerCanMerge: raw.user?.can_merge !== false,    reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) =>      reviewer.id === undefined ? [] : [reviewer.id],    ),    ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }),    ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }),  };} const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema);const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema);const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema);const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema);const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema);const decodeCommit = decodeJsonResult(RawCommitSchema);const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema);const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema);const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema);const decodeViewer = decodeJsonResult(RawViewerSchema);const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema); type DecodeFailure = Cause.Cause<Schema.SchemaError>; export interface GitLabProjectUsers {  readonly candidates: ReadonlyArray<PullRequestReviewerCandidate>;  /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */  readonly rawCount: number;} export interface GitLabMergeRequestListBatch {  readonly items: ReadonlyArray<GitLabMergeRequestListItem>;  /** Zero-based positions of the decoded items in GitLab's raw page. */  readonly rawIndexes: ReadonlyArray<number>;  /** Rows GitLab 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: one unexpected merge request *  must not blank the whole list. */export function decodeMergeRequestListJson(  raw: string,): Result.Result<GitLabMergeRequestListBatch, DecodeFailure> {  const decoded = decodeUnknownList(raw);  if (!Result.isSuccess(decoded)) {    return Result.fail(decoded.failure);  }  const items: GitLabMergeRequestListItem[] = [];  const rawIndexes: number[] = [];  for (const [rawIndex, entry] of decoded.success.entries()) {    const item = decodeMergeRequestEntry(entry);    if (Exit.isSuccess(item)) {      items.push(toListItem(item.value));      rawIndexes.push(rawIndex);    }  }  return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length });} export function decodeMergeRequestDetailJson(  raw: string,): Result.Result<GitLabMergeRequestDetail, DecodeFailure> {  const decoded = decodeMergeRequest(raw);  return Result.isSuccess(decoded)    ? Result.succeed(toDetail(decoded.success))    : Result.fail(decoded.failure);} export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> {  const decoded = decodeViewer(raw);  return Result.isSuccess(decoded)    ? Result.succeed(trimmed(decoded.success.username))    : Result.fail(decoded.failure);} /** * The people with access to the project, which `GET /projects/:id/users` answers with — the same * list GitLab's own reviewer field is filled from, including the members a group above the project * lends it. A malformed row is skipped rather than failing the menu it belongs to. * * Nobody is marked requested here: who has been asked lives on the merge request, and only the * caller holds both. */export function decodeProjectUsersJson(  raw: string,): Result.Result<GitLabProjectUsers, DecodeFailure> {  const decoded = decodeUnknownList(raw);  if (!Result.isSuccess(decoded)) {    return Result.fail(decoded.failure);  }  const candidates: PullRequestReviewerCandidate[] = [];  for (const entry of decoded.success) {    const user = decodeUserEntry(entry);    if (Exit.isFailure(user) || user.value.id === undefined) continue;    const actor = toActor(user.value);    if (actor === null) continue;    candidates.push({      ...actor,      id: String(user.value.id),      kind: "user",      isRequested: false,    });  }  return Result.succeed({ candidates, rawCount: decoded.success.length });} /** * GitLab settles the strategy per project rather than offering all three per merge request: * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a * separate switch. An unrecognized setting offers nothing rather than offering a strategy the * project forbids. */export function decodeProjectMergeCapabilitiesJson(  raw: string,): Result.Result<PullRequestMergeCapabilities, DecodeFailure> {  const decoded = decodeProjectMergeSettings(raw);  if (!Result.isSuccess(decoded)) {    return Result.fail(decoded.failure);  }  const mergeMethod = decoded.success.merge_method?.trim().toLowerCase();  const squashOption = decoded.success.squash_option?.trim().toLowerCase();  return Result.succeed({    merge: mergeMethod === "merge",    // Both semi-linear and fast-forward histories are reached by rebasing onto the target.

Lines 1–500 of 940.