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