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.
This commit is contained in:
Waleed Latif
2026-08-20 14:31:26 -07:00
parent 5826dd9556
commit 622a21bd9a
2 changed files with 54 additions and 4 deletions
@@ -357,6 +357,44 @@ describe('bitbucket maxItems cap', () => {
expect(syncContext.listingCapped).toBe(true) 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<string, unknown> = {}
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<string, unknown> = {}
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 () => { it('leaves the listing reconcilable when no cap is configured', async () => {
mockApi([[/\/src\//, () => jsonResponse({ values: [fileEntry('a.md'), fileEntry('b.md')] })]]) mockApi([[/\/src\//, () => jsonResponse({ values: [fileEntry('a.md'), fileEntry('b.md')] })]])
+16 -4
View File
@@ -714,11 +714,20 @@ function pendingDirectories(syncContext: Record<string, unknown> | undefined): s
* Applies the optional maxItems cap to a page, tracking the running total in * Applies the optional maxItems cap to a page, tracking the running total in
* syncContext and flagging `listingCapped` when the cap truncates the listing. * syncContext and flagging `listingCapped` when the cap truncates the listing.
* Skipped (oversized) documents ride along without consuming the cap. * 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( function applyMaxItemsCap(
documents: ExternalDocument[], documents: ExternalDocument[],
maxItems: number, maxItems: number,
syncContext: Record<string, unknown> | undefined syncContext: Record<string, unknown> | undefined,
sourceHasMore: boolean
): { documents: ExternalDocument[]; capped: boolean } { ): { documents: ExternalDocument[]; capped: boolean } {
if (maxItems <= 0) return { documents, capped: false } if (maxItems <= 0) return { documents, capped: false }
const alreadyIndexed = (syncContext?.totalDocsFetched as number) ?? 0 const alreadyIndexed = (syncContext?.totalDocsFetched as number) ?? 0
@@ -734,7 +743,8 @@ function applyMaxItemsCap(
) )
if (syncContext) { if (syncContext) {
syncContext.totalDocsFetched = alreadyIndexed + indexableCount 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 } return { documents: taken, capped: capReached }
} }
@@ -922,7 +932,8 @@ export const bitbucketConnector: ConnectorConfig = {
const { documents: capped, capped: hitLimit } = applyMaxItemsCap( const { documents: capped, capped: hitLimit } = applyMaxItemsCap(
documents, documents,
maxItems, maxItems,
syncContext syncContext,
Boolean(page.next) || frontier.length > 0
) )
if (hitLimit) return { documents: capped, hasMore: false } if (hitLimit) return { documents: capped, hasMore: false }
@@ -990,7 +1001,8 @@ export const bitbucketConnector: ConnectorConfig = {
const { documents: capped, capped: hitLimit } = applyMaxItemsCap( const { documents: capped, capped: hitLimit } = applyMaxItemsCap(
documents, documents,
maxItems, maxItems,
syncContext syncContext,
Boolean(page.next)
) )
if (hitLimit) return { documents: capped, hasMore: false } if (hitLimit) return { documents: capped, hasMore: false }