1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import * as BitbucketApi from "./BitbucketApi.ts";
6import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
7import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts";
8
9it.effect("pages through rows that share an update instant by excluding those already sent", () =>
10 Effect.gen(function* () {
11 // Bitbucket stamps to the microsecond; 3 shares 1 and 2's millisecond but sorts after them.
12 const boundary = "2026-10-12T00:00:00.000500+00:00";
13 const sameMillisecond = "2026-10-12T00:00:00.000200+00:00";
14 const older = "2026-10-11T00:00:00.000000+00:00";
15 const micros = (stamp: string) =>
16 Date.parse(stamp) * 1000 + Number(/\.\d{3}(\d{3})/.exec(stamp)?.[1] ?? 0);
17 const rows = [1, 2, 3, 4, 5].map((id) => ({
18 id,
19 title: `Pull request ${id}`,
20 state: "OPEN",
21 created_on: older,
22 updated_on: id <= 2 ? boundary : id === 3 ? sameMillisecond : older,
23 source: { branch: { name: `feat/${id}` } },
24 destination: { branch: { name: "main" } },
25 links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${id}` } },
26 }));
27 const filters: string[] = [];
28 const provider = yield* BitbucketPullRequestProvider.make.pipe(
29 Effect.provide(
30 BitbucketPullRequestApi.layer.pipe(
31 Layer.provide(
32 Layer.mock(BitbucketApi.BitbucketApi)({
33 request: ({ url }) => {
34 const filter =
35 new URL(url, "https://api.bitbucket.org").searchParams.get("q") ?? "";
36 filters.push(filter);
37 const [, operator, bound] = /updated_on (<=?) (\S+)/.exec(filter) ?? [];
38 const excluded = new Set(
39 Array.from(filter.matchAll(/id != (\d+)/g), (match) => Number(match[1])),
40 );
41 const values = rows.filter(
42 (row) =>
43 (bound === undefined ||
44 (operator === "<"
45 ? micros(row.updated_on) < micros(bound)
46 : micros(row.updated_on) <= micros(bound))) &&
47 !excluded.has(row.id),
48 );
49 return Effect.succeed({ body: JSON.stringify({ values }), truncated: false });
50 },
51 }),
52 ),
53 ),
54 ),
55 );
56 const list = (cursor?: {
57 readonly updatedBefore: string;
58 readonly delivered: number;
59 readonly seenAt: ReadonlyArray<number>;
60 }) =>
61 provider.listChangeRequests({
62 cwd: "/w",
63 repository: "acme/web",
64 host: "bitbucket.org",
65 state: "open",
66 involvement: "all",
67 viewer: "bilal",
68 limit: 2,
69 ...(cursor === undefined ? {} : { cursor }),
70 });
71
72 const first = yield* list();
73 assert.deepStrictEqual(
74 first.items.map((item) => item.number),
75 [1, 2],
76 );
77 assert.isTrue(first.truncated);
78
79 // The cursor carries the instant cut to the millisecond, as the app normalizes it.
80 const second = yield* list({
81 updatedBefore: "2026-10-12T00:00:00.000Z",
82 delivered: 2,
83 seenAt: [1, 2],
84 });
85 assert.deepStrictEqual(
86 second.items.map((item) => item.number),
87 [3, 4],
88 );
89 assert.include(filters.at(-1), "updated_on < 2026-10-12T00:00:00.001Z");
90 assert.include(filters.at(-1), "id != 1");
91 assert.include(filters.at(-1), "id != 2");
92
93 const last = yield* list({
94 updatedBefore: "2026-10-11T00:00:00.000Z",
95 delivered: 4,
96 seenAt: [4],
97 });
98 assert.deepStrictEqual(
99 last.items.map((item) => item.number),
100 [5],
101 );
102 assert.isFalse(last.truncated);
103 }),
104);