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, 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;});
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,
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;
});