import * as Cause from "effect/Cause";import * as DateTime from "effect/DateTime";import * as Exit from "effect/Exit";import * as Option from "effect/Option";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import type { PullRequestActor, PullRequestCheck, PullRequestCheckStatus, PullRequestComment, PullRequestCommit, PullRequestMergeability, PullRequestReviewThread, PullRequestReviewerCandidate, PullRequestState,} from "@t3tools/contracts";import { TrimmedNonEmptyString } from "@t3tools/contracts";import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { dedupeChecks } from "./pullRequestChecks.ts"; /** * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant * style as the GitHub and GitLab decoders: a new pull request state or build status must not * fail a whole payload. */const RawUserSchema = Schema.Struct({ /** * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are * not accepted there. Braced, and sent back exactly as it arrived. */ uuid: Schema.optional(Schema.NullOr(Schema.String)), /** Absent on an app account, which is why `display_name` has to stand in for it. */ nickname: Schema.optional(Schema.NullOr(Schema.String)), display_name: Schema.optional(Schema.NullOr(Schema.String)), links: Schema.optional( Schema.NullOr( Schema.Struct({ avatar: Schema.optional( Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), ), }), ), ),}); /** * Required, and required to be non-empty: the wire contract will not carry a change request * without a branch or a link, so a row missing one is skipped rather than breaking the response * it travels in. */const RawBranchSchema = Schema.Struct({ branch: Schema.Struct({ name: TrimmedNonEmptyString }), repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))),}); const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) }); const RawPullRequestSchema = Schema.Struct({ id: Schema.Int, title: Schema.String, description: Schema.optional(Schema.NullOr(Schema.String)), state: Schema.optional(Schema.NullOr(Schema.String)), draft: Schema.optional(Schema.Boolean), author: Schema.optional(Schema.NullOr(RawUserSchema)), source: RawBranchSchema, destination: RawBranchSchema, created_on: Schema.String, updated_on: Schema.String, reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), participants: Schema.optional( Schema.NullOr( Schema.Array( Schema.Struct({ user: Schema.optional(Schema.NullOr(RawUserSchema)), role: Schema.optional(Schema.NullOr(Schema.String)), approved: Schema.optional(Schema.Boolean), state: Schema.optional(Schema.NullOr(Schema.String)), participated_on: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), ), links: Schema.Struct({ html: Schema.Struct({ href: TrimmedNonEmptyString }) }),}); const RawPageSchema = Schema.Struct({ values: Schema.Array(Schema.Unknown), /** A total count, which Bitbucket omits on some endpoints. */ size: Schema.optional(Schema.NullOr(Schema.Int)), /** Present only while a further page exists. */ next: Schema.optional(Schema.NullOr(Schema.String)),}); const RawCommentSchema = Schema.Struct({ id: Schema.Int, content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))), user: Schema.optional(Schema.NullOr(RawUserSchema)), created_on: Schema.String, deleted: Schema.optional(Schema.Boolean), /** A comment still being drafted by its author. */ pending: Schema.optional(Schema.Boolean), /** Set on a reply, to the comment it answers — which may itself be a reply. */ parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))), inline: Schema.optional( Schema.NullOr( Schema.Struct({ path: Schema.optional(Schema.NullOr(Schema.String)), /** The line in the file as it was; set instead of `to` on a removed line. */ from: Schema.optional(Schema.NullOr(Schema.Int)), /** The line in the file as it is now. */ to: Schema.optional(Schema.NullOr(Schema.Int)), outdated: Schema.optional(Schema.NullOr(Schema.Boolean)), }), ), ), /** Non-null once someone has marked the thread resolved. */ resolution: Schema.optional(Schema.NullOr(Schema.Unknown)), links: Schema.optional( Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })), ),}); const RawCommitSchema = Schema.Struct({ hash: TrimmedNonEmptyString, message: Schema.optional(Schema.NullOr(Schema.String)), date: Schema.optional(Schema.NullOr(Schema.String)), author: Schema.optional( Schema.NullOr( Schema.Struct({ raw: Schema.optional(Schema.NullOr(Schema.String)), user: Schema.optional(Schema.NullOr(RawUserSchema)), }), ), ),}); const RawStatusSchema = Schema.Struct({ key: Schema.optional(Schema.NullOr(Schema.String)), name: Schema.optional(Schema.NullOr(Schema.String)), state: Schema.optional(Schema.NullOr(Schema.String)), description: Schema.optional(Schema.NullOr(Schema.String)), url: Schema.optional(Schema.NullOr(Schema.String)),}); const RawDiffstatSchema = Schema.Struct({ lines_added: Schema.optional(Schema.NullOr(Schema.Int)), lines_removed: Schema.optional(Schema.NullOr(Schema.Int)),}); /** One row of `/workspaces/{workspace}/members`, which wraps the account it is about. */const RawMemberSchema = Schema.Struct({ user: Schema.optional(Schema.NullOr(RawUserSchema)),}); const RawViewerSchema = Schema.Struct({ nickname: Schema.optional(Schema.NullOr(Schema.String)), display_name: Schema.optional(Schema.NullOr(Schema.String)),}); /** * `/user/permissions/repositories` filtered to one repository, which is the only place Bitbucket * states what the credentials may do with it: nothing on the repository, the pull request or the * workspace carries it. One row, or none where Bitbucket names no permission for this account. */const RawRepositoryPermissionsSchema = Schema.Struct({ values: Schema.optional( Schema.NullOr( Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })), ), ),}); export interface BitbucketPullRequest { readonly number: number; readonly title: string; readonly url: string; readonly author: PullRequestActor | null; readonly headBranch: string; readonly headRepositoryNameWithOwner: string | null; readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; /** * Bitbucket reports no conflict state on a pull request, so the list leaves it unknown. The * detail read asks the conflicts endpoint, which does answer. */ readonly mergeability: PullRequestMergeability; readonly createdAt: string; readonly updatedAt: string; readonly body: string; readonly reviewRequestLogins: ReadonlyArray<string>; readonly reviewers: ReadonlyArray<PullRequestActor>; /** The reviewers as Bitbucket addresses them, which is what writing the set back takes. */ readonly reviewerIds: ReadonlyArray<string>; /** Approvals and change requests, which Bitbucket keeps on its participants. */ readonly reviews: ReadonlyArray<PullRequestComment>;} function trimmed(value: string | null | undefined): string | null { const text = value?.trim() ?? ""; return text.length > 0 ? text : null;} /** * Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from * every host against each other as plain strings, so they are normalized to the same `Z` form * the other hosts already use. */function toIsoUtc(value: string): string { return Option.match(DateTime.make(value), { onNone: () => value, onSome: DateTime.formatIso, });} /** An app account has no nickname, so the display name is the only handle it has. */function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) { const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name); return login === null ? null : { login, name: trimmed(raw?.display_name), avatarUrl: trimmed(raw?.links?.avatar?.href), };} function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState { switch (raw.state?.trim().toUpperCase()) { case "MERGED": return "merged"; case "DECLINED": case "SUPERSEDED": return "closed"; default: return "open"; }} function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus { switch (value?.trim().toUpperCase()) { case "SUCCESSFUL": return "success"; case "FAILED": return "failure"; case "STOPPED": return "cancelled"; case "INPROGRESS": return "pending"; default: return "neutral"; }} /** * A participant who has voted is the closest Bitbucket has to a review, so it reads as one in * the conversation. Participants who have only been added carry no verdict and are skipped. */function toReviews( raw: Schema.Schema.Type<typeof RawPullRequestSchema>,): ReadonlyArray<PullRequestComment> { return (raw.participants ?? []).flatMap((participant): ReadonlyArray<PullRequestComment> => { const author = toActor(participant.user); const votedAt = trimmed(participant.participated_on); const reviewState = trimmed(participant.state) ?? (participant.approved === true ? "approved" : null); if (author === null || votedAt === null || reviewState === null) return []; return [ { id: `${raw.id}:${author.login}`, kind: "review", author, body: "", createdAt: toIsoUtc(votedAt), url: null, path: null, reviewState, }, ]; });} function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): BitbucketPullRequest { const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { const actor = toActor(reviewer); return actor === null ? [] : [actor]; }); return { number: raw.id, title: raw.title, url: raw.links.html.href, author: toActor(raw.author), headBranch: raw.source.branch.name, headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null, baseBranch: raw.destination.branch.name, state: toState(raw), isDraft: raw.draft ?? false, mergeability: "unknown", createdAt: toIsoUtc(raw.created_on), updatedAt: toIsoUtc(raw.updated_on), body: raw.description ?? "", reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), reviewers, reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => trimmed(reviewer.uuid) ?? []), reviews: toReviews(raw), };} const decodePage = decodeJsonResult(RawPageSchema);const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema);const decodePullRequest = decodeJsonResult(RawPullRequestSchema);const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema);const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema);const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema);const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema);const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema);const decodeViewer = decodeJsonResult(RawViewerSchema);const decodeConflicts = decodeJsonResult(RawPageSchema);const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema); type DecodeFailure = Cause.Cause<Schema.SchemaError>; export interface BitbucketPage<A> { readonly items: ReadonlyArray<A>; /** The whole URL of the next page, which Bitbucket sends rather than an offset. */ readonly next: string | null;} /** Malformed entries are skipped rather than failing the page, as on the other hosts. */export function decodePullRequestPageJson( raw: string,): Result.Result<BitbucketPage<BitbucketPullRequest>, DecodeFailure> { const decoded = decodePage(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const items: BitbucketPullRequest[] = []; for (const entry of decoded.success.values) { const item = decodePullRequestEntry(entry); if (Exit.isSuccess(item)) { items.push(toPullRequest(item.value)); } } return Result.succeed({ items, next: trimmed(decoded.success.next) });} export function decodePullRequestJson( raw: string,): Result.Result<BitbucketPullRequest, DecodeFailure> { const decoded = decodePullRequest(raw); return Result.isSuccess(decoded) ? Result.succeed(toPullRequest(decoded.success)) : Result.fail(decoded.failure);} export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> { const decoded = decodeViewer(raw); return Result.isSuccess(decoded) ? Result.succeed(trimmed(decoded.success.nickname) ?? trimmed(decoded.success.display_name)) : Result.fail(decoded.failure);} /** * Whether the configured credentials can write to the repository, which is what merging needs. * Bitbucket answers `admin`, `write` or `read`, and an empty page means it named no permission at * all for this account — an unknown standing, which is granted rather than guessed away. */export function decodeRepositoryPermissionJson(raw: string): Result.Result<boolean, DecodeFailure> { const decoded = decodeRepositoryPermissions(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const permission = trimmed(decoded.success.values?.[0]?.permission)?.toLowerCase() ?? null; return Result.succeed(permission === null || permission === "admin" || permission === "write");} /** * The workspace's members, which is the nearest thing Bitbucket has to "who may review this". * Nothing on a repository lists the people with access to it — `permissions-config/users` is for * administrators only — and a pull request can be sent to anyone in the workspace, so this is the * list Bitbucket's own reviewer field is filled from too. * * Nobody is marked requested here: who has been asked lives on the pull request, and only the * caller holds both. */export function decodeWorkspaceMembersJson( raw: string,): Result.Result<BitbucketPage<PullRequestReviewerCandidate>, DecodeFailure> { const decoded = decodePage(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const items: PullRequestReviewerCandidate[] = []; for (const entry of decoded.success.values) { const member = decodeMemberEntry(entry); if (Exit.isFailure(member)) continue; const uuid = trimmed(member.value.user?.uuid); const actor = toActor(member.value.user); if (uuid === null || actor === null) continue; items.push({ ...actor, id: uuid, kind: "user", isRequested: false }); } return Result.succeed({ items, next: trimmed(decoded.success.next) });} /** One comment as Bitbucket sent it, kept so threads can be assembled across pages. */export type BitbucketRawComment = Schema.Schema.Type<typeof RawCommentSchema>; export interface BitbucketComments { readonly comments: ReadonlyArray<PullRequestComment>; /** * The same comments unread, for `buildReviewThreads`. A reply and the remark it answers can * land on different pages, and only the caller holding every page can put them together. */ readonly entries: ReadonlyArray<BitbucketRawComment>; readonly next: string | null;} /** * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a * line opens a thread, and every reply that leads back to it belongs in it. A reply whose * parent is on a page that was not read has nowhere to go, and is left out rather than shown * as a thread of its own — it still stands in the flat conversation, which needs no parent. */export function buildReviewThreads( comments: ReadonlyArray<BitbucketRawComment>,): ReadonlyArray<PullRequestReviewThread> { const byId = new Map(comments.map((comment) => [comment.id, comment])); const rootOf = (comment: Schema.Schema.Type<typeof RawCommentSchema>) => { // Bounded by the number of comments read, so a parent cycle cannot spin here. let current = comment; for (let step = 0; step < byId.size; step += 1) { const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1); if (parent === undefined) return current; current = parent; } return current; }; const threads = new Map<number, PullRequestReviewThread>(); const replies = new Map<number, Array<Schema.Schema.Type<typeof RawCommentSchema>>>(); for (const comment of comments) { const root = rootOf(comment); const inline = root.inline; const path = trimmed(inline?.path); if (path === null) continue; if (root.id === comment.id) { // `to` is the line as the file stands now, `from` the line it replaced; a comment that // carries only `from` was written against the removed side. const side = inline?.to === null || inline?.to === undefined ? "left" : "right"; const line = side === "left" ? inline?.from : inline?.to; threads.set(root.id, { id: String(root.id), path, line: typeof line === "number" && line > 0 ? line : null, side, isResolved: root.resolution !== null && root.resolution !== undefined, isOutdated: inline?.outdated === true, comments: [], }); } const bucket = replies.get(root.id); if (bucket === undefined) replies.set(root.id, [comment]); else bucket.push(comment); } return [...threads.values()].flatMap((thread) => { const entries = (replies.get(Number(thread.id)) ?? []) .toSorted((left, right) => left.created_on.localeCompare(right.created_on)) .map((comment) => ({ id: String(comment.id), author: toActor(comment.user), body: comment.content?.raw ?? "", createdAt: toIsoUtc(comment.created_on), url: trimmed(comment.links?.html?.href), })); return entries.length === 0 ? [] : [{ ...thread, comments: entries }]; });} /** * Deleted comments and ones their author has not posted yet carry nothing to show. A comment * pinned to a file is a line-level review comment, which is what that kind means. */export function decodeCommentsJson(raw: string): Result.Result<BitbucketComments, DecodeFailure> { const decoded = decodePage(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const comments: PullRequestComment[] = []; const kept: Array<BitbucketRawComment> = []; for (const entry of decoded.success.values) { const decodedComment = decodeCommentEntry(entry); if (Exit.isFailure(decodedComment)) continue; const comment = decodedComment.value; if (comment.deleted === true || comment.pending === true) continue; const body = comment.content?.raw ?? ""; if (body.trim().length === 0) continue; kept.push(comment);Lines 1–500 of 631. Load more
import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { PullRequestActor, PullRequestCheck, PullRequestCheckStatus, PullRequestComment, PullRequestCommit, PullRequestMergeability, PullRequestReviewThread, PullRequestReviewerCandidate, PullRequestState, } from "@t3tools/contracts"; import { TrimmedNonEmptyString } from "@t3tools/contracts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { dedupeChecks } from "./pullRequestChecks.ts"; /** * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant * style as the GitHub and GitLab decoders: a new pull request state or build status must not * fail a whole payload. */ const RawUserSchema = Schema.Struct({ /** * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are * not accepted there. Braced, and sent back exactly as it arrived. */ uuid: Schema.optional(Schema.NullOr(Schema.String)), /** Absent on an app account, which is why `display_name` has to stand in for it. */ nickname: Schema.optional(Schema.NullOr(Schema.String)), display_name: Schema.optional(Schema.NullOr(Schema.String)), links: Schema.optional( Schema.NullOr( Schema.Struct({ avatar: Schema.optional( Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), ), }), ), ), }); /** * Required, and required to be non-empty: the wire contract will not carry a change request * without a branch or a link, so a row missing one is skipped rather than breaking the response * it travels in. */ const RawBranchSchema = Schema.Struct({ branch: Schema.Struct({ name: TrimmedNonEmptyString }), repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))), }); const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) }); const RawPullRequestSchema = Schema.Struct({ id: Schema.Int, title: Schema.String, description: Schema.optional(Schema.NullOr(Schema.String)), state: Schema.optional(Schema.NullOr(Schema.String)), draft: Schema.optional(Schema.Boolean), author: Schema.optional(Schema.NullOr(RawUserSchema)), source: RawBranchSchema, destination: RawBranchSchema, created_on: Schema.String, updated_on: Schema.String, reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), participants: Schema.optional( Schema.NullOr( Schema.Array( Schema.Struct({ user: Schema.optional(Schema.NullOr(RawUserSchema)), role: Schema.optional(Schema.NullOr(Schema.String)), approved: Schema.optional(Schema.Boolean), state: Schema.optional(Schema.NullOr(Schema.String)), participated_on: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), ), links: Schema.Struct({ html: Schema.Struct({ href: TrimmedNonEmptyString }) }), }); const RawPageSchema = Schema.Struct({ values: Schema.Array(Schema.Unknown), /** A total count, which Bitbucket omits on some endpoints. */ size: Schema.optional(Schema.NullOr(Schema.Int)), /** Present only while a further page exists. */ next: Schema.optional(Schema.NullOr(Schema.String)), }); const RawCommentSchema = Schema.Struct({ id: Schema.Int, content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))), user: Schema.optional(Schema.NullOr(RawUserSchema)), created_on: Schema.String, deleted: Schema.optional(Schema.Boolean), /** A comment still being drafted by its author. */ pending: Schema.optional(Schema.Boolean), /** Set on a reply, to the comment it answers — which may itself be a reply. */ parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))), inline: Schema.optional( Schema.NullOr( Schema.Struct({ path: Schema.optional(Schema.NullOr(Schema.String)), /** The line in the file as it was; set instead of `to` on a removed line. */ from: Schema.optional(Schema.NullOr(Schema.Int)), /** The line in the file as it is now. */ to: Schema.optional(Schema.NullOr(Schema.Int)), outdated: Schema.optional(Schema.NullOr(Schema.Boolean)), }), ), ), /** Non-null once someone has marked the thread resolved. */ resolution: Schema.optional(Schema.NullOr(Schema.Unknown)), links: Schema.optional( Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })), ), }); const RawCommitSchema = Schema.Struct({ hash: TrimmedNonEmptyString, message: Schema.optional(Schema.NullOr(Schema.String)), date: Schema.optional(Schema.NullOr(Schema.String)), author: Schema.optional( Schema.NullOr( Schema.Struct({ raw: Schema.optional(Schema.NullOr(Schema.String)), user: Schema.optional(Schema.NullOr(RawUserSchema)), }), ), ), }); const RawStatusSchema = Schema.Struct({ key: Schema.optional(Schema.NullOr(Schema.String)), name: Schema.optional(Schema.NullOr(Schema.String)), state: Schema.optional(Schema.NullOr(Schema.String)), description: Schema.optional(Schema.NullOr(Schema.String)), url: Schema.optional(Schema.NullOr(Schema.String)), }); const RawDiffstatSchema = Schema.Struct({ lines_added: Schema.optional(Schema.NullOr(Schema.Int)), lines_removed: Schema.optional(Schema.NullOr(Schema.Int)), }); /** One row of `/workspaces/{workspace}/members`, which wraps the account it is about. */ const RawMemberSchema = Schema.Struct({ user: Schema.optional(Schema.NullOr(RawUserSchema)), }); const RawViewerSchema = Schema.Struct({ nickname: Schema.optional(Schema.NullOr(Schema.String)), display_name: Schema.optional(Schema.NullOr(Schema.String)), }); /** * `/user/permissions/repositories` filtered to one repository, which is the only place Bitbucket * states what the credentials may do with it: nothing on the repository, the pull request or the * workspace carries it. One row, or none where Bitbucket names no permission for this account. */ const RawRepositoryPermissionsSchema = Schema.Struct({ values: Schema.optional( Schema.NullOr( Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })), ), ), }); export interface BitbucketPullRequest { readonly number: number; readonly title: string; readonly url: string; readonly author: PullRequestActor | null; readonly headBranch: string; readonly headRepositoryNameWithOwner: string | null; readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; /** * Bitbucket reports no conflict state on a pull request, so the list leaves it unknown. The * detail read asks the conflicts endpoint, which does answer. */ readonly mergeability: PullRequestMergeability; readonly createdAt: string; readonly updatedAt: string; readonly body: string; readonly reviewRequestLogins: ReadonlyArray<string>; readonly reviewers: ReadonlyArray<PullRequestActor>; /** The reviewers as Bitbucket addresses them, which is what writing the set back takes. */ readonly reviewerIds: ReadonlyArray<string>; /** Approvals and change requests, which Bitbucket keeps on its participants. */ readonly reviews: ReadonlyArray<PullRequestComment>; } function trimmed(value: string | null | undefined): string | null { const text = value?.trim() ?? ""; return text.length > 0 ? text : null; } /** * Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from * every host against each other as plain strings, so they are normalized to the same `Z` form * the other hosts already use. */ function toIsoUtc(value: string): string { return Option.match(DateTime.make(value), { onNone: () => value, onSome: DateTime.formatIso, }); } /** An app account has no nickname, so the display name is the only handle it has. */ function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) { const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name); return login === null ? null : { login, name: trimmed(raw?.display_name), avatarUrl: trimmed(raw?.links?.avatar?.href), }; } function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState { switch (raw.state?.trim().toUpperCase()) { case "MERGED": return "merged"; case "DECLINED": case "SUPERSEDED": return "closed"; default: return "open"; } } function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus { switch (value?.trim().toUpperCase()) { case "SUCCESSFUL": return "success"; case "FAILED": return "failure"; case "STOPPED": return "cancelled"; case "INPROGRESS": return "pending"; default: return "neutral"; } } /** * A participant who has voted is the closest Bitbucket has to a review, so it reads as one in * the conversation. Participants who have only been added carry no verdict and are skipped. */ function toReviews( raw: Schema.Schema.Type<typeof RawPullRequestSchema>, ): ReadonlyArray<PullRequestComment> { return (raw.participants ?? []).flatMap((participant): ReadonlyArray<PullRequestComment> => { const author = toActor(participant.user); const votedAt = trimmed(participant.participated_on); const reviewState = trimmed(participant.state) ?? (participant.approved === true ? "approved" : null); if (author === null || votedAt === null || reviewState === null) return []; return [ { id: `${raw.id}:${author.login}`, kind: "review", author, body: "", createdAt: toIsoUtc(votedAt), url: null, path: null, reviewState, }, ]; }); } function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): BitbucketPullRequest { const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { const actor = toActor(reviewer); return actor === null ? [] : [actor]; }); return { number: raw.id, title: raw.title, url: raw.links.html.href, author: toActor(raw.author), headBranch: raw.source.branch.name, headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null, baseBranch: raw.destination.branch.name, state: toState(raw), isDraft: raw.draft ?? false, mergeability: "unknown", createdAt: toIsoUtc(raw.created_on), updatedAt: toIsoUtc(raw.updated_on), body: raw.description ?? "", reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), reviewers, reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => trimmed(reviewer.uuid) ?? []), reviews: toReviews(raw), }; } const decodePage = decodeJsonResult(RawPageSchema); const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); const decodePullRequest = decodeJsonResult(RawPullRequestSchema); const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema); const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema); const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema); const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema); const decodeViewer = decodeJsonResult(RawViewerSchema); const decodeConflicts = decodeJsonResult(RawPageSchema); const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema); type DecodeFailure = Cause.Cause<Schema.SchemaError>; export interface BitbucketPage<A> { readonly items: ReadonlyArray<A>; /** The whole URL of the next page, which Bitbucket sends rather than an offset. */ readonly next: string | null; } /** Malformed entries are skipped rather than failing the page, as on the other hosts. */ export function decodePullRequestPageJson( raw: string, ): Result.Result<BitbucketPage<BitbucketPullRequest>, DecodeFailure> { const decoded = decodePage(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const items: BitbucketPullRequest[] = []; for (const entry of decoded.success.values) { const item = decodePullRequestEntry(entry); if (Exit.isSuccess(item)) { items.push(toPullRequest(item.value)); } } return Result.succeed({ items, next: trimmed(decoded.success.next) }); } export function decodePullRequestJson( raw: string, ): Result.Result<BitbucketPullRequest, DecodeFailure> { const decoded = decodePullRequest(raw); return Result.isSuccess(decoded) ? Result.succeed(toPullRequest(decoded.success)) : Result.fail(decoded.failure); } export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> { const decoded = decodeViewer(raw); return Result.isSuccess(decoded) ? Result.succeed(trimmed(decoded.success.nickname) ?? trimmed(decoded.success.display_name)) : Result.fail(decoded.failure); } /** * Whether the configured credentials can write to the repository, which is what merging needs. * Bitbucket answers `admin`, `write` or `read`, and an empty page means it named no permission at * all for this account — an unknown standing, which is granted rather than guessed away. */ export function decodeRepositoryPermissionJson(raw: string): Result.Result<boolean, DecodeFailure> { const decoded = decodeRepositoryPermissions(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const permission = trimmed(decoded.success.values?.[0]?.permission)?.toLowerCase() ?? null; return Result.succeed(permission === null || permission === "admin" || permission === "write"); } /** * The workspace's members, which is the nearest thing Bitbucket has to "who may review this". * Nothing on a repository lists the people with access to it — `permissions-config/users` is for * administrators only — and a pull request can be sent to anyone in the workspace, so this is the * list Bitbucket's own reviewer field is filled from too. * * Nobody is marked requested here: who has been asked lives on the pull request, and only the * caller holds both. */ export function decodeWorkspaceMembersJson( raw: string, ): Result.Result<BitbucketPage<PullRequestReviewerCandidate>, DecodeFailure> { const decoded = decodePage(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const items: PullRequestReviewerCandidate[] = []; for (const entry of decoded.success.values) { const member = decodeMemberEntry(entry); if (Exit.isFailure(member)) continue; const uuid = trimmed(member.value.user?.uuid); const actor = toActor(member.value.user); if (uuid === null || actor === null) continue; items.push({ ...actor, id: uuid, kind: "user", isRequested: false }); } return Result.succeed({ items, next: trimmed(decoded.success.next) }); } /** One comment as Bitbucket sent it, kept so threads can be assembled across pages. */ export type BitbucketRawComment = Schema.Schema.Type<typeof RawCommentSchema>; export interface BitbucketComments { readonly comments: ReadonlyArray<PullRequestComment>; /** * The same comments unread, for `buildReviewThreads`. A reply and the remark it answers can * land on different pages, and only the caller holding every page can put them together. */ readonly entries: ReadonlyArray<BitbucketRawComment>; readonly next: string | null; } /** * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a * line opens a thread, and every reply that leads back to it belongs in it. A reply whose * parent is on a page that was not read has nowhere to go, and is left out rather than shown * as a thread of its own — it still stands in the flat conversation, which needs no parent. */ export function buildReviewThreads( comments: ReadonlyArray<BitbucketRawComment>, ): ReadonlyArray<PullRequestReviewThread> { const byId = new Map(comments.map((comment) => [comment.id, comment])); const rootOf = (comment: Schema.Schema.Type<typeof RawCommentSchema>) => { // Bounded by the number of comments read, so a parent cycle cannot spin here. let current = comment; for (let step = 0; step < byId.size; step += 1) { const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1); if (parent === undefined) return current; current = parent; } return current; }; const threads = new Map<number, PullRequestReviewThread>(); const replies = new Map<number, Array<Schema.Schema.Type<typeof RawCommentSchema>>>(); for (const comment of comments) { const root = rootOf(comment); const inline = root.inline; const path = trimmed(inline?.path); if (path === null) continue; if (root.id === comment.id) { // `to` is the line as the file stands now, `from` the line it replaced; a comment that // carries only `from` was written against the removed side. const side = inline?.to === null || inline?.to === undefined ? "left" : "right"; const line = side === "left" ? inline?.from : inline?.to; threads.set(root.id, { id: String(root.id), path, line: typeof line === "number" && line > 0 ? line : null, side, isResolved: root.resolution !== null && root.resolution !== undefined, isOutdated: inline?.outdated === true, comments: [], }); } const bucket = replies.get(root.id); if (bucket === undefined) replies.set(root.id, [comment]); else bucket.push(comment); } return [...threads.values()].flatMap((thread) => { const entries = (replies.get(Number(thread.id)) ?? []) .toSorted((left, right) => left.created_on.localeCompare(right.created_on)) .map((comment) => ({ id: String(comment.id), author: toActor(comment.user), body: comment.content?.raw ?? "", createdAt: toIsoUtc(comment.created_on), url: trimmed(comment.links?.html?.href), })); return entries.length === 0 ? [] : [{ ...thread, comments: entries }]; }); } /** * Deleted comments and ones their author has not posted yet carry nothing to show. A comment * pinned to a file is a line-level review comment, which is what that kind means. */ export function decodeCommentsJson(raw: string): Result.Result<BitbucketComments, DecodeFailure> { const decoded = decodePage(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const comments: PullRequestComment[] = []; const kept: Array<BitbucketRawComment> = []; for (const entry of decoded.success.values) { const decodedComment = decodeCommentEntry(entry); if (Exit.isFailure(decodedComment)) continue; const comment = decodedComment.value; if (comment.deleted === true || comment.pending === true) continue; const body = comment.content?.raw ?? ""; if (body.trim().length === 0) continue; kept.push(comment);