packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts

import { isCommitSha } from "@t3tools/source-control-core/server/pullRequestUtils";
import * as Cache from "effect/Cache";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
import type {
  PullRequestAction,
  PullRequestCheck,
  PullRequestComment,
  PullRequestCommit,
  PullRequestListState,
  PullRequestMergeMethod,
  PullRequestMergeability,
  PullRequestReviewCommentDraft,
  PullRequestReviewPosition,
  PullRequestReviewThread,
  PullRequestReviewVerdict,
  PullRequestReviewerCandidateList,
} from "@t3tools/contracts";

import * as BitbucketApi from "./BitbucketApi.ts";
import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions";
import { sortChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
import {
  buildReviewThreads,
  decodeCommentsJson,
  decodeCommitsJson,
  decodeConflictsJson,
  decodeDiffstatJson,
  decodePullRequestJson,
  decodePullRequestPageJson,
  decodeRepositoryPermissionJson,
  decodeStatusesJson,
  decodeViewerJson,
  decodeWorkspaceMembersJson,
  type BitbucketDiffStat,
  type BitbucketPullRequest,
  type BitbucketRawComment,
} from "./bitbucketPullRequestJson.ts";
import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";

export class BitbucketPullRequestReadError extends Schema.TaggedError<BitbucketPullRequestReadError>()(
  "BitbucketPullRequestReadError",
  {
    operation: Schema.String,
    cause: Schema.Defect(),
  },
) {
  get detail(): string {
    return `Bitbucket returned an unreadable ${this.operation} response.`;
  }

  override get message(): string {
    return `Bitbucket failed in ${this.operation}: ${this.detail}`;
  }
}

export class BitbucketViewerUnavailableError extends Schema.TaggedError<BitbucketViewerUnavailableError>()(
  "BitbucketViewerUnavailableError",
  {},
) {
  get detail(): string {
    return "Bitbucket returned no account name for the configured credentials.";
  }

  override get message(): string {
    return `Bitbucket failed in getViewer: ${this.detail}`;
  }
}

export class BitbucketRepositoryUnsupportedError extends Schema.TaggedError<BitbucketRepositoryUnsupportedError>()(
  "BitbucketRepositoryUnsupportedError",
  {
    repository: Schema.String,
  },
) {
  get detail(): string {
    return "A Bitbucket repository is addressed as workspace/repository.";
  }

  override get message(): string {
    return `Bitbucket failed in resolveRepository: ${this.detail}`;
  }
}

export class BitbucketDiffCommitError extends Schema.TaggedError<BitbucketDiffCommitError>()(
  "BitbucketDiffCommitError",
  {},
) {
  get detail(): string {
    return "The named commit was not a commit sha.";
  }

  override get message(): string {
    return `Bitbucket failed in getPullRequestDiff: ${this.detail}`;
  }
}

export type BitbucketPullRequestApiError =
  | BitbucketApi.BitbucketApiError
  | BitbucketPullRequestReadError
  | BitbucketViewerUnavailableError
  | BitbucketRepositoryUnsupportedError
  | BitbucketDiffCommitError;

function isRepositoryPermissionUnavailableError(
  error: BitbucketPullRequestApiError,
): error is BitbucketApi.BitbucketResponseError {
  return (
    error._tag === "BitbucketResponseError" &&
    (error.status === 403 || error.status === 404 || error.status === 410)
  );
}

/** Requests above this size return an empty page without an error. */
const MAX_PAGE_SIZE = 50;
const MAX_LIST_PAGES = 10;
const CONVERSATION_PAGE_SIZE = 50;
const CONVERSATION_PAGES = 10;
const DIFF_MAX_BYTES = 8 * 1024 * 1024;
/** Must expire before the caller's version cache so refreshes reach Bitbucket. */
const REVISION_PATCH_TTL = Duration.seconds(5);
const REVISION_PATCH_CAPACITY = 16;
const encodeRevisionKey = Schema.encodeSync(
  Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.Number])),
);
export interface BitbucketPullRequestBatch {
  readonly items: ReadonlyArray<BitbucketPullRequest>;
  readonly truncated: boolean;
}

export class BitbucketPullRequestApi extends Context.Service<
  BitbucketPullRequestApi,
  {
    readonly getViewer: () => Effect.Effect<string, BitbucketPullRequestApiError>;

    readonly listPullRequests: (input: {
      readonly repository: string;
      readonly state: PullRequestListState;
      readonly limit: number;
      readonly query?: string | undefined;
      readonly cursor?: ProviderListCursor | undefined;
      /** Matched against the login a row shows, which is the nickname or else the display name. */
      readonly author?: string | undefined;
      readonly draft?: boolean | undefined;
    }) => Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError>;

    readonly getPullRequest: (input: {
      readonly repository: string;
      readonly number: number;
    }) => Effect.Effect<BitbucketPullRequest, BitbucketPullRequestApiError>;

    readonly getRepositoryPermission: (input: {
      readonly repository: string;
    }) => Effect.Effect<boolean, BitbucketPullRequestApiError>;

    readonly getPullRequestDiff: (input: {
      readonly repository: string;
      readonly number: number;
      readonly commit?: string | undefined;
    }) => Effect.Effect<
      { readonly patch: string; readonly truncated: boolean },
      BitbucketPullRequestApiError
    >;

    readonly getDiffStat: (input: {
      readonly repository: string;
      readonly number: number;
    }) => Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError>;

    /** Truncated patches cannot identify absent paths as deleted. */
    readonly getFileRevisions: (input: {
      readonly repository: string;
      readonly number: number;
      readonly paths: ReadonlyArray<string>;
    }) => Effect.Effect<
      { readonly revisions: ReadonlyMap<string, string>; readonly complete: boolean },
      BitbucketPullRequestApiError
    >;

    readonly getMergeability: (input: {
      readonly repository: string;
      readonly number: number;
    }) => Effect.Effect<PullRequestMergeability, BitbucketPullRequestApiError>;

    readonly listComments: (input: {
      readonly repository: string;
      readonly number: number;
    }) => Effect.Effect<
      {
        readonly comments: ReadonlyArray<PullRequestComment>;
        readonly threads: ReadonlyArray<PullRequestReviewThread>;
        readonly truncated: boolean;
      },
      BitbucketPullRequestApiError
    >;

    readonly listCommits: (input: {
      readonly repository: string;
      readonly number: number;
    }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, BitbucketPullRequestApiError>;

    readonly listChecks: (input: {
      readonly repository: string;
      readonly number: number;
    }) => Effect.Effect<ReadonlyArray<PullRequestCheck>, BitbucketPullRequestApiError>;

    readonly listReviewerCandidates: (input: {
      readonly repository: string;
      readonly number: number;
    }) => Effect.Effect<PullRequestReviewerCandidateList, BitbucketPullRequestApiError>;

    readonly setReviewerRequest: (input: {
      readonly repository: string;
      readonly number: number;
      readonly reviewers: ReadonlyArray<{ readonly id: string }>;
      readonly requested: boolean;
    }) => Effect.Effect<void, BitbucketPullRequestApiError>;

    readonly runAction: (input: {
      readonly repository: string;
      readonly number: number;
      readonly action: PullRequestAction;
      readonly mergeMethod?: PullRequestMergeMethod;
    }) => Effect.Effect<void, BitbucketPullRequestApiError>;

    readonly updateChangeRequest: (input: {
      readonly repository: string;
      readonly number: number;
      readonly title?: string | undefined;
      readonly body?: string | undefined;
    }) => Effect.Effect<void, BitbucketPullRequestApiError>;

    readonly comment: (input: {
      readonly repository: string;
      readonly number: number;
      readonly body: string;
    }) => Effect.Effect<void, BitbucketPullRequestApiError>;

    readonly updateComment: (input: {
      readonly repository: string;
      readonly number: number;
      readonly commentId: string;
      readonly body: string;
    }) => Effect.Effect<void, BitbucketPullRequestApiError>;

    readonly submitReview: (input: {
      readonly repository: string;
      readonly number: number;
      readonly verdict: PullRequestReviewVerdict;
      readonly body: string;
      readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
    }) => Effect.Effect<void, BitbucketPullRequestApiError>;

    readonly replyToComment: (input: {
      readonly repository: string;
      readonly number: number;
      readonly commentId: string;
      readonly body: string;
    }) => Effect.Effect<void, BitbucketPullRequestApiError>;

    readonly setCommentResolution: (input: {
      readonly repository: string;
      readonly number: number;
      readonly commentId: string;
      readonly resolved: boolean;
    }) => Effect.Effect<void, BitbucketPullRequestApiError>;
  }
>()("@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi") {}

function repositorySegments(
  repository: string,
): Result.Result<
  { readonly workspace: string; readonly slug: string },
  BitbucketRepositoryUnsupportedError
> {
  const segments = repository
    .split("/")
    .map((segment) => segment.trim())
    .filter((segment) => segment.length > 0);
  const [workspace, slug] = segments;
  if (segments.length !== 2 || workspace === undefined || slug === undefined) {
    return Result.fail(new BitbucketRepositoryUnsupportedError({ repository }));
  }
  return Result.succeed({ workspace, slug });
}

function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string {
  return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent(
    segments.slug,
  )}`;
}

function stateParams(state: PullRequestListState): ReadonlyArray<string> {
  switch (state) {
    case "open":
      return ["OPEN"];
    case "merged":
      return ["MERGED"];
    case "closed":
      return ["DECLINED", "SUPERSEDED"];
    case "all":
      return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"];
  }
}

function searchFilter(query: string): string {
  const literal = filterLiteral(query);
  return `(title ~ "${literal}" OR description ~ "${literal}")`;
}

function authorFilter(login: string): string {
  const literal = filterLiteral(login);
  return `(author.nickname = "${literal}" OR author.display_name = "${literal}")`;
}

/**
 * The backslash is escaped first, or escaping the quote would only produce a literal
 * backslash followed by a live quote.
 */
function filterLiteral(value: string): string {
  return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}

function mergeStrategy(method: PullRequestMergeMethod | undefined): string {
  switch (method) {
    case "squash":
      return "squash";
    case "rebase":
      return "rebase_fast_forward";
    default:
      return "merge_commit";
  }
}

function bitbucketReviewPosition(
  position: PullRequestReviewPosition,
): { readonly from: number } | { readonly to: number } {
  switch (position.kind) {
    case "added":
      return { to: position.newLine };
    case "deleted":
      return { from: position.oldLine };
    case "context":
      return position.side === "left" ? { from: position.oldLine } : { to: position.newLine };
  }
}

/** @public */
export const make = Effect.gen(function* () {
  const bitbucket = yield* BitbucketApi.BitbucketApi;

  const withRepository = <A>(
    repository: string,
    use: (
      path: string,
      workspace: string,
      slug: string,
    ) => Effect.Effect<A, BitbucketPullRequestApiError>,
  ): Effect.Effect<A, BitbucketPullRequestApiError> => {
    const segments = repositorySegments(repository);
    return Result.isSuccess(segments)
      ? use(repositoryPathOf(segments.success), segments.success.workspace, segments.success.slug)
      : Effect.fail(segments.failure);
  };

  const listPage = (input: {
    readonly url: string;
    readonly limit: number;
    readonly page: number;
    readonly collected: ReadonlyArray<BitbucketPullRequest>;
  }): Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError> =>
    bitbucket.request({ method: "GET", url: input.url }).pipe(
      Effect.flatMap((response) => {
        const decoded = decodePullRequestPageJson(response.body);
        if (!Result.isSuccess(decoded)) {
          return Effect.fail(
            new BitbucketPullRequestReadError({
              operation: "listPullRequests",
              cause: decoded.failure,
            }),
          );
        }
        const collected = [...input.collected, ...decoded.success.items];
        const next = decoded.success.next;
        if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) {
          return Effect.succeed({
            items: collected.slice(0, input.limit),
            truncated: next !== null || collected.length > input.limit,
          });
        }
        return listPage({ ...input, url: next, page: input.page + 1, collected });
      }),
    );

  const readPage = <A>(input: {
    readonly operation: string;
    readonly url: string;
    readonly decode: (body: string) => Result.Result<A, unknown>;
  }): Effect.Effect<A, BitbucketPullRequestApiError> =>
    bitbucket.request({ method: "GET", url: input.url }).pipe(
      Effect.flatMap((response) => {
        const decoded = input.decode(response.body);
        return Result.isSuccess(decoded)
          ? Effect.succeed(decoded.success)
          : Effect.fail(
              new BitbucketPullRequestReadError({
                operation: input.operation,
                cause: decoded.failure,
              }),
            );
      }),
    );

  /** Replies and parents can span pages; assemble threads after pagination. */
  const commentsPage = (input: {
    readonly url: string;
    readonly page: number;
    readonly comments: ReadonlyArray<PullRequestComment>;
    readonly entries: ReadonlyArray<BitbucketRawComment>;
  }): Effect.Effect<
    {
      readonly comments: ReadonlyArray<PullRequestComment>;
      readonly threads: ReadonlyArray<PullRequestReviewThread>;
      readonly truncated: boolean;
    },
    BitbucketPullRequestApiError
  > =>
    readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe(
      Effect.flatMap((page) => {
        const comments = [...input.comments, ...page.comments];
        const entries = [...input.entries, ...page.entries];
        if (page.next !== null && input.page < CONVERSATION_PAGES) {
          return commentsPage({ url: page.next, page: input.page + 1, comments, entries });
        }
        return Effect.succeed({
          comments,
          threads: buildReviewThreads(entries),
          truncated: page.next !== null,
        });
      }),
    );

  const itemPages = <A>(input: {
    readonly operation: string;
    readonly url: string;
    readonly decode: (
      body: string,
    ) => Result.Result<{ readonly items: ReadonlyArray<A>; readonly next: string | null }, unknown>;
    readonly items: ReadonlyArray<A>;
    /** Each commit page is oldest-first. */
    readonly prepend: boolean;
  }): Effect.Effect<ReadonlyArray<A>, BitbucketPullRequestApiError> =>
    readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe(
      Effect.flatMap((page) => {
        const items = input.prepend
          ? [...page.items, ...input.items]
          : [...input.items, ...page.items];
        return page.next === null
          ? Effect.succeed(items)
          : itemPages({ ...input, url: page.next, items });
      }),
    );

  const diffStatPages = (input: {
    readonly url: string;
    readonly totals: BitbucketDiffStat;
  }): Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError> =>
    readPage({ operation: "getDiffStat", url: input.url, decode: decodeDiffstatJson }).pipe(
      Effect.flatMap((page) => {
        const totals = {
          additions: input.totals.additions + page.additions,
          deletions: input.totals.deletions + page.deletions,
          changedFiles: input.totals.changedFiles + page.changedFiles,
        };
        return page.next === null
          ? Effect.succeed(totals)
          : diffStatPages({ url: page.next, totals });
      }),
    );

  const pullRequestDiff = (input: {
    readonly repository: string;
    readonly number: number;
    readonly commit?: string | undefined;
  }): Effect.Effect<
    { readonly patch: string; readonly truncated: boolean },
    BitbucketPullRequestApiError
  > =>
    input.commit !== undefined && !isCommitSha(input.commit)
      ? Effect.fail(new BitbucketDiffCommitError())
      : withRepository(input.repository, (path) =>
          bitbucket
            .request({
              method: "GET",
              url:
                input.commit === undefined
                  ? `${path}/pullrequests/${input.number}/diff`
                  : `${path}/diff/${input.commit}`,
              maxBytes: DIFF_MAX_BYTES,
            })
            .pipe(
              Effect.map((response) => ({ patch: response.body, truncated: response.truncated })),
            ),
        );

  // Cache revisions, not large patch bodies that need reparsing.
  const revisionPatches = yield* Cache.makeWith(
    (key: string) => {
      const [repository, number] = JSON.parse(key) as [string, number];
      return pullRequestDiff({ repository, number }).pipe(
        Effect.map((diff) => ({
          revisions: parseDiffFileRevisions(diff.patch),
          truncated: diff.truncated,
        })),
      );
    },
    {
      capacity: REVISION_PATCH_CAPACITY,
      timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero),
    },
  );

  return BitbucketPullRequestApi.of({
    getViewer: Effect.fn("BitbucketPullRequestApi.getViewer")(function () {
      return bitbucket.request({ method: "GET", url: "/user" }).pipe(
        Effect.flatMap((response): Effect.Effect<string, BitbucketPullRequestApiError> => {
          const decoded = decodeViewerJson(response.body);
          if (!Result.isSuccess(decoded)) {
            return Effect.fail(
              new BitbucketPullRequestReadError({ operation: "getViewer", cause: decoded.failure }),
            );
          }
          return decoded.success === null
            ? Effect.fail(new BitbucketViewerUnavailableError())
            : Effect.succeed(decoded.success);
        }),
      );
    }),

    listPullRequests: Effect.fn("BitbucketPullRequestApi.listPullRequests")(function (input) {
      return withRepository(input.repository, (path) => {
        const search = input.query?.trim() ?? "";
        // Exclude already-sent IDs at the inclusive timestamp before applying the page limit.
        const predicates = [
          ...(search.length === 0 ? [] : [searchFilter(search)]),
          ...(input.author === undefined ? [] : [authorFilter(input.author)]),
          ...(input.draft === undefined ? [] : [`draft = ${input.draft}`]),
          ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]),
          ...(input.cursor?.seenAt ?? []).map((number) => `id != ${number}`),
        ];
        return listPage({
          // Listings omit reviewers needed by `viewerReviewRequested`.
          url: `${path}/pullrequests?${stateParams(input.state)
            .map((state) => `state=${state}`)
            .join("&")}&pagelen=${MAX_PAGE_SIZE}&sort=-updated_on&fields=%2Bvalues.reviewers${
            predicates.length === 0 ? "" : `&q=${encodeURIComponent(predicates.join(" AND "))}`
          }`,
          limit: input.limit,
          page: 1,
          collected: [],
        });
      });
    }),

    getPullRequest: Effect.fn("BitbucketPullRequestApi.getPullRequest")(function (input) {
      return withRepository(input.repository, (path) =>
        readPage({
          operation: "getPullRequest",
          // Settlement needs `closed_on`, omitted by default.
          url: `${path}/pullrequests/${input.number}?fields=%2Bclosed_on`,
          decode: decodePullRequestJson,
        }),
      );
    }),

    // Permission endpoints may return 403/404/410 (CHANGE-2770); let writes enforce access.
    getRepositoryPermission: Effect.fn("BitbucketPullRequestApi.getRepositoryPermission")(
      function (input) {
        return withRepository(input.repository, (_path, workspace, slug) =>
          readPage({
            operation: "getRepositoryPermission",
            url: `/user/workspaces/${encodeURIComponent(workspace)}/permissions/repositories?q=${encodeURIComponent(
              `repository.full_name="${filterLiteral(`${workspace}/${slug}`)}"`,
            )}`,
            decode: decodeRepositoryPermissionJson,
          }),
        ).pipe(Effect.catchIf(isRepositoryPermissionUnavailableError, () => Effect.succeed(true)));
      },
    ),

    getPullRequestDiff: pullRequestDiff,

    getFileRevisions: Effect.fn("BitbucketPullRequestApi.getFileRevisions")(function* (input) {
      if (input.paths.length === 0)
        return { revisions: new Map<string, string>(), complete: false };
      return yield* Cache.get(
        revisionPatches,
        encodeRevisionKey([input.repository, input.number]),
      ).pipe(
        Effect.map((diff) => {
          const revisions = new Map(diff.revisions);
          // A truncated patch cannot identify absent paths as deleted.
          if (!diff.truncated) {
            for (const path of input.paths) {
              if (!revisions.has(path)) revisions.set(path, "");
            }
          }
          return { revisions, complete: !diff.truncated };
        }),
      );
    }),

    getDiffStat: Effect.fn("BitbucketPullRequestApi.getDiffStat")(function (input) {
      return withRepository(input.repository, (path) =>
        diffStatPages({
          url: `${path}/pullrequests/${input.number}/diffstat?pagelen=${MAX_PAGE_SIZE}`,
          totals: { additions: 0, deletions: 0, changedFiles: 0 },
        }),
      );
    }),

    getMergeability: Effect.fn("BitbucketPullRequestApi.getMergeability")(function (input) {
      return withRepository(input.repository, (path) =>
        readPage({
          operation: "getMergeability",
          url: `${path}/pullrequests/${input.number}/conflicts`,
          decode: decodeConflictsJson,
        }),
      );
    }),

    listComments: Effect.fn("BitbucketPullRequestApi.listComments")(function (input) {
      return withRepository(input.repository, (path) =>
        commentsPage({
          url: `${path}/pullrequests/${input.number}/comments?pagelen=${CONVERSATION_PAGE_SIZE}`,
          page: 1,
          comments: [],
          entries: [],
        }),
      );
    }),

    listCommits: Effect.fn("BitbucketPullRequestApi.listCommits")(function (input) {
      return withRepository(input.repository, (path) =>
        itemPages({
          operation: "listCommits",
          url: `${path}/pullrequests/${input.number}/commits?pagelen=${CONVERSATION_PAGE_SIZE}`,
          decode: decodeCommitsJson,
          items: [],
          prepend: true,
        }),
      );
    }),

    listChecks: Effect.fn("BitbucketPullRequestApi.listChecks")(function (input) {
      return withRepository(input.repository, (path) =>
        itemPages({
          operation: "listChecks",
          url: `${path}/pullrequests/${input.number}/statuses?pagelen=${CONVERSATION_PAGE_SIZE}`,
          decode: decodeStatusesJson,
          items: [],
          prepend: false,
        }).pipe(Effect.map(sortChecks)),
      );
    }),

    listReviewerCandidates: Effect.fn("BitbucketPullRequestApi.listReviewerCandidates")(
      function (input) {
        return withRepository(input.repository, (path, workspace) =>
          Effect.all(
            [
              readPage({
                operation: "getPullRequest",
                url: `${path}/pullrequests/${input.number}`,
                decode: decodePullRequestJson,
              }),
              readPage({
                operation: "listReviewerCandidates",
                url: `/workspaces/${encodeURIComponent(workspace)}/members?pagelen=${MAX_PAGE_SIZE}`,
                decode: decodeWorkspaceMembersJson,
              }),
            ],
            { concurrency: 2 },
          ).pipe(
            Effect.map(([pullRequest, members]) => {
              const requested = new Set(pullRequest.reviewerIds);
              const author = pullRequest.author?.login;
              return {
                // Bitbucket forbids requesting a review from the PR author.
                candidates: members.items.flatMap((candidate) =>
                  candidate.login === author
                    ? []
                    : [{ ...candidate, isRequested: requested.has(candidate.id) }],
                ),
                truncated: members.next !== null,
              };
            }),
          ),
        );
      },
    ),

    setReviewerRequest: Effect.fn("BitbucketPullRequestApi.setReviewerRequest")(function (input) {
      return withRepository(input.repository, (path) => {
        const pullRequest = `${path}/pullrequests/${input.number}`;
        return readPage({
          operation: "getPullRequest",
          url: pullRequest,
          decode: decodePullRequestJson,
        }).pipe(
          Effect.flatMap((current) => {
            // Bitbucket replaces the entire reviewer list.
            const uuids = new Set(current.reviewerIds);
            for (const reviewer of input.reviewers) {
              if (input.requested) uuids.add(reviewer.id);
              else uuids.delete(reviewer.id);
            }
            return bitbucket.request({
              method: "PUT",
              url: pullRequest,
              body: JSON.stringify({ reviewers: [...uuids].map((uuid) => ({ uuid })) }),
            });
          }),
          Effect.asVoid,
        );
      });
    }),

    runAction: Effect.fn("BitbucketPullRequestApi.runAction")(function (input) {
      return withRepository(input.repository, (path) => {
        const pullRequest = `${path}/pullrequests/${input.number}`;
        if (input.action === "merge") {
          return bitbucket
            .request({
              method: "POST",
              url: `${pullRequest}/merge`,
              body: JSON.stringify({ merge_strategy: mergeStrategy(input.mergeMethod) }),
            })
            .pipe(Effect.asVoid);
        }
        return bitbucket
          .request({ method: "POST", url: `${pullRequest}/decline` })
          .pipe(Effect.asVoid);
      });
    }),

    updateChangeRequest: Effect.fn("BitbucketPullRequestApi.updateChangeRequest")(function (input) {
      return withRepository(input.repository, (path) =>
        // Partial PUTs preserve concurrent edits to unchanged fields.
        bitbucket
          .request({
            method: "PUT",
            url: `${path}/pullrequests/${input.number}`,
            body: JSON.stringify({
              ...(input.title === undefined ? {} : { title: input.title }),
              ...(input.body === undefined ? {} : { description: input.body }),
            }),
          })
          .pipe(Effect.asVoid),
      );
    }),

    comment: Effect.fn("BitbucketPullRequestApi.comment")(function (input) {
      return withRepository(input.repository, (path) =>
        bitbucket
          .request({
            method: "POST",
            url: `${path}/pullrequests/${input.number}/comments`,
            body: JSON.stringify({ content: { raw: input.body } }),
          })
          .pipe(Effect.asVoid),
      );
    }),

    updateComment: Effect.fn("BitbucketPullRequestApi.updateComment")(function (input) {
      return withRepository(input.repository, (path) =>
        bitbucket
          .request({
            method: "PUT",
            url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent(
              input.commentId,
            )}`,
            body: JSON.stringify({ content: { raw: input.body } }),
          })
          .pipe(Effect.asVoid),
      );
    }),

    submitReview: Effect.fn("BitbucketPullRequestApi.submitReview")(function (input) {
      return withRepository(input.repository, (path) =>
        Effect.gen(function* () {
          const pullRequest = `${path}/pullrequests/${input.number}`;
          // A partially failed review must not leave an approval.
          yield* Effect.forEach(
            input.comments,
            (comment) =>
              bitbucket.request({
                method: "POST",
                url: `${pullRequest}/comments`,
                body: JSON.stringify({
                  content: { raw: comment.body },
                  inline: {
                    path: comment.path,
                    ...bitbucketReviewPosition(comment.position),
                  },
                }),
              }),
            { discard: true },
          );
          if (input.body.trim().length > 0) {
            yield* bitbucket.request({
              method: "POST",
              url: `${pullRequest}/comments`,
              // @effect-diagnostics-next-line preferSchemaOverJson:off
              body: JSON.stringify({ content: { raw: input.body } }),
            });
          }
          if (input.verdict === "approve") {
            yield* bitbucket.request({ method: "POST", url: `${pullRequest}/approve` });
          }
          if (input.verdict === "request-changes") {
            yield* bitbucket.request({ method: "POST", url: `${pullRequest}/request-changes` });
          }
        }),
      );
    }),

    replyToComment: Effect.fn("BitbucketPullRequestApi.replyToComment")(function (input) {
      return withRepository(input.repository, (path) =>
        bitbucket
          .request({
            method: "POST",
            url: `${path}/pullrequests/${input.number}/comments`,
            body: JSON.stringify({
              content: { raw: input.body },
              parent: { id: Number(input.commentId) },
            }),
          })
          .pipe(Effect.asVoid),
      );
    }),

    setCommentResolution: Effect.fn("BitbucketPullRequestApi.setCommentResolution")(
      function (input) {
        return withRepository(input.repository, (path) =>
          bitbucket
            .request({
              method: input.resolved ? "POST" : "DELETE",
              url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent(
                input.commentId,
              )}/resolve`,
            })
            .pipe(Effect.asVoid),
        );
      },
    ),
  });
});

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