333 lines · 12.0 KB · TypeScript
RawDownload
import * as Effect from "effect/Effect";import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";import {  PullRequestProviderError,  type PullRequestProviderFailure,  type ProviderChangeRequest,  type ProviderChangeRequestActivity,  type ProviderChangeRequestDetail,  type PullRequestProviderApi,} from "./PullRequestProvider.ts";import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts"; const CAPABILITIES: PullRequestCapabilities = {  diff: true,  comment: true,  // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that  // moves one in or out of draft, so neither is offered rather than failing when pressed.  actions: ["merge", "close"],  mergeMethods: ["merge", "squash", "rebase"],  search: true,  // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is  // read and none is offered.  reactions: false,  review: {    inlineComment: true,    reply: true,    resolve: true,    verdicts: ["comment", "approve", "request-changes"],  },  reviewers: { request: true, listCandidates: true },  edit: { changeRequest: true, comment: true },}; /** * What the configured account may do here, from the one thing Bitbucket states per viewer: the * repository permission. Merging needs `write` or `admin`, so that is what narrows. * * Declining stays offered whatever the permission. Bitbucket lets the author of a pull request * decline their own with no more than read access, and the permission response says nothing about * who opened this one — so withholding the control from the one person entitled to it is the * worse of the two mistakes. Commenting and reviewing are not narrowed either: read access is * enough to say something, to approve and to ask for changes. * * Asking for a review is left open for the same reason: Bitbucket takes a reviewer set from the * author of a pull request as well as from whoever can write, and says nothing here about which * of the two this account is. */export function bitbucketViewerPermissions(input: {  readonly canWrite: boolean;}): PullRequestViewerPermissions {  return {    actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.canWrite),    comment: true,    resolve: true,    verdicts: CAPABILITIES.review.verdicts,    requestReviewers: true,  };} /** The failures that mean the credentials are the problem, rather than one request. */export function bitbucketProviderFailure(  error: BitbucketPullRequestApi.BitbucketPullRequestApiError,): PullRequestProviderFailure {  // Bitbucket is read over HTTP with credentials from the environment, so there is no tool to be  // missing: unusable always means the credentials are absent or refused.  if (error._tag === "BitbucketResponseError" && error.status === 401) {    return { reason: "unauthenticated" };  }  if (error._tag === "BitbucketResponseError" && error.status === 429) {    return {      reason: "rate-limited",      ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),    };  }  return { reason: "failed" };} function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeRequest {  return {    number: pullRequest.number,    title: pullRequest.title,    url: pullRequest.url,    author: pullRequest.author,    headBranch: pullRequest.headBranch,    ...(pullRequest.headRepositoryNameWithOwner      ? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner }      : {}),    baseBranch: pullRequest.baseBranch,    state: pullRequest.state,    isDraft: pullRequest.isDraft,    mergeability: pullRequest.mergeability,    // Line counts are a separate read, which only the detail is worth spending on.    additions: 0,    deletions: 0,    createdAt: pullRequest.createdAt,    updatedAt: pullRequest.updatedAt,    reviewRequestLogins: pullRequest.reviewRequestLogins,    // Bitbucket has no labels on a pull request.    labels: [],  };} export const make = Effect.gen(function* () {  const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;   const fail =    (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) =>      new PullRequestProviderError({        provider: "bitbucket",        operation,        ...bitbucketProviderFailure(error),        // Every Bitbucket failure states its own fact; this names the operation around it, so        // the two do not stack into "failed in x: failed in y: ...".        detail: error.detail,        cause: error,      });   const provider: PullRequestProviderApi = {    kind: "bitbucket",    capabilities: CAPABILITIES,     // Bitbucket credentials come from the server's environment rather than a checkout, so the    // account is the same whichever workspace asks.    getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))),     listChangeRequests: (input) =>      api        .listPullRequests({          repository: input.repository,          state: input.state,          limit: input.limit,          query: input.query,          cursor: input.cursor,        })        .pipe(          Effect.mapError(fail("listChangeRequests")),          Effect.map((batch) => ({            items: batch.items.map(toChangeRequest),            truncated: batch.truncated,            // Bitbucket is asked for `-updated_on` whether or not it is being carried on from,            // so every page it answers is one a cursor can continue.            continues: true,          })),        ),     getChangeRequest: (input) => {      const target = { repository: input.repository, number: input.number };      return Effect.all(        [          api.getPullRequest(target),          api.getDiffStat(target),          api.getMergeability(target).pipe(Effect.orElseSucceed(() => "unknown" as const)),          api.listChecks(target).pipe(Effect.orElseSucceed(() => [])),          // A permission that could not be read is an unknown one, which is granted: a hidden          // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses          // at least says why.          api.getRepositoryPermission(target).pipe(Effect.orElseSucceed(() => true)),        ],        { concurrency: 5 },      ).pipe(        Effect.mapError(fail("getChangeRequest")),        Effect.map(          ([            pullRequest,            diffStat,            mergeability,            checks,            canWrite,          ]): ProviderChangeRequestDetail => ({            ...toChangeRequest(pullRequest),            mergeability,            additions: diffStat.additions,            deletions: diffStat.deletions,            changedFiles: diffStat.changedFiles,            body: pullRequest.body,            mergedAt: pullRequest.state === "merged" ? pullRequest.updatedAt : null,            closedAt: pullRequest.state === "closed" ? pullRequest.updatedAt : null,            reviewers: pullRequest.reviewers,            checks,            // Bitbucket publishes no per-repository list of allowed strategies, so the ones it            // supports are all offered and a strategy the repository forbids fails on merge.            mergeCapabilities: { merge: true, squash: true, rebase: true },            viewerPermissions: bitbucketViewerPermissions({ canWrite }),          }),        ),      );    },     getChangeRequestActivity: (input) => {      const target = { repository: input.repository, number: input.number };      return Effect.all(        [          // Reviews ride on the pull request itself, so this inexpensive core read is repeated          // here rather than making the core response wait for the conversation endpoints.          api.getPullRequest(target),          api            .listComments(target)            .pipe(Effect.orElseSucceed(() => ({ comments: [], threads: [], truncated: true }))),          api.listCommits(target).pipe(Effect.orElseSucceed(() => [])),        ],        { concurrency: 3 },      ).pipe(        Effect.mapError(fail("getChangeRequestActivity")),        Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({          comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) =>            left.createdAt.localeCompare(right.createdAt),          ),          commentCount: comments.comments.length + pullRequest.reviews.length,          commentsTruncated: comments.truncated,          reviewThreads: comments.threads,          commits,        })),      );    },     getViewerPermissions: (input) =>      api.getRepositoryPermission({ repository: input.repository }).pipe(        Effect.mapError(fail("getViewerPermissions")),        Effect.map((canWrite) => bitbucketViewerPermissions({ canWrite })),      ),     // `/diff` answers with the whole patch and pages nothing, so the first slice is the last.    getDiff: (input) =>      api        .getPullRequestDiff({          repository: input.repository,          number: input.number,          ...(input.commit === undefined ? {} : { commit: input.commit }),        })        .pipe(          Effect.mapError(fail("getDiff")),          Effect.map((diff) => ({ ...diff, nextCursor: null })),        ),     // Users only: Bitbucket requests a review of an account, and has no group that stands in for    // one on a pull request.    listReviewerCandidates: (input) =>      api        .listReviewerCandidates({ repository: input.repository, number: input.number })        .pipe(Effect.mapError(fail("listReviewerCandidates"))),     setReviewerRequest: (input) =>      api        .setReviewerRequest({          repository: input.repository,          number: input.number,          reviewers: input.reviewers,          requested: input.requested,        })        .pipe(Effect.mapError(fail("setReviewerRequest"))),     runAction: (input) =>      api        .runAction({          repository: input.repository,          number: input.number,          action: input.action,          ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),        })        .pipe(Effect.mapError(fail("runAction"))),     updateChangeRequest: (input) =>      api        .updateChangeRequest({          repository: input.repository,          number: input.number,          title: input.title,          body: input.body,        })        .pipe(Effect.mapError(fail("updateChangeRequest"))),     comment: (input) =>      api        .comment({ repository: input.repository, number: input.number, body: input.body })        .pipe(Effect.mapError(fail("comment"))),     updateComment: (input) =>      api        .updateComment({          repository: input.repository,          number: input.number,          commentId: input.commentId,          body: input.body,        })        .pipe(Effect.mapError(fail("updateComment"))),     submitReview: (input) =>      api        .submitReview({          repository: input.repository,          number: input.number,          verdict: input.verdict,          body: input.body,          comments: input.comments,        })        .pipe(Effect.mapError(fail("submitReview"))),     replyToThread: (input) =>      api        .replyToComment({          repository: input.repository,          number: input.number,          commentId: input.threadId,          body: input.body,        })        .pipe(Effect.mapError(fail("replyToThread"))),     // Never called: `capabilities.reactions` is false, and the service refuses without it.    setReaction: () =>      Effect.fail(        new PullRequestProviderError({          provider: "bitbucket",          operation: "setReaction",          reason: "failed",          detail: "Bitbucket does not support reactions.",        }),      ),     setThreadResolution: (input) =>      api        .setCommentResolution({          repository: input.repository,          number: input.number,          commentId: input.threadId,          resolved: input.resolved,        })        .pipe(Effect.mapError(fail("setThreadResolution"))),  };   return provider;});