1import * as Cache from "effect/Cache";
2import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Layer from "effect/Layer";
7import * as Result from "effect/Result";
8import * as Schema from "effect/Schema";
9import type {
10 PullRequestAction,
11 PullRequestCheck,
12 PullRequestComment,
13 PullRequestCommit,
14 PullRequestListState,
15 PullRequestMergeMethod,
16 PullRequestMergeability,
17 PullRequestReviewCommentDraft,
18 PullRequestReviewPosition,
19 PullRequestReviewThread,
20 PullRequestReviewVerdict,
21 PullRequestReviewerCandidateList,
22} from "@t3tools/contracts";
23
24import * as BitbucketApi from "./BitbucketApi.ts";
25import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions";
26import {
27 buildReviewThreads,
28 decodeCommentsJson,
29 decodeCommitsJson,
30 decodeConflictsJson,
31 decodeDiffstatJson,
32 decodePullRequestJson,
33 decodePullRequestPageJson,
34 decodeRepositoryPermissionJson,
35 decodeStatusesJson,
36 decodeViewerJson,
37 decodeWorkspaceMembersJson,
38 type BitbucketDiffStat,
39 type BitbucketPullRequest,
40 type BitbucketRawComment,
41} from "./bitbucketPullRequestJson.ts";
42import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
43
44/**
45 * Names the read that produced unusable output, so a failure reports the call it came from
46 * rather than borrowing another operation's message.
47 */
48export class BitbucketPullRequestReadError extends Schema.TaggedError<BitbucketPullRequestReadError>()(
49 "BitbucketPullRequestReadError",
50 {
51 operation: Schema.String,
52 cause: Schema.Defect(),
53 },
54) {
55 get detail(): string {
56 return `Bitbucket returned an unreadable ${this.operation} response.`;
57 }
58
59 override get message(): string {
60 return `Bitbucket failed in ${this.operation}: ${this.detail}`;
61 }
62}
63
64/** Not a decode failure: Bitbucket answered, the account it answered for just has no handle. */
65export class BitbucketViewerUnavailableError extends Schema.TaggedError<BitbucketViewerUnavailableError>()(
66 "BitbucketViewerUnavailableError",
67 {},
68) {
69 get detail(): string {
70 return "Bitbucket returned no account name for the configured credentials.";
71 }
72
73 override get message(): string {
74 return `Bitbucket failed in getViewer: ${this.detail}`;
75 }
76}
77
78/** A repository that is not `workspace/slug`, which is the only form Bitbucket addresses. */
79export class BitbucketRepositoryUnsupportedError extends Schema.TaggedError<BitbucketRepositoryUnsupportedError>()(
80 "BitbucketRepositoryUnsupportedError",
81 {
82 repository: Schema.String,
83 },
84) {
85 get detail(): string {
86 return "A Bitbucket repository is addressed as workspace/repository.";
87 }
88
89 override get message(): string {
90 return `Bitbucket failed in resolveRepository: ${this.detail}`;
91 }
92}
93
94/** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */
95export class BitbucketDiffCommitError extends Schema.TaggedError<BitbucketDiffCommitError>()(
96 "BitbucketDiffCommitError",
97 {},
98) {
99 get detail(): string {
100 return "The named commit was not a commit sha.";
101 }
102
103 override get message(): string {
104 return `Bitbucket failed in getPullRequestDiff: ${this.detail}`;
105 }
106}
107
108export type BitbucketPullRequestApiError =
109 | BitbucketApi.BitbucketApiError
110 | BitbucketPullRequestReadError
111 | BitbucketViewerUnavailableError
112 | BitbucketRepositoryUnsupportedError
113 | BitbucketDiffCommitError;
114
115/** The permissions endpoint refusing or missing for these credentials, rather than answering. */
116function isRepositoryPermissionUnavailableError(
117 error: BitbucketPullRequestApiError,
118): error is BitbucketApi.BitbucketResponseError {
119 return (
120 error._tag === "BitbucketResponseError" &&
121 (error.status === 403 || error.status === 404 || error.status === 410)
122 );
123}
124
125/**
126 * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no
127 * error at all, so this is a number to respect rather than to push against.
128 */
129const MAX_PAGE_SIZE = 50;
130/** Pages to walk before a listing is reported as truncated. */
131const MAX_LIST_PAGES = 10;
132/** The page size for pull request conversations, commits, and checks. */
133const CONVERSATION_PAGE_SIZE = 50;
134/**
135 * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves
136 * fifty comments a page, so this is five hundred — beyond any pull request a person is reading,
137 * and an end to a walk whose only other stop is Bitbucket running out.
138 */
139const CONVERSATION_PAGES = 10;
140/** The same ceiling the gh and glab diff reads use. */
141const DIFF_MAX_BYTES = 8 * 1024 * 1024;
142/**
143 * Deliberately far shorter than the window the caller holds versions for: a refresh drops what the
144 * caller holds precisely so the next read reaches Bitbucket, and this must not be what answers it
145 * instead.
146 */
147const REVISION_PATCH_TTL = Duration.seconds(5);
148const REVISION_PATCH_CAPACITY = 16;
149export interface BitbucketPullRequestBatch {
150 readonly items: ReadonlyArray<BitbucketPullRequest>;
151 readonly truncated: boolean;
152}
153
154export class BitbucketPullRequestApi extends Context.Service<
155 BitbucketPullRequestApi,
156 {
157 /** A function rather than a value, so the request is built per call and not at layer time. */
158 readonly getViewer: () => Effect.Effect<string, BitbucketPullRequestApiError>;
159
160 readonly listPullRequests: (input: {
161 readonly repository: string;
162 readonly state: PullRequestListState;
163 readonly limit: number;
164 /** Free text, matched against a pull request's title and description. */
165 readonly query?: string | undefined;
166 /** Where to carry on from, as a predicate on `updated_on` beside any other. */
167 readonly cursor?: ProviderListCursor | undefined;
168 }) => Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError>;
169
170 readonly getPullRequest: (input: {
171 readonly repository: string;
172 readonly number: number;
173 }) => Effect.Effect<BitbucketPullRequest, BitbucketPullRequestApiError>;
174
175 /** True where the credentials can write to the repository, which is what merging needs. */
176 readonly getRepositoryPermission: (input: {
177 readonly repository: string;
178 }) => Effect.Effect<boolean, BitbucketPullRequestApiError>;
179
180 readonly getPullRequestDiff: (input: {
181 readonly repository: string;
182 readonly number: number;
183 /** One commit's own changes, rather than everything the pull request carries. */
184 readonly commit?: string | undefined;
185 }) => Effect.Effect<
186 { readonly patch: string; readonly truncated: boolean },
187 BitbucketPullRequestApiError
188 >;
189
190 readonly getDiffStat: (input: {
191 readonly repository: string;
192 readonly number: number;
193 }) => Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError>;
194
195 /**
196 * What the pull request's head has of each of these paths, as opaque ids, read off the pull
197 * request's own patch, the only place Bitbucket states a file's version. A path the patch does
198 * not carry is answered as the empty revision, and left out altogether when the patch was cut
199 * short at the byte ceiling and so cannot be spoken for.
200 *
201 * Answers with every file the patch carries rather than only the paths asked about, since
202 * reading one file's version here means parsing all of them. `complete` is false for a patch
203 * cut short, which cannot speak for what came after the cut.
204 */
205 readonly getFileRevisions: (input: {
206 readonly repository: string;
207 readonly number: number;
208 readonly paths: ReadonlyArray<string>;
209 }) => Effect.Effect<
210 { readonly revisions: ReadonlyMap<string, string>; readonly complete: boolean },
211 BitbucketPullRequestApiError
212 >;
213
214 readonly getMergeability: (input: {
215 readonly repository: string;
216 readonly number: number;
217 }) => Effect.Effect<PullRequestMergeability, BitbucketPullRequestApiError>;
218
219 readonly listComments: (input: {
220 readonly repository: string;
221 readonly number: number;
222 }) => Effect.Effect<
223 {
224 readonly comments: ReadonlyArray<PullRequestComment>;
225 readonly threads: ReadonlyArray<PullRequestReviewThread>;
226 readonly truncated: boolean;
227 },
228 BitbucketPullRequestApiError
229 >;
230
231 readonly listCommits: (input: {
232 readonly repository: string;
233 readonly number: number;
234 }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, BitbucketPullRequestApiError>;
235
236 readonly listChecks: (input: {
237 readonly repository: string;
238 readonly number: number;
239 }) => Effect.Effect<ReadonlyArray<PullRequestCheck>, BitbucketPullRequestApiError>;
240
241 /**
242 * Who this pull request may be sent to, and who it has already been sent to. Two reads at
243 * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull
244 * request, and neither answers for the other.
245 */
246 readonly listReviewerCandidates: (input: {
247 readonly repository: string;
248 readonly number: number;
249 }) => Effect.Effect<PullRequestReviewerCandidateList, BitbucketPullRequestApiError>;
250
251 readonly setReviewerRequest: (input: {
252 readonly repository: string;
253 readonly number: number;
254 readonly reviewers: ReadonlyArray<{ readonly id: string }>;
255 readonly requested: boolean;
256 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
257
258 readonly runAction: (input: {
259 readonly repository: string;
260 readonly number: number;
261 readonly action: PullRequestAction;
262 readonly mergeMethod?: PullRequestMergeMethod;
263 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
264
265 readonly updateChangeRequest: (input: {
266 readonly repository: string;
267 readonly number: number;
268 readonly title?: string | undefined;
269 readonly body?: string | undefined;
270 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
271
272 readonly comment: (input: {
273 readonly repository: string;
274 readonly number: number;
275 readonly body: string;
276 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
277
278 readonly updateComment: (input: {
279 readonly repository: string;
280 readonly number: number;
281 readonly commentId: string;
282 readonly body: string;
283 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
284
285 readonly submitReview: (input: {
286 readonly repository: string;
287 readonly number: number;
288 readonly verdict: PullRequestReviewVerdict;
289 readonly body: string;
290 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
291 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
292
293 readonly replyToComment: (input: {
294 readonly repository: string;
295 readonly number: number;
296 readonly commentId: string;
297 readonly body: string;
298 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
299
300 readonly setCommentResolution: (input: {
301 readonly repository: string;
302 readonly number: number;
303 readonly commentId: string;
304 readonly resolved: boolean;
305 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
306 }
307>()("@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi") {}
308
309/** `workspace/slug`; Bitbucket has no deeper nesting to address. */
310function repositorySegments(
311 repository: string,
312): Result.Result<
313 { readonly workspace: string; readonly slug: string },
314 BitbucketRepositoryUnsupportedError
315> {
316 const segments = repository
317 .split("/")
318 .map((segment) => segment.trim())
319 .filter((segment) => segment.length > 0);
320 const [workspace, slug] = segments;
321 if (segments.length !== 2 || workspace === undefined || slug === undefined) {
322 return Result.fail(new BitbucketRepositoryUnsupportedError({ repository }));
323 }
324 return Result.succeed({ workspace, slug });
325}
326
327function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string {
328 return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent(
329 segments.slug,
330 )}`;
331}
332
333/**
334 * A commit sha arrives from the reader and goes straight into a request path, so it is checked
335 * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a
336 * whole sha.
337 */
338function isCommitSha(value: string): boolean {
339 return /^[0-9a-f]{7,64}$/i.test(value);
340}
341
342/**
343 * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks
344 * for each. It separates a declined pull request from one superseded by another, and both read
345 * as closed here.
346 */
347function stateParams(state: PullRequestListState): ReadonlyArray<string> {
348 switch (state) {
349 case "open":
350 return ["OPEN"];
351 case "merged":
352 return ["MERGED"];
353 case "closed":
354 return ["DECLINED", "SUPERSEDED"];
355 case "all":
356 return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"];
357 }
358}
359
360/**
361 * Bitbucket has no search term, only a filter expression, so free text becomes one: a
362 * case-insensitive contains against the two fields a pull request carries words in. The
363 * parentheses matter, because the expression is ANDed with the state filter beside it and an
364 * unbracketed `OR` would swallow it.
365 *
366 * A string literal in that grammar is delimited by double quotes, so the reader's text is
367 * escaped before it goes inside one — a quote would otherwise end the literal and leave the
368 * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so
369 * nothing in it reaches the query string as a parameter of its own.
370 */
371function searchFilter(query: string): string {
372 const literal = filterLiteral(query);
373 return `(title ~ "${literal}" OR description ~ "${literal}")`;
374}
375
376/**
377 * Text as a string literal of Bitbucket's filter grammar. The backslash is escaped first, or
378 * escaping the quote would only produce a literal backslash followed by a live quote.
379 */
380function filterLiteral(value: string): string {
381 return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
382}
383
384/** Bitbucket's merge strategies, named differently from the three the contract carries. */
385function mergeStrategy(method: PullRequestMergeMethod | undefined): string {
386 switch (method) {
387 case "squash":
388 return "squash";
389 case "rebase":
390 // The linear history GitHub calls "rebase and merge".
391 return "rebase_fast_forward";
392 default:
393 return "merge_commit";
394 }
395}
396
397function bitbucketReviewPosition(
398 position: PullRequestReviewPosition,
399): { readonly from: number } | { readonly to: number } {
400 switch (position.kind) {
401 case "added":
402 return { to: position.newLine };
403 case "deleted":
404 return { from: position.oldLine };
405 case "context":
406 return position.side === "left" ? { from: position.oldLine } : { to: position.newLine };
407 }
408}
409
410/** @public Service construction is part of the canonical Effect module API. */
411export const make = Effect.gen(function* () {
412 const bitbucket = yield* BitbucketApi.BitbucketApi;
413
414 /**
415 * The repository's API path and normalized segments, used for workspace reads and filters.
416 */
417 const withRepository = <A>(
418 repository: string,
419 use: (
420 path: string,
421 workspace: string,
422 slug: string,
423 ) => Effect.Effect<A, BitbucketPullRequestApiError>,
424 ): Effect.Effect<A, BitbucketPullRequestApiError> => {
425 const segments = repositorySegments(repository);
426 return Result.isSuccess(segments)
427 ? use(repositoryPathOf(segments.success), segments.success.workspace, segments.success.slug)
428 : Effect.fail(segments.failure);
429 };
430
431 /**
432 * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it
433 * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at
434 * the page cap — and anything but running out of pages means there is more to be had.
435 */
436 const listPage = (input: {
437 readonly url: string;
438 readonly limit: number;
439 readonly page: number;
440 readonly collected: ReadonlyArray<BitbucketPullRequest>;
441 }): Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError> =>
442 bitbucket.request({ method: "GET", url: input.url }).pipe(
443 Effect.flatMap((response) => {
444 const decoded = decodePullRequestPageJson(response.body);
445 if (!Result.isSuccess(decoded)) {
446 return Effect.fail(
447 new BitbucketPullRequestReadError({
448 operation: "listPullRequests",
449 cause: decoded.failure,
450 }),
451 );
452 }
453 const collected = [...input.collected, ...decoded.success.items];
454 const next = decoded.success.next;
455 if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) {
456 return Effect.succeed({
457 items: collected.slice(0, input.limit),
458 // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the
459 // count rather than on the last page is holding rows it is about to drop. Those are
460 // more results just as surely as another page would be.
461 truncated: next !== null || collected.length > input.limit,
462 });
463 }
464 return listPage({ ...input, url: next, page: input.page + 1, collected });
465 }),
466 );
467
468 const readPage = <A>(input: {
469 readonly operation: string;
470 readonly url: string;
471 readonly decode: (body: string) => Result.Result<A, unknown>;
472 }): Effect.Effect<A, BitbucketPullRequestApiError> =>
473 bitbucket.request({ method: "GET", url: input.url }).pipe(
474 Effect.flatMap((response) => {
475 const decoded = input.decode(response.body);
476 return Result.isSuccess(decoded)
477 ? Effect.succeed(decoded.success)
478 : Effect.fail(
479 new BitbucketPullRequestReadError({
480 operation: input.operation,
481 cause: decoded.failure,
482 }),
483 );
484 }),
485 );
486
487 /**
488 * The conversation, following the `next` Bitbucket sends until it sends none. Threads are
489 * assembled once at the end rather than per page, because a reply and the remark it answers
490 * can land either side of a page boundary.
491 */
492 const commentsPage = (input: {
493 readonly url: string;
494 readonly page: number;
495 readonly comments: ReadonlyArray<PullRequestComment>;
496 readonly entries: ReadonlyArray<BitbucketRawComment>;
497 }): Effect.Effect<
498 {
499 readonly comments: ReadonlyArray<PullRequestComment>;
500 readonly threads: ReadonlyArray<PullRequestReviewThread>;
501 readonly truncated: boolean;
502 },
503 BitbucketPullRequestApiError
504 > =>
505 readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe(
506 Effect.flatMap((page) => {
507 const comments = [...input.comments, ...page.comments];
508 const entries = [...input.entries, ...page.entries];
509 if (page.next !== null && input.page < CONVERSATION_PAGES) {
510 return commentsPage({ url: page.next, page: input.page + 1, comments, entries });
511 }
512 return Effect.succeed({
513 comments,
514 threads: buildReviewThreads(entries),
515 truncated: page.next !== null,
516 });
517 }),
518 );
519
520 /** Walks a Bitbucket cursor to its end and combines every decoded item. */
521 const itemPages = <A>(input: {
522 readonly operation: string;
523 readonly url: string;
524 readonly decode: (
525 body: string,
526 ) => Result.Result<{ readonly items: ReadonlyArray<A>; readonly next: string | null }, unknown>;
527 readonly items: ReadonlyArray<A>;
528 /** Commit pages are individually oldest-first, so older pages are prepended. */
529 readonly prepend: boolean;
530 }): Effect.Effect<ReadonlyArray<A>, BitbucketPullRequestApiError> =>
531 readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe(
532 Effect.flatMap((page) => {
533 const items = input.prepend
534 ? [...page.items, ...input.items]
535 : [...input.items, ...page.items];
536 return page.next === null
537 ? Effect.succeed(items)
538 : itemPages({ ...input, url: page.next, items });
539 }),
540 );
541
542 /** Diffstat has one aggregate per page, so its totals are folded while following `next`. */
543 const diffStatPages = (input: {
544 readonly url: string;
545 readonly totals: BitbucketDiffStat;
546 }): Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError> =>
547 readPage({ operation: "getDiffStat", url: input.url, decode: decodeDiffstatJson }).pipe(
548 Effect.flatMap((page) => {
549 const totals = {
550 additions: input.totals.additions + page.additions,
551 deletions: input.totals.deletions + page.deletions,
552 changedFiles: input.totals.changedFiles + page.changedFiles,
553 };
554 return page.next === null
555 ? Effect.succeed(totals)
556 : diffStatPages({ url: page.next, totals });
557 }),
558 );
559
560 const pullRequestDiff = (input: {
561 readonly repository: string;
562 readonly number: number;
563 readonly commit?: string | undefined;
564 }): Effect.Effect<
565 { readonly patch: string; readonly truncated: boolean },
566 BitbucketPullRequestApiError
567 > =>
568 input.commit !== undefined && !isCommitSha(input.commit)
569 ? Effect.fail(new BitbucketDiffCommitError())
570 : withRepository(input.repository, (path) =>
571 // Already a unified patch, so it needs no decoding at all, only a bound, which a
572 // diff of any size would otherwise ignore. A commit's own patch sits beside the pull
573 // request's at `/diff/{sha}` and reads the same way.
574 bitbucket
575 .request({
576 method: "GET",
577 url:
578 input.commit === undefined
579 ? `${path}/pullrequests/${input.number}/diff`
580 : `${path}/diff/${input.commit}`,
581 maxBytes: DIFF_MAX_BYTES,
582 })
583 .pipe(
584 Effect.map((response) => ({ patch: response.body, truncated: response.truncated })),
585 ),
586 );
587
588 /**
589 * What the version reads that come one tick at a time want out of the pull request's whole
590 * patch, shared between them. The parsed answer rather than the patch, which at this capacity
591 * would hold sixteen bodies of up to the byte ceiling each resident, and saves walking a patch
592 * of a hundred thousand lines again on every tick.
593 */
594 const revisionPatches = yield* Cache.makeWith(
595 (key: string) => {
596 const [repository, number] = JSON.parse(key) as [string, number];
597 return pullRequestDiff({ repository, number }).pipe(
598 Effect.map((diff) => ({
599 revisions: parseDiffFileRevisions(diff.patch),
600 truncated: diff.truncated,
601 })),
602 );
603 },
604 {
605 capacity: REVISION_PATCH_CAPACITY,
606 // A failure is not held: the tick after it should reach Bitbucket rather than be handed the
607 // same error for as long as a good patch would have lasted.
608 timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero),
609 },
610 );
611
612 return BitbucketPullRequestApi.of({
613 getViewer: () =>
614 bitbucket.request({ method: "GET", url: "/user" }).pipe(
615 Effect.flatMap((response): Effect.Effect<string, BitbucketPullRequestApiError> => {
616 const decoded = decodeViewerJson(response.body);
617 if (!Result.isSuccess(decoded)) {
618 return Effect.fail(
619 new BitbucketPullRequestReadError({ operation: "getViewer", cause: decoded.failure }),
620 );
621 }
622 return decoded.success === null
623 ? Effect.fail(new BitbucketViewerUnavailableError())
624 : Effect.succeed(decoded.success);
625 }),
626 ),
627
628 listPullRequests: (input) =>
629 withRepository(input.repository, (path) => {
630 const search = input.query?.trim() ?? "";
631 // Both narrowings share the one `q` Bitbucket takes, so they are ANDed rather than one
632 // replacing the other. The boundary instant is read inclusively — the rows already sent
633 // at it come back and the caller drops them, which is what keeps their neighbours at the
634 // same instant from being skipped. A date is a bare literal in this grammar, and this one
635 // was checked against a timestamp's shape before it got here.
636 const predicates = [
637 ...(search.length === 0 ? [] : [searchFilter(search)]),
638 ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]),
639 ];
640 return listPage({
641 // Reviewers are not on a listing by default, and `viewerReviewRequested` needs them.
642 url: `${path}/pullrequests?${stateParams(input.state)
643 .map((state) => `state=${state}`)
644 .join("&")}&pagelen=${MAX_PAGE_SIZE}&sort=-updated_on&fields=%2Bvalues.reviewers${
645 predicates.length === 0 ? "" : `&q=${encodeURIComponent(predicates.join(" AND "))}`
646 }`,
647 limit: input.limit,
648 page: 1,
649 collected: [],
650 });
651 }),
652
653 getPullRequest: (input) =>
654 withRepository(input.repository, (path) =>
655 readPage({
656 operation: "getPullRequest",
657 url: `${path}/pullrequests/${input.number}`,
658 decode: decodePullRequestJson,
659 }),
660 ),
661
662 // Ask for the caller's effective permission within this workspace. The full name identifies
663 // the repository by its slug, even when its display name differs.
664 //
665 // Bitbucket has retired permission endpoints before (CHANGE-2770), and some credentials may
666 // not reach `/user` endpoints at all. A 403, 404 or 410 here is read as a permission that
667 // could not be learned, the same as an empty page: it grants, and the write itself says why
668 // if the account may not do it. Other failures (a bad token, a network fault) still fail.
669 getRepositoryPermission: (input) =>
670 withRepository(input.repository, (_path, workspace, slug) =>
671 readPage({
672 operation: "getRepositoryPermission",
673 url: `/user/workspaces/${encodeURIComponent(workspace)}/permissions/repositories?q=${encodeURIComponent(
674 `repository.full_name="${filterLiteral(`${workspace}/${slug}`)}"`,
675 )}`,
676 decode: decodeRepositoryPermissionJson,
677 }),
678 ).pipe(Effect.catchIf(isRepositoryPermissionUnavailableError, () => Effect.succeed(true))),
679
680 getPullRequestDiff: pullRequestDiff,
681
682 getFileRevisions: (input) =>
683 input.paths.length === 0
684 ? Effect.succeed({ revisions: new Map(), complete: false })
685 : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe(
686 Effect.map((diff) => {
687 const revisions = new Map(diff.revisions);
688 // A patch cut short at the byte ceiling says nothing about the files past the cut,
689 // so those paths are left out rather than reported as removed.
690 if (!diff.truncated) {
691 for (const path of input.paths) {
692 if (!revisions.has(path)) revisions.set(path, "");
693 }
694 }
695 return { revisions, complete: !diff.truncated };
696 }),
697 ),
698
699 getDiffStat: (input) =>
700 withRepository(input.repository, (path) =>
701 diffStatPages({
702 url: `${path}/pullrequests/${input.number}/diffstat?pagelen=${MAX_PAGE_SIZE}`,
703 totals: { additions: 0, deletions: 0, changedFiles: 0 },
704 }),
705 ),
706
707 getMergeability: (input) =>
708 withRepository(input.repository, (path) =>
709 readPage({
710 operation: "getMergeability",
711 url: `${path}/pullrequests/${input.number}/conflicts`,
712 decode: decodeConflictsJson,
713 }),
714 ),
715
716 listComments: (input) =>
717 withRepository(input.repository, (path) =>
718 commentsPage({
719 url: `${path}/pullrequests/${input.number}/comments?pagelen=${CONVERSATION_PAGE_SIZE}`,
720 page: 1,
721 comments: [],
722 entries: [],
723 }),
724 ),
725
726 listCommits: (input) =>
727 withRepository(input.repository, (path) =>
728 itemPages({
729 operation: "listCommits",
730 url: `${path}/pullrequests/${input.number}/commits?pagelen=${CONVERSATION_PAGE_SIZE}`,
731 decode: decodeCommitsJson,
732 items: [],
733 prepend: true,
734 }),
735 ),
736
737 listChecks: (input) =>
738 withRepository(input.repository, (path) =>
739 itemPages({
740 operation: "listChecks",
741 url: `${path}/pullrequests/${input.number}/statuses?pagelen=${CONVERSATION_PAGE_SIZE}`,
742 decode: decodeStatusesJson,
743 items: [],
744 prepend: false,
745 }),
746 ),
747
748 listReviewerCandidates: (input) =>
749 withRepository(input.repository, (path, workspace) =>
750 Effect.all(
751 [
752 readPage({
753 operation: "getPullRequest",
754 url: `${path}/pullrequests/${input.number}`,
755 decode: decodePullRequestJson,
756 }),
757 readPage({
758 operation: "listReviewerCandidates",
759 url: `/workspaces/${encodeURIComponent(workspace)}/members?pagelen=${MAX_PAGE_SIZE}`,
760 decode: decodeWorkspaceMembersJson,
761 }),
762 ],
763 { concurrency: 2 },
764 ).pipe(
765 Effect.map(([pullRequest, members]) => {
766 const requested = new Set(pullRequest.reviewerIds);
767 const author = pullRequest.author?.login;
768 return {
769 // The author is dropped rather than shown unusable: Bitbucket refuses to make the
770 // person who opened a pull request its reviewer.
771 candidates: members.items.flatMap((candidate) =>
772 candidate.login === author
773 ? []
774 : [{ ...candidate, isRequested: requested.has(candidate.id) }],
775 ),
776 truncated: members.next !== null,
777 };
778 }),
779 ),
780 ),
781
782 setReviewerRequest: (input) =>
783 withRepository(input.repository, (path) => {
784 const pullRequest = `${path}/pullrequests/${input.number}`;
785 return readPage({
786 operation: "getPullRequest",
787 url: pullRequest,
788 decode: decodePullRequestJson,
789 }).pipe(
790 Effect.flatMap((current) => {
791 // Bitbucket has no endpoint that adds or removes one reviewer: the pull request's
792 // `reviewers` is written whole, so the set that is already there is read first and
793 // the change applied to it. Everything else about the pull request is left out of
794 // the body, which leaves it as it was.
795 const uuids = new Set(current.reviewerIds);
796 for (const reviewer of input.reviewers) {
797 if (input.requested) uuids.add(reviewer.id);
798 else uuids.delete(reviewer.id);
799 }
800 return bitbucket.request({
801 method: "PUT",
802 url: pullRequest,
803 body: JSON.stringify({ reviewers: [...uuids].map((uuid) => ({ uuid })) }),
804 });
805 }),
806 Effect.asVoid,
807 );
808 }),
809
810 runAction: (input) =>
811 withRepository(input.repository, (path) => {
812 const pullRequest = `${path}/pullrequests/${input.number}`;
813 // Only merge and close reach here: the provider declares the others unsupported, so the
814 // surface never offers them.
815 if (input.action === "merge") {
816 return bitbucket
817 .request({
818 method: "POST",
819 url: `${pullRequest}/merge`,
820 body: JSON.stringify({ merge_strategy: mergeStrategy(input.mergeMethod) }),
821 })
822 .pipe(Effect.asVoid);
823 }
824 return bitbucket
825 .request({ method: "POST", url: `${pullRequest}/decline` })
826 .pipe(Effect.asVoid);
827 }),
828
829 updateChangeRequest: (input) =>
830 withRepository(input.repository, (path) =>
831 // Only the words this call rewrites travel in the body: as `setReviewerRequest` above
832 // relies on, Bitbucket's PUT is a partial update, so any field left out is left as it
833 // was — sending `reviewers` back here would overwrite a change another user made to it
834 // between this call being issued and the request landing.
835 bitbucket
836 .request({
837 method: "PUT",
838 url: `${path}/pullrequests/${input.number}`,
839 body: JSON.stringify({
840 ...(input.title === undefined ? {} : { title: input.title }),
841 ...(input.body === undefined ? {} : { description: input.body }),
842 }),
843 })
844 .pipe(Effect.asVoid),
845 ),
846
847 comment: (input) =>
848 withRepository(input.repository, (path) =>
849 bitbucket
850 .request({
851 method: "POST",
852 url: `${path}/pullrequests/${input.number}/comments`,
853 // A JSON document rather than a form field, so the body stays text whatever it says.
854 body: JSON.stringify({ content: { raw: input.body } }),
855 })
856 .pipe(Effect.asVoid),
857 ),
858
859 updateComment: (input) =>
860 withRepository(input.repository, (path) =>
861 bitbucket
862 .request({
863 // Bitbucket keeps a pull request's remarks and its line comments in the one
864 // collection, so this endpoint rewrites either kind.
865 method: "PUT",
866 url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent(
867 input.commentId,
868 )}`,
869 body: JSON.stringify({ content: { raw: input.body } }),
870 })
871 .pipe(Effect.asVoid),
872 ),
873
874 submitReview: (input) =>
875 withRepository(input.repository, (path) =>
876 Effect.gen(function* () {
877 const pullRequest = `${path}/pullrequests/${input.number}`;
878 // Bitbucket has no pending review, so a review is replayed as the requests it is
879 // made of: the line comments, then the summary, then the verdict. The verdict goes
880 // last so a review that fails part-way is never left standing as an approval.
881 yield* Effect.forEach(
882 input.comments,
883 (comment) =>
884 bitbucket.request({
885 method: "POST",
886 url: `${pullRequest}/comments`,
887 body: JSON.stringify({
888 content: { raw: comment.body },
889 inline: {
890 path: comment.path,
891 ...bitbucketReviewPosition(comment.position),
892 },
893 }),
894 }),
895 { discard: true },
896 );
897 if (input.body.trim().length > 0) {
898 yield* bitbucket.request({
899 method: "POST",
900 url: `${pullRequest}/comments`,
901 // @effect-diagnostics-next-line preferSchemaOverJson:off
902 body: JSON.stringify({ content: { raw: input.body } }),
903 });
904 }
905 if (input.verdict === "approve") {
906 yield* bitbucket.request({ method: "POST", url: `${pullRequest}/approve` });
907 }
908 if (input.verdict === "request-changes") {
909 yield* bitbucket.request({ method: "POST", url: `${pullRequest}/request-changes` });
910 }
911 }),
912 ),
913
914 replyToComment: (input) =>
915 withRepository(input.repository, (path) =>
916 bitbucket
917 .request({
918 method: "POST",
919 url: `${path}/pullrequests/${input.number}/comments`,
920 body: JSON.stringify({
921 content: { raw: input.body },
922 parent: { id: Number(input.commentId) },
923 }),
924 })
925 .pipe(Effect.asVoid),
926 ),
927
928 setCommentResolution: (input) =>
929 withRepository(input.repository, (path) =>
930 bitbucket
931 .request({
932 // Resolving is a sub-resource that is created and deleted, rather than a field.
933 method: input.resolved ? "POST" : "DELETE",
934 url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent(
935 input.commentId,
936 )}/resolve`,
937 })
938 .pipe(Effect.asVoid),
939 ),
940 });
941});
942
943export const layer = Layer.effect(BitbucketPullRequestApi, make);