988 lines · 34.6 KB · TypeScript
RawDownload
import { afterEach, assert, expect, it, vi } from "@effect/vitest";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; const mockedRequest = vi.fn<BitbucketApi.BitbucketApi["Service"]["request"]>(); const layer = it.layer(  BitbucketPullRequestApi.layer.pipe(    Layer.provide(      Layer.mock(BitbucketApi.BitbucketApi)({        request: mockedRequest,      }),    ),  ),); /** The shape `request` answers with: a body plus whether it had to be cut short. */function response(body: string) {  return { body, truncated: false };} function page(count: number, firstNumber: number, next?: string): string {  return JSON.stringify({    pagelen: 50,    size: count,    values: Array.from({ length: count }, (_, index) => ({      id: firstNumber + index,      title: `Pull request ${firstNumber + index}`,      state: "OPEN",      created_on: "2026-06-16T05:04:32+00:00",      updated_on: "2026-06-16T05:04:33+00:00",      source: { branch: { name: "feat/page" } },      destination: { branch: { name: "master" } },      links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${firstNumber}` } },    })),    ...(next === undefined ? {} : { next }),  });} function valuePage(values: ReadonlyArray<unknown>, next?: string): string {  return JSON.stringify({ values, ...(next === undefined ? {} : { next }) });} /** Who opened the pull request, and two accounts that could review it. */const bilal = { uuid: "{bilal}", nickname: "bilal" };const octocat = { uuid: "{octocat}", nickname: "octocat" };const hubot = { uuid: "{hubot}", nickname: "hubot" }; /** One pull request as `/pullrequests/{id}` answers with it. */function pullRequestJson(overrides: Record<string, unknown>): string {  return JSON.stringify({    id: 7,    title: "Pull request 7",    state: "OPEN",    author: bilal,    created_on: "2026-06-16T05:04:32+00:00",    updated_on: "2026-06-16T05:04:33+00:00",    source: { branch: { name: "feat/page" } },    destination: { branch: { name: "master" } },    links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/7" } },    ...overrides,  });} /** The request the nth call made. */function callAt(index: number) {  const call = mockedRequest.mock.calls[index];  assert.isDefined(call);  return call[0];} /** The filter expression of the nth request, read back out of its query string. */function filterOfCall(index: number): string | null {  const url = callAt(index).url;  return new URLSearchParams(url.slice(url.indexOf("?") + 1)).get("q");} afterEach(() => {  mockedRequest.mockReset();}); layer("BitbucketPullRequestApi.layer", (it) => {  it.effect("asks for reviewers, newest first, at Bitbucket's page ceiling", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(3, 1))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       const batch = yield* api.listPullRequests({        repository: "acme/web",        state: "open",        limit: 50,      });       assert.strictEqual(batch.items.length, 3);      assert.isFalse(batch.truncated);      const url = callAt(0).url;      expect(url).toContain("/repositories/acme/web/pullrequests");      expect(url).toContain("state=OPEN");      // Over 50 Bitbucket answers with an empty page and no error, so it is never exceeded.      expect(url).toContain("pagelen=50");      expect(url).toContain("sort=-updated_on");      expect(url).toContain("fields=%2Bvalues.reviewers");    }),  );   it.effect("follows the cursor Bitbucket sends rather than counting offsets", () =>    Effect.gen(function* () {      const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2";      mockedRequest        .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next))))        .mockReturnValueOnce(Effect.succeed(response(page(50, 51))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       const batch = yield* api.listPullRequests({        repository: "acme/web",        state: "open",        limit: 100,      });       assert.strictEqual(batch.items.length, 100);      assert.isFalse(batch.truncated);      assert.strictEqual(callAt(1).url, next);    }),  );   it.effect("stops at the caller's page and says more remain", () =>    Effect.gen(function* () {      const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2";      mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(50, 1, next))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       const batch = yield* api.listPullRequests({        repository: "acme/web",        state: "open",        limit: 50,      });       assert.strictEqual(batch.items.length, 50);      assert.isTrue(batch.truncated);      assert.strictEqual(mockedRequest.mock.calls.length, 1);    }),  );   it.effect("counts the rows it walked past as more to come", () =>    Effect.gen(function* () {      // Bitbucket pages in fifties whatever was asked for, so a request for ninety-nine reads a      // hundred and drops one. That row is more results, and saying otherwise takes the "load      // more" away from a listing that has not finished.      const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2";      mockedRequest        .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next))))        .mockReturnValueOnce(Effect.succeed(response(page(50, 51))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       const batch = yield* api.listPullRequests({        repository: "acme/web",        state: "open",        limit: 99,      });       assert.strictEqual(batch.items.length, 99);      assert.isTrue(batch.truncated);    }),  );   it.effect("searches with a filter expression, which is all Bitbucket offers", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       yield* api.listPullRequests({        repository: "acme/web",        state: "open",        limit: 50,        query: "page",      });       expect(filterOfCall(0)).toBe('(title ~ "page" OR description ~ "page")');      // The state filter beside it still stands, which the brackets are there to keep.      expect(callAt(0).url).toContain("state=OPEN");    }),  );   it.effect("escapes a quote and a backslash, so a search cannot reshape the filter", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       yield* api.listPullRequests({        repository: "acme/web",        state: "open",        limit: 50,        query: String.raw`a\" OR state = "MERGED"`,      });       const literal = String.raw`a\\\" OR state = \"MERGED\"`;      expect(filterOfCall(0)).toBe(`(title ~ "${literal}" OR description ~ "${literal}")`);    }),  );   it.effect("asks for no filter at all when the reader typed only spaces", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       yield* api.listPullRequests({        repository: "acme/web",        state: "open",        limit: 50,        query: "   ",      });       assert.isNull(filterOfCall(0));    }),  );   it.effect("carries on from the instant the last slice ended on", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       yield* api.listPullRequests({        repository: "acme/web",        state: "open",        limit: 50,        cursor: { updatedBefore: "2026-07-02T00:00:00.123456+00:00", delivered: 50 },      });       // Inclusive, so the rows already sent at that instant come back for the caller to drop.      expect(filterOfCall(0)).toBe("updated_on <= 2026-07-02T00:00:00.123456+00:00");      expect(callAt(0).url).toContain("sort=-updated_on");    }),  );   it.effect("narrows by the reader's words and by where it left off at once", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       yield* api.listPullRequests({        repository: "acme/web",        state: "open",        limit: 50,        query: "page",        cursor: { updatedBefore: "2026-07-02T00:00:00+00:00", delivered: 50 },      });       // Bitbucket takes one `q`, so the two narrowings are joined rather than one replacing the      // other — and the search keeps its brackets, which is what keeps the AND out of its OR.      expect(filterOfCall(0)).toBe(        '(title ~ "page" OR description ~ "page") AND updated_on <= 2026-07-02T00:00:00+00:00',      );    }),  );   it.effect("asks for declined pull requests on the closed tab", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 });       expect(callAt(0).url).toContain("state=DECLINED");    }),  );   it.effect("asks for every state at once on the All tab", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       yield* api.listPullRequests({ repository: "acme/web", state: "all", limit: 50 });       // Bitbucket unions repeated state parameters, which is the only way to span them.      const url = callAt(0).url;      for (const state of ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]) {        expect(url).toContain(`state=${state}`);      }    }),  );   it.effect("counts a superseded pull request as closed", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 });       expect(callAt(0).url).toContain("state=DECLINED");      expect(callAt(0).url).toContain("state=SUPERSEDED");    }),  );   it.effect("refuses a repository that is not workspace and slug", () =>    Effect.gen(function* () {      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       const error = yield* Effect.flip(        api.listPullRequests({ repository: "acme/team/web", state: "open", limit: 50 }),      );       assert.strictEqual(error._tag, "BitbucketRepositoryUnsupportedError");      assert.strictEqual(mockedRequest.mock.calls.length, 0);    }),  );   it.effect("returns the diff verbatim, because Bitbucket already sends a patch", () =>    Effect.gen(function* () {      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";      mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch)));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       const diff = yield* api.getPullRequestDiff({ repository: "acme/web", number: 7 });       assert.strictEqual(diff.patch, patch);      assert.isFalse(diff.truncated);      expect(callAt(0)).toMatchObject({        url: "/repositories/acme/web/pullrequests/7/diff",        // A diff of any size would otherwise be read into memory whole.        maxBytes: 8 * 1024 * 1024,      });    }),  );   it.effect("reads a named commit's own patch, which pages no further than the whole of it", () =>    Effect.gen(function* () {      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";      mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch)));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       const diff = yield* api.getPullRequestDiff({        repository: "acme/web",        number: 7,        commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",      });       assert.strictEqual(diff.patch, patch);      expect(callAt(0)).toMatchObject({        url: "/repositories/acme/web/diff/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",        maxBytes: 8 * 1024 * 1024,      });    }),  );   it.effect("refuses a commit that is not a sha rather than reading it into a URL", () =>    Effect.gen(function* () {      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       const error = yield* Effect.flip(        api.getPullRequestDiff({          repository: "acme/web",          number: 7,          commit: "../../acme/other/diff/deadbeef",        }),      );       assert.strictEqual(error._tag, "BitbucketDiffCommitError");      assert.strictEqual(mockedRequest.mock.calls.length, 0);    }),  );   it.effect("aggregates every diffstat page", () =>    Effect.gen(function* () {      const next = "https://api.bitbucket.org/2.0/diffstat?page=2";      mockedRequest        .mockReturnValueOnce(          Effect.succeed(            response(              valuePage(                [                  { lines_added: 9, lines_removed: 2 },                  { lines_added: 3, lines_removed: 1 },                ],                next,              ),            ),          ),        )        .mockReturnValueOnce(          Effect.succeed(response(valuePage([{ lines_added: 4, lines_removed: 7 }]))),        );      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       const stat = yield* api.getDiffStat({ repository: "acme/web", number: 7 });       expect(stat).toEqual({ additions: 16, deletions: 10, changedFiles: 3 });      expect(callAt(1).url).toBe(next);    }),  );   it.effect("returns the complete commit timeline oldest first across pages", () =>    Effect.gen(function* () {      const next = "https://api.bitbucket.org/2.0/commits?page=2";      mockedRequest        .mockReturnValueOnce(          Effect.succeed(            response(              valuePage(                [                  { hash: "ddd", message: "fourth", date: "2026-07-04T00:00:00Z" },                  { hash: "ccc", message: "third", date: "2026-07-03T00:00:00Z" },                ],                next,              ),            ),          ),        )        .mockReturnValueOnce(          Effect.succeed(            response(              valuePage([                { hash: "bbb", message: "second", date: "2026-07-02T00:00:00Z" },                { hash: "aaa", message: "first", date: "2026-07-01T00:00:00Z" },              ]),            ),          ),        );      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       const commits = yield* api.listCommits({ repository: "acme/web", number: 7 });       expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb", "ccc", "ddd"]);      expect(callAt(1).url).toBe(next);    }),  );   it.effect("returns build statuses from every page", () =>    Effect.gen(function* () {      const next = "https://api.bitbucket.org/2.0/statuses?page=2";      mockedRequest        .mockReturnValueOnce(          Effect.succeed(response(valuePage([{ name: "Build", state: "SUCCESSFUL" }], next))),        )        .mockReturnValueOnce(          Effect.succeed(response(valuePage([{ name: "Lint", state: "FAILED" }]))),        );      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       const checks = yield* api.listChecks({ repository: "acme/web", number: 7 });       expect(checks.map((check) => [check.name, check.status])).toEqual([        ["Build", "success"],        ["Lint", "failure"],      ]);      expect(callAt(1).url).toBe(next);    }),  );   it.effect("reads an empty conflict list as mergeable", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       const mergeability = yield* api.getMergeability({ repository: "acme/web", number: 7 });       assert.strictEqual(mergeability, "mergeable");      expect(callAt(0).url).toBe("/repositories/acme/web/pullrequests/7/conflicts");    }),  );   it.effect("merges with Bitbucket's own name for the strategy", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValue(Effect.succeed(response("{}")));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       yield* api.runAction({        repository: "acme/web",        number: 7,        action: "merge",        mergeMethod: "rebase",      });       expect(callAt(0)).toMatchObject({        method: "POST",        url: "/repositories/acme/web/pullrequests/7/merge",        body: '{"merge_strategy":"rebase_fast_forward"}',      });    }),  );   it.effect("closes a pull request by declining it", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValue(Effect.succeed(response("{}")));      const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;       yield* api.runAction({ repository: "acme/web", number: 7, action: "close" });       expect(callAt(0)).toMatchObject({        method: "POST",        url: "/repositories/acme/web/pullrequests/7/decline",      });    }),  );   it.effect("posts a comment as a JSON document, so the body stays text", () =>    Effect.gen(function* () {      mockedRequest.mockReturnValue(Effect.succeed(response("{}")));

Lines 1–500 of 988.