864 lines · 32.4 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,  PullRequestCheck,  PullRequestComment,  PullRequestCommit,  PullRequestListState,  PullRequestMergeMethod,  PullRequestMergeability,  PullRequestReviewCommentDraft,  PullRequestReviewPosition,  PullRequestReviewThread,  PullRequestReviewVerdict,  PullRequestReviewerCandidateList,} from "@t3tools/contracts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";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 "./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 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}`;  }} /** Not a decode failure: Bitbucket answered, the account it answered for just has no handle. */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}`;  }} /** A repository that is not `workspace/slug`, which is the only form Bitbucket addresses. */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}`;  }} /** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */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; /** * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a * permission — Bitbucket sends this for every account now, not only ones it would have refused. */function isRepositoryPermissionRemovedError(  error: BitbucketPullRequestApiError,): error is BitbucketApi.BitbucketResponseError {  return error._tag === "BitbucketResponseError" && error.status === 410;} /** * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no * error at all, so this is a number to respect rather than to push against. */const MAX_PAGE_SIZE = 50;/** Pages to walk before a listing is reported as truncated. */const MAX_LIST_PAGES = 10;/** The page size for pull request conversations, commits, and checks. */const CONVERSATION_PAGE_SIZE = 50;/** * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves * fifty comments a page, so this is five hundred — beyond any pull request a person is reading, * and an end to a walk whose only other stop is Bitbucket running out. */const CONVERSATION_PAGES = 10;/** The same ceiling the gh and glab diff reads use. */const DIFF_MAX_BYTES = 8 * 1024 * 1024; export interface BitbucketPullRequestBatch {  readonly items: ReadonlyArray<BitbucketPullRequest>;  readonly truncated: boolean;} export class BitbucketPullRequestApi extends Context.Service<  BitbucketPullRequestApi,  {    /** A function rather than a value, so the request is built per call and not at layer time. */    readonly getViewer: () => Effect.Effect<string, BitbucketPullRequestApiError>;     readonly listPullRequests: (input: {      readonly repository: string;      readonly state: PullRequestListState;      readonly limit: number;      /** Free text, matched against a pull request's title and description. */      readonly query?: string | undefined;      /** Where to carry on from, as a predicate on `updated_on` beside any other. */      readonly cursor?: ProviderListCursor | undefined;    }) => Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError>;     readonly getPullRequest: (input: {      readonly repository: string;      readonly number: number;    }) => Effect.Effect<BitbucketPullRequest, BitbucketPullRequestApiError>;     /** True where the credentials can write to the repository, which is what merging needs. */    readonly getRepositoryPermission: (input: {      readonly repository: string;    }) => Effect.Effect<boolean, BitbucketPullRequestApiError>;     readonly getPullRequestDiff: (input: {      readonly repository: string;      readonly number: number;      /** One commit's own changes, rather than everything the pull request carries. */      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>;     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>;     /**     * Who this pull request may be sent to, and who it has already been sent to. Two reads at     * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull     * request, and neither answers for the other.     */    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>;  }>()("t3/pullRequest/BitbucketPullRequestApi") {} /** `workspace/slug`; Bitbucket has no deeper nesting to address. */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,  )}`;} /** * 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);} /** * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks * for each. It separates a declined pull request from one superseded by another, and both read * as closed here. */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"];  }} /** * Bitbucket has no search term, only a filter expression, so free text becomes one: a * case-insensitive contains against the two fields a pull request carries words in. The * parentheses matter, because the expression is ANDed with the state filter beside it and an * unbracketed `OR` would swallow it. * * A string literal in that grammar is delimited by double quotes, so the reader's text is * escaped before it goes inside one — a quote would otherwise end the literal and leave the * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so * nothing in it reaches the query string as a parameter of its own. */function searchFilter(query: string): string {  const literal = filterLiteral(query);  return `(title ~ "${literal}" OR description ~ "${literal}")`;} /** * Text as a string literal of Bitbucket's filter grammar. 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('"', '\\"');} /** Bitbucket's merge strategies, named differently from the three the contract carries. */function mergeStrategy(method: PullRequestMergeMethod | undefined): string {  switch (method) {    case "squash":      return "squash";    case "rebase":      // The linear history GitHub calls "rebase and merge".      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 Service construction is part of the canonical Effect module API. */export const make = Effect.gen(function* () {  const bitbucket = yield* BitbucketApi.BitbucketApi;   /**   * The repository's own path, and the workspace above it — which the people who may review are   * kept on rather than on the repository, so both are handed over at once.   */  const withRepository = <A>(    repository: string,    use: (path: string, workspace: string) => Effect.Effect<A, BitbucketPullRequestApiError>,  ): Effect.Effect<A, BitbucketPullRequestApiError> => {    const segments = repositorySegments(repository);    return Result.isSuccess(segments)      ? use(repositoryPathOf(segments.success), segments.success.workspace)      : Effect.fail(segments.failure);  };   /**   * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it   * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at   * the page cap — and anything but running out of pages means there is more to be had.   */  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),            // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the            // count rather than on the last page is holding rows it is about to drop. Those are            // more results just as surely as another page would be.            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,              }),            );      }),    );   /**   * The conversation, following the `next` Bitbucket sends until it sends none. Threads are   * assembled once at the end rather than per page, because a reply and the remark it answers   * can land either side of a page boundary.   */  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,        });      }),    );   /** Walks a Bitbucket cursor to its end and combines every decoded item. */  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>;    /** Commit pages are individually oldest-first, so older pages are prepended. */    readonly prepend: boolean;  }): Effect.Effect<ReadonlyArray<A>, BitbucketPullRequestApiError> =>    readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe(      Effect.flatMap((page) => {

Lines 1–500 of 864.