@@ -520,11 +520,17 @@ function readWorkItemFilters(sourceConfig: Record<string, unknown>): WorkItemFil
520520
521521/**
522522 * Builds the WIQL query for the configured work-item filters. User-supplied
523- * values are escaped against WIQL string-literal injection. When a custom WIQL
524- * query is provided it is used verbatim and the structured filters are ignored.
525- * `lastSyncAt` narrows results to items changed since the previous sync.
523+ * values are escaped against WIQL string-literal injection. `lastSyncAt`
524+ * narrows results to items changed since the previous sync, and `idAfter`
525+ * restricts to items with a greater id (used to probe past the 20,000-item
526+ * WIQL cap).
527+ *
528+ * A custom WIQL query is used verbatim: neither the incremental changed-date
529+ * filter nor the probe condition can be injected into arbitrary user WIQL
530+ * safely, so custom queries always run as full listings on every sync. Change
531+ * detection still short-circuits unchanged items via the content hash.
526532 */
527- function buildWiql ( filters : WorkItemFilters , lastSyncAt ?: Date ) : string {
533+ function buildWiql ( filters : WorkItemFilters , lastSyncAt ?: Date , idAfter ?: number ) : string {
528534 if ( filters . customWiql ) return filters . customWiql
529535
530536 const clauses : string [ ] = [ '[System.TeamProject] = @project' ]
@@ -543,6 +549,9 @@ function buildWiql(filters: WorkItemFilters, lastSyncAt?: Date): string {
543549 if ( lastSyncAt ) {
544550 clauses . push ( `[System.ChangedDate] >= '${ lastSyncAt . toISOString ( ) } '` )
545551 }
552+ if ( idAfter !== undefined ) {
553+ clauses . push ( `[System.Id] > ${ idAfter } ` )
554+ }
546555
547556 return `SELECT [System.Id] FROM workitems WHERE ${ clauses . join ( ' AND ' ) } ORDER BY [System.ChangedDate] DESC`
548557}
@@ -556,9 +565,10 @@ async function queryWorkItemIds(
556565 accessToken : string ,
557566 organization : string ,
558567 project : string ,
559- wiql : string
568+ wiql : string ,
569+ top : number = WIQL_MAX_RESULTS
560570) : Promise < number [ ] > {
561- const url = `${ ADO_BASE_URL } /${ encodeURIComponent ( organization ) } /${ encodeURIComponent ( project ) } /_apis/wit/wiql?$top=${ WIQL_MAX_RESULTS } &api-version=${ WIQL_API_VERSION } `
571+ const url = `${ ADO_BASE_URL } /${ encodeURIComponent ( organization ) } /${ encodeURIComponent ( project ) } /_apis/wit/wiql?$top=${ top } &api-version=${ WIQL_API_VERSION } `
562572 const response = await fetchWithRetry ( url , {
563573 method : 'POST' ,
564574 headers : {
@@ -1250,10 +1260,31 @@ async function listWorkItems(
12501260 const wiql = buildWiql ( filters , lastSyncAt )
12511261 ids = await queryWorkItemIds ( accessToken , organization , project , wiql )
12521262 if ( syncContext ) syncContext . workItemIds = ids
1253- }
12541263
1255- if ( ids . length >= WIQL_MAX_RESULTS && syncContext ) {
1256- syncContext . listingCapped = true
1264+ if ( ids . length >= WIQL_MAX_RESULTS && syncContext ) {
1265+ /**
1266+ * The WIQL result filled the 20,000-item cap. Distinguish an exact fit
1267+ * from genuine truncation: for structured filters, probe for any
1268+ * matching item with an id beyond the largest returned one and only
1269+ * flag the listing incomplete when one exists — otherwise deletion
1270+ * reconciliation would be disabled forever for a project with exactly
1271+ * 20,000 matching items. Custom WIQL cannot be probed (no safe clause
1272+ * injection), so it is flagged conservatively.
1273+ */
1274+ let truncated = true
1275+ if ( ! filters . customWiql ) {
1276+ let maxId = 0
1277+ for ( const id of ids ) {
1278+ if ( id > maxId ) maxId = id
1279+ }
1280+ const probeWiql = buildWiql ( filters , lastSyncAt , maxId )
1281+ const beyond = await queryWorkItemIds ( accessToken , organization , project , probeWiql , 1 )
1282+ truncated = beyond . length > 0
1283+ }
1284+ if ( truncated ) {
1285+ syncContext . listingCapped = true
1286+ }
1287+ }
12571288 }
12581289
12591290 if ( ids . length === 0 ) {
@@ -1405,7 +1436,7 @@ export const azureDevopsConnector: ConnectorConfig = {
14051436 mode : 'advanced' ,
14061437 placeholder : 'SELECT [System.Id] FROM workitems WHERE ...' ,
14071438 description :
1408- 'Advanced: a full WIQL query selecting [System.Id]. Overrides the type, state, area path, and tag filters when set.' ,
1439+ 'Advanced: a full WIQL query selecting [System.Id]. Overrides the type, state, area path, and tag filters when set. Custom queries always run as full listings on every sync (the incremental changed-date filter is not applied). ' ,
14091440 } ,
14101441 {
14111442 id : 'repositoryName' ,
0 commit comments