1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
4
5import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
6import {
7 PullRequestProviderError,
8 type PullRequestProviderFailure,
9 type ProviderChangeRequest,
10 type ProviderChangeRequestActivity,
11 type ProviderChangeRequestDetail,
12 type PullRequestProviderApi,
13} from "@t3tools/source-control-core/server/PullRequestProvider";
14import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts";
15import { definition } from "../client/definition.ts";
16
17const CAPABILITIES: PullRequestCapabilities = {
18 diff: true,
19 comment: true,
20 actions: [...definition.changeRequestActions],
21 mergeMethods: ["merge", "squash", "rebase"],
22 search: true,
23 // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is
24 // read and none is offered.
25 reactions: false,
26 review: {
27 inlineComment: true,
28 reply: true,
29 resolve: true,
30 verdicts: ["comment", "approve", "request-changes"],
31 },
32 reviewers: { request: true, listCandidates: true },
33 edit: { changeRequest: true, comment: true },
34 // Bitbucket Cloud states nothing about what a reviewer has already read: no endpoint carries a
35 // viewed file, and the per-pull-request properties it does offer are one value shared by
36 // everyone rather than one per reader. So the marks are kept here, and the client says whose
37 // they are rather than implying bitbucket.org will show them.
38 viewedFiles: "environment",
39};
40
41/**
42 * What the configured account may do here, from the one thing Bitbucket states per viewer: the
43 * repository permission. Merging needs `write` or `admin`, so that is what narrows.
44 *
45 * Declining stays offered whatever the permission. Bitbucket lets the author of a pull request
46 * decline their own with no more than read access, and the permission response says nothing about
47 * who opened this one — so withholding the control from the one person entitled to it is the
48 * worse of the two mistakes. Commenting and reviewing are not narrowed either: read access is
49 * enough to say something, to approve and to ask for changes.
50 *
51 * Asking for a review is left open for the same reason: Bitbucket takes a reviewer set from the
52 * author of a pull request as well as from whoever can write, and says nothing here about which
53 * of the two this account is.
54 */
55export function bitbucketViewerPermissions(input: {
56 readonly canWrite: boolean;
57}): PullRequestViewerPermissions {
58 return {
59 actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.canWrite),
60 comment: true,
61 resolve: true,
62 verdicts: CAPABILITIES.review.verdicts,
63 requestReviewers: true,
64 };
65}
66
67/** The failures that mean the credentials are the problem, rather than one request. */
68export function bitbucketProviderFailure(
69 error: BitbucketPullRequestApi.BitbucketPullRequestApiError,
70): PullRequestProviderFailure {
71 // Bitbucket is read over HTTP with credentials from the environment, so there is no tool to be
72 // missing: unusable always means the credentials are absent or refused.
73 if (error._tag === "BitbucketResponseError" && error.status === 401) {
74 return { reason: "unauthenticated" };
75 }
76 if (
77 (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") &&
78 error.status === 429
79 ) {
80 return {
81 reason: "rate-limited",
82 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
83 };
84 }
85 if (
86 (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") &&
87 error.status === 404
88 ) {
89 return { reason: "not-found" };
90 }
91 return { reason: "failed" };
92}
93
94function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeRequest {
95 return {
96 number: pullRequest.number,
97 title: pullRequest.title,
98 url: pullRequest.url,
99 author: pullRequest.author,
100 headBranch: pullRequest.headBranch,
101 ...(pullRequest.headSha ? { headSha: pullRequest.headSha } : {}),
102 ...(pullRequest.headRepositoryNameWithOwner
103 ? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner }
104 : {}),
105 baseBranch: pullRequest.baseBranch,
106 state: pullRequest.state,
107 isDraft: pullRequest.isDraft,
108 mergeability: pullRequest.mergeability,
109 // Line counts are a separate read, which only the detail is worth spending on.
110 additions: 0,
111 deletions: 0,
112 createdAt: pullRequest.createdAt,
113 updatedAt: pullRequest.updatedAt,
114 reviewRequestLogins: pullRequest.reviewRequestLogins,
115 // Bitbucket has no labels on a pull request.
116 labels: [],
117 };
118}
119
120export const make = Effect.gen(function* () {
121 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
122
123 const fail =
124 (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) =>
125 new PullRequestProviderError({
126 provider: SourceControlProviderKind.make("bitbucket"),
127 operation,
128 ...bitbucketProviderFailure(error),
129 // Every Bitbucket failure states its own fact; this names the operation around it, so
130 // the two do not stack into "failed in x: failed in y: ...".
131 detail: error.detail,
132 cause: error,
133 });
134
135 const recoverRead = <A>(
136 read: Effect.Effect<A, BitbucketPullRequestApi.BitbucketPullRequestApiError>,
137 fallback: A,
138 ) => {
139 const recover = () => Effect.succeed(fallback);
140 return Effect.catchTags(read, {
141 BitbucketResponseError: (error) => (error.status === 429 ? Effect.fail(error) : recover()),
142 BitbucketUntrustedUrlError: recover,
143 BitbucketRepositoryLocatorError: recover,
144 BitbucketRequestError: recover,
145 BitbucketResponseBodyReadError: (error) =>
146 error.status === 429 ? Effect.fail(error) : recover(),
147 BitbucketResponseDecodeError: recover,
148 BitbucketRepositoryVcsResolveError: recover,
149 BitbucketRepositoryRemotesListError: recover,
150 BitbucketRepositoryRemoteNotFoundError: recover,
151 BitbucketPullRequestBodyReadError: recover,
152 BitbucketCheckoutError: recover,
153 BitbucketPullRequestReadError: recover,
154 BitbucketViewerUnavailableError: recover,
155 BitbucketRepositoryUnsupportedError: recover,
156 BitbucketDiffCommitError: recover,
157 });
158 };
159
160 const provider: PullRequestProviderApi = {
161 kind: SourceControlProviderKind.make("bitbucket"),
162 capabilities: CAPABILITIES,
163
164 // Bitbucket credentials come from the server's environment rather than a checkout, so the
165 // account is the same whichever workspace asks.
166 getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))),
167
168 listChangeRequests: (input) =>
169 api
170 .listPullRequests({
171 repository: input.repository,
172 state: input.state,
173 limit: input.limit,
174 query: input.query,
175 cursor: input.cursor,
176 })
177 .pipe(
178 Effect.mapError(fail("listChangeRequests")),
179 Effect.map((batch) => ({
180 items: batch.items.map(toChangeRequest),
181 truncated: batch.truncated,
182 // Bitbucket is asked for `-updated_on` whether or not it is being carried on from,
183 // so every page it answers is one a cursor can continue.
184 continues: true,
185 })),
186 ),
187
188 getChangeRequestChecks: (input) =>
189 Effect.all([api.getPullRequest(input), api.listChecks(input)], { concurrency: 2 }).pipe(
190 Effect.map(([pullRequest, checks]) => ({ state: pullRequest.state, checks })),
191 Effect.mapError(fail("getChangeRequestChecks")),
192 ),
193
194 getChangeRequest: (input) => {
195 const target = { repository: input.repository, number: input.number };
196 return Effect.all(
197 [
198 api.getPullRequest(target),
199 api.getDiffStat(target),
200 recoverRead(api.getMergeability(target), "unknown" as const),
201 recoverRead(api.listChecks(target), []),
202 // A permission that could not be read is an unknown one, which is granted: a hidden
203 // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses
204 // at least says why.
205 recoverRead(api.getRepositoryPermission(target), true),
206 ],
207 { concurrency: 5 },
208 ).pipe(
209 Effect.mapError(fail("getChangeRequest")),
210 Effect.map(
211 ([
212 pullRequest,
213 diffStat,
214 mergeability,
215 checks,
216 canWrite,
217 ]): ProviderChangeRequestDetail => ({
218 ...toChangeRequest(pullRequest),
219 mergeability,
220 additions: diffStat.additions,
221 deletions: diffStat.deletions,
222 changedFiles: diffStat.changedFiles,
223 body: pullRequest.body,
224 mergedAt: null,
225 closedAt: null,
226 reviewers: pullRequest.reviewers,
227 checks,
228 // Bitbucket publishes no per-repository list of allowed strategies, so the ones it
229 // supports are all offered and a strategy the repository forbids fails on merge.
230 mergeCapabilities: { merge: true, squash: true, rebase: true },
231 viewerPermissions: bitbucketViewerPermissions({ canWrite }),
232 }),
233 ),
234 );
235 },
236
237 getChangeRequestActivity: (input) => {
238 const target = { repository: input.repository, number: input.number };
239 return Effect.all(
240 [
241 // Reviews ride on the pull request itself, so this inexpensive core read is repeated
242 // here rather than making the core response wait for the conversation endpoints.
243 api.getPullRequest(target),
244 recoverRead(api.listComments(target), { comments: [], threads: [], truncated: true }),
245 recoverRead(api.listCommits(target), []),
246 ],
247 { concurrency: 3 },
248 ).pipe(
249 Effect.mapError(fail("getChangeRequestActivity")),
250 Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({
251 comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) =>
252 left.createdAt.localeCompare(right.createdAt),
253 ),
254 commentCount: comments.comments.length + pullRequest.reviews.length,
255 commentsTruncated: comments.truncated,
256 reviewThreads: comments.threads,
257 commits,
258 })),
259 );
260 },
261
262 getViewerPermissions: (input) =>
263 api.getRepositoryPermission({ repository: input.repository }).pipe(
264 Effect.mapError(fail("getViewerPermissions")),
265 Effect.map((canWrite) => bitbucketViewerPermissions({ canWrite })),
266 ),
267
268 // `/diff` answers with the whole patch and pages nothing, so the first slice is the last.
269 getDiff: (input) =>
270 api
271 .getPullRequestDiff({
272 repository: input.repository,
273 number: input.number,
274 ...(input.commit === undefined ? {} : { commit: input.commit }),
275 })
276 .pipe(
277 Effect.mapError(fail("getDiff")),
278 Effect.map((diff) => ({ ...diff, nextCursor: null })),
279 ),
280
281 getFileRevisions: (input) =>
282 api
283 .getFileRevisions({
284 repository: input.repository,
285 number: input.number,
286 paths: input.paths,
287 })
288 .pipe(Effect.mapError(fail("getFileRevisions"))),
289
290 // Users only: Bitbucket requests a review of an account, and has no group that stands in for
291 // one on a pull request.
292 listReviewerCandidates: (input) =>
293 api
294 .listReviewerCandidates({ repository: input.repository, number: input.number })
295 .pipe(Effect.mapError(fail("listReviewerCandidates"))),
296
297 setReviewerRequest: (input) =>
298 api
299 .setReviewerRequest({
300 repository: input.repository,
301 number: input.number,
302 reviewers: input.reviewers,
303 requested: input.requested,
304 })
305 .pipe(Effect.mapError(fail("setReviewerRequest"))),
306
307 runAction: (input) =>
308 api
309 .runAction({
310 repository: input.repository,
311 number: input.number,
312 action: input.action,
313 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
314 })
315 .pipe(Effect.mapError(fail("runAction"))),
316
317 updateChangeRequest: (input) =>
318 api
319 .updateChangeRequest({
320 repository: input.repository,
321 number: input.number,
322 title: input.title,
323 body: input.body,
324 })
325 .pipe(Effect.mapError(fail("updateChangeRequest"))),
326
327 comment: (input) =>
328 api
329 .comment({ repository: input.repository, number: input.number, body: input.body })
330 .pipe(Effect.mapError(fail("comment"))),
331
332 updateComment: (input) =>
333 api
334 .updateComment({
335 repository: input.repository,
336 number: input.number,
337 commentId: input.commentId,
338 body: input.body,
339 })
340 .pipe(Effect.mapError(fail("updateComment"))),
341
342 submitReview: (input) =>
343 api
344 .submitReview({
345 repository: input.repository,
346 number: input.number,
347 verdict: input.verdict,
348 body: input.body,
349 comments: input.comments,
350 })
351 .pipe(Effect.mapError(fail("submitReview"))),
352
353 replyToThread: (input) =>
354 api
355 .replyToComment({
356 repository: input.repository,
357 number: input.number,
358 commentId: input.threadId,
359 body: input.body,
360 })
361 .pipe(Effect.mapError(fail("replyToThread"))),
362
363 // Never called: `capabilities.reactions` is false, and the service refuses without it.
364 setReaction: () =>
365 Effect.fail(
366 new PullRequestProviderError({
367 provider: SourceControlProviderKind.make("bitbucket"),
368 operation: "setReaction",
369 reason: "failed",
370 detail: "Bitbucket does not support reactions.",
371 }),
372 ),
373
374 setThreadResolution: (input) =>
375 api
376 .setCommentResolution({
377 repository: input.repository,
378 number: input.number,
379 commentId: input.threadId,
380 resolved: input.resolved,
381 })
382 .pipe(Effect.mapError(fail("setThreadResolution"))),
383 };
384
385 return provider;
386});