| 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as TestClock from "effect/testing/TestClock"; |
| 6 | |
| 7 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 8 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; |
| 9 | |
| 10 | const mockedRequest = vi.fn<BitbucketApi.BitbucketApi["Service"]["request"]>(); |
| 11 | |
| 12 | const layer = it.layer( |
| 13 | BitbucketPullRequestApi.layer.pipe( |
| 14 | Layer.provide( |
| 15 | Layer.mock(BitbucketApi.BitbucketApi)({ |
| 16 | request: mockedRequest, |
| 17 | }), |
| 18 | ), |
| 19 | ), |
| 20 | ); |
| 21 | |
| 22 | /** The shape `request` answers with: a body plus whether it had to be cut short. */ |
| 23 | function response(body: string) { |
| 24 | return { body, truncated: false }; |
| 25 | } |
| 26 | |
| 27 | function page(count: number, firstNumber: number, next?: string): string { |
| 28 | return JSON.stringify({ |
| 29 | pagelen: 50, |
| 30 | size: count, |
| 31 | values: Array.from({ length: count }, (_, index) => ({ |
| 32 | id: firstNumber + index, |
| 33 | title: `Pull request ${firstNumber + index}`, |
| 34 | state: "OPEN", |
| 35 | created_on: "2026-06-16T05:04:32+00:00", |
| 36 | updated_on: "2026-06-16T05:04:33+00:00", |
| 37 | source: { branch: { name: "feat/page" } }, |
| 38 | destination: { branch: { name: "master" } }, |
| 39 | links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${firstNumber}` } }, |
| 40 | })), |
| 41 | ...(next === undefined ? {} : { next }), |
| 42 | }); |
| 43 | } |
| 44 | |
| 45 | function valuePage(values: ReadonlyArray<unknown>, next?: string): string { |
| 46 | return JSON.stringify({ values, ...(next === undefined ? {} : { next }) }); |
| 47 | } |
| 48 | |
| 49 | /** Who opened the pull request, and two accounts that could review it. */ |
| 50 | const bilal = { uuid: "{bilal}", nickname: "bilal" }; |
| 51 | const octocat = { uuid: "{octocat}", nickname: "octocat" }; |
| 52 | const hubot = { uuid: "{hubot}", nickname: "hubot" }; |
| 53 | |
| 54 | /** One pull request as `/pullrequests/{id}` answers with it. */ |
| 55 | function pullRequestJson(overrides: Record<string, unknown>): string { |
| 56 | return JSON.stringify({ |
| 57 | id: 7, |
| 58 | title: "Pull request 7", |
| 59 | state: "OPEN", |
| 60 | author: bilal, |
| 61 | created_on: "2026-06-16T05:04:32+00:00", |
| 62 | updated_on: "2026-06-16T05:04:33+00:00", |
| 63 | source: { branch: { name: "feat/page" } }, |
| 64 | destination: { branch: { name: "master" } }, |
| 65 | links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/7" } }, |
| 66 | ...overrides, |
| 67 | }); |
| 68 | } |
| 69 | |
| 70 | /** The request the nth call made. */ |
| 71 | function callAt(index: number) { |
| 72 | const call = mockedRequest.mock.calls[index]; |
| 73 | assert.isDefined(call); |
| 74 | return call[0]; |
| 75 | } |
| 76 | |
| 77 | /** The filter expression of the nth request, read back out of its query string. */ |
| 78 | function filterOfCall(index: number): string | null { |
| 79 | const url = callAt(index).url; |
| 80 | return new URLSearchParams(url.slice(url.indexOf("?") + 1)).get("q"); |
| 81 | } |
| 82 | |
| 83 | afterEach(() => { |
| 84 | mockedRequest.mockReset(); |
| 85 | }); |
| 86 | |
| 87 | layer("BitbucketPullRequestApi.layer", (it) => { |
| 88 | it.effect("asks for reviewers, newest first, at Bitbucket's page ceiling", () => |
| 89 | Effect.gen(function* () { |
| 90 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(3, 1)))); |
| 91 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 92 | |
| 93 | const batch = yield* api.listPullRequests({ |
| 94 | repository: "acme/web", |
| 95 | state: "open", |
| 96 | limit: 50, |
| 97 | }); |
| 98 | |
| 99 | assert.strictEqual(batch.items.length, 3); |
| 100 | assert.isFalse(batch.truncated); |
| 101 | const url = callAt(0).url; |
| 102 | expect(url).toContain("/repositories/acme/web/pullrequests"); |
| 103 | expect(url).toContain("state=OPEN"); |
| 104 | // Over 50 Bitbucket answers with an empty page and no error, so it is never exceeded. |
| 105 | expect(url).toContain("pagelen=50"); |
| 106 | expect(url).toContain("sort=-updated_on"); |
| 107 | expect(url).toContain("fields=%2Bvalues.reviewers"); |
| 108 | }), |
| 109 | ); |
| 110 | |
| 111 | it.effect("follows the cursor Bitbucket sends rather than counting offsets", () => |
| 112 | Effect.gen(function* () { |
| 113 | const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; |
| 114 | mockedRequest |
| 115 | .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) |
| 116 | .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); |
| 117 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 118 | |
| 119 | const batch = yield* api.listPullRequests({ |
| 120 | repository: "acme/web", |
| 121 | state: "open", |
| 122 | limit: 100, |
| 123 | }); |
| 124 | |
| 125 | assert.strictEqual(batch.items.length, 100); |
| 126 | assert.isFalse(batch.truncated); |
| 127 | assert.strictEqual(callAt(1).url, next); |
| 128 | }), |
| 129 | ); |
| 130 | |
| 131 | it.effect("stops at the caller's page and says more remain", () => |
| 132 | Effect.gen(function* () { |
| 133 | const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; |
| 134 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))); |
| 135 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 136 | |
| 137 | const batch = yield* api.listPullRequests({ |
| 138 | repository: "acme/web", |
| 139 | state: "open", |
| 140 | limit: 50, |
| 141 | }); |
| 142 | |
| 143 | assert.strictEqual(batch.items.length, 50); |
| 144 | assert.isTrue(batch.truncated); |
| 145 | assert.strictEqual(mockedRequest.mock.calls.length, 1); |
| 146 | }), |
| 147 | ); |
| 148 | |
| 149 | it.effect("counts the rows it walked past as more to come", () => |
| 150 | Effect.gen(function* () { |
| 151 | // Bitbucket pages in fifties whatever was asked for, so a request for ninety-nine reads a |
| 152 | // hundred and drops one. That row is more results, and saying otherwise takes the "load |
| 153 | // more" away from a listing that has not finished. |
| 154 | const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; |
| 155 | mockedRequest |
| 156 | .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) |
| 157 | .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); |
| 158 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 159 | |
| 160 | const batch = yield* api.listPullRequests({ |
| 161 | repository: "acme/web", |
| 162 | state: "open", |
| 163 | limit: 99, |
| 164 | }); |
| 165 | |
| 166 | assert.strictEqual(batch.items.length, 99); |
| 167 | assert.isTrue(batch.truncated); |
| 168 | }), |
| 169 | ); |
| 170 | |
| 171 | it.effect("searches with a filter expression, which is all Bitbucket offers", () => |
| 172 | Effect.gen(function* () { |
| 173 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); |
| 174 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 175 | |
| 176 | yield* api.listPullRequests({ |
| 177 | repository: "acme/web", |
| 178 | state: "open", |
| 179 | limit: 50, |
| 180 | query: "page", |
| 181 | }); |
| 182 | |
| 183 | expect(filterOfCall(0)).toBe('(title ~ "page" OR description ~ "page")'); |
| 184 | // The state filter beside it still stands, which the brackets are there to keep. |
| 185 | expect(callAt(0).url).toContain("state=OPEN"); |
| 186 | }), |
| 187 | ); |
| 188 | |
| 189 | it.effect("escapes a quote and a backslash, so a search cannot reshape the filter", () => |
| 190 | Effect.gen(function* () { |
| 191 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); |
| 192 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 193 | |
| 194 | yield* api.listPullRequests({ |
| 195 | repository: "acme/web", |
| 196 | state: "open", |
| 197 | limit: 50, |
| 198 | query: String.raw`a\" OR state = "MERGED"`, |
| 199 | }); |
| 200 | |
| 201 | const literal = String.raw`a\\\" OR state = \"MERGED\"`; |
| 202 | expect(filterOfCall(0)).toBe(`(title ~ "${literal}" OR description ~ "${literal}")`); |
| 203 | }), |
| 204 | ); |
| 205 | |
| 206 | it.effect("asks for no filter at all when the reader typed only spaces", () => |
| 207 | Effect.gen(function* () { |
| 208 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); |
| 209 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 210 | |
| 211 | yield* api.listPullRequests({ |
| 212 | repository: "acme/web", |
| 213 | state: "open", |
| 214 | limit: 50, |
| 215 | query: " ", |
| 216 | }); |
| 217 | |
| 218 | assert.isNull(filterOfCall(0)); |
| 219 | }), |
| 220 | ); |
| 221 | |
| 222 | it.effect("carries on from the instant the last slice ended on", () => |
| 223 | Effect.gen(function* () { |
| 224 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); |
| 225 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 226 | |
| 227 | yield* api.listPullRequests({ |
| 228 | repository: "acme/web", |
| 229 | state: "open", |
| 230 | limit: 50, |
| 231 | cursor: { updatedBefore: "2026-07-02T00:00:00.123456+00:00", delivered: 50 }, |
| 232 | }); |
| 233 | |
| 234 | // Inclusive, so the rows already sent at that instant come back for the caller to drop. |
| 235 | expect(filterOfCall(0)).toBe("updated_on <= 2026-07-02T00:00:00.123456+00:00"); |
| 236 | expect(callAt(0).url).toContain("sort=-updated_on"); |
| 237 | }), |
| 238 | ); |
| 239 | |
| 240 | it.effect("narrows by the reader's words and by where it left off at once", () => |
| 241 | Effect.gen(function* () { |
| 242 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); |
| 243 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 244 | |
| 245 | yield* api.listPullRequests({ |
| 246 | repository: "acme/web", |
| 247 | state: "open", |
| 248 | limit: 50, |
| 249 | query: "page", |
| 250 | cursor: { updatedBefore: "2026-07-02T00:00:00+00:00", delivered: 50 }, |
| 251 | }); |
| 252 | |
| 253 | // Bitbucket takes one `q`, so the two narrowings are joined rather than one replacing the |
| 254 | // other — and the search keeps its brackets, which is what keeps the AND out of its OR. |
| 255 | expect(filterOfCall(0)).toBe( |
| 256 | '(title ~ "page" OR description ~ "page") AND updated_on <= 2026-07-02T00:00:00+00:00', |
| 257 | ); |
| 258 | }), |
| 259 | ); |
| 260 | |
| 261 | it.effect("asks for declined pull requests on the closed tab", () => |
| 262 | Effect.gen(function* () { |
| 263 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); |
| 264 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 265 | |
| 266 | yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); |
| 267 | |
| 268 | expect(callAt(0).url).toContain("state=DECLINED"); |
| 269 | }), |
| 270 | ); |
| 271 | |
| 272 | it.effect("asks for every state at once on the All tab", () => |
| 273 | Effect.gen(function* () { |
| 274 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); |
| 275 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 276 | |
| 277 | yield* api.listPullRequests({ repository: "acme/web", state: "all", limit: 50 }); |
| 278 | |
| 279 | // Bitbucket unions repeated state parameters, which is the only way to span them. |
| 280 | const url = callAt(0).url; |
| 281 | for (const state of ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]) { |
| 282 | expect(url).toContain(`state=${state}`); |
| 283 | } |
| 284 | }), |
| 285 | ); |
| 286 | |
| 287 | it.effect("counts a superseded pull request as closed", () => |
| 288 | Effect.gen(function* () { |
| 289 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); |
| 290 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 291 | |
| 292 | yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); |
| 293 | |
| 294 | expect(callAt(0).url).toContain("state=DECLINED"); |
| 295 | expect(callAt(0).url).toContain("state=SUPERSEDED"); |
| 296 | }), |
| 297 | ); |
| 298 | |
| 299 | it.effect("refuses a repository that is not workspace and slug", () => |
| 300 | Effect.gen(function* () { |
| 301 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 302 | |
| 303 | const error = yield* Effect.flip( |
| 304 | api.listPullRequests({ repository: "acme/team/web", state: "open", limit: 50 }), |
| 305 | ); |
| 306 | |
| 307 | assert.strictEqual(error._tag, "BitbucketRepositoryUnsupportedError"); |
| 308 | assert.strictEqual(mockedRequest.mock.calls.length, 0); |
| 309 | }), |
| 310 | ); |
| 311 | |
| 312 | it.effect("returns the diff verbatim, because Bitbucket already sends a patch", () => |
| 313 | Effect.gen(function* () { |
| 314 | const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; |
| 315 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); |
| 316 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 317 | |
| 318 | const diff = yield* api.getPullRequestDiff({ repository: "acme/web", number: 7 }); |
| 319 | |
| 320 | assert.strictEqual(diff.patch, patch); |
| 321 | assert.isFalse(diff.truncated); |
| 322 | expect(callAt(0)).toMatchObject({ |
| 323 | url: "/repositories/acme/web/pullrequests/7/diff", |
| 324 | // A diff of any size would otherwise be read into memory whole. |
| 325 | maxBytes: 8 * 1024 * 1024, |
| 326 | }); |
| 327 | }), |
| 328 | ); |
| 329 | |
| 330 | it.effect("reads a named commit's own patch, which pages no further than the whole of it", () => |
| 331 | Effect.gen(function* () { |
| 332 | const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; |
| 333 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); |
| 334 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 335 | |
| 336 | const diff = yield* api.getPullRequestDiff({ |
| 337 | repository: "acme/web", |
| 338 | number: 7, |
| 339 | commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", |
| 340 | }); |
| 341 | |
| 342 | assert.strictEqual(diff.patch, patch); |
| 343 | expect(callAt(0)).toMatchObject({ |
| 344 | url: "/repositories/acme/web/diff/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", |
| 345 | maxBytes: 8 * 1024 * 1024, |
| 346 | }); |
| 347 | }), |
| 348 | ); |
| 349 | |
| 350 | it.effect("refuses a commit that is not a sha rather than reading it into a URL", () => |
| 351 | Effect.gen(function* () { |
| 352 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 353 | |
| 354 | const error = yield* Effect.flip( |
| 355 | api.getPullRequestDiff({ |
| 356 | repository: "acme/web", |
| 357 | number: 7, |
| 358 | commit: "../../acme/other/diff/deadbeef", |
| 359 | }), |
| 360 | ); |
| 361 | |
| 362 | assert.strictEqual(error._tag, "BitbucketDiffCommitError"); |
| 363 | assert.strictEqual(mockedRequest.mock.calls.length, 0); |
| 364 | }), |
| 365 | ); |
| 366 | |
| 367 | it.effect("reads every file version the patch states, not only the paths asked about", () => |
| 368 | Effect.gen(function* () { |
| 369 | mockedRequest.mockReturnValueOnce( |
| 370 | Effect.succeed( |
| 371 | response( |
| 372 | [ |
| 373 | "diff --git a/a.ts b/a.ts", |
| 374 | "index 1111111..2222222 100644", |
| 375 | "--- a/a.ts", |
| 376 | "+++ b/a.ts", |
| 377 | "@@ -1 +1 @@", |
| 378 | "-a", |
| 379 | "+b", |
| 380 | "diff --git a/b.ts b/b.ts", |
| 381 | "index 3333333..4444444 100644", |
| 382 | "--- a/b.ts", |
| 383 | "+++ b/b.ts", |
| 384 | "@@ -1 +1 @@", |
| 385 | "-c", |
| 386 | "+d", |
| 387 | "", |
| 388 | ].join("\n"), |
| 389 | ), |
| 390 | ), |
| 391 | ); |
| 392 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 393 | |
| 394 | const revisions = yield* api.getFileRevisions({ |
| 395 | repository: "acme/web", |
| 396 | number: 71, |
| 397 | paths: ["a.ts", "missing.ts"], |
| 398 | }); |
| 399 | |
| 400 | // `b.ts` was not asked about and is reported anyway: parsing the patch for `a.ts` read it |
| 401 | // too, and the caller holding it is what stops the next tick paying for the patch again. |
| 402 | // `missing.ts` was asked about and the whole patch was read without finding it, which is |
| 403 | // what a file this pull request deletes looks like, so it is answered as the empty version. |
| 404 | assert.deepStrictEqual( |
| 405 | [...revisions.revisions], |
| 406 | [ |
| 407 | ["a.ts", "2222222"], |
| 408 | ["b.ts", "4444444"], |
| 409 | ["missing.ts", ""], |
| 410 | ], |
| 411 | ); |
| 412 | assert.strictEqual(revisions.complete, true); |
| 413 | expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" }); |
| 414 | }), |
| 415 | ); |
| 416 | |
| 417 | it.effect("says nothing about the files past the end of a patch it could not read whole", () => |
| 418 | Effect.gen(function* () { |
| 419 | // Bitbucket's patch is read up to a byte ceiling, and a file past the cut was not looked at. |
| 420 | // Answering for it as deleted would clear a mark on it once and for good. |
| 421 | mockedRequest.mockReturnValueOnce( |
| 422 | Effect.succeed({ |
| 423 | body: "diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n", |
| 424 | truncated: true, |
| 425 | }), |
| 426 | ); |
| 427 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 428 | |
| 429 | const revisions = yield* api.getFileRevisions({ |
| 430 | repository: "acme/web", |
| 431 | number: 72, |
| 432 | paths: ["a.ts", "past-the-cut.ts"], |
| 433 | }); |
| 434 | |
| 435 | assert.deepStrictEqual([...revisions.revisions], [["a.ts", "2222222"]]); |
| 436 | // `past-the-cut.ts` gets no empty version, and nothing here may be held as the whole story. |
| 437 | assert.strictEqual(revisions.complete, false); |
| 438 | }), |
| 439 | ); |
| 440 | |
| 441 | it.effect("reads the patch once for a run of ticks, not once a tick", () => |
| 442 | Effect.gen(function* () { |
| 443 | mockedRequest.mockReturnValue( |
| 444 | Effect.succeed( |
| 445 | response( |
| 446 | [ |
| 447 | "diff --git a/a.ts b/a.ts", |
| 448 | "index 1111111..2222222 100644", |
| 449 | "@@ -1 +1 @@", |
| 450 | "diff --git a/b.ts b/b.ts", |
| 451 | "index 3333333..4444444 100644", |
| 452 | "@@ -1 +1 @@", |
| 453 | "", |
| 454 | ].join("\n"), |
| 455 | ), |
| 456 | ), |
| 457 | ); |
| 458 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 459 | |
| 460 | const first = yield* api.getFileRevisions({ |
| 461 | repository: "acme/web", |
| 462 | number: 74, |
| 463 | paths: ["a.ts"], |
| 464 | }); |
| 465 | // A path nobody has asked about before, which is what every tick after the first names. |
| 466 | const second = yield* api.getFileRevisions({ |
| 467 | repository: "acme/web", |
| 468 | number: 74, |
| 469 | paths: ["b.ts"], |
| 470 | }); |
| 471 | |
| 472 | const both = [ |
| 473 | ["a.ts", "2222222"], |
| 474 | ["b.ts", "4444444"], |
| 475 | ]; |
| 476 | assert.deepStrictEqual([...first.revisions], both); |
| 477 | assert.deepStrictEqual([...second.revisions], both); |
| 478 | assert.strictEqual(mockedRequest.mock.calls.length, 1); |
| 479 | }), |
| 480 | ); |
| 481 | |
| 482 | it.effect("reads the patch afresh once the one it held has aged out", () => |
| 483 | Effect.gen(function* () { |
| 484 | mockedRequest.mockReturnValue( |
| 485 | Effect.succeed( |
| 486 | response("diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n"), |
| 487 | ), |
| 488 | ); |
| 489 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 490 | const read = () => |
| 491 | api.getFileRevisions({ repository: "acme/web", number: 75, paths: ["a.ts"] }); |
| 492 | |
| 493 | yield* read(); |
| 494 | yield* read(); |
| 495 | assert.strictEqual(mockedRequest.mock.calls.length, 1); |
| 496 | |
| 497 | // Well inside the window the caller holds versions for: a refresh drops what it holds so |
| 498 | // that the read after it reaches Bitbucket, and this must not answer that read instead. |
| 499 | yield* TestClock.adjust(Duration.seconds(30)); |
| 500 | yield* read(); |
| 501 | assert.strictEqual(mockedRequest.mock.calls.length, 2); |
| 502 | }), |
| 503 | ); |
| 504 | |
| 505 | it.effect("asks Bitbucket nothing when no file has been ticked off", () => |
| 506 | Effect.gen(function* () { |
| 507 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 508 | |
| 509 | const revisions = yield* api.getFileRevisions({ |
| 510 | repository: "acme/web", |
| 511 | number: 73, |
| 512 | paths: [], |
| 513 | }); |
| 514 | |
| 515 | assert.strictEqual(revisions.revisions.size, 0); |
| 516 | assert.strictEqual(mockedRequest.mock.calls.length, 0); |
| 517 | }), |
| 518 | ); |
| 519 | |
| 520 | it.effect("aggregates every diffstat page", () => |
| 521 | Effect.gen(function* () { |
| 522 | const next = "https://api.bitbucket.org/2.0/diffstat?page=2"; |
| 523 | mockedRequest |
| 524 | .mockReturnValueOnce( |
| 525 | Effect.succeed( |
| 526 | response( |
| 527 | valuePage( |
| 528 | [ |
| 529 | { lines_added: 9, lines_removed: 2 }, |
| 530 | { lines_added: 3, lines_removed: 1 }, |
| 531 | ], |
| 532 | next, |
| 533 | ), |
| 534 | ), |
| 535 | ), |
| 536 | ) |
| 537 | .mockReturnValueOnce( |
| 538 | Effect.succeed(response(valuePage([{ lines_added: 4, lines_removed: 7 }]))), |
| 539 | ); |
| 540 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 541 | |
| 542 | const stat = yield* api.getDiffStat({ repository: "acme/web", number: 7 }); |
| 543 | |
| 544 | expect(stat).toEqual({ additions: 16, deletions: 10, changedFiles: 3 }); |
| 545 | expect(callAt(1).url).toBe(next); |
| 546 | }), |
| 547 | ); |
| 548 | |
| 549 | it.effect("returns the complete commit timeline oldest first across pages", () => |
| 550 | Effect.gen(function* () { |
| 551 | const next = "https://api.bitbucket.org/2.0/commits?page=2"; |
| 552 | mockedRequest |
| 553 | .mockReturnValueOnce( |
| 554 | Effect.succeed( |
| 555 | response( |
| 556 | valuePage( |
| 557 | [ |
| 558 | { hash: "ddd", message: "fourth", date: "2026-07-04T00:00:00Z" }, |
| 559 | { hash: "ccc", message: "third", date: "2026-07-03T00:00:00Z" }, |
| 560 | ], |
| 561 | next, |
| 562 | ), |
| 563 | ), |
| 564 | ), |
| 565 | ) |
| 566 | .mockReturnValueOnce( |
| 567 | Effect.succeed( |
| 568 | response( |
| 569 | valuePage([ |
| 570 | { hash: "bbb", message: "second", date: "2026-07-02T00:00:00Z" }, |
| 571 | { hash: "aaa", message: "first", date: "2026-07-01T00:00:00Z" }, |
| 572 | ]), |
| 573 | ), |
| 574 | ), |
| 575 | ); |
| 576 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 577 | |
| 578 | const commits = yield* api.listCommits({ repository: "acme/web", number: 7 }); |
| 579 | |
| 580 | expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb", "ccc", "ddd"]); |
| 581 | expect(callAt(1).url).toBe(next); |
| 582 | }), |
| 583 | ); |
| 584 | |
| 585 | it.effect("returns build statuses from every page", () => |
| 586 | Effect.gen(function* () { |
| 587 | const next = "https://api.bitbucket.org/2.0/statuses?page=2"; |
| 588 | mockedRequest |
| 589 | .mockReturnValueOnce( |
| 590 | Effect.succeed(response(valuePage([{ name: "Build", state: "SUCCESSFUL" }], next))), |
| 591 | ) |
| 592 | .mockReturnValueOnce( |
| 593 | Effect.succeed(response(valuePage([{ name: "Lint", state: "FAILED" }]))), |
| 594 | ); |
| 595 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 596 | |
| 597 | const checks = yield* api.listChecks({ repository: "acme/web", number: 7 }); |
| 598 | |
| 599 | expect(checks.map((check) => [check.name, check.status])).toEqual([ |
| 600 | ["Build", "success"], |
| 601 | ["Lint", "failure"], |
| 602 | ]); |
| 603 | expect(callAt(1).url).toBe(next); |
| 604 | }), |
| 605 | ); |
| 606 | |
| 607 | it.effect("reads an empty conflict list as mergeable", () => |
| 608 | Effect.gen(function* () { |
| 609 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); |
| 610 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 611 | |
| 612 | const mergeability = yield* api.getMergeability({ repository: "acme/web", number: 7 }); |
| 613 | |
| 614 | assert.strictEqual(mergeability, "mergeable"); |
| 615 | expect(callAt(0).url).toBe("/repositories/acme/web/pullrequests/7/conflicts"); |
| 616 | }), |
| 617 | ); |
| 618 | |
| 619 | it.effect("merges with Bitbucket's own name for the strategy", () => |
| 620 | Effect.gen(function* () { |
| 621 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); |
| 622 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 623 | |
| 624 | yield* api.runAction({ |
| 625 | repository: "acme/web", |
| 626 | number: 7, |
| 627 | action: "merge", |
| 628 | mergeMethod: "rebase", |
| 629 | }); |
| 630 | |
| 631 | expect(callAt(0)).toMatchObject({ |
| 632 | method: "POST", |
| 633 | url: "/repositories/acme/web/pullrequests/7/merge", |
| 634 | body: '{"merge_strategy":"rebase_fast_forward"}', |
| 635 | }); |
| 636 | }), |
| 637 | ); |
| 638 | |
| 639 | it.effect("closes a pull request by declining it", () => |
| 640 | Effect.gen(function* () { |
| 641 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); |
| 642 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 643 | |
| 644 | yield* api.runAction({ repository: "acme/web", number: 7, action: "close" }); |
| 645 | |
| 646 | expect(callAt(0)).toMatchObject({ |
| 647 | method: "POST", |
| 648 | url: "/repositories/acme/web/pullrequests/7/decline", |
| 649 | }); |
| 650 | }), |
| 651 | ); |
| 652 | |
| 653 | it.effect("posts a comment as a JSON document, so the body stays text", () => |
| 654 | Effect.gen(function* () { |
| 655 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); |
| 656 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 657 | |
| 658 | yield* api.comment({ repository: "acme/web", number: 7, body: "true" }); |
| 659 | |
| 660 | expect(callAt(0)).toMatchObject({ |
| 661 | method: "POST", |
| 662 | url: "/repositories/acme/web/pullrequests/7/comments", |
| 663 | body: '{"content":{"raw":"true"}}', |
| 664 | }); |
| 665 | }), |
| 666 | ); |
| 667 | |
| 668 | it.effect("rewrites a title alone, without touching anything else", () => |
| 669 | Effect.gen(function* () { |
| 670 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); |
| 671 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 672 | |
| 673 | yield* api.updateChangeRequest({ repository: "acme/web", number: 7, title: "A new title" }); |
| 674 | |
| 675 | const call = callAt(0); |
| 676 | expect(call.method).toBe("PUT"); |
| 677 | expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); |
| 678 | // Bitbucket's PUT is a partial update, so a field left out of the body is left as it was. |
| 679 | expect(JSON.parse(call.body ?? "")).toEqual({ title: "A new title" }); |
| 680 | }), |
| 681 | ); |
| 682 | |
| 683 | it.effect("leaves out the half of the pull request it was not asked about", () => |
| 684 | Effect.gen(function* () { |
| 685 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); |
| 686 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 687 | |
| 688 | yield* api.updateChangeRequest({ repository: "acme/web", number: 7, body: "New body." }); |
| 689 | |
| 690 | expect(JSON.parse(callAt(0).body ?? "")).toEqual({ description: "New body." }); |
| 691 | }), |
| 692 | ); |
| 693 | |
| 694 | it.effect("writes both fields when both were rewritten", () => |
| 695 | Effect.gen(function* () { |
| 696 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); |
| 697 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 698 | |
| 699 | yield* api.updateChangeRequest({ |
| 700 | repository: "acme/web", |
| 701 | number: 7, |
| 702 | title: "A new title", |
| 703 | body: "New body.", |
| 704 | }); |
| 705 | |
| 706 | expect(JSON.parse(callAt(0).body ?? "")).toEqual({ |
| 707 | title: "A new title", |
| 708 | description: "New body.", |
| 709 | }); |
| 710 | }), |
| 711 | ); |
| 712 | |
| 713 | it.effect("rewrites a comment where it stands, whichever kind it is", () => |
| 714 | Effect.gen(function* () { |
| 715 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); |
| 716 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 717 | |
| 718 | yield* api.updateComment({ |
| 719 | repository: "acme/web", |
| 720 | number: 7, |
| 721 | commentId: "10", |
| 722 | body: "Edited.", |
| 723 | }); |
| 724 | |
| 725 | expect(callAt(0)).toMatchObject({ |
| 726 | method: "PUT", |
| 727 | url: "/repositories/acme/web/pullrequests/7/comments/10", |
| 728 | body: '{"content":{"raw":"Edited."}}', |
| 729 | }); |
| 730 | }), |
| 731 | ); |
| 732 | |
| 733 | it.effect("fails the read when Bitbucket answers with something unreadable", () => |
| 734 | Effect.gen(function* () { |
| 735 | mockedRequest.mockReturnValueOnce( |
| 736 | Effect.succeed(response(JSON.stringify({ error: "nope" }))), |
| 737 | ); |
| 738 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 739 | |
| 740 | const error = yield* Effect.flip(api.getPullRequest({ repository: "acme/web", number: 7 })); |
| 741 | |
| 742 | assert.strictEqual(error._tag, "BitbucketPullRequestReadError"); |
| 743 | }), |
| 744 | ); |
| 745 | |
| 746 | it.effect("states a failure once, without stacking one message inside another", () => |
| 747 | Effect.gen(function* () { |
| 748 | mockedRequest.mockReturnValueOnce( |
| 749 | Effect.fail( |
| 750 | new BitbucketApi.BitbucketResponseError({ |
| 751 | operation: "request", |
| 752 | status: 500, |
| 753 | responseBodyLength: 0, |
| 754 | }), |
| 755 | ), |
| 756 | ); |
| 757 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 758 | |
| 759 | const error = yield* Effect.flip(api.getViewer()); |
| 760 | |
| 761 | // The fact only; the provider adds the operation around it. |
| 762 | assert.strictEqual(error.detail, "Bitbucket returned HTTP 500."); |
| 763 | }), |
| 764 | ); |
| 765 | |
| 766 | it.effect("fails when the credentials belong to no named account", () => |
| 767 | Effect.gen(function* () { |
| 768 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(JSON.stringify({})))); |
| 769 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 770 | |
| 771 | const error = yield* Effect.flip(api.getViewer()); |
| 772 | |
| 773 | assert.strictEqual(error._tag, "BitbucketViewerUnavailableError"); |
| 774 | }), |
| 775 | ); |
| 776 | |
| 777 | it.effect("follows Bitbucket's cursor and reassembles a thread that spans two pages", () => |
| 778 | Effect.gen(function* () { |
| 779 | mockedRequest.mockReturnValueOnce( |
| 780 | Effect.succeed( |
| 781 | response( |
| 782 | JSON.stringify({ |
| 783 | next: "https://api.bitbucket.org/2.0/comments?page=2", |
| 784 | values: [ |
| 785 | { |
| 786 | id: 10, |
| 787 | content: { raw: "rename this" }, |
| 788 | user: { nickname: "bilal" }, |
| 789 | created_on: "2026-06-16T05:04:32+00:00", |
| 790 | inline: { path: "src/a.ts", to: 12 }, |
| 791 | }, |
| 792 | ], |
| 793 | }), |
| 794 | ), |
| 795 | ), |
| 796 | ); |
| 797 | mockedRequest.mockReturnValueOnce( |
| 798 | Effect.succeed( |
| 799 | response( |
| 800 | // The reply arrives a page after the remark it answers, which is why the threads |
| 801 | // are only assembled once every page is in hand. |
| 802 | JSON.stringify({ |
| 803 | values: [ |
| 804 | { |
| 805 | id: 11, |
| 806 | content: { raw: "done" }, |
| 807 | user: { nickname: "julius" }, |
| 808 | created_on: "2026-06-16T06:04:32+00:00", |
| 809 | parent: { id: 10 }, |
| 810 | }, |
| 811 | ], |
| 812 | }), |
| 813 | ), |
| 814 | ), |
| 815 | ); |
| 816 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 817 | |
| 818 | const { comments, threads, truncated } = yield* api.listComments({ |
| 819 | repository: "acme/web", |
| 820 | number: 7, |
| 821 | }); |
| 822 | |
| 823 | expect(callAt(1).url).toBe("https://api.bitbucket.org/2.0/comments?page=2"); |
| 824 | expect(comments.map((comment) => comment.id)).toEqual(["10", "11"]); |
| 825 | expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11"]); |
| 826 | assert.isFalse(truncated); |
| 827 | }), |
| 828 | ); |
| 829 | |
| 830 | it.effect("stops the comment walk at its bound and says the conversation was cut short", () => |
| 831 | Effect.gen(function* () { |
| 832 | // Bitbucket that always names a next page: the walk has to end itself. |
| 833 | mockedRequest.mockReturnValue( |
| 834 | Effect.succeed( |
| 835 | response( |
| 836 | JSON.stringify({ |
| 837 | next: "https://api.bitbucket.org/2.0/comments?page=2", |
| 838 | values: [ |
| 839 | { |
| 840 | id: 10, |
| 841 | content: { raw: "again" }, |
| 842 | created_on: "2026-06-16T05:04:32+00:00", |
| 843 | }, |
| 844 | ], |
| 845 | }), |
| 846 | ), |
| 847 | ), |
| 848 | ); |
| 849 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 850 | |
| 851 | const { truncated } = yield* api.listComments({ repository: "acme/web", number: 7 }); |
| 852 | |
| 853 | assert.strictEqual(mockedRequest.mock.calls.length, 10); |
| 854 | assert.isTrue(truncated); |
| 855 | }), |
| 856 | ); |
| 857 | |
| 858 | it.effect("reassembles a thread from the flat comment list, replies included", () => |
| 859 | Effect.gen(function* () { |
| 860 | mockedRequest.mockReturnValueOnce( |
| 861 | Effect.succeed( |
| 862 | response( |
| 863 | JSON.stringify({ |
| 864 | values: [ |
| 865 | { |
| 866 | id: 10, |
| 867 | content: { raw: "rename this" }, |
| 868 | user: { nickname: "bilal" }, |
| 869 | created_on: "2026-06-16T05:04:32+00:00", |
| 870 | inline: { path: "src/a.ts", to: 12, from: null }, |
| 871 | resolution: { type: "pullrequest_comment_resolution" }, |
| 872 | }, |
| 873 | { |
| 874 | id: 11, |
| 875 | content: { raw: "done" }, |
| 876 | user: { nickname: "julius" }, |
| 877 | created_on: "2026-06-16T06:04:32+00:00", |
| 878 | parent: { id: 10 }, |
| 879 | }, |
| 880 | // A reply to a reply still belongs to the thread its root opened. |
| 881 | { |
| 882 | id: 12, |
| 883 | content: { raw: "thanks" }, |
| 884 | user: { nickname: "bilal" }, |
| 885 | created_on: "2026-06-16T07:04:32+00:00", |
| 886 | parent: { id: 11 }, |
| 887 | }, |
| 888 | { |
| 889 | id: 13, |
| 890 | content: { raw: "ship it" }, |
| 891 | user: { nickname: "bilal" }, |
| 892 | created_on: "2026-06-16T08:04:32+00:00", |
| 893 | }, |
| 894 | ], |
| 895 | }), |
| 896 | ), |
| 897 | ), |
| 898 | ); |
| 899 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 900 | |
| 901 | const { threads } = yield* api.listComments({ repository: "acme/web", number: 7 }); |
| 902 | |
| 903 | assert.strictEqual(threads.length, 1); |
| 904 | expect(threads[0]).toMatchObject({ |
| 905 | id: "10", |
| 906 | path: "src/a.ts", |
| 907 | line: 12, |
| 908 | side: "right", |
| 909 | isResolved: true, |
| 910 | }); |
| 911 | expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11", "12"]); |
| 912 | }), |
| 913 | ); |
| 914 | |
| 915 | it.effect("writes a review's line comments, its summary, then its verdict", () => |
| 916 | Effect.gen(function* () { |
| 917 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); |
| 918 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 919 | |
| 920 | yield* api.submitReview({ |
| 921 | repository: "acme/web", |
| 922 | number: 7, |
| 923 | verdict: "request-changes", |
| 924 | body: "Two things.", |
| 925 | comments: [ |
| 926 | { |
| 927 | path: "src/a.ts", |
| 928 | position: { kind: "deleted", oldLine: 12 }, |
| 929 | body: "why remove?", |
| 930 | }, |
| 931 | ], |
| 932 | }); |
| 933 | |
| 934 | expect(callAt(0).url).toContain("/pullrequests/7/comments"); |
| 935 | expect(JSON.parse(callAt(0).body ?? "")).toEqual({ |
| 936 | content: { raw: "why remove?" }, |
| 937 | inline: { path: "src/a.ts", from: 12 }, |
| 938 | }); |
| 939 | expect(callAt(1).url).toContain("/pullrequests/7/comments"); |
| 940 | // The verdict goes last, so a review that failed part-way is never a rejection either. |
| 941 | expect(callAt(2).url).toContain("/pullrequests/7/request-changes"); |
| 942 | }), |
| 943 | ); |
| 944 | |
| 945 | it.effect("resolves by creating the sub-resource and unresolves by deleting it", () => |
| 946 | Effect.gen(function* () { |
| 947 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); |
| 948 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 949 | |
| 950 | yield* api.setCommentResolution({ |
| 951 | repository: "acme/web", |
| 952 | number: 7, |
| 953 | commentId: "10", |
| 954 | resolved: true, |
| 955 | }); |
| 956 | yield* api.setCommentResolution({ |
| 957 | repository: "acme/web", |
| 958 | number: 7, |
| 959 | commentId: "10", |
| 960 | resolved: false, |
| 961 | }); |
| 962 | |
| 963 | assert.strictEqual(callAt(0).method, "POST"); |
| 964 | assert.strictEqual(callAt(1).method, "DELETE"); |
| 965 | expect(callAt(0).url).toContain("/comments/10/resolve"); |
| 966 | }), |
| 967 | ); |
| 968 | |
| 969 | it.effect("replies by naming the comment it answers", () => |
| 970 | Effect.gen(function* () { |
| 971 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); |
| 972 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 973 | |
| 974 | yield* api.replyToComment({ |
| 975 | repository: "acme/web", |
| 976 | number: 7, |
| 977 | commentId: "10", |
| 978 | body: "Fixed.", |
| 979 | }); |
| 980 | |
| 981 | expect(JSON.parse(callAt(0).body ?? "")).toEqual({ |
| 982 | content: { raw: "Fixed." }, |
| 983 | parent: { id: 10 }, |
| 984 | }); |
| 985 | }), |
| 986 | ); |
| 987 | |
| 988 | it.effect("asks for the credentials' permission on this repository, and nobody else's", () => |
| 989 | Effect.gen(function* () { |
| 990 | mockedRequest.mockReturnValue( |
| 991 | Effect.succeed( |
| 992 | response( |
| 993 | JSON.stringify({ values: [{ type: "repository_permission", permission: "read" }] }), |
| 994 | ), |
| 995 | ), |
| 996 | ); |
| 997 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 998 | |
| 999 | assert.isFalse(yield* api.getRepositoryPermission({ repository: "acme/web" })); |
| 1000 | |
| 1001 | expect(new URL(callAt(0).url, "https://api.bitbucket.org").pathname).toBe( |
| 1002 | "/user/workspaces/acme/permissions/repositories", |
| 1003 | ); |
| 1004 | assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/web"'); |
| 1005 | }), |
| 1006 | ); |
| 1007 | |
| 1008 | it.effect("matches repository permissions after normalizing whitespace around segments", () => |
| 1009 | Effect.gen(function* () { |
| 1010 | mockedRequest.mockImplementation((input) => { |
| 1011 | const url = new URL(input.url, "https://api.bitbucket.org"); |
| 1012 | const matches = |
| 1013 | url.pathname === "/user/workspaces/acme/permissions/repositories" && |
| 1014 | url.searchParams.get("q") === 'repository.full_name="acme/web"'; |
| 1015 | return Effect.succeed(response(valuePage(matches ? [{ permission: "read" }] : []))); |
| 1016 | }); |
| 1017 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 1018 | |
| 1019 | // An unmatched, empty permission page is treated as unknown and would allow this attempt. |
| 1020 | assert.isFalse(yield* api.getRepositoryPermission({ repository: " acme / web " })); |
| 1021 | }), |
| 1022 | ); |
| 1023 | |
| 1024 | it.effect("escapes a repository name before it goes inside a filter literal", () => |
| 1025 | Effect.gen(function* () { |
| 1026 | mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] })))); |
| 1027 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 1028 | |
| 1029 | yield* api.getRepositoryPermission({ repository: 'acme/we"b' }); |
| 1030 | |
| 1031 | // A quote would otherwise end the literal and leave the rest standing as filter syntax. |
| 1032 | assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/we\\"b"'); |
| 1033 | }), |
| 1034 | ); |
| 1035 | |
| 1036 | const permissionFailure = (status: number) => |
| 1037 | Effect.fail( |
| 1038 | new BitbucketApi.BitbucketResponseError({ |
| 1039 | operation: "request", |
| 1040 | status, |
| 1041 | responseBodyLength: 0, |
| 1042 | }), |
| 1043 | ); |
| 1044 | |
| 1045 | it.effect.each([403, 404, 410])( |
| 1046 | "reads HTTP %s from the permissions endpoint as an unknown permission", |
| 1047 | (status) => |
| 1048 | Effect.gen(function* () { |
| 1049 | mockedRequest.mockReturnValue(permissionFailure(status)); |
| 1050 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 1051 | |
| 1052 | assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" })); |
| 1053 | }), |
| 1054 | ); |
| 1055 | |
| 1056 | it.effect.each([401, 500])("still fails the permission read on HTTP %s", (status) => |
| 1057 | Effect.gen(function* () { |
| 1058 | mockedRequest.mockReturnValue(permissionFailure(status)); |
| 1059 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 1060 | |
| 1061 | const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" })); |
| 1062 | assert.strictEqual(error._tag, "BitbucketResponseError"); |
| 1063 | if (error._tag === "BitbucketResponseError") { |
| 1064 | assert.strictEqual(error.status, status); |
| 1065 | } |
| 1066 | }), |
| 1067 | ); |
| 1068 | |
| 1069 | it.effect("reads the workspace's people and marks whoever is already a reviewer", () => |
| 1070 | Effect.gen(function* () { |
| 1071 | mockedRequest |
| 1072 | .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) |
| 1073 | .mockReturnValueOnce( |
| 1074 | Effect.succeed( |
| 1075 | response( |
| 1076 | JSON.stringify({ values: [{ user: bilal }, { user: octocat }, { user: hubot }] }), |
| 1077 | ), |
| 1078 | ), |
| 1079 | ); |
| 1080 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 1081 | |
| 1082 | const list = yield* api.listReviewerCandidates({ repository: "acme/web", number: 7 }); |
| 1083 | |
| 1084 | // The people live on the workspace: nothing on a repository lists who may review it. |
| 1085 | expect(callAt(1).url).toBe("/workspaces/acme/members?pagelen=50"); |
| 1086 | expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ |
| 1087 | ["{octocat}", true], |
| 1088 | ["{hubot}", false], |
| 1089 | ]); |
| 1090 | assert.isFalse(list.truncated); |
| 1091 | }), |
| 1092 | ); |
| 1093 | |
| 1094 | it.effect("writes the reviewer set back with the one being asked added to it", () => |
| 1095 | Effect.gen(function* () { |
| 1096 | mockedRequest |
| 1097 | .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) |
| 1098 | .mockReturnValueOnce(Effect.succeed(response("{}"))); |
| 1099 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 1100 | |
| 1101 | yield* api.setReviewerRequest({ |
| 1102 | repository: "acme/web", |
| 1103 | number: 7, |
| 1104 | reviewers: [{ id: "{hubot}" }], |
| 1105 | requested: true, |
| 1106 | }); |
| 1107 | |
| 1108 | // Bitbucket writes `reviewers` whole, so the one already on the pull request travels with |
| 1109 | // the new one or the request would take them off it. |
| 1110 | const call = callAt(1); |
| 1111 | expect(call.method).toBe("PUT"); |
| 1112 | expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); |
| 1113 | expect(JSON.parse(call.body ?? "")).toEqual({ |
| 1114 | reviewers: [{ uuid: "{octocat}" }, { uuid: "{hubot}" }], |
| 1115 | }); |
| 1116 | }), |
| 1117 | ); |
| 1118 | |
| 1119 | it.effect("takes a reviewer out of the set rather than clearing it", () => |
| 1120 | Effect.gen(function* () { |
| 1121 | mockedRequest |
| 1122 | .mockReturnValueOnce( |
| 1123 | Effect.succeed(response(pullRequestJson({ reviewers: [octocat, hubot] }))), |
| 1124 | ) |
| 1125 | .mockReturnValueOnce(Effect.succeed(response("{}"))); |
| 1126 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; |
| 1127 | |
| 1128 | yield* api.setReviewerRequest({ |
| 1129 | repository: "acme/web", |
| 1130 | number: 7, |
| 1131 | reviewers: [{ id: "{hubot}" }], |
| 1132 | requested: false, |
| 1133 | }); |
| 1134 | |
| 1135 | expect(JSON.parse(callAt(1).body ?? "")).toEqual({ reviewers: [{ uuid: "{octocat}" }] }); |
| 1136 | }), |
| 1137 | ); |
| 1138 | }); |