| 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Result from "effect/Result"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | import type { |
| 8 | PullRequestActor, |
| 9 | PullRequestCheck, |
| 10 | PullRequestCheckStatus, |
| 11 | PullRequestComment, |
| 12 | PullRequestCommit, |
| 13 | PullRequestMergeability, |
| 14 | PullRequestReviewThread, |
| 15 | PullRequestReviewerCandidate, |
| 16 | PullRequestState, |
| 17 | } from "@t3tools/contracts"; |
| 18 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; |
| 19 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 20 | |
| 21 | import { 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 | */ |
| 28 | const 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 | */ |
| 53 | const 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 | |
| 61 | const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) }); |
| 62 | |
| 63 | const 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 | |
| 91 | const 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 | |
| 99 | const 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 | |
| 128 | const 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 | |
| 142 | const 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 | |
| 150 | const 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. */ |
| 156 | const RawMemberSchema = Schema.Struct({ |
| 157 | user: Schema.optional(Schema.NullOr(RawUserSchema)), |
| 158 | }); |
| 159 | |
| 160 | const 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 | */ |
| 169 | const 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 | |
| 177 | export 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 | |
| 204 | function 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 | */ |
| 214 | function 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. */ |
| 222 | function 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 | |
| 233 | function 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 | |
| 245 | function 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 | */ |
| 264 | function 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 | |
| 288 | function 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 | |
| 315 | const decodePage = decodeJsonResult(RawPageSchema); |
| 316 | const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); |
| 317 | const decodePullRequest = decodeJsonResult(RawPullRequestSchema); |
| 318 | const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema); |
| 319 | const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); |
| 320 | const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema); |
| 321 | const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema); |
| 322 | const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema); |
| 323 | const decodeViewer = decodeJsonResult(RawViewerSchema); |
| 324 | const decodeConflicts = decodeJsonResult(RawPageSchema); |
| 325 | const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema); |
| 326 | |
| 327 | type DecodeFailure = Cause.Cause<Schema.SchemaError>; |
| 328 | |
| 329 | export 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. */ |
| 336 | export 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 | |
| 353 | export 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 | |
| 362 | export 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 | */ |
| 374 | export 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 | */ |
| 392 | export 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. */ |
| 412 | export type BitbucketRawComment = Schema.Schema.Type<typeof RawCommentSchema>; |
| 413 | |
| 414 | export 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 | */ |
| 430 | export 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 | */ |
| 490 | export 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 | |
| 520 | export 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 | |
| 552 | export 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 | |
| 588 | export interface BitbucketDiffStat { |
| 589 | readonly additions: number; |
| 590 | readonly deletions: number; |
| 591 | readonly changedFiles: number; |
| 592 | } |
| 593 | |
| 594 | export interface BitbucketDiffStatPage extends BitbucketDiffStat { |
| 595 | readonly next: string | null; |
| 596 | } |
| 597 | |
| 598 | /** One entry per changed file, each carrying that file's line counts. */ |
| 599 | export 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 | */ |
| 628 | export 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 | } |