1import { resolvePullRequestAuthorFilter, SourceControlProviderKind } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
4
5import * as BitbucketApi from "./BitbucketApi.ts";
6import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
7import {
8 PullRequestProviderError,
9 type PullRequestProviderFailure,
10 type ProviderChangeRequest,
11 type ProviderChangeRequestActivity,
12 type ProviderChangeRequestDetail,
13 type ProviderChangeRequestSummary,
14 type PullRequestProviderApi,
15} from "@t3tools/source-control-core/server/PullRequestProvider";
16import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts";
17import { definition } from "../shared/definition.ts";
18
19const CAPABILITIES: PullRequestCapabilities = {
20 diff: true,
21 comment: true,
22 actions: [...definition.changeRequestActions],
23 mergeMethods: ["merge", "squash", "rebase"],
24 search: true,
25 reactions: false,
26 mentions: false,
27 referenceSigils: [],
28 review: {
29 inlineComment: true,
30 reply: true,
31 resolve: true,
32 verdicts: ["comment", "approve", "request-changes"],
33 },
34 reviewers: { request: true, listCandidates: true, searchCandidates: false },
35 edit: { changeRequest: true, comment: true },
36 // Bitbucket PR properties are shared, not per-reader viewed marks.
37 viewedFiles: "environment",
38};
39
40const UNAVAILABLE_REQUIREMENT = {
41 missing: "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.",
42 unauthenticated:
43 "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.",
44};
45
46/**
47 * Bitbucket lets the author of a pull request decline their own with no more than read access,
48 * and the permission response says nothing about who opened this one — so withholding the
49 * control from the one person entitled to it is the worse of the two mistakes.
50 */
51export function bitbucketViewerPermissions(input: {
52 readonly canWrite: boolean;
53}): PullRequestViewerPermissions {
54 return {
55 actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.canWrite),
56 comment: true,
57 resolve: true,
58 verdicts: CAPABILITIES.review.verdicts,
59 requestReviewers: true,
60 };
61}
62
63export function bitbucketProviderFailure(
64 error: BitbucketPullRequestApi.BitbucketPullRequestApiError,
65): PullRequestProviderFailure {
66 if (
67 (error._tag === "BitbucketResponseError" && error.status === 401) ||
68 error._tag === "SettingsSecretUnresolvedError"
69 ) {
70 return { reason: "unauthenticated" };
71 }
72 if (error._tag === "SourceControlRateLimitPausedError") {
73 return { reason: "rate-limited", retryAt: error.retryAt };
74 }
75 if (
76 (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") &&
77 error.status === 429
78 ) {
79 return {
80 reason: "rate-limited",
81 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
82 };
83 }
84 if (
85 (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") &&
86 error.status === 404
87 ) {
88 return { reason: "not-found" };
89 }
90 return { reason: "failed" };
91}
92
93function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeRequest {
94 return {
95 number: pullRequest.number,
96 title: pullRequest.title,
97 url: pullRequest.url,
98 author: pullRequest.author,
99 headBranch: pullRequest.headBranch,
100 ...(pullRequest.headSha ? { headSha: pullRequest.headSha } : {}),
101 ...(pullRequest.headRepositoryNameWithOwner
102 ? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner }
103 : {}),
104 baseBranch: pullRequest.baseBranch,
105 state: pullRequest.state,
106 isDraft: pullRequest.isDraft,
107 mergeability: pullRequest.mergeability,
108 additions: 0,
109 deletions: 0,
110 createdAt: pullRequest.createdAt,
111 updatedAt: pullRequest.updatedAt,
112 reviewRequestLogins: pullRequest.reviewRequestLogins,
113 labels: [],
114 };
115}
116
117/** The write's reply has no `closed_on`, line counts, or conflict state, so those stay absent. */
118function toActionSummary(pullRequest: BitbucketPullRequest): ProviderChangeRequestSummary {
119 return {
120 number: pullRequest.number,
121 title: pullRequest.title,
122 url: pullRequest.url,
123 author: pullRequest.author,
124 headBranch: pullRequest.headBranch,
125 baseBranch: pullRequest.baseBranch,
126 state: pullRequest.state,
127 isDraft: pullRequest.isDraft,
128 updatedAt: pullRequest.updatedAt,
129 ...(pullRequest.headSha ? { headSha: pullRequest.headSha } : {}),
130 };
131}
132
133export const make = Effect.gen(function* () {
134 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
135 const servesHost = yield* BitbucketApi.BitbucketServesHost.pipe(Effect.orDie);
136
137 const fail =
138 (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) =>
139 new PullRequestProviderError({
140 provider: SourceControlProviderKind.make("bitbucket"),
141 operation,
142 ...bitbucketProviderFailure(error),
143 detail: error._tag === "SettingsSecretUnresolvedError" ? error.message : error.detail,
144 cause: error,
145 });
146
147 const recoverRead = <A>(
148 read: Effect.Effect<A, BitbucketPullRequestApi.BitbucketPullRequestApiError>,
149 fallback: A,
150 ) => {
151 const recover = () => Effect.succeed(fallback);
152 return Effect.catchTags(read, {
153 BitbucketResponseError: (error) => (error.status === 429 ? Effect.fail(error) : recover()),
154 BitbucketUntrustedUrlError: recover,
155 BitbucketRepositoryLocatorError: recover,
156 BitbucketRequestError: recover,
157 BitbucketResponseBodyReadError: (error) =>
158 error.status === 429 ? Effect.fail(error) : recover(),
159 BitbucketResponseDecodeError: recover,
160 BitbucketRepositoryVcsResolveError: recover,
161 BitbucketRepositoryRemotesListError: recover,
162 BitbucketRepositoryRemoteNotFoundError: recover,
163 BitbucketPullRequestBodyReadError: recover,
164 BitbucketCheckoutError: recover,
165 BitbucketPullRequestReadError: recover,
166 BitbucketViewerUnavailableError: recover,
167 BitbucketRepositoryUnsupportedError: recover,
168 BitbucketDiffCommitError: recover,
169 });
170 };
171
172 const provider: PullRequestProviderApi = {
173 kind: SourceControlProviderKind.make("bitbucket"),
174 capabilities: CAPABILITIES,
175 servesHost,
176 unavailableRequirement: UNAVAILABLE_REQUIREMENT,
177 readsPublicHostWithoutCheckout: true,
178
179 getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))),
180
181 listChangeRequests: (input) =>
182 api
183 .listPullRequests({
184 repository: input.repository,
185 state: input.state,
186 limit: input.limit,
187 query: input.query,
188 cursor: input.cursor,
189 author:
190 input.filters?.author === undefined
191 ? undefined
192 : resolvePullRequestAuthorFilter(input.filters.author, input.viewer),
193 draft: input.filters?.draft === undefined ? undefined : input.filters.draft === "only",
194 })
195 .pipe(
196 Effect.mapError(fail("listChangeRequests")),
197 Effect.map((batch) => ({
198 items: batch.items.map(toChangeRequest),
199 truncated: batch.truncated,
200 continues: true,
201 })),
202 ),
203
204 getChangeRequestChecks: (input) =>
205 Effect.all([api.getPullRequest(input), api.listChecks(input)], { concurrency: 2 }).pipe(
206 Effect.map(([pullRequest, checks]) => ({ state: pullRequest.state, checks })),
207 Effect.mapError(fail("getChangeRequestChecks")),
208 ),
209
210 getChangeRequestPreview: (input) =>
211 api.getPullRequest({ repository: input.repository, number: input.number }).pipe(
212 Effect.mapError(fail("getChangeRequestPreview")),
213 Effect.map((pullRequest) => ({
214 number: pullRequest.number,
215 title: pullRequest.title,
216 url: pullRequest.url,
217 author: pullRequest.author,
218 state: pullRequest.state,
219 isDraft: pullRequest.isDraft,
220 createdAt: pullRequest.createdAt,
221 })),
222 ),
223
224 getChangeRequest: (input) => {
225 const target = { repository: input.repository, number: input.number };
226 return Effect.all(
227 [
228 api.getPullRequest(target),
229 api.getDiffStat(target),
230 recoverRead(api.getMergeability(target), "unknown" as const),
231 recoverRead(api.listChecks(target), []),
232 recoverRead(api.getRepositoryPermission(target), true),
233 ],
234 { concurrency: 5 },
235 ).pipe(
236 Effect.mapError(fail("getChangeRequest")),
237 Effect.map(
238 ([
239 pullRequest,
240 diffStat,
241 mergeability,
242 checks,
243 canWrite,
244 ]): ProviderChangeRequestDetail => ({
245 ...toChangeRequest(pullRequest),
246 mergeability,
247 additions: diffStat.additions,
248 deletions: diffStat.deletions,
249 changedFiles: diffStat.changedFiles,
250 body: pullRequest.body,
251 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
252 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
253 reviewers: pullRequest.reviewers,
254 checks,
255 // Bitbucket exposes no strategy permissions; unsupported strategies fail at merge.
256 mergeCapabilities: { merge: true, squash: true, rebase: true },
257 viewerPermissions: bitbucketViewerPermissions({ canWrite }),
258 }),
259 ),
260 );
261 },
262
263 getChangeRequestActivity: (input) => {
264 const target = { repository: input.repository, number: input.number };
265 return Effect.all(
266 [
267 api.getPullRequest(target),
268 recoverRead(api.listComments(target), { comments: [], threads: [], truncated: true }),
269 recoverRead(api.listCommits(target), []),
270 ],
271 { concurrency: 3 },
272 ).pipe(
273 Effect.mapError(fail("getChangeRequestActivity")),
274 Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({
275 comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) =>
276 left.createdAt.localeCompare(right.createdAt),
277 ),
278 commentCount: comments.comments.length + pullRequest.reviews.length,
279 commentsTruncated: comments.truncated,
280 reviewThreads: comments.threads,
281 commits,
282 })),
283 );
284 },
285
286 getViewerPermissions: (input) =>
287 api.getRepositoryPermission({ repository: input.repository }).pipe(
288 Effect.mapError(fail("getViewerPermissions")),
289 Effect.map((canWrite) => bitbucketViewerPermissions({ canWrite })),
290 ),
291
292 // `/diff` returns the whole patch without pagination.
293 getDiff: (input) =>
294 api
295 .getPullRequestDiff({
296 repository: input.repository,
297 number: input.number,
298 ...(input.commit === undefined ? {} : { commit: input.commit }),
299 })
300 .pipe(
301 Effect.mapError(fail("getDiff")),
302 Effect.map((diff) => ({ ...diff, nextCursor: null })),
303 ),
304
305 getFileRevisions: (input) =>
306 api
307 .getFileRevisions({
308 repository: input.repository,
309 number: input.number,
310 paths: input.paths,
311 })
312 .pipe(Effect.mapError(fail("getFileRevisions"))),
313
314 // Bitbucket supports user reviewers only.
315 listReviewerCandidates: (input) =>
316 api
317 .listReviewerCandidates({ repository: input.repository, number: input.number })
318 .pipe(Effect.mapError(fail("listReviewerCandidates"))),
319
320 setReviewerRequest: (input) =>
321 api
322 .setReviewerRequest({
323 repository: input.repository,
324 number: input.number,
325 reviewers: input.reviewers,
326 requested: input.requested,
327 })
328 .pipe(Effect.mapError(fail("setReviewerRequest"))),
329
330 runAction: (input) =>
331 api
332 .runAction({
333 repository: input.repository,
334 number: input.number,
335 action: input.action,
336 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
337 })
338 .pipe(
339 Effect.mapError(fail("runAction")),
340 Effect.map((pullRequest) => pullRequest && toActionSummary(pullRequest)),
341 ),
342
343 updateChangeRequest: (input) =>
344 api
345 .updateChangeRequest({
346 repository: input.repository,
347 number: input.number,
348 title: input.title,
349 body: input.body,
350 })
351 .pipe(Effect.mapError(fail("updateChangeRequest"))),
352
353 comment: (input) =>
354 api
355 .comment({ repository: input.repository, number: input.number, body: input.body })
356 .pipe(Effect.mapError(fail("comment"))),
357
358 updateComment: (input) =>
359 api
360 .updateComment({
361 repository: input.repository,
362 number: input.number,
363 commentId: input.commentId,
364 body: input.body,
365 })
366 .pipe(Effect.mapError(fail("updateComment"))),
367
368 submitReview: (input) =>
369 api
370 .submitReview({
371 repository: input.repository,
372 number: input.number,
373 verdict: input.verdict,
374 body: input.body,
375 comments: input.comments,
376 })
377 .pipe(Effect.mapError(fail("submitReview"))),
378
379 replyToThread: (input) =>
380 api
381 .replyToComment({
382 repository: input.repository,
383 number: input.number,
384 commentId: input.threadId,
385 body: input.body,
386 })
387 .pipe(Effect.mapError(fail("replyToThread"))),
388
389 setReaction: () =>
390 Effect.fail(
391 new PullRequestProviderError({
392 provider: SourceControlProviderKind.make("bitbucket"),
393 operation: "setReaction",
394 reason: "failed",
395 detail: "Bitbucket does not support reactions.",
396 }),
397 ),
398
399 setThreadResolution: (input) =>
400 api
401 .setCommentResolution({
402 repository: input.repository,
403 number: input.number,
404 commentId: input.threadId,
405 resolved: input.resolved,
406 })
407 .pipe(Effect.mapError(fail("setThreadResolution"))),
408 };
409
410 return provider;
411});