diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts index bf5ea9d944738..4173849550afc 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts @@ -148,7 +148,7 @@ layer("BitbucketPullRequestApi.layer", (it) => { }); expect(filterOfCall(0)).toBe( - '(title ~ "page" OR description ~ "page") AND updated_on <= 2026-07-02T00:00:00+00:00', + '(title ~ "page" OR description ~ "page") AND updated_on < 2026-07-02T00:00:00.001Z', ); }), ); diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts index d5ad8491ac86b..28ea54263f45b 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts @@ -1,9 +1,11 @@ import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { @@ -326,6 +328,15 @@ function filterLiteral(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); } +// Bitbucket keeps microseconds; include the cursor's whole millisecond before excluding seen IDs. +function updatedBeforeFilter(updatedBefore: string): string { + return Option.match(DateTime.make(updatedBefore), { + onNone: () => `updated_on <= ${updatedBefore}`, + onSome: (instant) => + `updated_on < ${DateTime.formatIso(DateTime.add(instant, { milliseconds: 1 }))}`, + }); +} + function mergeStrategy(method: PullRequestMergeMethod | undefined): string { switch (method) { case "squash": @@ -544,12 +555,12 @@ export const make = Effect.gen(function* () { listPullRequests: (input) => withRepository(input.repository, (path) => { const search = input.query?.trim() ?? ""; - // Exclude already-sent IDs at the inclusive timestamp before applying the page limit. + // Exclude already-sent IDs in the boundary millisecond before applying the page limit. const predicates = [ ...(search.length === 0 ? [] : [searchFilter(search)]), ...(input.author === undefined ? [] : [authorFilter(input.author)]), ...(input.draft === undefined ? [] : [`draft = ${input.draft}`]), - ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]), + ...(input.cursor === undefined ? [] : [updatedBeforeFilter(input.cursor.updatedBefore)]), ...(input.cursor?.seenAt ?? []).map((number) => `id != ${number}`), ]; return listPage({ diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.listing.test.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.listing.test.ts index f68e010ac00b6..bfb1447d02dbb 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.listing.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.listing.test.ts @@ -8,14 +8,18 @@ import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts it.effect("pages through rows that share an update instant by excluding those already sent", () => Effect.gen(function* () { - const boundary = "2026-10-12T00:00:00.000Z"; - const older = "2026-10-11T00:00:00.000Z"; + // Bitbucket stamps to the microsecond; 3 shares 1 and 2's millisecond but sorts after them. + const boundary = "2026-10-12T00:00:00.000500+00:00"; + const sameMillisecond = "2026-10-12T00:00:00.000200+00:00"; + const older = "2026-10-11T00:00:00.000000+00:00"; + const micros = (stamp: string) => + Date.parse(stamp) * 1000 + Number(/\.\d{3}(\d{3})/.exec(stamp)?.[1] ?? 0); const rows = [1, 2, 3, 4, 5].map((id) => ({ id, title: `Pull request ${id}`, state: "OPEN", created_on: older, - updated_on: id <= 3 ? boundary : older, + updated_on: id <= 2 ? boundary : id === 3 ? sameMillisecond : older, source: { branch: { name: `feat/${id}` } }, destination: { branch: { name: "main" } }, links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${id}` } }, @@ -30,13 +34,16 @@ it.effect("pages through rows that share an update instant by excluding those al const filter = new URL(url, "https://api.bitbucket.org").searchParams.get("q") ?? ""; filters.push(filter); - const updatedBefore = /updated_on <= (\S+)/.exec(filter)?.[1]; + const [, operator, bound] = /updated_on (<=?) (\S+)/.exec(filter) ?? []; const excluded = new Set( Array.from(filter.matchAll(/id != (\d+)/g), (match) => Number(match[1])), ); const values = rows.filter( (row) => - (updatedBefore === undefined || row.updated_on <= updatedBefore) && + (bound === undefined || + (operator === "<" + ? micros(row.updated_on) < micros(bound) + : micros(row.updated_on) <= micros(bound))) && !excluded.has(row.id), ); return Effect.succeed({ body: JSON.stringify({ values }), truncated: false }); @@ -69,16 +76,25 @@ it.effect("pages through rows that share an update instant by excluding those al ); assert.isTrue(first.truncated); - const second = yield* list({ updatedBefore: boundary, delivered: 2, seenAt: [1, 2] }); + // The cursor carries the instant cut to the millisecond, as the app normalizes it. + const second = yield* list({ + updatedBefore: "2026-10-12T00:00:00.000Z", + delivered: 2, + seenAt: [1, 2], + }); assert.deepStrictEqual( second.items.map((item) => item.number), [3, 4], ); - assert.include(filters.at(-1), `updated_on <= ${boundary}`); + assert.include(filters.at(-1), "updated_on < 2026-10-12T00:00:00.001Z"); assert.include(filters.at(-1), "id != 1"); assert.include(filters.at(-1), "id != 2"); - const last = yield* list({ updatedBefore: older, delivered: 4, seenAt: [4] }); + const last = yield* list({ + updatedBefore: "2026-10-11T00:00:00.000Z", + delivered: 4, + seenAt: [4], + }); assert.deepStrictEqual( last.items.map((item) => item.number), [5],