Skip to content
Source
Sign inSign up
Open History to see the last commit

BitbucketPullRequestApi.ts

879 lines31.0 KB

1import { isCommitSha } from "@t3tools/source-control-core/server/pullRequestUtils";
2import * as Cache from "effect/Cache";
3import * as Context from "effect/Context";
4import * as DateTime from "effect/DateTime";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Result from "effect/Result";
11import * as Schema from "effect/Schema";
12import type {
13 PullRequestAction,
14 PullRequestCheck,
15 PullRequestComment,
16 PullRequestCommit,
17 PullRequestListState,
18 PullRequestMergeMethod,
19 PullRequestMergeability,
20 PullRequestReviewCommentDraft,
21 PullRequestReviewPosition,
22 PullRequestReviewThread,
23 PullRequestReviewVerdict,
24 PullRequestReviewerCandidateList,
25} from "@t3tools/contracts";
26​
27import * as BitbucketApi from "./BitbucketApi.ts";
28import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions";
29import { sortChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
30import {
31 buildReviewThreads,
32 decodeCommentsJson,
33 decodeCommitsJson,
34 decodeConflictsJson,
35 decodeDiffstatJson,
36 decodePullRequestJson,
37 decodePullRequestPageJson,
38 decodeRepositoryPermissionJson,
39 decodeStatusesJson,
40 decodeViewerJson,
41 decodeWorkspaceMembersJson,
42 type BitbucketDiffStat,
43 type BitbucketPullRequest,
44 type BitbucketRawComment,
45} from "./bitbucketPullRequestJson.ts";
46import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
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 }
63​
64export class BitbucketViewerUnavailableError extends Schema.TaggedError<BitbucketViewerUnavailableError>()(
65 "BitbucketViewerUnavailableError",
66 {},
67) {
68 get detail(): string {
69 return "Bitbucket returned no account name for the configured credentials.";
70 }
71​
72 override get message(): string {
73 return `Bitbucket failed in getViewer: ${this.detail}`;
74 }
76​
77export class BitbucketRepositoryUnsupportedError extends Schema.TaggedError<BitbucketRepositoryUnsupportedError>()(
78 "BitbucketRepositoryUnsupportedError",
79 {
80 repository: Schema.String,
81 },
82) {
83 get detail(): string {
84 return "A Bitbucket repository is addressed as workspace/repository.";
85 }
86​
87 override get message(): string {
88 return `Bitbucket failed in resolveRepository: ${this.detail}`;
89 }
91​
92export class BitbucketDiffCommitError extends Schema.TaggedError<BitbucketDiffCommitError>()(
93 "BitbucketDiffCommitError",
94 {},
95) {
96 get detail(): string {
97 return "The named commit was not a commit sha.";
98 }
99​
100 override get message(): string {
101 return `Bitbucket failed in getPullRequestDiff: ${this.detail}`;
102 }
104​
105export type BitbucketPullRequestApiError =
106 | BitbucketApi.BitbucketApiError
107 | BitbucketPullRequestReadError
108 | BitbucketViewerUnavailableError
109 | BitbucketRepositoryUnsupportedError
110 | BitbucketDiffCommitError;
111​
112function isRepositoryPermissionUnavailableError(
113 error: BitbucketPullRequestApiError,
114): error is BitbucketApi.BitbucketResponseError {
115 return (
116 error._tag === "BitbucketResponseError" &&
117 (error.status === 403 || error.status === 404 || error.status === 410)
118 );
120​
121/** Requests above this size return an empty page without an error. */
122const MAX_PAGE_SIZE = 50;
123const MAX_LIST_PAGES = 10;
124const CONVERSATION_PAGE_SIZE = 50;
125const CONVERSATION_PAGES = 10;
126const DIFF_MAX_BYTES = 8 * 1024 * 1024;
127/** Must expire before the caller's version cache so refreshes reach Bitbucket. */
128const REVISION_PATCH_TTL = Duration.seconds(5);
129const REVISION_PATCH_CAPACITY = 16;
130const encodeRevisionKey = Schema.encodeSync(
131 Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.Number])),
132);
133export interface BitbucketPullRequestBatch {
134 readonly items: ReadonlyArray<BitbucketPullRequest>;
135 readonly truncated: boolean;
137​
138export class BitbucketPullRequestApi extends Context.Service<
139 BitbucketPullRequestApi,
140 {
141 readonly getViewer: () => Effect.Effect<string, BitbucketPullRequestApiError>;
142​
143 readonly listPullRequests: (input: {
144 readonly repository: string;
145 readonly state: PullRequestListState;
146 readonly limit: number;
147 readonly query?: string | undefined;
148 readonly cursor?: ProviderListCursor | undefined;
149 /** Matched against the login a row shows, which is the nickname or else the display name. */
150 readonly author?: string | undefined;
151 readonly draft?: boolean | undefined;
152 }) => Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError>;
153​
154 readonly getPullRequest: (input: {
155 readonly repository: string;
156 readonly number: number;
157 }) => Effect.Effect<BitbucketPullRequest, BitbucketPullRequestApiError>;
158​
159 readonly getRepositoryPermission: (input: {
160 readonly repository: string;
161 }) => Effect.Effect<boolean, BitbucketPullRequestApiError>;
162​
163 readonly getPullRequestDiff: (input: {
164 readonly repository: string;
165 readonly number: number;
166 readonly commit?: string | undefined;
167 }) => Effect.Effect<
168 { readonly patch: string; readonly truncated: boolean },
169 BitbucketPullRequestApiError
170 >;
171​
172 readonly getDiffStat: (input: {
173 readonly repository: string;
174 readonly number: number;
175 }) => Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError>;
176​
177 /** Truncated patches cannot identify absent paths as deleted. */
178 readonly getFileRevisions: (input: {
179 readonly repository: string;
180 readonly number: number;
181 readonly paths: ReadonlyArray<string>;
182 }) => Effect.Effect<
183 { readonly revisions: ReadonlyMap<string, string>; readonly complete: boolean },
184 BitbucketPullRequestApiError
185 >;
186​
187 readonly getMergeability: (input: {
188 readonly repository: string;
189 readonly number: number;
190 }) => Effect.Effect<PullRequestMergeability, BitbucketPullRequestApiError>;
191​
192 readonly listComments: (input: {
193 readonly repository: string;
194 readonly number: number;
195 }) => Effect.Effect<
196 {
197 readonly comments: ReadonlyArray<PullRequestComment>;
198 readonly threads: ReadonlyArray<PullRequestReviewThread>;
199 readonly truncated: boolean;
200 },
201 BitbucketPullRequestApiError
202 >;
203​
204 readonly listCommits: (input: {
205 readonly repository: string;
206 readonly number: number;
207 }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, BitbucketPullRequestApiError>;
208​
209 readonly listChecks: (input: {
210 readonly repository: string;
211 readonly number: number;
212 }) => Effect.Effect<ReadonlyArray<PullRequestCheck>, BitbucketPullRequestApiError>;
213​
214 readonly listReviewerCandidates: (input: {
215 readonly repository: string;
216 readonly number: number;
217 }) => Effect.Effect<PullRequestReviewerCandidateList, BitbucketPullRequestApiError>;
218​
219 readonly setReviewerRequest: (input: {
220 readonly repository: string;
221 readonly number: number;
222 readonly reviewers: ReadonlyArray<{ readonly id: string }>;
223 readonly requested: boolean;
224 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
225​
226 readonly runAction: (input: {
227 readonly repository: string;
228 readonly number: number;
229 readonly action: PullRequestAction;
230 readonly mergeMethod?: PullRequestMergeMethod;
231 }) => Effect.Effect<BitbucketPullRequest | void, BitbucketPullRequestApiError>;
232​
233 readonly updateChangeRequest: (input: {
234 readonly repository: string;
235 readonly number: number;
236 readonly title?: string | undefined;
237 readonly body?: string | undefined;
238 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
239​
240 readonly comment: (input: {
241 readonly repository: string;
242 readonly number: number;
243 readonly body: string;
244 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
245​
246 readonly updateComment: (input: {
247 readonly repository: string;
248 readonly number: number;
249 readonly commentId: string;
250 readonly body: string;
251 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
252​
253 readonly submitReview: (input: {
254 readonly repository: string;
255 readonly number: number;
256 readonly verdict: PullRequestReviewVerdict;
257 readonly body: string;
258 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
259 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
260​
261 readonly replyToComment: (input: {
262 readonly repository: string;
263 readonly number: number;
264 readonly commentId: string;
265 readonly body: string;
266 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
267​
268 readonly setCommentResolution: (input: {
269 readonly repository: string;
270 readonly number: number;
271 readonly commentId: string;
272 readonly resolved: boolean;
273 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
274 }
275>()("@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi") {}
276​
277function repositorySegments(
278 repository: string,
279): Result.Result<
280 { readonly workspace: string; readonly slug: string },
281 BitbucketRepositoryUnsupportedError
282> {
283 const segments = repository
284 .split("/")
285 .map((segment) => segment.trim())
286 .filter((segment) => segment.length > 0);
287 const [workspace, slug] = segments;
288 if (segments.length !== 2 || workspace === undefined || slug === undefined) {
289 return Result.fail(new BitbucketRepositoryUnsupportedError({ repository }));
290 }
291 return Result.succeed({ workspace, slug });
293​
294function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string {
295 return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent(
296 segments.slug,
297 )}`;
299​
300function stateParams(state: PullRequestListState): ReadonlyArray<string> {
301 switch (state) {
302 case "open":
303 return ["OPEN"];
304 case "merged":
305 return ["MERGED"];
306 case "closed":
307 return ["DECLINED", "SUPERSEDED"];
308 case "all":
309 return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"];
310 }
312​
313function searchFilter(query: string): string {
314 const literal = filterLiteral(query);
315 return `(title ~ "${literal}" OR description ~ "${literal}")`;
317​
318function authorFilter(login: string): string {
319 const literal = filterLiteral(login);
320 return `(author.nickname = "${literal}" OR author.display_name = "${literal}")`;
322​
323/**
324 * The backslash is escaped first, or escaping the quote would only produce a literal
325 * backslash followed by a live quote.
326 */
327function filterLiteral(value: string): string {
328 return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
330​
331// Bitbucket keeps microseconds; include the cursor's whole millisecond before excluding seen IDs.
332function updatedBeforeFilter(updatedBefore: string): string {
333 return Option.match(DateTime.make(updatedBefore), {
334 onNone: () => `updated_on <= ${updatedBefore}`,
335 onSome: (instant) =>
336 `updated_on < ${DateTime.formatIso(DateTime.add(instant, { milliseconds: 1 }))}`,
337 });
339​
340function mergeStrategy(method: PullRequestMergeMethod | undefined): string {
341 switch (method) {
342 case "squash":
343 return "squash";
344 case "rebase":
345 return "rebase_fast_forward";
346 default:
347 return "merge_commit";
348 }
350​
351function bitbucketReviewPosition(
352 position: PullRequestReviewPosition,
353): { readonly from: number } | { readonly to: number } {
354 switch (position.kind) {
355 case "added":
356 return { to: position.newLine };
357 case "deleted":
358 return { from: position.oldLine };
359 case "context":
360 return position.side === "left" ? { from: position.oldLine } : { to: position.newLine };
361 }
363​
364/** @public */
365export const make = Effect.gen(function* () {
366 const bitbucket = yield* BitbucketApi.BitbucketApi;
367​
368 const withRepository = <A>(
369 repository: string,
370 use: (
371 path: string,
372 workspace: string,
373 slug: string,
374 ) => Effect.Effect<A, BitbucketPullRequestApiError>,
375 ): Effect.Effect<A, BitbucketPullRequestApiError> => {
376 const segments = repositorySegments(repository);
377 return Result.isSuccess(segments)
378 ? use(repositoryPathOf(segments.success), segments.success.workspace, segments.success.slug)
379 : Effect.fail(segments.failure);
380 };
381​
382 const listPage = (input: {
383 readonly url: string;
384 readonly limit: number;
385 readonly page: number;
386 readonly collected: ReadonlyArray<BitbucketPullRequest>;
387 }): Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError> =>
388 bitbucket.request({ method: "GET", url: input.url }).pipe(
389 Effect.flatMap((response) => {
390 const decoded = decodePullRequestPageJson(response.body);
391 if (!Result.isSuccess(decoded)) {
392 return Effect.fail(
393 new BitbucketPullRequestReadError({
394 operation: "listPullRequests",
395 cause: decoded.failure,
396 }),
397 );
398 }
399 const collected = [...input.collected, ...decoded.success.items];
400 const next = decoded.success.next;
401 if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) {
402 return Effect.succeed({
403 items: collected.slice(0, input.limit),
404 truncated: next !== null || collected.length > input.limit,
405 });
406 }
407 return listPage({ ...input, url: next, page: input.page + 1, collected });
408 }),
409 );
410​
411 const readPage = <A>(input: {
412 readonly operation: string;
413 readonly url: string;
414 readonly decode: (body: string) => Result.Result<A, unknown>;
415 }): Effect.Effect<A, BitbucketPullRequestApiError> =>
416 bitbucket.request({ method: "GET", url: input.url }).pipe(
417 Effect.flatMap((response) => {
418 const decoded = input.decode(response.body);
419 return Result.isSuccess(decoded)
420 ? Effect.succeed(decoded.success)
421 : Effect.fail(
422 new BitbucketPullRequestReadError({
423 operation: input.operation,
424 cause: decoded.failure,
425 }),
426 );
427 }),
428 );
429​
430 /** Replies and parents can span pages; assemble threads after pagination. */
431 const commentsPage = (input: {
432 readonly url: string;
433 readonly page: number;
434 readonly comments: ReadonlyArray<PullRequestComment>;
435 readonly entries: ReadonlyArray<BitbucketRawComment>;
436 }): Effect.Effect<
437 {
438 readonly comments: ReadonlyArray<PullRequestComment>;
439 readonly threads: ReadonlyArray<PullRequestReviewThread>;
440 readonly truncated: boolean;
441 },
442 BitbucketPullRequestApiError
443 > =>
444 readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe(
445 Effect.flatMap((page) => {
446 const comments = [...input.comments, ...page.comments];
447 const entries = [...input.entries, ...page.entries];
448 if (page.next !== null && input.page < CONVERSATION_PAGES) {
449 return commentsPage({ url: page.next, page: input.page + 1, comments, entries });
450 }
451 return Effect.succeed({
452 comments,
453 threads: buildReviewThreads(entries),
454 truncated: page.next !== null,
455 });
456 }),
457 );
458​
459 const itemPages = <A>(input: {
460 readonly operation: string;
461 readonly url: string;
462 readonly decode: (
463 body: string,
464 ) => Result.Result<{ readonly items: ReadonlyArray<A>; readonly next: string | null }, unknown>;
465 readonly items: ReadonlyArray<A>;
466 /** Each commit page is oldest-first. */
467 readonly prepend: boolean;
468 }): Effect.Effect<ReadonlyArray<A>, BitbucketPullRequestApiError> =>
469 readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe(
470 Effect.flatMap((page) => {
471 const items = input.prepend
472 ? [...page.items, ...input.items]
473 : [...input.items, ...page.items];
474 return page.next === null
475 ? Effect.succeed(items)
476 : itemPages({ ...input, url: page.next, items });
477 }),
478 );
479​
480 const diffStatPages = (input: {
481 readonly url: string;
482 readonly totals: BitbucketDiffStat;
483 }): Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError> =>
484 readPage({ operation: "getDiffStat", url: input.url, decode: decodeDiffstatJson }).pipe(
485 Effect.flatMap((page) => {
486 const totals = {
487 additions: input.totals.additions + page.additions,
488 deletions: input.totals.deletions + page.deletions,
489 changedFiles: input.totals.changedFiles + page.changedFiles,
490 };
491 return page.next === null
492 ? Effect.succeed(totals)
493 : diffStatPages({ url: page.next, totals });
494 }),
495 );
496​
497 const pullRequestDiff = (input: {
498 readonly repository: string;
499 readonly number: number;
500 readonly commit?: string | undefined;
501 }): Effect.Effect<
502 { readonly patch: string; readonly truncated: boolean },
503 BitbucketPullRequestApiError
504 > =>
505 input.commit !== undefined && !isCommitSha(input.commit)
506 ? Effect.fail(new BitbucketDiffCommitError())
507 : withRepository(input.repository, (path) =>
508 bitbucket
509 .request({
510 method: "GET",
511 url:
512 input.commit === undefined
513 ? `${path}/pullrequests/${input.number}/diff`
514 : `${path}/diff/${input.commit}`,
515 maxBytes: DIFF_MAX_BYTES,
516 })
517 .pipe(
518 Effect.map((response) => ({ patch: response.body, truncated: response.truncated })),
519 ),
520 );
521​
522 // Cache revisions, not large patch bodies that need reparsing.
523 const revisionPatches = yield* Cache.makeWith(
524 (key: string) => {
525 const [repository, number] = JSON.parse(key) as [string, number];
526 return pullRequestDiff({ repository, number }).pipe(
527 Effect.map((diff) => ({
528 revisions: parseDiffFileRevisions(diff.patch),
529 truncated: diff.truncated,
530 })),
531 );
532 },
533 {
534 capacity: REVISION_PATCH_CAPACITY,
535 timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero),
536 },
537 );
538​
539 return BitbucketPullRequestApi.of({
540 getViewer: Effect.fn("BitbucketPullRequestApi.getViewer")(function () {
541 return bitbucket.request({ method: "GET", url: "/user" }).pipe(
542 Effect.flatMap((response): Effect.Effect<string, BitbucketPullRequestApiError> => {
543 const decoded = decodeViewerJson(response.body);
544 if (!Result.isSuccess(decoded)) {
545 return Effect.fail(
546 new BitbucketPullRequestReadError({ operation: "getViewer", cause: decoded.failure }),
547 );
548 }
549 return decoded.success === null
550 ? Effect.fail(new BitbucketViewerUnavailableError())
551 : Effect.succeed(decoded.success);
552 }),
553 );
554 }),
555​
556 listPullRequests: Effect.fn("BitbucketPullRequestApi.listPullRequests")(function (input) {
557 return withRepository(input.repository, (path) => {
558 const search = input.query?.trim() ?? "";
559 // Exclude already-sent IDs in the boundary millisecond before applying the page limit.
560 const predicates = [
561 ...(search.length === 0 ? [] : [searchFilter(search)]),
562 ...(input.author === undefined ? [] : [authorFilter(input.author)]),
563 ...(input.draft === undefined ? [] : [`draft = ${input.draft}`]),
564 ...(input.cursor === undefined ? [] : [updatedBeforeFilter(input.cursor.updatedBefore)]),
565 ...(input.cursor?.seenAt ?? []).map((number) => `id != ${number}`),
566 ];
567 return listPage({
568 // Listings omit reviewers needed by `viewerReviewRequested`.
569 url: `${path}/pullrequests?${stateParams(input.state)
570 .map((state) => `state=${state}`)
571 .join("&")}&pagelen=${MAX_PAGE_SIZE}&sort=-updated_on&fields=%2Bvalues.reviewers${
572 predicates.length === 0 ? "" : `&q=${encodeURIComponent(predicates.join(" AND "))}`
573 }`,
574 limit: input.limit,
575 page: 1,
576 collected: [],
577 });
578 });
579 }),
580​
581 getPullRequest: Effect.fn("BitbucketPullRequestApi.getPullRequest")(function (input) {
582 return withRepository(input.repository, (path) =>
583 readPage({
584 operation: "getPullRequest",
585 // Settlement needs `closed_on`, omitted by default.
586 url: `${path}/pullrequests/${input.number}?fields=%2Bclosed_on`,
587 decode: decodePullRequestJson,
588 }),
589 );
590 }),
591​
592 // Permission endpoints may return 403/404/410 (CHANGE-2770); let writes enforce access.
593 getRepositoryPermission: Effect.fn("BitbucketPullRequestApi.getRepositoryPermission")(
594 function (input) {
595 return withRepository(input.repository, (_path, workspace, slug) =>
596 readPage({
597 operation: "getRepositoryPermission",
598 url: `/user/workspaces/${encodeURIComponent(workspace)}/permissions/repositories?q=${encodeURIComponent(
599 `repository.full_name="${filterLiteral(`${workspace}/${slug}`)}"`,
600 )}`,
601 decode: decodeRepositoryPermissionJson,
602 }),
603 ).pipe(Effect.catchIf(isRepositoryPermissionUnavailableError, () => Effect.succeed(true)));
604 },
605 ),
606​
607 getPullRequestDiff: pullRequestDiff,
608​
609 getFileRevisions: Effect.fn("BitbucketPullRequestApi.getFileRevisions")(function* (input) {
610 if (input.paths.length === 0)
611 return { revisions: new Map<string, string>(), complete: false };
612 return yield* Cache.get(
613 revisionPatches,
614 encodeRevisionKey([input.repository, input.number]),
615 ).pipe(
616 Effect.map((diff) => {
617 const revisions = new Map(diff.revisions);
618 // A truncated patch cannot identify absent paths as deleted.
619 if (!diff.truncated) {
620 for (const path of input.paths) {
621 if (!revisions.has(path)) revisions.set(path, "");
622 }
623 }
624 return { revisions, complete: !diff.truncated };
625 }),
626 );
627 }),
628​
629 getDiffStat: Effect.fn("BitbucketPullRequestApi.getDiffStat")(function (input) {
630 return withRepository(input.repository, (path) =>
631 diffStatPages({
632 url: `${path}/pullrequests/${input.number}/diffstat?pagelen=${MAX_PAGE_SIZE}`,
633 totals: { additions: 0, deletions: 0, changedFiles: 0 },
634 }),
635 );
636 }),
637​
638 getMergeability: Effect.fn("BitbucketPullRequestApi.getMergeability")(function (input) {
639 return withRepository(input.repository, (path) =>
640 readPage({
641 operation: "getMergeability",
642 url: `${path}/pullrequests/${input.number}/conflicts`,
643 decode: decodeConflictsJson,
644 }),
645 );
646 }),
647​
648 listComments: Effect.fn("BitbucketPullRequestApi.listComments")(function (input) {
649 return withRepository(input.repository, (path) =>
650 commentsPage({
651 url: `${path}/pullrequests/${input.number}/comments?pagelen=${CONVERSATION_PAGE_SIZE}`,
652 page: 1,
653 comments: [],
654 entries: [],
655 }),
656 );
657 }),
658​
659 listCommits: Effect.fn("BitbucketPullRequestApi.listCommits")(function (input) {
660 return withRepository(input.repository, (path) =>
661 itemPages({
662 operation: "listCommits",
663 url: `${path}/pullrequests/${input.number}/commits?pagelen=${CONVERSATION_PAGE_SIZE}`,
664 decode: decodeCommitsJson,
665 items: [],
666 prepend: true,
667 }),
668 );
669 }),
670​
671 listChecks: Effect.fn("BitbucketPullRequestApi.listChecks")(function (input) {
672 return withRepository(input.repository, (path) =>
673 itemPages({
674 operation: "listChecks",
675 url: `${path}/pullrequests/${input.number}/statuses?pagelen=${CONVERSATION_PAGE_SIZE}`,
676 decode: decodeStatusesJson,
677 items: [],
678 prepend: false,
679 }).pipe(Effect.map(sortChecks)),
680 );
681 }),
682​
683 listReviewerCandidates: Effect.fn("BitbucketPullRequestApi.listReviewerCandidates")(
684 function (input) {
685 return withRepository(input.repository, (path, workspace) =>
686 Effect.all(
687 [
688 readPage({
689 operation: "getPullRequest",
690 url: `${path}/pullrequests/${input.number}`,
691 decode: decodePullRequestJson,
692 }),
693 readPage({
694 operation: "listReviewerCandidates",
695 url: `/workspaces/${encodeURIComponent(workspace)}/members?pagelen=${MAX_PAGE_SIZE}`,
696 decode: decodeWorkspaceMembersJson,
697 }),
698 ],
699 { concurrency: 2 },
700 ).pipe(
701 Effect.map(([pullRequest, members]) => {
702 const requested = new Set(pullRequest.reviewerIds);
703 const author = pullRequest.author?.login;
704 return {
705 // Bitbucket forbids requesting a review from the PR author.
706 candidates: members.items.flatMap((candidate) =>
707 candidate.login === author
708 ? []
709 : [{ ...candidate, isRequested: requested.has(candidate.id) }],
710 ),
711 truncated: members.next !== null,
712 };
713 }),
714 ),
715 );
716 },
717 ),
718​
719 setReviewerRequest: Effect.fn("BitbucketPullRequestApi.setReviewerRequest")(function (input) {
720 return withRepository(input.repository, (path) => {
721 const pullRequest = `${path}/pullrequests/${input.number}`;
722 return readPage({
723 operation: "getPullRequest",
724 url: pullRequest,
725 decode: decodePullRequestJson,
726 }).pipe(
727 Effect.flatMap((current) => {
728 // Bitbucket replaces the entire reviewer list.
729 const uuids = new Set(current.reviewerIds);
730 for (const reviewer of input.reviewers) {
731 if (input.requested) uuids.add(reviewer.id);
732 else uuids.delete(reviewer.id);
733 }
734 return bitbucket.request({
735 method: "PUT",
736 url: pullRequest,
737 body: JSON.stringify({ reviewers: [...uuids].map((uuid) => ({ uuid })) }),
738 });
739 }),
740 Effect.asVoid,
741 );
742 });
743 }),
744​
745 runAction: Effect.fn("BitbucketPullRequestApi.runAction")(function (input) {
746 return withRepository(input.repository, (path) => {
747 const pullRequest = `${path}/pullrequests/${input.number}`;
748 // Both reply with the pull request; a merge that runs long replies with a task instead.
749 const reply = Effect.map((response: { readonly body: string }) => {
750 const decoded = decodePullRequestJson(response.body);
751 return Result.isSuccess(decoded) ? decoded.success : undefined;
752 });
753 if (input.action === "merge") {
754 return bitbucket
755 .request({
756 method: "POST",
757 url: `${pullRequest}/merge`,
758 body: JSON.stringify({ merge_strategy: mergeStrategy(input.mergeMethod) }),
759 })
760 .pipe(reply);
761 }
762 return bitbucket.request({ method: "POST", url: `${pullRequest}/decline` }).pipe(reply);
763 });
764 }),
765​
766 updateChangeRequest: Effect.fn("BitbucketPullRequestApi.updateChangeRequest")(function (input) {
767 return withRepository(input.repository, (path) =>
768 // Partial PUTs preserve concurrent edits to unchanged fields.
769 bitbucket
770 .request({
771 method: "PUT",
772 url: `${path}/pullrequests/${input.number}`,
773 body: JSON.stringify({
774 ...(input.title === undefined ? {} : { title: input.title }),
775 ...(input.body === undefined ? {} : { description: input.body }),
776 }),
777 })
778 .pipe(Effect.asVoid),
779 );
780 }),
781​
782 comment: Effect.fn("BitbucketPullRequestApi.comment")(function (input) {
783 return withRepository(input.repository, (path) =>
784 bitbucket
785 .request({
786 method: "POST",
787 url: `${path}/pullrequests/${input.number}/comments`,
788 body: JSON.stringify({ content: { raw: input.body } }),
789 })
790 .pipe(Effect.asVoid),
791 );
792 }),
793​
794 updateComment: Effect.fn("BitbucketPullRequestApi.updateComment")(function (input) {
795 return withRepository(input.repository, (path) =>
796 bitbucket
797 .request({
798 method: "PUT",
799 url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent(
800 input.commentId,
801 )}`,
802 body: JSON.stringify({ content: { raw: input.body } }),
803 })
804 .pipe(Effect.asVoid),
805 );
806 }),
807​
808 submitReview: Effect.fn("BitbucketPullRequestApi.submitReview")(function (input) {
809 return withRepository(input.repository, (path) =>
810 Effect.gen(function* () {
811 const pullRequest = `${path}/pullrequests/${input.number}`;
812 // A partially failed review must not leave an approval.
813 yield* Effect.forEach(
814 input.comments,
815 (comment) =>
816 bitbucket.request({
817 method: "POST",
818 url: `${pullRequest}/comments`,
819 body: JSON.stringify({
820 content: { raw: comment.body },
821 inline: {
822 path: comment.path,
823 ...bitbucketReviewPosition(comment.position),
824 },
825 }),
826 }),
827 { discard: true },
828 );
829 if (input.body.trim().length > 0) {
830 yield* bitbucket.request({
831 method: "POST",
832 url: `${pullRequest}/comments`,
833 // @effect-diagnostics-next-line globalJsonInEffect:off
834 body: JSON.stringify({ content: { raw: input.body } }),
835 });
836 }
837 if (input.verdict === "approve") {
838 yield* bitbucket.request({ method: "POST", url: `${pullRequest}/approve` });
839 }
840 if (input.verdict === "request-changes") {
841 yield* bitbucket.request({ method: "POST", url: `${pullRequest}/request-changes` });
842 }
843 }),
844 );
845 }),
846​
847 replyToComment: Effect.fn("BitbucketPullRequestApi.replyToComment")(function (input) {
848 return withRepository(input.repository, (path) =>
849 bitbucket
850 .request({
851 method: "POST",
852 url: `${path}/pullrequests/${input.number}/comments`,
853 body: JSON.stringify({
854 content: { raw: input.body },
855 parent: { id: Number(input.commentId) },
856 }),
857 })
858 .pipe(Effect.asVoid),
859 );
860 }),
861​
862 setCommentResolution: Effect.fn("BitbucketPullRequestApi.setCommentResolution")(
863 function (input) {
864 return withRepository(input.repository, (path) =>
865 bitbucket
866 .request({
867 method: input.resolved ? "POST" : "DELETE",
868 url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent(
869 input.commentId,
870 )}/resolve`,
871 })
872 .pipe(Effect.asVoid),
873 );
874 },
875 ),
876 });
877});
878​
879export const layer = Layer.effect(BitbucketPullRequestApi, make);