apps/server/src/pullRequest/GitLabPullRequestCli.ts

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,
  PullRequestReaction,
  PullRequestReactionContent,
  PullRequestReviewCommentDraft,
  PullRequestReviewPosition,
  PullRequestReviewThread,
  PullRequestReviewVerdict,
  PullRequestReviewerCandidateList,
} from "@t3tools/contracts";

import * as GitLabCli from "../sourceControl/GitLabCli.ts";
import {
  AWARD_EMOJI_GRAPHQL_QUERY,
  decodeAwardEmojiJson,
  decodeCommitDiffRefsJson,
  decodeCommitsJson,
  decodeDiffRefsJson,
  decodeDiscussionsJson,
  decodeMergeRequestDetailJson,
  decodeMergeRequestDiffsJson,
  decodeMergeRequestListJson,
  decodeNotesJson,
  decodeOwnAwardIdJson,
  decodeProjectMergeCapabilitiesJson,
  decodeProjectUsersJson,
  decodeRepositoryBlobsJson,
  decodeViewerJson,
  gitLabAwardName,
  REPOSITORY_BLOBS_GRAPHQL_QUERY,
  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.TaggedError<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.TaggedError<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.TaggedError<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.TaggedError<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.TaggedError<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.TaggedError<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.TaggedError<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>;

    /**
     * What the merge request's head has of each of these paths, as blob ids.
     *
     * The head sha comes from the merge request's own diff refs, so the answer is the version a
     * reader is looking at rather than whatever the source branch has moved on to. A path the
     * head does not have is answered as the empty revision, since the batch it was asked in was
     * looked at; a batch GitLab did not answer for is left out instead.
     */
    readonly getFileRevisions: (input: {
      readonly cwd: string;
      readonly repository: string;
      readonly number: number;
      readonly paths: ReadonlyArray<string>;
    }) => Effect.Effect<ReadonlyMap<string, string>, 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>;

    /** Whichever of the two is given is sent. GitLab calls a merge request's body its description. */
    readonly updateMergeRequest: (input: {
      readonly cwd: string;
      readonly repository: string;
      readonly number: number;
      readonly title?: string | undefined;
      readonly description?: string | undefined;
    }) => Effect.Effect<void, GitLabPullRequestCliError>;

    readonly commentOnMergeRequest: (input: {
      readonly cwd: string;
      readonly repository: string;
      readonly number: number;
      readonly body: string;
    }) => Effect.Effect<void, GitLabPullRequestCliError>;

    readonly updateNote: (input: {
      readonly cwd: string;
      readonly repository: string;
      readonly number: number;
      readonly noteId: string;
      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>;

    /** The awards on the merge request and on every note of it, keyed by the note's REST id. */
    readonly listReactions: (input: {
      readonly cwd: string;
      readonly repository: string;
      readonly number: number;
    }) => Effect.Effect<
      {
        readonly reactions: ReadonlyArray<PullRequestReaction>;
        readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
      },
      GitLabPullRequestCliError
    >;

    /**
     * Awards an emoji, or takes the award back. `noteId` is a note of the merge request; absent
     * awards the merge request itself, which is where its description's reactions live.
     */
    readonly setReaction: (input: {
      readonly cwd: string;
      readonly repository: string;
      readonly number: number;
      readonly noteId?: string | undefined;
      readonly content: PullRequestReactionContent;
      readonly reacted: 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 gitLabReviewPositionLines(
  position: PullRequestReviewPosition,
):
  | { readonly new_line: number }
  | { readonly old_line: number }
  | { readonly old_line: number; readonly new_line: number } {
  switch (position.kind) {
    case "added":
      return { new_line: position.newLine };
    case "deleted":
      return { old_line: position.oldLine };
    case "context":
      return { old_line: position.oldLine, new_line: position.newLine };
  }
}

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"] : []),
      ];
    // The same command with the flag the other way up: here the wait is the whole point, so
    // glab is told to arm the merge rather than talked out of it.
    case "enable-auto-merge":
      return [
        "merge",
        "--auto-merge=true",
        "--yes",
        ...(mergeMethod === "squash" ? ["--squash"] : []),
        ...(mergeMethod === "rebase" ? ["--rebase"] : []),
      ];
    // Never reached: taking the arming back has no `glab mr` command, so it goes to the API.
    case "disable-auto-merge":
      return [];
    case "ready":
      return ["update", "--ready"];
    case "draft":
      return ["update", "--draft"];
    case "close":
      return ["close"];
    // A rebase, because GitLab has no other way to move a branch onto its target: there is no
    // merge-the-target-in equivalent of GitHub's update button, which is why this host declares
    // `rebase` alone and never has to read the method it was handed.
    case "update-branch":
      return ["rebase"];
    case "reopen":
      return ["reopen"];
    // Never reached: this host does not declare the action, so the service refuses it first.
    case "revert":
    case "approve-workflows":
      throw new Error(`GitLab merge request action ${action} is unsupported`);
  }
}

/** @public Service construction is part of the canonical Effect module API. */
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)],
      ])}`,
    }).pipe(
      Effect.flatMap((result) => {
        const raw = result.stdout.trim();
        if (raw.length === 0) {
          return Effect.succeed({
            items: input.collected,
            truncated: false,
            cursorAdvance: input.cursorAdvance,
          });
        }
        const decoded = decodeMergeRequestListJson(raw);
        if (!Result.isSuccess(decoded)) {
          return Effect.fail(
            new GitLabMergeRequestReadError({
              command: "glab",
              cwd: input.cwd,
              operation: "listMergeRequests",
              cause: decoded.failure,
            }),
          );
        }
        const pageItems: GitLabMergeRequestListItem[] = [];
        const pageRawIndexes: number[] = [];
        for (const [index, item] of decoded.success.items.entries()) {
          const rawIndex = decoded.success.rawIndexes[index]!;
          if (rawIndex < skipOnFirstPage) continue;
          pageItems.push(item);
          pageRawIndexes.push(rawIndex);
        }
        const remaining = input.limit - input.collected.length;
        const lastItemRawIndex = pageRawIndexes[remaining - 1];
        if (lastItemRawIndex !== undefined) {
          const consumed = lastItemRawIndex + 1 - skipOnFirstPage;
          return Effect.succeed({
            items: [...input.collected, ...pageItems.slice(0, remaining)],
            truncated:
              lastItemRawIndex + 1 < decoded.success.rawCount ||
              decoded.success.rawCount === perPage,
            cursorAdvance: input.cursorAdvance + consumed,
          });
        }
        const collected = [...input.collected, ...pageItems];
        const consumed = Math.max(0, decoded.success.rawCount - skipOnFirstPage);
        // Counted before decoding, so a skipped malformed row cannot end paging early.
        const exhausted = decoded.success.rawCount < perPage;
        if (exhausted) {
          return Effect.succeed({
            items: collected,
            truncated: false,
            cursorAdvance: input.cursorAdvance + consumed,
          });
        }
        if (input.page >= lastPage) {
          return Effect.succeed({
            items: collected,
            truncated: true,
            cursorAdvance: input.cursorAdvance + consumed,
          });
        }
        return listPage({
          ...input,
          page: input.page + 1,
          collected,
          cursorAdvance: input.cursorAdvance + consumed,
        });
      }),
    );
  };

  /**
   * One page of a merge request's files, as a patch that stands on its own. GitLab pages
   * `/diffs` by offset and has no cursor of its own, so the page number is the cursor; the
   * caller carries on from it for as long as GitLab keeps handing full pages back.
   *
   * A named commit is read from the commit's own diff, which answers in the same shape and pages
   * the same way.
   */
  const diffPage = (input: {
    readonly cwd: string;
    readonly repository: string;
    readonly number: number;
    readonly page: number;
    readonly commit?: string | undefined;
  }): Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError> =>
    api({
      cwd: input.cwd,
      path: `projects/${projectPath(input.repository)}/${
        input.commit === undefined
          ? `merge_requests/${input.number}/diffs`
          : `repository/commits/${input.commit}/diff`
      }?${query([
        ["per_page", String(MAX_PAGE_SIZE)],
        ["page", String(input.page)],
      ])}`,
      maxOutputBytes: DIFF_MAX_OUTPUT_BYTES,
      timeoutMs: DIFF_TIMEOUT_MS,
    }).pipe(
      Effect.flatMap((result) => {
        // A byte-truncated response is a JSON prefix, so this page cannot be read at all.
        // Answering with no cursor would call the diff whole while silently dropping this page
        // and every one after it, so the read fails and says which page could not be had.
        if (result.stdoutTruncated) {
          return Effect.fail(
            new GitLabMergeRequestReadError({
              command: "glab",
              cwd: input.cwd,
              operation: "getMergeRequestDiff",
              cause: new Error(
                `Page ${input.page} of the merge request diff was too large to read.`,
              ),
            }),
          );
        }
        const decoded = decodeMergeRequestDiffsJson(result.stdout.trim());
        if (!Result.isSuccess(decoded)) {
          return Effect.fail(
            new GitLabMergeRequestReadError({
              command: "glab",
              cwd: input.cwd,
              operation: "getMergeRequestDiff",
              cause: decoded.failure,
            }),
          );
        }
        const patch = decoded.success.patch;
        // Counted before decoding, so a page whose files all failed to decode still moves on
        // rather than pointing the reader back at the page it just read.
        const morePages = decoded.success.rawCount >= MAX_PAGE_SIZE;
        return Effect.succeed({
          // The slice ends on a newline, so a file GitLab gave a header and no hunks for does
          // not run into the first line of the next slice.
          patch: patch.length === 0 ? patch : patch.replace(/\n?$/, "\n"),
          truncated: decoded.success.truncated,
          nextCursor: morePages ? String(input.page + 1) : null,
        });
      }),
    );

  /**
   * The conversation, a page at a time. GitLab pages by offset and reports no total, so a short
   * page is the only thing that says it is done — and the raw count decides, not the kept one:
   * the notes GitLab wrote itself are dropped, and a whole page of them still means there is
   * more to read.
   */
  const notesPage = (input: {
    readonly cwd: string;
    readonly repository: string;
    readonly number: number;
    readonly page: number;
    readonly collected: ReadonlyArray<PullRequestComment>;
  }): Effect.Effect<
    { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean },
    GitLabPullRequestCliError
  > =>
    api({
      cwd: input.cwd,
      path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes?${query(
        [
          ["per_page", String(MAX_PAGE_SIZE)],
          ["page", String(input.page)],
          ["order_by", "created_at"],
          ["sort", "asc"],
        ],
      )}`,
    }).pipe(
      Effect.flatMap((result) => {
        const decoded = decodeNotesJson(result.stdout.trim());
        if (!Result.isSuccess(decoded)) {
          return Effect.fail(
            new GitLabMergeRequestReadError({
              command: "glab",
              cwd: input.cwd,
              operation: "listNotes",
              cause: decoded.failure,
            }),
          );
        }
        const collected = [...input.collected, ...decoded.success.comments];
        if (decoded.success.rawCount < MAX_PAGE_SIZE) {
          return Effect.succeed({ comments: collected, truncated: false });
        }
        return input.page >= CONVERSATION_PAGES
          ? Effect.succeed({ comments: collected, truncated: true })
          : notesPage({ ...input, page: input.page + 1, collected });
      }),
    );

  /** The positioned discussions, walked the same way and stopped by the same bound. */
  const discussionsPage = (input: {
    readonly cwd: string;
    readonly repository: string;
    readonly number: number;
    readonly page: number;
    readonly collected: ReadonlyArray<PullRequestReviewThread>;
  }): Effect.Effect<
    { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean },
    GitLabPullRequestCliError
  > =>
    api({
      cwd: input.cwd,
      path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions?${query(
        [
          ["per_page", String(MAX_PAGE_SIZE)],
          ["page", String(input.page)],
        ],
      )}`,
    }).pipe(
      Effect.flatMap((result) => {
        const decoded = decodeDiscussionsJson(result.stdout.trim());
        if (!Result.isSuccess(decoded)) {
          return Effect.fail(
            new GitLabMergeRequestReadError({
              command: "glab",
              cwd: input.cwd,
              operation: "listDiscussions",
              cause: decoded.failure,
            }),
          );
        }
        const collected = [...input.collected, ...decoded.success.threads];
        // The raw count again: this endpoint returns the plain notes too, so a full page of
        // those is not the end of the positioned ones.
        if (decoded.success.rawCount < MAX_PAGE_SIZE) {
          return Effect.succeed({ threads: collected, truncated: false });
        }
        return input.page >= CONVERSATION_PAGES
          ? Effect.succeed({ threads: collected, truncated: true })
          : discussionsPage({ ...input, page: input.page + 1, collected });
      }),
    );

  /**
   * The revisions a positioned comment is written against. GitLab resolves a comment's line
   * against these three shas, so a review with line comments cannot be sent without them.
   */
  const getDiffRefs = (input: {
    readonly cwd: string;
    readonly repository: string;
    readonly number: number;
  }): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> =>
    api({
      cwd: input.cwd,
      path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
    }).pipe(
      Effect.flatMap((result): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => {
        const decoded = decodeDiffRefsJson(result.stdout.trim());
        if (!Result.isSuccess(decoded)) {
          return Effect.fail(
            new GitLabMergeRequestReadError({
              command: "glab",
              cwd: input.cwd,
              operation: "getDiffRefs",
              cause: decoded.failure,
            }),
          );
        }
        // A merge request with no diff refs is a well-formed answer that cannot carry a
        // positioned comment — a dead end, but not something that failed to be read.
        return decoded.success === null
          ? Effect.fail(
              new GitLabDiffRefsUnavailableError({
                command: "glab",
                cwd: input.cwd,
                number: input.number,
              }),
            )
          : Effect.succeed(decoded.success);
      }),
    );

  const getCommitDiffRefs = (input: {
    readonly cwd: string;
    readonly repository: string;
    readonly commit: string;
    readonly allowRoot: boolean;
  }): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> =>
    api({
      cwd: input.cwd,
      path: `projects/${projectPath(input.repository)}/repository/commits/${input.commit}`,
    }).pipe(
      Effect.flatMap((result): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => {
        const decoded = decodeCommitDiffRefsJson(result.stdout.trim());
        if (!Result.isSuccess(decoded)) {
          return Effect.fail(
            new GitLabMergeRequestReadError({
              command: "glab",
              cwd: input.cwd,
              operation: "getMergeRequestDiffFileContents",
              cause: decoded.failure,
            }),
          );
        }
        return decoded.success === null
          ? input.allowRoot
            ? Effect.succeed({
                baseSha: "",
                headSha: input.commit,
                startSha: "",
              })
            : Effect.fail(
                new GitLabDiffCommitParentUnavailableError({
                  command: "glab",
                  cwd: input.cwd,
                  commit: input.commit,
                }),
              )
          : Effect.succeed(decoded.success);
      }),
    );

  /**
   * The merge request itself, which several calls need for different parts of it: the detail for
   * everything, and the reviewer paths for the ids GitLab writes a reviewer set with.
   */
  const mergeRequestDetail = (input: {
    readonly cwd: string;
    readonly repository: string;
    readonly number: number;
  }): Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError> =>
    api({
      cwd: input.cwd,
      // How far behind the target branch this one is comes only when asked for by name, and it
      // is asked for here rather than on a second read because it is the same merge request.
      path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}?${query([
        ["include_diverged_commits_count", "true"],
      ])}`,
    }).pipe(
      Effect.flatMap((result) => {
        const decoded = decodeMergeRequestDetailJson(result.stdout.trim());
        return Result.isSuccess(decoded)
          ? Effect.succeed(decoded.success)
          : Effect.fail(
              new GitLabMergeRequestReadError({
                command: "glab",
                cwd: input.cwd,
                operation: "getMergeRequestDetail",
                cause: decoded.failure,
              }),
            );
      }),
    );

  /** The people with access to the project, one page deep. */
  const projectUsers = (input: {
    readonly cwd: string;
    readonly repository: string;
  }): Effect.Effect<GitLabProjectUsers, GitLabPullRequestCliError> =>
    api({
      cwd: input.cwd,
      path: `projects/${projectPath(input.repository)}/users?${query([
        ["per_page", String(MAX_PAGE_SIZE)],
      ])}`,
    }).pipe(
      Effect.flatMap((result) => {
        const decoded = decodeProjectUsersJson(result.stdout.trim());
        return Result.isSuccess(decoded)
          ? Effect.succeed(decoded.success)
          : Effect.fail(
              new GitLabMergeRequestReadError({
                command: "glab",
                cwd: input.cwd,
                operation: "listReviewerCandidates",
                cause: decoded.failure,
              }),
            );
      }),
    );

  /** Where an award is written: a note of the merge request, or the merge request itself. */
  const awardSubjectPath = (input: {
    readonly repository: string;
    readonly number: number;
    readonly noteId?: string | undefined;
  }) => {
    const mergeRequest = `projects/${projectPath(input.repository)}/merge_requests/${input.number}`;
    return input.noteId === undefined
      ? `${mergeRequest}/award_emoji`
      : `${mergeRequest}/notes/${encodeURIComponent(input.noteId)}/award_emoji`;
  };

  /**
   * The awards on the merge request and its notes, a page of notes at a time. Bounded by the same
   * count as the conversation itself: awards past the notes that were read belong to notes the
   * page is not showing.
   */
  const awardsPage = (input: {
    readonly cwd: string;
    readonly repository: string;
    readonly number: number;
    readonly cursor: string | null;
    readonly page: number;
    readonly collected: {
      readonly reactions: ReadonlyArray<PullRequestReaction>;
      readonly reactionsByNoteId: Map<string, ReadonlyArray<PullRequestReaction>>;
    } | null;
  }): Effect.Effect<
    {
      readonly reactions: ReadonlyArray<PullRequestReaction>;
      readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
    },
    GitLabPullRequestCliError
  > =>
    api({
      cwd: input.cwd,
      path: "graphql",
      method: "POST",
      stdin: JSON.stringify({
        query: AWARD_EMOJI_GRAPHQL_QUERY,
        variables: {
          fullPath: input.repository,
          iid: String(input.number),
          cursor: input.cursor,
        },
      }),
    }).pipe(
      Effect.flatMap((result) => {
        const decoded = decodeAwardEmojiJson(result.stdout.trim());
        if (!Result.isSuccess(decoded)) {
          return Effect.fail(
            new GitLabMergeRequestReadError({
              command: "glab",
              cwd: input.cwd,
              operation: "listReactions",
              cause: decoded.failure,
            }),
          );
        }
        const collected = input.collected ?? {
          reactions: decoded.success.reactions,
          reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(),
        };
        for (const [id, reactions] of decoded.success.reactionsByNoteId)
          collected.reactionsByNoteId.set(id, reactions);
        return decoded.success.nextCursor === null || input.page >= CONVERSATION_PAGES
          ? Effect.succeed(collected)
          : awardsPage({
              ...input,
              cursor: decoded.success.nextCursor,
              page: input.page + 1,
              collected,
            });
      }),
    );

  /**
   * GitLab charges the blobs query by how many paths it is handed, and its connection hands back
   * one page. A hundred at a time keeps each request inside both.
   */
  const BLOB_PATHS_PER_REQUEST = 100;

  const blobsAt = (input: {
    readonly cwd: string;
    readonly repository: string;
    readonly ref: string;
    readonly paths: ReadonlyArray<string>;
  }): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> =>
    api({
      cwd: input.cwd,
      path: "graphql",
      method: "POST",
      stdin: JSON.stringify({
        query: REPOSITORY_BLOBS_GRAPHQL_QUERY,
        variables: { fullPath: input.repository, ref: input.ref, paths: input.paths },
      }),
    }).pipe(
      Effect.flatMap(
        (result): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> => {
          const decoded = decodeRepositoryBlobsJson(result.stdout.trim());
          return Result.isSuccess(decoded)
            ? Effect.succeed(decoded.success)
            : Effect.fail(
                new GitLabMergeRequestReadError({
                  command: "glab",
                  cwd: input.cwd,
                  operation: "getFileRevisions",
                  cause: decoded.failure,
                }),
              );
        },
      ),
    );

  const fileRevisions = (input: {
    readonly cwd: string;
    readonly repository: string;
    readonly number: number;
    readonly paths: ReadonlyArray<string>;
  }): Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError> =>
    input.paths.length === 0
      ? Effect.succeed(new Map())
      : getDiffRefs(input).pipe(
          Effect.flatMap((refs) => {
            const batches: Array<ReadonlyArray<string>> = [];
            for (let at = 0; at < input.paths.length; at += BLOB_PATHS_PER_REQUEST) {
              batches.push(input.paths.slice(at, at + BLOB_PATHS_PER_REQUEST));
            }
            return Effect.forEach(
              batches,
              (paths) =>
                blobsAt({ ...input, ref: refs.headSha, paths }).pipe(
                  Effect.map((page) => ({ paths, page })),
                ),
              { concurrency: 2 },
            ).pipe(
              Effect.map((pages) => {
                const revisions = new Map<string, string>();
                for (const { paths, page } of pages) {
                  // A batch GitLab did not answer says nothing about its paths, so they are left
                  // out and the caller reads them as versions it could not learn, which leaves
                  // the marks on them alone. Filling them in as removed would report every file
                  // a reader has cleared as changed over a project the token cannot see.
                  if (page === null) continue;
                  for (const [path, oid] of page) revisions.set(path, oid);
                  // Within a batch that was answered, every path was looked for at the head, so
                  // one that is not there is one the merge request removed. Said as the empty
                  // revision, which is an answer the caller can compare against and keep.
                  for (const path of paths) {
                    if (!revisions.has(path)) revisions.set(path, "");
                  }
                }
                return revisions as ReadonlyMap<string, string>;
              }),
            );
          }),
        );

  const viewerUsername = (input: { readonly cwd: string }) =>
    api({ cwd: input.cwd, path: "user" }).pipe(
      Effect.flatMap((result): Effect.Effect<string, GitLabPullRequestCliError> => {
        const decoded = decodeViewerJson(result.stdout.trim());
        if (!Result.isSuccess(decoded)) {
          return Effect.fail(
            new GitLabMergeRequestReadError({
              command: "glab",
              cwd: input.cwd,
              operation: "getViewerUsername",
              cause: decoded.failure,
            }),
          );
        }
        return decoded.success === null
          ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd }))
          : Effect.succeed(decoded.success);
      }),
    );

  return GitLabPullRequestCli.of({
    getViewerUsername: viewerUsername,

    listMergeRequests: (input) => {
      const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE);
      const page = Math.floor((input.cursor?.delivered ?? 0) / perPage) + 1;
      return listPage({ ...input, page, collected: [], cursorAdvance: 0 });
    },

    getMergeRequestDetail: mergeRequestDetail,

    listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }),

    listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }),

    getFileRevisions: fileRevisions,

    setReaction: (input) =>
      Effect.gen(function* () {
        const subject = awardSubjectPath(input);
        if (input.reacted) {
          yield* api({
            cwd: input.cwd,
            path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`,
            method: "POST",
          });
          return;
        }
        // GitLab deletes an award by its id and takes no emoji name there, so the reader's own
        // award of that name is looked up first. Nothing to delete is success: the reaction the
        // caller asked to take back is already gone.
        const viewer = yield* viewerUsername({ cwd: input.cwd });
        const listed = yield* api({ cwd: input.cwd, path: subject });
        const own = decodeOwnAwardIdJson(listed.stdout.trim(), {
          content: input.content,
          viewer,
        });
        if (!Result.isSuccess(own)) {
          return yield* new GitLabMergeRequestReadError({
            command: "glab",
            cwd: input.cwd,
            operation: "setReaction",
            cause: own.failure,
          });
        }
        if (own.success === null) return;
        yield* api({
          cwd: input.cwd,
          path: `${subject}/${own.success}`,
          method: "DELETE",
        });
      }),

    listCommits: (input) =>
      api({
        cwd: input.cwd,
        path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/commits?${query(
          [
            ["per_page", String(COMMIT_PAGE_SIZE)],
            ["with_stats", "true"],
          ],
        )}`,
      }).pipe(
        Effect.flatMap((result) => {
          const decoded = decodeCommitsJson(result.stdout.trim());
          return Result.isSuccess(decoded)
            ? Effect.succeed(decoded.success)
            : Effect.fail(
                new GitLabMergeRequestReadError({
                  command: "glab",
                  cwd: input.cwd,
                  operation: "listCommits",
                  cause: decoded.failure,
                }),
              );
        }),
      ),

    getMergeRequestDiff: (input) => {
      if (input.commit !== undefined && !isCommitSha(input.commit)) {
        return Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd }));
      }
      const target = {
        cwd: input.cwd,
        repository: input.repository,
        number: input.number,
        ...(input.commit === undefined ? {} : { commit: input.commit }),
      };
      if (input.cursor === undefined) {
        return diffPage({ ...target, page: 1 });
      }
      const page = diffCursorPage(input.cursor);
      return page === null
        ? Effect.fail(new GitLabDiffCursorError({ command: "glab", cwd: input.cwd }))
        : diffPage({ ...target, page });
    },

    getMergeRequestDiffFileContents: (input) =>
      Effect.gen(function* () {
        if (input.commit !== undefined && !isCommitSha(input.commit)) {
          return yield* new GitLabDiffCommitError({ command: "glab", cwd: input.cwd });
        }
        const refs = yield* input.commit === undefined
          ? getDiffRefs(input)
          : getCommitDiffRefs({
              cwd: input.cwd,
              repository: input.repository,
              commit: input.commit,
              allowRoot: input.changeType === "new",
            });

        const readFile = (revision: string, filePath: string) =>
          api({
            cwd: input.cwd,
            path: `projects/${projectPath(input.repository)}/repository/files/${encodeURIComponent(
              filePath,
            )}/raw?ref=${encodeURIComponent(revision)}`,
            maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES,
            timeoutMs: DIFF_TIMEOUT_MS,
          }).pipe(
            Effect.flatMap((result) =>
              result.stdoutTruncated ||
              result.stdout.includes("\0") ||
              result.stdoutInvalidUtf8 === true
                ? Effect.fail(
                    new GitLabDiffFileContentsUnavailableError({
                      command: "glab",
                      cwd: input.cwd,
                      path: filePath,
                      reason: result.stdoutTruncated ? "oversized" : "binary",
                    }),
                  )
                : Effect.succeed(result.stdout),
            ),
          );

        const [oldContents, newContents] = yield* Effect.all(
          [
            input.changeType === "new" ? Effect.succeed("") : readFile(refs.baseSha, input.oldPath),
            input.changeType === "deleted"
              ? Effect.succeed("")
              : readFile(refs.headSha, input.newPath),
          ],
          { concurrency: 2 },
        );
        return { oldContents, newContents };
      }),

    getProjectMergeCapabilities: (input) =>
      api({
        cwd: input.cwd,
        path: `projects/${projectPath(input.repository)}?license=false`,
      }).pipe(
        Effect.flatMap((result) => {
          const decoded = decodeProjectMergeCapabilitiesJson(result.stdout.trim());
          return Result.isSuccess(decoded)
            ? Effect.succeed(decoded.success)
            : Effect.fail(
                new GitLabMergeRequestReadError({
                  command: "glab",
                  cwd: input.cwd,
                  operation: "getProjectMergeCapabilities",
                  cause: decoded.failure,
                }),
              );
        }),
      ),

    listReviewerCandidates: (input) =>
      Effect.all([mergeRequestDetail(input), projectUsers(input)], { concurrency: 2 }).pipe(
        Effect.map(([mergeRequest, users]) => {
          const author = mergeRequest.author?.login;
          const requested = new Set(mergeRequest.reviewRequestLogins);
          return {
            // The author is dropped rather than shown unusable: GitLab refuses to make the person
            // who opened a merge request its reviewer.
            candidates: users.candidates.flatMap((candidate) =>
              candidate.login === author
                ? []
                : [{ ...candidate, isRequested: requested.has(candidate.login) }],
            ),
            truncated: users.rawCount >= MAX_PAGE_SIZE,
          };
        }),
      ),

    setReviewerRequest: (input) =>
      mergeRequestDetail(input).pipe(
        Effect.flatMap((mergeRequest) => {
          // GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` replaces the
          // whole set, so the set that is already there is read first and the change applied to
          // it. Asking again for somebody already on it writes the same set back, which is how
          // GitLab re-requests a review.
          const ids = new Set(mergeRequest.reviewerIds);
          for (const reviewer of input.reviewers) {
            const id = Number(reviewer.id);
            // A candidate GitLab did not name is not an id it would accept, and sending it would
            // rewrite the reviewer set around a number nobody chose.
            if (!Number.isSafeInteger(id) || id <= 0) continue;
            if (input.requested) ids.add(id);
            else ids.delete(id);
          }
          return api({
            cwd: input.cwd,
            path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
            method: "PUT",
            stdin: JSON.stringify({ reviewer_ids: [...ids] }),
          });
        }),
        Effect.asVoid,
      ),

    runMergeRequestAction: (input) => {
      // `glab mr merge` arms auto-merge and never disarms it, so the one direction the CLI has
      // no flag for is asked of GitLab directly through the same `api` passthrough the rest of
      // this module writes with.
      if (input.action === "disable-auto-merge") {
        return api({
          cwd: input.cwd,
          path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/cancel_merge_when_pipeline_succeeds`,
          method: "POST",
        }).pipe(Effect.asVoid);
      }
      const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod);
      return gitlab
        .execute({
          cwd: input.cwd,
          args: ["mr", subcommand!, String(input.number), "--repo", input.repository, ...flags],
        })
        .pipe(Effect.asVoid);
    },

    updateMergeRequest: (input) =>
      api({
        cwd: input.cwd,
        path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
        method: "PUT",
        // Only the fields the caller asked to change: GitLab leaves out what it is not sent, and
        // clears what it is sent empty — so a title corrected on its own must carry no
        // description at all.
        stdin: JSON.stringify({
          ...(input.title === undefined ? {} : { title: input.title }),
          ...(input.description === undefined ? {} : { description: input.description }),
        }),
      }).pipe(Effect.asVoid),

    commentOnMergeRequest: (input) =>
      api({
        cwd: input.cwd,
        path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes`,
        method: "POST",
        // A JSON body rather than a `--raw-field`: glab coerces a field that reads as a
        // literal `true` or a number, and a comment body is text either way.
        stdin: JSON.stringify({ body: input.body }),
      }).pipe(Effect.asVoid),

    updateNote: (input) =>
      api({
        cwd: input.cwd,
        path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes/${encodeURIComponent(
          input.noteId,
        )}`,
        method: "PUT",
        stdin: JSON.stringify({ body: input.body }),
      }).pipe(Effect.asVoid),

    listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }),

    submitReview: (input) =>
      Effect.gen(function* () {
        const project = projectPath(input.repository);
        const mergeRequest = `projects/${project}/merge_requests/${input.number}`;
        // GitLab has no pending review to attach comments to, so a review is replayed as the
        // requests it is made of: the line comments, then the summary, then the verdict. A
        // failure part-way therefore leaves what was already posted in place, which is why
        // the verdict goes last — a half-sent review is never an approval.
        if (input.comments.length > 0) {
          const refs = yield* getDiffRefs(input);
          yield* Effect.forEach(
            input.comments,
            (comment) =>
              api({
                cwd: input.cwd,
                path: `${mergeRequest}/discussions`,
                method: "POST",
                stdin: JSON.stringify({
                  body: comment.body,
                  position: {
                    base_sha: refs.baseSha,
                    head_sha: refs.headSha,
                    start_sha: refs.startSha,
                    position_type: "text",
                    // Both paths are sent because GitLab resolves a position against both
                    // sides of the diff. They differ only for a renamed file, which is why the
                    // draft carries the name the file had before the change.
                    old_path: comment.oldPath ?? comment.path,
                    new_path: comment.path,
                    ...gitLabReviewPositionLines(comment.position),
                  },
                }),
              }),
            { discard: true },
          );
        }
        if (input.body.trim().length > 0) {
          yield* api({
            cwd: input.cwd,
            path: `${mergeRequest}/notes`,
            method: "POST",
            // A JSON body rather than a `--raw-field`, for the reason the plain comment gives:
            // glab coerces a field that reads as a literal `true` or a number.
            // @effect-diagnostics-next-line preferSchemaOverJson:off
            stdin: JSON.stringify({ body: input.body }),
          });
        }
        if (input.verdict === "approve") {
          yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" });
        }
      }),

    replyToDiscussion: (input) =>
      api({
        cwd: input.cwd,
        path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent(
          input.discussionId,
        )}/notes`,
        method: "POST",
        stdin: JSON.stringify({ body: input.body }),
      }).pipe(Effect.asVoid),

    setDiscussionResolution: (input) =>
      api({
        cwd: input.cwd,
        path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent(
          input.discussionId,
        )}`,
        method: "PUT",
        stdin: JSON.stringify({ resolved: input.resolved }),
      }).pipe(Effect.asVoid),
  });
});

export const layer = Layer.effect(GitLabPullRequestCli, make);