From 622a21bd9a47b08166d65a91c97c2dd942c86572 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 14:31:26 -0700 Subject: [PATCH] fix(connectors): only flag a Bitbucket listing capped when the cap withheld something Review round 2. takeIndexableWithinCap reports capReached as soon as the running total equals maxItems, which is also true of a listing that ended at exactly that count. Setting listingCapped there suppressed deletion reconciliation for a complete listing, so upstream-deleted files and pull requests could stay in the knowledge base indefinitely. applyMaxItemsCap now takes whether Bitbucket had more content beyond the page -- a next link, or directories still queued on the frontier -- and flags the listing only when the cap actually withheld something, matching the Databricks, Google Chat, and Workday connectors. --- .../connectors/bitbucket/bitbucket.test.ts | 38 +++++++++++++++++++ apps/sim/connectors/bitbucket/bitbucket.ts | 20 ++++++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/apps/sim/connectors/bitbucket/bitbucket.test.ts b/apps/sim/connectors/bitbucket/bitbucket.test.ts index f6460e41c2..bab02ea9c7 100644 --- a/apps/sim/connectors/bitbucket/bitbucket.test.ts +++ b/apps/sim/connectors/bitbucket/bitbucket.test.ts @@ -357,6 +357,44 @@ describe('bitbucket maxItems cap', () => { expect(syncContext.listingCapped).toBe(true) }) + it('leaves the listing reconcilable when a complete listing ends exactly on the cap', async () => { + mockApi([[/\/src\//, () => jsonResponse({ values: [fileEntry('a.md'), fileEntry('b.md')] })]]) + + const syncContext: Record = {} + const result = await bitbucketConnector.listDocuments( + ACCESS_TOKEN, + { ...CONFIG, maxItems: '2' }, + undefined, + syncContext + ) + + expect(result.documents).toHaveLength(2) + expect(syncContext.listingCapped).toBeUndefined() + }) + + it('flags the listing capped when the cap lands on a page boundary with more to come', async () => { + mockApi([ + [ + /\/src\//, + () => + jsonResponse({ + values: [fileEntry('a.md'), fileEntry('b.md')], + next: 'https://api.bitbucket.org/2.0/repositories/acme/widgets/src/abc/?page=2', + }), + ], + ]) + + const syncContext: Record = {} + await bitbucketConnector.listDocuments( + ACCESS_TOKEN, + { ...CONFIG, maxItems: '2' }, + undefined, + syncContext + ) + + expect(syncContext.listingCapped).toBe(true) + }) + it('leaves the listing reconcilable when no cap is configured', async () => { mockApi([[/\/src\//, () => jsonResponse({ values: [fileEntry('a.md'), fileEntry('b.md')] })]]) diff --git a/apps/sim/connectors/bitbucket/bitbucket.ts b/apps/sim/connectors/bitbucket/bitbucket.ts index 1c02cf54eb..da47d45e8a 100644 --- a/apps/sim/connectors/bitbucket/bitbucket.ts +++ b/apps/sim/connectors/bitbucket/bitbucket.ts @@ -714,11 +714,20 @@ function pendingDirectories(syncContext: Record | undefined): s * Applies the optional maxItems cap to a page, tracking the running total in * syncContext and flagging `listingCapped` when the cap truncates the listing. * Skipped (oversized) documents ride along without consuming the cap. + * + * `sourceHasMore` is whether Bitbucket still had content beyond this page — a + * `next` link, or directories still queued on the frontier. It is required because + * `takeIndexableWithinCap` reports `capReached` as soon as the running total + * *equals* maxItems, which is also true of a listing that ended at exactly that + * count. Setting `listingCapped` there would suppress deletion reconciliation for + * a listing that was in fact complete, leaving upstream-deleted files indexed + * indefinitely, so the flag is set only when the cap actually withheld something. */ function applyMaxItemsCap( documents: ExternalDocument[], maxItems: number, - syncContext: Record | undefined + syncContext: Record | undefined, + sourceHasMore: boolean ): { documents: ExternalDocument[]; capped: boolean } { if (maxItems <= 0) return { documents, capped: false } const alreadyIndexed = (syncContext?.totalDocsFetched as number) ?? 0 @@ -734,7 +743,8 @@ function applyMaxItemsCap( ) if (syncContext) { syncContext.totalDocsFetched = alreadyIndexed + indexableCount - if (capReached) syncContext.listingCapped = true + const withheld = taken.length < documents.length || sourceHasMore + if (capReached && withheld) syncContext.listingCapped = true } return { documents: taken, capped: capReached } } @@ -922,7 +932,8 @@ export const bitbucketConnector: ConnectorConfig = { const { documents: capped, capped: hitLimit } = applyMaxItemsCap( documents, maxItems, - syncContext + syncContext, + Boolean(page.next) || frontier.length > 0 ) if (hitLimit) return { documents: capped, hasMore: false } @@ -990,7 +1001,8 @@ export const bitbucketConnector: ConnectorConfig = { const { documents: capped, capped: hitLimit } = applyMaxItemsCap( documents, maxItems, - syncContext + syncContext, + Boolean(page.next) ) if (hitLimit) return { documents: capped, hasMore: false }