1153 lines · 42.3 KB · TypeScript
RawDownload
import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import type {  PullRequestAction,  PullRequestComment,  PullRequestCommit,  PullRequestInvolvement,  PullRequestListState,  PullRequestMergeCapabilities,  PullRequestMergeMethod,  PullRequestReviewCommentDraft,  PullRequestReviewThread,  PullRequestReviewVerdict,  PullRequestReviewerCandidateList,} from "@t3tools/contracts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts";import {  decodeCommitDiffRefsJson,  decodeCommitsJson,  decodeDiffRefsJson,  decodeDiscussionsJson,  decodeMergeRequestDetailJson,  decodeMergeRequestDiffsJson,  decodeMergeRequestListJson,  decodeNotesJson,  decodeProjectMergeCapabilitiesJson,  decodeProjectUsersJson,  decodeViewerJson,  type GitLabDiffRefs,  type GitLabMergeRequestDetail,  type GitLabMergeRequestListItem,  type GitLabProjectUsers,} from "./gitLabMergeRequestJson.ts";import type { ProviderListCursor } from "./PullRequestProvider.ts"; /** * Names the read that produced unusable output, so a failure reports the call it came from * rather than borrowing another operation's message. */export class GitLabMergeRequestReadError extends Schema.TaggedErrorClass<GitLabMergeRequestReadError>()(  "GitLabMergeRequestReadError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,    operation: Schema.String,    cause: Schema.Defect(),  },) {  get detail(): string {    return `GitLab CLI returned an unreadable ${this.operation} response.`;  }   override get message(): string {    return `GitLab CLI failed in ${this.operation}: ${this.detail}`;  }} /** Not a decode failure: glab answered, the account it answered for just has no username. */export class GitLabViewerUnavailableError extends Schema.TaggedErrorClass<GitLabViewerUnavailableError>()(  "GitLabViewerUnavailableError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,  },) {  get detail(): string {    return "GitLab CLI returned no username for the authenticated account.";  }   override get message(): string {    return `GitLab CLI failed in getViewerUsername: ${this.detail}`;  }} /** Not a decode failure: GitLab answered, the merge request just has no revisions to place a *  comment against. */export class GitLabDiffRefsUnavailableError extends Schema.TaggedErrorClass<GitLabDiffRefsUnavailableError>()(  "GitLabDiffRefsUnavailableError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,    number: Schema.Int,  },) {  get detail(): string {    return "The merge request reported no diff revisions.";  }   override get message(): string {    return `GitLab CLI failed in getDiffRefs: ${this.detail}`;  }} /** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */export class GitLabDiffCursorError extends Schema.TaggedErrorClass<GitLabDiffCursorError>()(  "GitLabDiffCursorError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,  },) {  get detail(): string {    return "The diff cursor was not one this merge request handed out.";  }   override get message(): string {    return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`;  }} /** Not a decode failure: the reader named a commit that is not a sha this project could hold. */export class GitLabDiffCommitError extends Schema.TaggedErrorClass<GitLabDiffCommitError>()(  "GitLabDiffCommitError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,  },) {  get detail(): string {    return "The named commit was not a commit sha.";  }   override get message(): string {    return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`;  }} /** The commit exists and decoded, but it has no parent to use as the old revision. */export class GitLabDiffCommitParentUnavailableError extends Schema.TaggedErrorClass<GitLabDiffCommitParentUnavailableError>()(  "GitLabDiffCommitParentUnavailableError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,    commit: Schema.String,  },) {  get detail(): string {    return `Commit ${this.commit} reported no parent revision.`;  }   override get message(): string {    return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`;  }} /** A blob exists, but expanding it would be unsafe or would not produce text. */export class GitLabDiffFileContentsUnavailableError extends Schema.TaggedErrorClass<GitLabDiffFileContentsUnavailableError>()(  "GitLabDiffFileContentsUnavailableError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,    path: Schema.String,    reason: Schema.Literals(["oversized", "binary"]),  },) {  get detail(): string {    return this.reason === "oversized"      ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.`      : `The diff file '${this.path}' is binary.`;  }   override get message(): string {    return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`;  }} export type GitLabPullRequestCliError =  | GitLabCli.GitLabCliError  | GitLabMergeRequestReadError  | GitLabDiffCursorError  | GitLabDiffCommitError  | GitLabDiffCommitParentUnavailableError  | GitLabDiffFileContentsUnavailableError  | GitLabDiffRefsUnavailableError  | GitLabViewerUnavailableError; /** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */const MAX_PAGE_SIZE = 100;/** Commit history is read one page deep; the rest of a long history stays on GitLab. */const COMMIT_PAGE_SIZE = 100;/** * Pages of the conversation to follow before it is reported as truncated. GitLab caps a page at * a hundred, so this is a thousand notes and a thousand discussions — more than any merge * request a person is reading holds, and a walk that ends whatever the host has. */const CONVERSATION_PAGES = 10;const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;const DIFF_TIMEOUT_MS = 60_000;const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; export interface GitLabMergeRequestListBatch {  readonly items: ReadonlyArray<GitLabMergeRequestListItem>;  readonly truncated: boolean;  /** Raw GitLab rows consumed to produce this page, including malformed rows. */  readonly cursorAdvance: number;} export interface GitLabMergeRequestDiffSlice {  readonly patch: string;  /** Files in this slice had their hunks withheld, as opposed to there being more slices. */  readonly truncated: boolean;  /** Where the next slice starts, or null once the patch is whole. */  readonly nextCursor: string | null;} export class GitLabPullRequestCli extends Context.Service<  GitLabPullRequestCli,  {    readonly getViewerUsername: (input: {      readonly cwd: string;    }) => Effect.Effect<string, GitLabPullRequestCliError>;     readonly listMergeRequests: (input: {      readonly cwd: string;      readonly repository: string;      readonly state: PullRequestListState;      readonly involvement: PullRequestInvolvement;      readonly viewer: string;      readonly limit: number;      /** Free text for GitLab's own `search`, which matches title and description. */      readonly query?: string | undefined;      /** Where to carry on from in GitLab's stable update-ordered row set. */      readonly cursor?: ProviderListCursor | undefined;    }) => Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError>;     readonly getMergeRequestDetail: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;    }) => Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError>;     readonly listNotes: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;    }) => Effect.Effect<      { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean },      GitLabPullRequestCliError    >;     readonly listCommits: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;    }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, GitLabPullRequestCliError>;     readonly getMergeRequestDiff: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      /** Absent asks for the first slice; anything else is a cursor a slice handed back. */      readonly cursor?: string | undefined;      /** One commit's own changes, rather than everything the merge request carries. */      readonly commit?: string | undefined;    }) => Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError>;     readonly getMergeRequestDiffFileContents: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly commit?: string | undefined;      readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted";      readonly oldPath: string;      readonly newPath: string;    }) => Effect.Effect<      { readonly oldContents: string; readonly newContents: string },      GitLabPullRequestCliError    >;     readonly getProjectMergeCapabilities: (input: {      readonly cwd: string;      readonly repository: string;    }) => Effect.Effect<PullRequestMergeCapabilities, GitLabPullRequestCliError>;     /**     * Who this merge request may be sent to, and who it has already been sent to. Two reads at     * once, because GitLab keeps the people with access on the project and the reviewers on the     * merge request, and neither answers for the other.     */    readonly listReviewerCandidates: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;    }) => Effect.Effect<PullRequestReviewerCandidateList, GitLabPullRequestCliError>;     readonly setReviewerRequest: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly reviewers: ReadonlyArray<{ readonly id: string }>;      readonly requested: boolean;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     readonly runMergeRequestAction: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly action: PullRequestAction;      readonly mergeMethod?: PullRequestMergeMethod;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     readonly commentOnMergeRequest: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly body: string;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     readonly listDiscussions: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;    }) => Effect.Effect<      { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean },      GitLabPullRequestCliError    >;     readonly submitReview: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly verdict: PullRequestReviewVerdict;      readonly body: string;      readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     readonly replyToDiscussion: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly discussionId: string;      readonly body: string;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     readonly setDiscussionResolution: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly discussionId: string;      readonly resolved: boolean;    }) => Effect.Effect<void, GitLabPullRequestCliError>;  }>()("t3/pullRequest/GitLabPullRequestCli") {} /** The REST API addresses a project by its URL-encoded full path. */function projectPath(repository: string): string {  return encodeURIComponent(repository.trim());} function stateParam(state: PullRequestListState): string {  // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed,  // and it spans every state under `all`.  return state === "open" ? "opened" : state;} function involvementParams(input: {  readonly involvement: PullRequestInvolvement;  readonly viewer: string;}): ReadonlyArray<readonly [string, string]> {  switch (input.involvement) {    case "authored":      return [["author_username", input.viewer]];    case "reviewing":      return [["reviewer_username", input.viewer]];    case "all":      return [];  }} /** * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor * arrives from the reader as a string and goes straight into a query, so it is parsed rather * than trusted; the length bound keeps a page number out of exponential notation. */function diffCursorPage(cursor: string): number | null {  return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null;} /** * A commit sha arrives from the reader and goes straight into a request path, so it is checked * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a * whole sha. */function isCommitSha(value: string): boolean {  return /^[0-9a-f]{7,64}$/i.test(value);} function searchParams(search: string | undefined): ReadonlyArray<readonly [string, string]> {  const trimmed = search?.trim() ?? "";  return trimmed.length === 0 ? [] : [["search", trimmed]];} function query(params: ReadonlyArray<readonly [string, string]>): string {  return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");} function actionArgs(  action: PullRequestAction,  mergeMethod: PullRequestMergeMethod | undefined,): ReadonlyArray<string> {  switch (action) {    case "merge":      return [        "merge",        // glab turns on auto-merge whenever a pipeline is running. The button means merge now.        "--auto-merge=false",        "--yes",        ...(mergeMethod === "squash" ? ["--squash"] : []),        ...(mergeMethod === "rebase" ? ["--rebase"] : []),      ];    case "ready":      return ["update", "--ready"];    case "draft":      return ["update", "--draft"];    case "close":      return ["close"];    case "reopen":      return ["reopen"];  }} export const make = Effect.gen(function* () {  const gitlab = yield* GitLabCli.GitLabCli;   const api = (input: {    readonly cwd: string;    readonly path: string;    readonly method?: string;    readonly stdin?: string;    readonly maxOutputBytes?: number;    readonly timeoutMs?: number;  }) =>    gitlab.execute({      cwd: input.cwd,      args: [        "api",        input.path,        ...(input.method === undefined ? [] : ["--method", input.method]),        // A raw body from stdin: argv is visible in process listings and is echoed back        // inside process-runner failure messages. Unlike `gh`, `glab api --input` sends no        // Content-Type at all, and GitLab answers a bodyless content type with HTTP 415.        ...(input.stdin === undefined          ? []          : ["--input", "-", "--header", "Content-Type: application/json"]),      ],      ...(input.stdin === undefined ? {} : { stdin: input.stdin }),      ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),      ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),    });   /**   * `per_page` stops at 100, so a larger page is walked one request at a time. The walk is   * bounded twice over: it stops on a short page or once the extra row that reveals a next   * page has been read, and it never asks for more pages than the caller's page needs. The   * second bound is what makes it terminate when every row on a page fails to decode, which   * leaves nothing collected but does not mean GitLab has run out of rows.   */  const listPage = (input: {    readonly cwd: string;    readonly repository: string;    readonly state: PullRequestListState;    readonly involvement: PullRequestInvolvement;    readonly viewer: string;    readonly limit: number;    readonly query?: string | undefined;    readonly cursor?: ProviderListCursor | undefined;    readonly page: number;    readonly collected: ReadonlyArray<GitLabMergeRequestListItem>;    readonly cursorAdvance: number;  }): Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError> => {    // A continuation uses GitLab's offset pagination. Its timestamp filter is inclusive and has    // no tie-breaker, so a page where many rows share the boundary would otherwise return the    // same prefix forever. `delivered` is the stable offset the service has already handed over.    const delivered = input.cursor?.delivered ?? 0;    const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE);    const firstPage = Math.floor(delivered / perPage) + 1;    const skipOnFirstPage = input.page === firstPage ? delivered % perPage : 0;    // A page made entirely of malformed rows has no item from which the service can build a    // continuation. Bound the walk to the raw span this request asked for rather than recursing    // forever on a host that keeps returning full unusable pages.    const lastPage = Math.floor((delivered + input.limit) / perPage) + 1;    return api({      cwd: input.cwd,      path: `projects/${projectPath(input.repository)}/merge_requests?${query([        ["state", stateParam(input.state)],        ...involvementParams(input),        // The listing is read through `glab api` rather than `glab mr list`, so the search is        // the REST API's own `search` parameter — the one `mr list --search` passes on. It        // matches title and description, and travels URL-encoded like every other value here,        // so no text in it can become a parameter of its own.        ...searchParams(input.query),        ["order_by", "updated_at"],        ["sort", "desc"],        ["per_page", String(perPage)],        ["page", String(input.page)],      ])}`,

Lines 1–500 of 1153.