1import * as Cause from "effect/Cause";
2import * as DateTime from "effect/DateTime";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import type {
8 PullRequestActor,
9 PullRequestCheck,
10 PullRequestCheckStatus,
11 PullRequestComment,
12 PullRequestCommit,
13 PullRequestMergeability,
14 PullRequestReviewThread,
15 PullRequestReviewerCandidate,
16 PullRequestState,
17} from "@t3tools/contracts";
18import { TrimmedNonEmptyString } from "@t3tools/contracts";
19import { decodeJsonResult } from "@t3tools/shared/schemaJson";
20
21import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
22
23/**
24 * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant
25 * style as the GitHub and GitLab decoders: a new pull request state or build status must not
26 * fail a whole payload.
27 */
28const RawUserSchema = Schema.Struct({
29 /**
30 * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are
31 * not accepted there. Braced, and sent back exactly as it arrived.
32 */
33 uuid: Schema.optional(Schema.NullOr(Schema.String)),
34 /** Absent on an app account, which is why `display_name` has to stand in for it. */
35 nickname: Schema.optional(Schema.NullOr(Schema.String)),
36 display_name: Schema.optional(Schema.NullOr(Schema.String)),
37 links: Schema.optional(
38 Schema.NullOr(
39 Schema.Struct({
40 avatar: Schema.optional(
41 Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })),
42 ),
43 }),
44 ),
45 ),
46});
47
48/**
49 * Required, and required to be non-empty: the wire contract will not carry a change request
50 * without a branch or a link, so a row missing one is skipped rather than breaking the response
51 * it travels in.
52 */
53const RawBranchSchema = Schema.Struct({
54 branch: Schema.Struct({ name: TrimmedNonEmptyString }),
55 commit: Schema.optional(
56 Schema.NullOr(Schema.Struct({ hash: Schema.optional(Schema.NullOr(Schema.String)) })),
57 ),
58 repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))),
59});
60
61const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) });
62
63const RawPullRequestSchema = Schema.Struct({
64 id: Schema.Int,
65 title: Schema.String,
66 description: Schema.optional(Schema.NullOr(Schema.String)),
67 state: Schema.optional(Schema.NullOr(Schema.String)),
68 draft: Schema.optional(Schema.Boolean),
69 author: Schema.optional(Schema.NullOr(RawUserSchema)),
70 source: RawBranchSchema,
71 destination: RawBranchSchema,
72 created_on: Schema.String,
73 updated_on: Schema.String,
74 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))),
75 participants: Schema.optional(
76 Schema.NullOr(
77 Schema.Array(
78 Schema.Struct({
79 user: Schema.optional(Schema.NullOr(RawUserSchema)),
80 role: Schema.optional(Schema.NullOr(Schema.String)),
81 approved: Schema.optional(Schema.Boolean),
82 state: Schema.optional(Schema.NullOr(Schema.String)),
83 participated_on: Schema.optional(Schema.NullOr(Schema.String)),
84 }),
85 ),
86 ),
87 ),
88 links: Schema.Struct({ html: Schema.Struct({ href: TrimmedNonEmptyString }) }),
89});
90
91const RawPageSchema = Schema.Struct({
92 values: Schema.Array(Schema.Unknown),
93 /** A total count, which Bitbucket omits on some endpoints. */
94 size: Schema.optional(Schema.NullOr(Schema.Int)),
95 /** Present only while a further page exists. */
96 next: Schema.optional(Schema.NullOr(Schema.String)),
97});
98
99const RawCommentSchema = Schema.Struct({
100 id: Schema.Int,
101 content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))),
102 user: Schema.optional(Schema.NullOr(RawUserSchema)),
103 created_on: Schema.String,
104 deleted: Schema.optional(Schema.Boolean),
105 /** A comment still being drafted by its author. */
106 pending: Schema.optional(Schema.Boolean),
107 /** Set on a reply, to the comment it answers — which may itself be a reply. */
108 parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))),
109 inline: Schema.optional(
110 Schema.NullOr(
111 Schema.Struct({
112 path: Schema.optional(Schema.NullOr(Schema.String)),
113 /** The line in the file as it was; set instead of `to` on a removed line. */
114 from: Schema.optional(Schema.NullOr(Schema.Int)),
115 /** The line in the file as it is now. */
116 to: Schema.optional(Schema.NullOr(Schema.Int)),
117 outdated: Schema.optional(Schema.NullOr(Schema.Boolean)),
118 }),
119 ),
120 ),
121 /** Non-null once someone has marked the thread resolved. */
122 resolution: Schema.optional(Schema.NullOr(Schema.Unknown)),
123 links: Schema.optional(
124 Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })),
125 ),
126});
127
128const RawCommitSchema = Schema.Struct({
129 hash: TrimmedNonEmptyString,
130 message: Schema.optional(Schema.NullOr(Schema.String)),
131 date: Schema.optional(Schema.NullOr(Schema.String)),
132 author: Schema.optional(
133 Schema.NullOr(
134 Schema.Struct({
135 raw: Schema.optional(Schema.NullOr(Schema.String)),
136 user: Schema.optional(Schema.NullOr(RawUserSchema)),
137 }),
138 ),
139 ),
140});
141
142const RawStatusSchema = Schema.Struct({
143 key: Schema.optional(Schema.NullOr(Schema.String)),
144 name: Schema.optional(Schema.NullOr(Schema.String)),
145 state: Schema.optional(Schema.NullOr(Schema.String)),
146 description: Schema.optional(Schema.NullOr(Schema.String)),
147 url: Schema.optional(Schema.NullOr(Schema.String)),
148});
149
150const RawDiffstatSchema = Schema.Struct({
151 lines_added: Schema.optional(Schema.NullOr(Schema.Int)),
152 lines_removed: Schema.optional(Schema.NullOr(Schema.Int)),
153});
154
155/** One row of `/workspaces/{workspace}/members`, which wraps the account it is about. */
156const RawMemberSchema = Schema.Struct({
157 user: Schema.optional(Schema.NullOr(RawUserSchema)),
158});
159
160const RawViewerSchema = Schema.Struct({
161 nickname: Schema.optional(Schema.NullOr(Schema.String)),
162 display_name: Schema.optional(Schema.NullOr(Schema.String)),
163});
164
165/**
166 * The caller's workspace-scoped repository permissions, filtered to one repository.
167 * One row, or none where Bitbucket names no permission for this account.
168 */
169const RawRepositoryPermissionsSchema = Schema.Struct({
170 values: Schema.optional(
171 Schema.NullOr(
172 Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })),
173 ),
174 ),
175});
176
177export interface BitbucketPullRequest {
178 readonly number: number;
179 readonly title: string;
180 readonly url: string;
181 readonly author: PullRequestActor | null;
182 readonly headBranch: string;
183 readonly headSha: string | null;
184 readonly headRepositoryNameWithOwner: string | null;
185 readonly baseBranch: string;
186 readonly state: PullRequestState;
187 readonly isDraft: boolean;
188 /**
189 * Bitbucket reports no conflict state on a pull request, so the list leaves it unknown. The
190 * detail read asks the conflicts endpoint, which does answer.
191 */
192 readonly mergeability: PullRequestMergeability;
193 readonly createdAt: string;
194 readonly updatedAt: string;
195 readonly body: string;
196 readonly reviewRequestLogins: ReadonlyArray<string>;
197 readonly reviewers: ReadonlyArray<PullRequestActor>;
198 /** The reviewers as Bitbucket addresses them, which is what writing the set back takes. */
199 readonly reviewerIds: ReadonlyArray<string>;
200 /** Approvals and change requests, which Bitbucket keeps on its participants. */
201 readonly reviews: ReadonlyArray<PullRequestComment>;
202}
203
204function trimmed(value: string | null | undefined): string | null {
205 const text = value?.trim() ?? "";
206 return text.length > 0 ? text : null;
207}
208
209/**
210 * Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from
211 * every host against each other as plain strings, so they are normalized to the same `Z` form
212 * the other hosts already use.
213 */
214function toIsoUtc(value: string): string {
215 return Option.match(DateTime.make(value), {
216 onNone: () => value,
217 onSome: DateTime.formatIso,
218 });
219}
220
221/** An app account has no nickname, so the display name is the only handle it has. */
222function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) {
223 const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name);
224 return login === null
225 ? null
226 : {
227 login,
228 name: trimmed(raw?.display_name),
229 avatarUrl: trimmed(raw?.links?.avatar?.href),
230 };
231}
232
233function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState {
234 switch (raw.state?.trim().toUpperCase()) {
235 case "MERGED":
236 return "merged";
237 case "DECLINED":
238 case "SUPERSEDED":
239 return "closed";
240 default:
241 return "open";
242 }
243}
244
245function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus {
246 switch (value?.trim().toUpperCase()) {
247 case "SUCCESSFUL":
248 return "success";
249 case "FAILED":
250 return "failure";
251 case "STOPPED":
252 return "cancelled";
253 case "INPROGRESS":
254 return "pending";
255 default:
256 return "neutral";
257 }
258}
259
260/**
261 * A participant who has voted is the closest Bitbucket has to a review, so it reads as one in
262 * the conversation. Participants who have only been added carry no verdict and are skipped.
263 */
264function toReviews(
265 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
266): ReadonlyArray<PullRequestComment> {
267 return (raw.participants ?? []).flatMap((participant): ReadonlyArray<PullRequestComment> => {
268 const author = toActor(participant.user);
269 const votedAt = trimmed(participant.participated_on);
270 const reviewState =
271 trimmed(participant.state) ?? (participant.approved === true ? "approved" : null);
272 if (author === null || votedAt === null || reviewState === null) return [];
273 return [
274 {
275 id: `${raw.id}:${author.login}`,
276 kind: "review",
277 author,
278 body: "",
279 createdAt: toIsoUtc(votedAt),
280 url: null,
281 path: null,
282 reviewState,
283 },
284 ];
285 });
286}
287
288function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): BitbucketPullRequest {
289 const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => {
290 const actor = toActor(reviewer);
291 return actor === null ? [] : [actor];
292 });
293 return {
294 number: raw.id,
295 title: raw.title,
296 url: raw.links.html.href,
297 author: toActor(raw.author),
298 headBranch: raw.source.branch.name,
299 headSha: trimmed(raw.source.commit?.hash),
300 headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null,
301 baseBranch: raw.destination.branch.name,
302 state: toState(raw),
303 isDraft: raw.draft ?? false,
304 mergeability: "unknown",
305 createdAt: toIsoUtc(raw.created_on),
306 updatedAt: toIsoUtc(raw.updated_on),
307 body: raw.description ?? "",
308 reviewRequestLogins: reviewers.map((reviewer) => reviewer.login),
309 reviewers,
310 reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => trimmed(reviewer.uuid) ?? []),
311 reviews: toReviews(raw),
312 };
313}
314
315const decodePage = decodeJsonResult(RawPageSchema);
316const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema);
317const decodePullRequest = decodeJsonResult(RawPullRequestSchema);
318const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema);
319const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema);
320const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema);
321const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema);
322const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema);
323const decodeViewer = decodeJsonResult(RawViewerSchema);
324const decodeConflicts = decodeJsonResult(RawPageSchema);
325const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema);
326
327type DecodeFailure = Cause.Cause<Schema.SchemaError>;
328
329export interface BitbucketPage<A> {
330 readonly items: ReadonlyArray<A>;
331 /** The whole URL of the next page, which Bitbucket sends rather than an offset. */
332 readonly next: string | null;
333}
334
335/** Malformed entries are skipped rather than failing the page, as on the other hosts. */
336export function decodePullRequestPageJson(
337 raw: string,
338): Result.Result<BitbucketPage<BitbucketPullRequest>, DecodeFailure> {
339 const decoded = decodePage(raw);
340 if (!Result.isSuccess(decoded)) {
341 return Result.fail(decoded.failure);
342 }
343 const items: BitbucketPullRequest[] = [];
344 for (const entry of decoded.success.values) {
345 const item = decodePullRequestEntry(entry);
346 if (Exit.isSuccess(item)) {
347 items.push(toPullRequest(item.value));
348 }
349 }
350 return Result.succeed({ items, next: trimmed(decoded.success.next) });
351}
352
353export function decodePullRequestJson(
354 raw: string,
355): Result.Result<BitbucketPullRequest, DecodeFailure> {
356 const decoded = decodePullRequest(raw);
357 return Result.isSuccess(decoded)
358 ? Result.succeed(toPullRequest(decoded.success))
359 : Result.fail(decoded.failure);
360}
361
362export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> {
363 const decoded = decodeViewer(raw);
364 return Result.isSuccess(decoded)
365 ? Result.succeed(trimmed(decoded.success.nickname) ?? trimmed(decoded.success.display_name))
366 : Result.fail(decoded.failure);
367}
368
369/**
370 * Whether the configured credentials can write to the repository, which is what merging needs.
371 * Bitbucket answers `admin`, `write` or `read`, and an empty page means it named no permission at
372 * all for this account — an unknown standing, which is granted rather than guessed away.
373 */
374export function decodeRepositoryPermissionJson(raw: string): Result.Result<boolean, DecodeFailure> {
375 const decoded = decodeRepositoryPermissions(raw);
376 if (!Result.isSuccess(decoded)) {
377 return Result.fail(decoded.failure);
378 }
379 const permission = trimmed(decoded.success.values?.[0]?.permission)?.toLowerCase() ?? null;
380 return Result.succeed(permission === null || permission === "admin" || permission === "write");
381}
382
383/**
384 * The workspace's members, which is the nearest thing Bitbucket has to "who may review this".
385 * Nothing on a repository lists the people with access to it — `permissions-config/users` is for
386 * administrators only — and a pull request can be sent to anyone in the workspace, so this is the
387 * list Bitbucket's own reviewer field is filled from too.
388 *
389 * Nobody is marked requested here: who has been asked lives on the pull request, and only the
390 * caller holds both.
391 */
392export function decodeWorkspaceMembersJson(
393 raw: string,
394): Result.Result<BitbucketPage<PullRequestReviewerCandidate>, DecodeFailure> {
395 const decoded = decodePage(raw);
396 if (!Result.isSuccess(decoded)) {
397 return Result.fail(decoded.failure);
398 }
399 const items: PullRequestReviewerCandidate[] = [];
400 for (const entry of decoded.success.values) {
401 const member = decodeMemberEntry(entry);
402 if (Exit.isFailure(member)) continue;
403 const uuid = trimmed(member.value.user?.uuid);
404 const actor = toActor(member.value.user);
405 if (uuid === null || actor === null) continue;
406 items.push({ ...actor, id: uuid, kind: "user", isRequested: false });
407 }
408 return Result.succeed({ items, next: trimmed(decoded.success.next) });
409}
410
411/** One comment as Bitbucket sent it, kept so threads can be assembled across pages. */
412export type BitbucketRawComment = Schema.Schema.Type<typeof RawCommentSchema>;
413
414export interface BitbucketComments {
415 readonly comments: ReadonlyArray<PullRequestComment>;
416 /**
417 * The same comments unread, for `buildReviewThreads`. A reply and the remark it answers can
418 * land on different pages, and only the caller holding every page can put them together.
419 */
420 readonly entries: ReadonlyArray<BitbucketRawComment>;
421 readonly next: string | null;
422}
423
424/**
425 * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a
426 * line opens a thread, and every reply that leads back to it belongs in it. A reply whose
427 * parent is on a page that was not read has nowhere to go, and is left out rather than shown
428 * as a thread of its own — it still stands in the flat conversation, which needs no parent.
429 */
430export function buildReviewThreads(
431 comments: ReadonlyArray<BitbucketRawComment>,
432): ReadonlyArray<PullRequestReviewThread> {
433 const byId = new Map(comments.map((comment) => [comment.id, comment]));
434 const rootOf = (comment: Schema.Schema.Type<typeof RawCommentSchema>) => {
435 // Bounded by the number of comments read, so a parent cycle cannot spin here.
436 let current = comment;
437 for (let step = 0; step < byId.size; step += 1) {
438 const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1);
439 if (parent === undefined) return current;
440 current = parent;
441 }
442 return current;
443 };
444
445 const threads = new Map<number, PullRequestReviewThread>();
446 const replies = new Map<number, Array<Schema.Schema.Type<typeof RawCommentSchema>>>();
447 for (const comment of comments) {
448 const root = rootOf(comment);
449 const inline = root.inline;
450 const path = trimmed(inline?.path);
451 if (path === null) continue;
452 if (root.id === comment.id) {
453 // `to` is the line as the file stands now, `from` the line it replaced; a comment that
454 // carries only `from` was written against the removed side.
455 const side = inline?.to === null || inline?.to === undefined ? "left" : "right";
456 const line = side === "left" ? inline?.from : inline?.to;
457 threads.set(root.id, {
458 id: String(root.id),
459 path,
460 line: typeof line === "number" && line > 0 ? line : null,
461 side,
462 isResolved: root.resolution !== null && root.resolution !== undefined,
463 isOutdated: inline?.outdated === true,
464 comments: [],
465 });
466 }
467 const bucket = replies.get(root.id);
468 if (bucket === undefined) replies.set(root.id, [comment]);
469 else bucket.push(comment);
470 }
471
472 return [...threads.values()].flatMap((thread) => {
473 const entries = (replies.get(Number(thread.id)) ?? [])
474 .toSorted((left, right) => left.created_on.localeCompare(right.created_on))
475 .map((comment) => ({
476 id: String(comment.id),
477 author: toActor(comment.user),
478 body: comment.content?.raw ?? "",
479 createdAt: toIsoUtc(comment.created_on),
480 url: trimmed(comment.links?.html?.href),
481 }));
482 return entries.length === 0 ? [] : [{ ...thread, comments: entries }];
483 });
484}
485
486/**
487 * Deleted comments and ones their author has not posted yet carry nothing to show. A comment
488 * pinned to a file is a line-level review comment, which is what that kind means.
489 */
490export function decodeCommentsJson(raw: string): Result.Result<BitbucketComments, DecodeFailure> {
491 const decoded = decodePage(raw);
492 if (!Result.isSuccess(decoded)) {
493 return Result.fail(decoded.failure);
494 }
495 const comments: PullRequestComment[] = [];
496 const kept: Array<BitbucketRawComment> = [];
497 for (const entry of decoded.success.values) {
498 const decodedComment = decodeCommentEntry(entry);
499 if (Exit.isFailure(decodedComment)) continue;
500 const comment = decodedComment.value;
501 if (comment.deleted === true || comment.pending === true) continue;
502 const body = comment.content?.raw ?? "";
503 if (body.trim().length === 0) continue;
504 kept.push(comment);
505 const path = trimmed(comment.inline?.path);
506 comments.push({
507 id: String(comment.id),
508 kind: path === null ? "issue-comment" : "review-comment",
509 author: toActor(comment.user),
510 body,
511 createdAt: toIsoUtc(comment.created_on),
512 url: trimmed(comment.links?.html?.href),
513 path,
514 reviewState: null,
515 });
516 }
517 return Result.succeed({ comments, entries: kept, next: trimmed(decoded.success.next) });
518}
519
520export function decodeCommitsJson(
521 raw: string,
522): Result.Result<BitbucketPage<PullRequestCommit>, DecodeFailure> {
523 const decoded = decodePage(raw);
524 if (!Result.isSuccess(decoded)) {
525 return Result.fail(decoded.failure);
526 }
527 const commits: PullRequestCommit[] = [];
528 for (const entry of decoded.success.values) {
529 const decodedCommit = decodeCommitEntry(entry);
530 if (Exit.isFailure(decodedCommit)) continue;
531 const commit = decodedCommit.value;
532 const committedDate = trimmed(commit.date);
533 if (committedDate === null) continue;
534 const linkedAuthor = toActor(commit.author?.user);
535 const rawAuthor = trimmed(commit.author?.raw);
536 commits.push({
537 oid: commit.hash,
538 messageHeadline: (commit.message ?? "").split("\n")[0] ?? "",
539 committedDate: toIsoUtc(committedDate),
540 authors:
541 linkedAuthor !== null
542 ? [linkedAuthor]
543 : rawAuthor === null
544 ? []
545 : [{ login: rawAuthor, name: rawAuthor, avatarUrl: null }],
546 });
547 }
548 // Bitbucket lists a pull request's commits newest first; the timeline reads oldest first.
549 return Result.succeed({ items: commits.toReversed(), next: trimmed(decoded.success.next) });
550}
551
552export function decodeStatusesJson(
553 raw: string,
554): Result.Result<BitbucketPage<PullRequestCheck>, DecodeFailure> {
555 const decoded = decodePage(raw);
556 if (!Result.isSuccess(decoded)) {
557 return Result.fail(decoded.failure);
558 }
559 const checks: Array<{
560 readonly check: PullRequestCheck;
561 readonly workflowName: string | null;
562 readonly at: string | null;
563 }> = [];
564 for (const entry of decoded.success.values) {
565 const decodedStatus = decodeStatusEntry(entry);
566 if (Exit.isFailure(decodedStatus)) continue;
567 const status = decodedStatus.value;
568 const name = trimmed(status.name) ?? trimmed(status.key);
569 if (name === null) continue;
570 // Bitbucket re-uses a status key when a pipeline is run again, so the same check can appear
571 // twice on one page. Nothing decoded here says which copy is newer, so the later one wins,
572 // which is the order Bitbucket writes an update in. The key is kept as the workflow name so
573 // two different pipelines that display the same name are not folded into one.
574 checks.push({
575 check: {
576 name,
577 status: toBuildStatus(status.state),
578 description: trimmed(status.description),
579 url: trimmed(status.url),
580 },
581 workflowName: trimmed(status.key),
582 at: null,
583 });
584 }
585 return Result.succeed({ items: dedupeChecks(checks), next: trimmed(decoded.success.next) });
586}
587
588export interface BitbucketDiffStat {
589 readonly additions: number;
590 readonly deletions: number;
591 readonly changedFiles: number;
592}
593
594export interface BitbucketDiffStatPage extends BitbucketDiffStat {
595 readonly next: string | null;
596}
597
598/** One entry per changed file, each carrying that file's line counts. */
599export function decodeDiffstatJson(
600 raw: string,
601): Result.Result<BitbucketDiffStatPage, DecodeFailure> {
602 const decoded = decodePage(raw);
603 if (!Result.isSuccess(decoded)) {
604 return Result.fail(decoded.failure);
605 }
606 let additions = 0;
607 let deletions = 0;
608 let changedFiles = 0;
609 for (const entry of decoded.success.values) {
610 const decodedStat = decodeDiffstatEntry(entry);
611 if (Exit.isFailure(decodedStat)) continue;
612 additions += decodedStat.value.lines_added ?? 0;
613 deletions += decodedStat.value.lines_removed ?? 0;
614 changedFiles += 1;
615 }
616 return Result.succeed({
617 additions,
618 deletions,
619 changedFiles,
620 next: trimmed(decoded.success.next),
621 });
622}
623
624/**
625 * The conflicts endpoint answers with one entry per conflicting path, so an empty page is the
626 * only statement Bitbucket makes that a pull request merges cleanly.
627 */
628export function decodeConflictsJson(
629 raw: string,
630): Result.Result<PullRequestMergeability, DecodeFailure> {
631 const decoded = decodeConflicts(raw);
632 return Result.isSuccess(decoded)
633 ? Result.succeed(decoded.success.values.length === 0 ? "mergeable" : "conflicting")
634 : Result.fail(decoded.failure);
635}