@elisra-devops/docgen-data-provider 1.105.0 → 1.107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,58 @@ type DocTypeBranchConfig = {
27
27
  fallbackStart?: any;
28
28
  };
29
29
 
30
+ type HistoricalQueryListItem = {
31
+ id: string;
32
+ queryName: string;
33
+ path: string;
34
+ };
35
+
36
+ type HistoricalWorkItemSnapshot = {
37
+ id: number;
38
+ workItemType: string;
39
+ title: string;
40
+ state: string;
41
+ areaPath: string;
42
+ iterationPath: string;
43
+ versionId: number | null;
44
+ versionTimestamp: string;
45
+ description: string;
46
+ steps: string;
47
+ testPhase: string;
48
+ relatedLinkCount: number;
49
+ workItemUrl: string;
50
+ };
51
+
52
+ type HistoricalSnapshotResult = {
53
+ queryId: string;
54
+ queryName: string;
55
+ asOf: string;
56
+ total: number;
57
+ rows: HistoricalWorkItemSnapshot[];
58
+ snapshotMap: Map<number, HistoricalWorkItemSnapshot>;
59
+ };
60
+
61
+ const HISTORICAL_WIT_API_VERSIONS: Array<string | null> = ['7.1', '5.1', null];
62
+ const HISTORICAL_BATCH_MAX_IDS = 200;
63
+ const HISTORICAL_WORK_ITEM_FIELDS = [
64
+ 'System.Id',
65
+ 'System.WorkItemType',
66
+ 'System.Title',
67
+ 'System.State',
68
+ 'System.AreaPath',
69
+ 'System.IterationPath',
70
+ 'System.Rev',
71
+ 'System.ChangedDate',
72
+ 'System.Description',
73
+ 'Microsoft.VSTS.TCM.Steps',
74
+ 'Elisra.TestPhase',
75
+ 'Custom.TestPhase',
76
+ ];
77
+
78
+ /** Default fields fetched per work item in tree/flat query parsing. */
79
+ const WI_DEFAULT_FIELDS =
80
+ 'System.Description,System.Title,Microsoft.VSTS.TCM.ReproSteps,Microsoft.VSTS.CMMI.Symptom';
81
+
30
82
  export default class TicketsDataProvider {
31
83
  orgUrl: string = '';
32
84
  token: string = '';
@@ -187,12 +239,21 @@ export default class TicketsDataProvider {
187
239
  * @param docType document type
188
240
  * @returns
189
241
  */
242
+ private normalizeSharedQueriesPath(path: string): string {
243
+ const raw = String(path || '').trim();
244
+ if (!raw) return '';
245
+ const normalized = raw.toLowerCase();
246
+ if (normalized === 'shared' || normalized === 'shared queries') return '';
247
+ return raw;
248
+ }
249
+
190
250
  async GetSharedQueries(project: string, path: string, docType: string = ''): Promise<any> {
191
251
  let url;
192
252
  try {
193
- if (path === '')
253
+ const normalizedPath = this.normalizeSharedQueriesPath(path);
254
+ if (normalizedPath === '')
194
255
  url = `${this.orgUrl}${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all`;
195
- else url = `${this.orgUrl}${project}/_apis/wit/queries/${path}?$depth=2&$expand=all`;
256
+ else url = `${this.orgUrl}${project}/_apis/wit/queries/${normalizedPath}?$depth=2&$expand=all`;
196
257
  let queries: any = await TFSServices.getItemContent(url, this.token);
197
258
  logger.debug(`doctype: ${docType}`);
198
259
  const normalizedDocType = (docType || '').toLowerCase();
@@ -201,8 +262,7 @@ export default class TicketsDataProvider {
201
262
  switch (normalizedDocType) {
202
263
  case 'std':
203
264
  case 'stp': {
204
- const rootCandidates =
205
- normalizedDocType === 'stp' ? (['stp', 'std'] as const) : (['std'] as const);
265
+ const rootCandidates = normalizedDocType === 'stp' ? (['stp', 'std'] as const) : (['std'] as const);
206
266
  let stdRoot = queriesWithChildren;
207
267
  let stdRootFound = false;
208
268
  for (const candidate of rootCandidates) {
@@ -216,7 +276,7 @@ export default class TicketsDataProvider {
216
276
  logger.debug(
217
277
  `[GetSharedQueries][${normalizedDocType}] using ${
218
278
  stdRootFound ? 'dedicated folder' : 'root queries'
219
- }`
279
+ }`,
220
280
  );
221
281
  // Each branch describes the dedicated folder names, the fetch routine, and how to validate results.
222
282
  const stdBranches = await this.fetchDocTypeBranches(queriesWithChildren, stdRoot, [
@@ -271,7 +331,7 @@ export default class TicketsDataProvider {
271
331
  case 'str': {
272
332
  const { root: strRoot, found: strRootFound } = await this.getDocTypeRoot(
273
333
  queriesWithChildren,
274
- 'str'
334
+ 'str',
275
335
  );
276
336
  logger.debug(`[GetSharedQueries][str] using ${strRootFound ? 'dedicated folder' : 'root queries'}`);
277
337
  const strBranches = await this.fetchDocTypeBranches(queriesWithChildren, strRoot, [
@@ -338,12 +398,12 @@ export default class TicketsDataProvider {
338
398
  case 'test-reporter': {
339
399
  const { root: testReporterRoot, found: testReporterFound } = await this.getDocTypeRoot(
340
400
  queriesWithChildren,
341
- 'test-reporter'
401
+ 'test-reporter',
342
402
  );
343
403
  logger.debug(
344
404
  `[GetSharedQueries][test-reporter] using ${
345
405
  testReporterFound ? 'dedicated folder' : 'root queries'
346
- }`
406
+ }`,
347
407
  );
348
408
  const testReporterBranches = await this.fetchDocTypeBranches(
349
409
  queriesWithChildren,
@@ -356,13 +416,15 @@ export default class TicketsDataProvider {
356
416
  fetcher: (folder: any) => this.fetchTestReporterQueries(folder),
357
417
  validator: (result: any) => this.hasAnyQueryTree(result?.testAssociatedTree),
358
418
  },
359
- ]
419
+ ],
360
420
  );
361
421
  const testReporterFetch = testReporterBranches['testReporter'];
362
422
  return testReporterFetch?.result ?? { testAssociatedTree: null };
363
423
  }
364
424
  case 'srs':
365
425
  return await this.fetchSrsQueries(queriesWithChildren);
426
+ case 'sysrs':
427
+ return await this.fetchSysRsQueries(queriesWithChildren);
366
428
  case 'svd': {
367
429
  const { root: svdRoot, found } = await this.getDocTypeRoot(queriesWithChildren, 'svd');
368
430
  if (!found) {
@@ -399,6 +461,13 @@ export default class TicketsDataProvider {
399
461
  knownBugsQueryTree: knownBugsFetch?.result ?? null,
400
462
  };
401
463
  }
464
+ case 'historical-query':
465
+ case 'historical': {
466
+ const { tree1 } = await this.structureAllQueryPath(queriesWithChildren);
467
+ return {
468
+ historicalQueryTree: tree1 ? [tree1] : [],
469
+ };
470
+ }
402
471
  default:
403
472
  break;
404
473
  }
@@ -429,7 +498,7 @@ export default class TicketsDataProvider {
429
498
  field.name !== 'Title' &&
430
499
  field.name !== 'Description' &&
431
500
  field.name !== 'Work Item Type' &&
432
- field.name !== 'Steps'
501
+ field.name !== 'Steps',
433
502
  )
434
503
  .map((field: any) => {
435
504
  return {
@@ -456,7 +525,7 @@ export default class TicketsDataProvider {
456
525
  onlyTestReq,
457
526
  null,
458
527
  ['Requirement'],
459
- ['Test Case']
528
+ ['Test Case'],
460
529
  );
461
530
  return { reqTestTree, testReqTree };
462
531
  }
@@ -489,7 +558,7 @@ export default class TicketsDataProvider {
489
558
  'Review',
490
559
  'Test Plan',
491
560
  'Test Suite',
492
- ]
561
+ ],
493
562
  );
494
563
  return { linkedMomTree };
495
564
  }
@@ -545,7 +614,7 @@ export default class TicketsDataProvider {
545
614
  onlySourceSide,
546
615
  null,
547
616
  ['Bug', 'Change Request'],
548
- ['Test Case']
617
+ ['Test Case'],
549
618
  );
550
619
  return { OpenPcrToTestTree, TestToOpenPcrTree };
551
620
  }
@@ -561,7 +630,7 @@ export default class TicketsDataProvider {
561
630
  true,
562
631
  null,
563
632
  ['Requirement', 'Bug', 'Change Request'],
564
- ['Test Case']
633
+ ['Test Case'],
565
634
  );
566
635
  return { testAssociatedTree };
567
636
  }
@@ -591,7 +660,7 @@ export default class TicketsDataProvider {
591
660
  undefined,
592
661
  true, // Enable processing of both tree and direct link queries, including flat queries
593
662
  excludedFolderNames,
594
- true
663
+ true,
595
664
  );
596
665
  return { systemRequirementsQueryTree };
597
666
  }
@@ -617,19 +686,17 @@ export default class TicketsDataProvider {
617
686
 
618
687
  const systemToSoftwareFolder = await this.findChildFolderByName(
619
688
  srsFolderWithChildren,
620
- 'System to Software'
689
+ 'System to Software',
621
690
  );
622
691
  const softwareToSystemFolder = await this.findChildFolderByName(
623
692
  srsFolderWithChildren,
624
- 'Software to System'
693
+ 'Software to System',
625
694
  );
626
695
 
627
- const systemToSoftwareRequirementsQueries = await this.fetchRequirementsTraceQueriesForFolder(
628
- systemToSoftwareFolder
629
- );
630
- const softwareToSystemRequirementsQueries = await this.fetchRequirementsTraceQueriesForFolder(
631
- softwareToSystemFolder
632
- );
696
+ const systemToSoftwareRequirementsQueries =
697
+ await this.fetchRequirementsTraceQueriesForFolder(systemToSoftwareFolder);
698
+ const softwareToSystemRequirementsQueries =
699
+ await this.fetchRequirementsTraceQueriesForFolder(softwareToSystemFolder);
633
700
 
634
701
  return {
635
702
  systemRequirementsQueries,
@@ -638,6 +705,40 @@ export default class TicketsDataProvider {
638
705
  };
639
706
  }
640
707
 
708
+ private async fetchSysRsQueries(rootQueries: any) {
709
+ const { root: sysRsRoot, found: sysRsRootFound } = await this.getDocTypeRoot(rootQueries, 'sysrs');
710
+ logger.debug(`[GetSharedQueries][sysrs] using ${sysRsRootFound ? 'dedicated folder' : 'root queries'}`);
711
+
712
+ const systemRequirementsQueries = await this.fetchSystemRequirementQueries(sysRsRoot, [
713
+ 'System To Customer',
714
+ 'System To Subsystem',
715
+ ]);
716
+
717
+ const systemToCustomerFolder = await this.findChildFolderByPossibleNames(sysRsRoot, [
718
+ 'system to customer',
719
+ 'system-to-customer',
720
+ 'system customer',
721
+ 'subsystem to system',
722
+ 'customer to system',
723
+ ]);
724
+ const systemToSubsystemFolder = await this.findChildFolderByPossibleNames(sysRsRoot, [
725
+ 'system to subsystem',
726
+ 'system-to-subsystem',
727
+ 'system subsystem',
728
+ ]);
729
+
730
+ const subsystemToSystemRequirementsQueries =
731
+ await this.fetchRequirementsTraceQueriesForFolder(systemToCustomerFolder);
732
+ const systemToSubsystemRequirementsQueries =
733
+ await this.fetchRequirementsTraceQueriesForFolder(systemToSubsystemFolder);
734
+
735
+ return {
736
+ systemRequirementsQueries,
737
+ subsystemToSystemRequirementsQueries,
738
+ systemToSubsystemRequirementsQueries,
739
+ };
740
+ }
741
+
641
742
  /**
642
743
  * Fetches and structures linked queries related to requirements traceability with area path filtering.
643
744
  *
@@ -662,7 +763,7 @@ export default class TicketsDataProvider {
662
763
  ['epic', 'feature', 'requirement'],
663
764
  ['epic', 'feature', 'requirement'],
664
765
  'sys', // Source area filter for tree1: System area paths
665
- 'soft' // Target area filter for tree1: Software area paths (tree2 will be reversed automatically)
766
+ 'soft', // Target area filter for tree1: Software area paths (tree2 will be reversed automatically)
666
767
  );
667
768
  return { SystemToSoftwareRequirementsTree, SoftwareToSystemRequirementsTree };
668
769
  }
@@ -680,7 +781,7 @@ export default class TicketsDataProvider {
680
781
  ['epic', 'feature', 'requirement'],
681
782
  undefined,
682
783
  undefined,
683
- true
784
+ true,
684
785
  );
685
786
  return tree1;
686
787
  }
@@ -727,7 +828,7 @@ export default class TicketsDataProvider {
727
828
  const normalizedName = childName.toLowerCase();
728
829
  return (
729
830
  parentWithChildren.children.find(
730
- (child: any) => child.isFolder && (child.name || '').toLowerCase() === normalizedName
831
+ (child: any) => child.isFolder && (child.name || '').toLowerCase() === normalizedName,
731
832
  ) || null
732
833
  );
733
834
  }
@@ -836,7 +937,7 @@ export default class TicketsDataProvider {
836
937
  startingFolder: any,
837
938
  fetcher: (folder: any) => Promise<any>,
838
939
  logContext: string,
839
- validator?: (result: any) => boolean
940
+ validator?: (result: any) => boolean,
840
941
  ): Promise<{ result: any; usedFolder: any }> {
841
942
  const rootWithChildren = await this.ensureQueryChildren(rootQueries);
842
943
  const candidates = await this.buildFallbackChain(rootWithChildren, startingFolder);
@@ -870,7 +971,7 @@ export default class TicketsDataProvider {
870
971
  private async fetchDocTypeBranches(
871
972
  queriesWithChildren: any,
872
973
  docRoot: any,
873
- branches: DocTypeBranchConfig[]
974
+ branches: DocTypeBranchConfig[],
874
975
  ): Promise<Record<string, FallbackFetchOutcome>> {
875
976
  const results: Record<string, FallbackFetchOutcome> = {};
876
977
  const effectiveDocRoot = docRoot ?? queriesWithChildren;
@@ -884,7 +985,7 @@ export default class TicketsDataProvider {
884
985
  if (branch.folderNames?.length && effectiveDocRoot) {
885
986
  const resolvedFolder = await this.findChildFolderByPossibleNames(
886
987
  effectiveDocRoot,
887
- branch.folderNames
988
+ branch.folderNames,
888
989
  );
889
990
  if (resolvedFolder) {
890
991
  startingFolder = resolvedFolder;
@@ -899,7 +1000,7 @@ export default class TicketsDataProvider {
899
1000
  startingFolder,
900
1001
  branch.fetcher,
901
1002
  branch.label,
902
- branch.validator
1003
+ branch.validator,
903
1004
  );
904
1005
 
905
1006
  logger.debug(`${branch.label} final folder: ${fetchOutcome.usedFolder?.name ?? '<root>'}`);
@@ -955,7 +1056,7 @@ export default class TicketsDataProvider {
955
1056
  private async findPathToNode(
956
1057
  currentNode: any,
957
1058
  targetId: string,
958
- visited: Set<string> = new Set<string>()
1059
+ visited: Set<string> = new Set<string>(),
959
1060
  ): Promise<any[] | null> {
960
1061
  if (!currentNode) {
961
1062
  return null;
@@ -989,7 +1090,7 @@ export default class TicketsDataProvider {
989
1090
 
990
1091
  private async getDocTypeRoot(
991
1092
  rootQueries: any,
992
- docTypeName: string
1093
+ docTypeName: string,
993
1094
  ): Promise<{ root: any; found: boolean }> {
994
1095
  if (!rootQueries) {
995
1096
  return { root: rootQueries, found: false };
@@ -1022,7 +1123,8 @@ export default class TicketsDataProvider {
1022
1123
  async GetQueryResultsFromWiql(
1023
1124
  wiqlHref: string = '',
1024
1125
  displayAsTable: boolean = false,
1025
- testCaseToRelatedWiMap: Map<number, Set<any>>
1126
+ testCaseToRelatedWiMap: Map<number, Set<any>>,
1127
+ fetchAllFields: boolean = false,
1026
1128
  ): Promise<any> {
1027
1129
  try {
1028
1130
  if (!wiqlHref) {
@@ -1038,13 +1140,13 @@ export default class TicketsDataProvider {
1038
1140
  case QueryType.OneHop:
1039
1141
  return displayAsTable
1040
1142
  ? await this.parseDirectLinkedQueryResultForTableFormat(queryResult, testCaseToRelatedWiMap)
1041
- : await this.parseTreeQueryResult(queryResult);
1143
+ : await this.parseTreeQueryResult(queryResult, fetchAllFields);
1042
1144
  case QueryType.Tree:
1043
- return await this.parseTreeQueryResult(queryResult);
1145
+ return await this.parseTreeQueryResult(queryResult, fetchAllFields);
1044
1146
  case QueryType.Flat:
1045
1147
  return displayAsTable
1046
1148
  ? await this.parseFlatQueryResultForTableFormat(queryResult)
1047
- : await this.parseFlatQueryResult(queryResult);
1149
+ : await this.parseFlatQueryResult(queryResult, fetchAllFields);
1048
1150
  default:
1049
1151
  break;
1050
1152
  }
@@ -1055,7 +1157,7 @@ export default class TicketsDataProvider {
1055
1157
 
1056
1158
  private async parseDirectLinkedQueryResultForTableFormat(
1057
1159
  queryResult: QueryTree,
1058
- testCaseToRelatedWiMap: Map<number, Set<any>>
1160
+ testCaseToRelatedWiMap: Map<number, Set<any>>,
1059
1161
  ) {
1060
1162
  const { columns, workItemRelations } = queryResult;
1061
1163
 
@@ -1104,17 +1206,17 @@ export default class TicketsDataProvider {
1104
1206
  const allSourcePromises = Array.from(sourceIds).map((id) =>
1105
1207
  this.limit(() => {
1106
1208
  const relation = workItemRelations.find(
1107
- (r) => (!r.source && r.target.id === id) || r.source?.id === id
1209
+ (r) => (!r.source && r.target.id === id) || r.source?.id === id,
1108
1210
  );
1109
1211
  return this.fetchWIForQueryResult(relation, columnsToShowMap, columnSourceMap, true);
1110
- })
1212
+ }),
1111
1213
  );
1112
1214
 
1113
1215
  const allTargetPromises = Array.from(targetIds).map((id) =>
1114
1216
  this.limit(() => {
1115
1217
  const relation = workItemRelations.find((r) => r.target?.id === id);
1116
1218
  return this.fetchWIForQueryResult(relation, columnsToShowMap, columnTargetsMap, true);
1117
- })
1219
+ }),
1118
1220
  );
1119
1221
 
1120
1222
  // Wait for all fetches to complete in parallel (with concurrency control)
@@ -1188,7 +1290,7 @@ export default class TicketsDataProvider {
1188
1290
  private mapTestCaseToRelatedItem(
1189
1291
  sourceWi: any,
1190
1292
  targetWi: any,
1191
- testCaseToRelatedItemMap: Map<number, Set<any>>
1293
+ testCaseToRelatedItemMap: Map<number, Set<any>>,
1192
1294
  ) {
1193
1295
  if (sourceWi.fields['System.WorkItemType'] == 'Test Case') {
1194
1296
  if (!testCaseToRelatedItemMap.has(sourceWi.id)) {
@@ -1230,7 +1332,7 @@ export default class TicketsDataProvider {
1230
1332
  const wiSet: Set<any> = new Set();
1231
1333
  if (workItems) {
1232
1334
  const fetchPromises = workItems.map((workItem) =>
1233
- this.limit(() => this.fetchWIForQueryResult(workItem, columnsToShowMap, fieldsToIncludeMap, false))
1335
+ this.limit(() => this.fetchWIForQueryResult(workItem, columnsToShowMap, fieldsToIncludeMap, false)),
1234
1336
  );
1235
1337
 
1236
1338
  const fetchedWorkItems = await Promise.all(fetchPromises);
@@ -1244,7 +1346,7 @@ export default class TicketsDataProvider {
1244
1346
  };
1245
1347
  }
1246
1348
 
1247
- private async parseTreeQueryResult(queryResult: QueryTree) {
1349
+ private async parseTreeQueryResult(queryResult: QueryTree, fetchAllFields: boolean = false) {
1248
1350
  const { workItemRelations } = queryResult;
1249
1351
  if (!workItemRelations) return null;
1250
1352
 
@@ -1258,11 +1360,11 @@ export default class TicketsDataProvider {
1258
1360
  // This ensures nodes with non-hierarchy links are also available
1259
1361
  for (const rel of workItemRelations) {
1260
1362
  const t = rel.target;
1261
- if (!allItems[t.id]) await this.initTreeQueryResultItem(t, allItems);
1363
+ if (!allItems[t.id]) await this.initTreeQueryResultItem(t, allItems, fetchAllFields);
1262
1364
 
1263
1365
  // Also initialize source nodes if they exist
1264
1366
  if (rel.source && !allItems[rel.source.id]) {
1265
- await this.initTreeQueryResultItem(rel.source, allItems);
1367
+ await this.initTreeQueryResultItem(rel.source, allItems, fetchAllFields);
1266
1368
  }
1267
1369
 
1268
1370
  if (rel.rel === null && rel.source === null) {
@@ -1273,7 +1375,7 @@ export default class TicketsDataProvider {
1273
1375
  }
1274
1376
  }
1275
1377
  logger.debug(
1276
- `parseTreeQueryResult: Found ${rootOrder.length} roots, ${Object.keys(allItems).length} total nodes`
1378
+ `parseTreeQueryResult: Found ${rootOrder.length} roots, ${Object.keys(allItems).length} total nodes`,
1277
1379
  );
1278
1380
 
1279
1381
  // Attach only forward hierarchy edges; dedupe children by id per parent
@@ -1293,11 +1395,11 @@ export default class TicketsDataProvider {
1293
1395
  // Nodes should already be initialized, but double-check
1294
1396
  if (!allItems[parentId]) {
1295
1397
  logger.warn(`Parent ${parentId} not found, initializing now`);
1296
- await this.initTreeQueryResultItem(rel.source, allItems);
1398
+ await this.initTreeQueryResultItem(rel.source, allItems, fetchAllFields);
1297
1399
  }
1298
1400
  if (!allItems[childId]) {
1299
1401
  logger.warn(`Child ${childId} not found, initializing now`);
1300
- await this.initTreeQueryResultItem(rel.target, allItems);
1402
+ await this.initTreeQueryResultItem(rel.target, allItems, fetchAllFields);
1301
1403
  }
1302
1404
 
1303
1405
  const parent = allItems[parentId];
@@ -1313,13 +1415,13 @@ export default class TicketsDataProvider {
1313
1415
  }
1314
1416
  }
1315
1417
  logger.debug(
1316
- `parseTreeQueryResult: ${hierarchyCount} hierarchy links, ${skippedNonHierarchy} non-hierarchy links skipped`
1418
+ `parseTreeQueryResult: ${hierarchyCount} hierarchy links, ${skippedNonHierarchy} non-hierarchy links skipped`,
1317
1419
  );
1318
1420
 
1319
1421
  // Return roots in original order, excluding those that became children
1320
1422
  const roots = rootOrder.filter((id) => rootSet.has(id)).map((id) => allItems[id]);
1321
1423
  logger.debug(
1322
- `parseTreeQueryResult: Returning ${roots.length} roots with ${Object.keys(allItems).length} total items`
1424
+ `parseTreeQueryResult: Returning ${roots.length} roots with ${Object.keys(allItems).length} total items`,
1323
1425
  );
1324
1426
 
1325
1427
  // Optional: clean helper sets
@@ -1332,32 +1434,42 @@ export default class TicketsDataProvider {
1332
1434
  };
1333
1435
  }
1334
1436
 
1335
- private async initTreeQueryResultItem(item: any, allItems: any) {
1336
- const urlWi = `${item.url}?fields=System.Description,System.Title,Microsoft.VSTS.TCM.ReproSteps,Microsoft.VSTS.CMMI.Symptom`;
1437
+ private async initTreeQueryResultItem(item: any, allItems: any, fetchAllFields: boolean = false) {
1438
+ const urlWi = fetchAllFields ? `${item.url}` : `${item.url}?fields=${WI_DEFAULT_FIELDS}`;
1337
1439
  const wi = await TFSServices.getItemContent(urlWi, this.token);
1440
+ const fields = wi?.fields || {};
1338
1441
  // need to fetch the WI with only the the title, the web URL and the description
1339
1442
  allItems[item.id] = {
1340
1443
  id: item.id,
1341
- title: wi.fields['System.Title'] || '',
1342
- description: wi.fields['Microsoft.VSTS.CMMI.Symptom'] ?? wi.fields['System.Description'] ?? '',
1444
+ title: fields['System.Title'] || '',
1445
+ description: fields['Microsoft.VSTS.CMMI.Symptom'] ?? fields['System.Description'] ?? '',
1343
1446
  htmlUrl: wi._links.html.href,
1447
+ fields,
1448
+ workItemType: fields['System.WorkItemType'] || '',
1344
1449
  children: [],
1345
1450
  };
1346
1451
  }
1347
1452
 
1348
- private async initFlatQueryResultItem(item: any, workItemMap: Map<number, any>) {
1349
- const urlWi = `${item.url}?fields=System.Description,System.Title,Microsoft.VSTS.TCM.ReproSteps,Microsoft.VSTS.CMMI.Symptom`;
1453
+ private async initFlatQueryResultItem(
1454
+ item: any,
1455
+ workItemMap: Map<number, any>,
1456
+ fetchAllFields: boolean = false,
1457
+ ) {
1458
+ const urlWi = fetchAllFields ? `${item.url}` : `${item.url}?fields=${WI_DEFAULT_FIELDS}`;
1350
1459
  const wi = await TFSServices.getItemContent(urlWi, this.token);
1460
+ const fields = wi?.fields || {};
1351
1461
  // need to fetch the WI with only the the title, the web URL and the description
1352
1462
  workItemMap.set(item.id, {
1353
1463
  id: item.id,
1354
- title: wi.fields['System.Title'] || '',
1355
- description: wi.fields['Microsoft.VSTS.CMMI.Symptom'] ?? wi.fields['System.Description'] ?? '',
1464
+ title: fields['System.Title'] || '',
1465
+ description: fields['Microsoft.VSTS.CMMI.Symptom'] ?? fields['System.Description'] ?? '',
1356
1466
  htmlUrl: wi._links.html.href,
1467
+ fields,
1468
+ workItemType: fields['System.WorkItemType'] || '',
1357
1469
  });
1358
1470
  }
1359
1471
 
1360
- private async parseFlatQueryResult(queryResult: QueryTree) {
1472
+ private async parseFlatQueryResult(queryResult: QueryTree, fetchAllFields: boolean = false) {
1361
1473
  const { workItems } = queryResult;
1362
1474
  if (!workItems) {
1363
1475
  logger.warn(`No work items were found for this requested query`);
@@ -1368,7 +1480,7 @@ export default class TicketsDataProvider {
1368
1480
  const workItemsResultMap: Map<number, any> = new Map();
1369
1481
  for (const wi of workItems) {
1370
1482
  if (!workItemsResultMap.has(wi.id)) {
1371
- await this.initFlatQueryResultItem(wi, workItemsResultMap);
1483
+ await this.initFlatQueryResultItem(wi, workItemsResultMap, fetchAllFields);
1372
1484
  }
1373
1485
  }
1374
1486
  return [...workItemsResultMap.values()];
@@ -1381,7 +1493,7 @@ export default class TicketsDataProvider {
1381
1493
  receivedObject: any,
1382
1494
  columnMap: Map<string, string>,
1383
1495
  resultedRefNameMap: Map<string, string>,
1384
- isRelation: boolean
1496
+ isRelation: boolean,
1385
1497
  ) {
1386
1498
  const url = isRelation ? `${receivedObject.target.url}` : `${receivedObject.url}`;
1387
1499
  const wi: any = await TFSServices.getItemContent(url, this.token);
@@ -1414,7 +1526,7 @@ export default class TicketsDataProvider {
1414
1526
  if (modeledResult.queryType == 'tree') {
1415
1527
  let levelResults: Array<Workitem> = Helper.LevelBuilder(
1416
1528
  modeledResult,
1417
- modeledResult.workItems[0].fields[0].value
1529
+ modeledResult.workItems[0].fields[0].value,
1418
1530
  );
1419
1531
  return levelResults;
1420
1532
  }
@@ -1442,6 +1554,597 @@ export default class TicketsDataProvider {
1442
1554
  return await this.GetQueryResultsByWiqlHref(wiql.href, project);
1443
1555
  }
1444
1556
 
1557
+ private normalizeHistoricalAsOf(value: string): string {
1558
+ const raw = String(value || '').trim();
1559
+ if (!raw) {
1560
+ throw new Error('asOf date-time is required');
1561
+ }
1562
+ const parsed = new Date(raw);
1563
+ if (Number.isNaN(parsed.getTime())) {
1564
+ throw new Error(`Invalid date-time value: ${raw}`);
1565
+ }
1566
+ return parsed.toISOString();
1567
+ }
1568
+
1569
+ private normalizeHistoricalCompareValue(value: unknown): string {
1570
+ if (value == null) {
1571
+ return '';
1572
+ }
1573
+ if (typeof value === 'string') {
1574
+ return value.replace(/\s+/g, ' ').trim();
1575
+ }
1576
+ if (Array.isArray(value)) {
1577
+ return value
1578
+ .map((item) => this.normalizeHistoricalCompareValue(item))
1579
+ .filter((item) => item !== '')
1580
+ .sort((a, b) => a.localeCompare(b))
1581
+ .join('; ');
1582
+ }
1583
+ if (typeof value === 'object') {
1584
+ const fallback = value as Record<string, unknown>;
1585
+ if (typeof fallback.displayName === 'string') {
1586
+ return this.normalizeHistoricalCompareValue(fallback.displayName);
1587
+ }
1588
+ if (typeof fallback.name === 'string') {
1589
+ return this.normalizeHistoricalCompareValue(fallback.name);
1590
+ }
1591
+ return this.normalizeHistoricalCompareValue(JSON.stringify(value));
1592
+ }
1593
+ return String(value).trim();
1594
+ }
1595
+
1596
+ private normalizeTestPhaseValue(value: unknown): string {
1597
+ const rendered = this.normalizeHistoricalCompareValue(value);
1598
+ if (!rendered) {
1599
+ return '';
1600
+ }
1601
+ const parts = rendered
1602
+ .split(/[;,]/)
1603
+ .map((part) => part.trim())
1604
+ .filter((part) => part.length > 0);
1605
+ if (parts.length <= 1) {
1606
+ return rendered;
1607
+ }
1608
+ const unique = Array.from(new Set(parts));
1609
+ unique.sort((a, b) => a.localeCompare(b));
1610
+ return unique.join('; ');
1611
+ }
1612
+
1613
+ private isTestCaseType(workItemType: string): boolean {
1614
+ const normalized = String(workItemType || '').trim().toLowerCase();
1615
+ return normalized === 'test case' || normalized === 'testcase';
1616
+ }
1617
+
1618
+ private toHistoricalRevision(value: unknown): number | null {
1619
+ const n = Number(value);
1620
+ if (!Number.isFinite(n)) {
1621
+ return null;
1622
+ }
1623
+ return n;
1624
+ }
1625
+
1626
+ private extractHistoricalWorkItemIds(queryResult: any): number[] {
1627
+ const ids = new Set<number>();
1628
+
1629
+ const pushId = (candidate: unknown) => {
1630
+ const n = Number(candidate);
1631
+ if (Number.isFinite(n)) {
1632
+ ids.add(n);
1633
+ }
1634
+ };
1635
+
1636
+ const workItems = Array.isArray(queryResult?.workItems) ? queryResult.workItems : [];
1637
+ for (const workItem of workItems) {
1638
+ pushId(workItem?.id);
1639
+ }
1640
+
1641
+ const workItemRelations = Array.isArray(queryResult?.workItemRelations)
1642
+ ? queryResult.workItemRelations
1643
+ : [];
1644
+ for (const relation of workItemRelations) {
1645
+ pushId(relation?.source?.id);
1646
+ pushId(relation?.target?.id);
1647
+ }
1648
+
1649
+ return Array.from(ids.values()).sort((a, b) => a - b);
1650
+ }
1651
+
1652
+ private appendAsOfToWiql(wiql: string, asOfIso: string): string {
1653
+ const raw = String(wiql || '').trim();
1654
+ if (!raw) {
1655
+ return '';
1656
+ }
1657
+ const withoutAsOf = raw.replace(/\s+ASOF\s+'[^']*'/i, '');
1658
+ return `${withoutAsOf} ASOF '${asOfIso}'`;
1659
+ }
1660
+
1661
+ private appendApiVersion(url: string, apiVersion: string | null): string {
1662
+ if (!apiVersion) {
1663
+ return url;
1664
+ }
1665
+ const separator = url.includes('?') ? '&' : '?';
1666
+ return `${url}${separator}api-version=${encodeURIComponent(apiVersion)}`;
1667
+ }
1668
+
1669
+ private shouldRetryHistoricalWithLowerVersion(error: any): boolean {
1670
+ const status = Number(error?.response?.status || 0);
1671
+ if (status === 401 || status === 403) {
1672
+ return false;
1673
+ }
1674
+ if (status >= 500) {
1675
+ return true;
1676
+ }
1677
+ if ([400, 404, 405, 406, 410].includes(status)) {
1678
+ return true;
1679
+ }
1680
+ const message = String(error?.response?.data?.message || error?.message || '');
1681
+ return /api[- ]?version/i.test(message);
1682
+ }
1683
+
1684
+ private async withHistoricalApiVersionFallback<T>(
1685
+ operation: string,
1686
+ task: (apiVersion: string | null) => Promise<T>,
1687
+ ): Promise<{ apiVersion: string | null; result: T }> {
1688
+ let lastError: any = null;
1689
+ for (const apiVersion of HISTORICAL_WIT_API_VERSIONS) {
1690
+ try {
1691
+ const result = await task(apiVersion);
1692
+ return { apiVersion, result };
1693
+ } catch (error: any) {
1694
+ lastError = error;
1695
+ const status = Number(error?.response?.status || 0);
1696
+ const apiVersionLabel = apiVersion || 'default';
1697
+ logger.warn(
1698
+ `[${operation}] failed with api-version=${apiVersionLabel}${status ? ` (status ${status})` : ''}`,
1699
+ );
1700
+ if (
1701
+ !this.shouldRetryHistoricalWithLowerVersion(error) ||
1702
+ apiVersion === HISTORICAL_WIT_API_VERSIONS[HISTORICAL_WIT_API_VERSIONS.length - 1]
1703
+ ) {
1704
+ throw error;
1705
+ }
1706
+ }
1707
+ }
1708
+ throw lastError || new Error(`[${operation}] Failed to execute historical request`);
1709
+ }
1710
+
1711
+ private chunkHistoricalWorkItemIds(ids: number[]): number[][] {
1712
+ const chunks: number[][] = [];
1713
+ for (let i = 0; i < ids.length; i += HISTORICAL_BATCH_MAX_IDS) {
1714
+ chunks.push(ids.slice(i, i + HISTORICAL_BATCH_MAX_IDS));
1715
+ }
1716
+ return chunks;
1717
+ }
1718
+
1719
+ private normalizeHistoricalQueryPath(path: string): string {
1720
+ const rawPath = String(path || '').trim();
1721
+ const normalizedRoot = rawPath.toLowerCase();
1722
+ if (
1723
+ rawPath === '' ||
1724
+ normalizedRoot === 'shared' ||
1725
+ normalizedRoot === 'shared queries'
1726
+ ) {
1727
+ return 'Shared%20Queries';
1728
+ }
1729
+
1730
+ const segments = rawPath
1731
+ .replace(/\\/g, '/')
1732
+ .split('/')
1733
+ .filter((segment) => segment.trim() !== '')
1734
+ .map((segment) => {
1735
+ try {
1736
+ return encodeURIComponent(decodeURIComponent(segment));
1737
+ } catch (error) {
1738
+ return encodeURIComponent(segment);
1739
+ }
1740
+ });
1741
+ return segments.join('/');
1742
+ }
1743
+
1744
+ private normalizeHistoricalQueryRoot(root: any): any {
1745
+ if (!root) return root;
1746
+ if (Array.isArray(root?.children) || typeof root?.isFolder === 'boolean') {
1747
+ return root;
1748
+ }
1749
+ if (Array.isArray(root?.value)) {
1750
+ return {
1751
+ id: 'root',
1752
+ name: 'Shared Queries',
1753
+ isFolder: true,
1754
+ children: root.value,
1755
+ };
1756
+ }
1757
+ return root;
1758
+ }
1759
+
1760
+ private async fetchHistoricalWorkItemsBatch(
1761
+ project: string,
1762
+ ids: number[],
1763
+ asOf: string,
1764
+ apiVersion: string | null,
1765
+ ): Promise<any[]> {
1766
+ const workItemsBatchUrl = this.appendApiVersion(
1767
+ `${this.orgUrl}${project}/_apis/wit/workitemsbatch`,
1768
+ apiVersion,
1769
+ );
1770
+ const idChunks = this.chunkHistoricalWorkItemIds(ids);
1771
+ try {
1772
+ const batchResponses = await Promise.all(
1773
+ idChunks.map((idChunk) =>
1774
+ this.limit(() =>
1775
+ TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1776
+ ids: idChunk,
1777
+ asOf,
1778
+ $expand: 'Relations',
1779
+ fields: HISTORICAL_WORK_ITEM_FIELDS,
1780
+ }),
1781
+ ),
1782
+ ),
1783
+ );
1784
+ return batchResponses.flatMap((batch) => (Array.isArray(batch?.value) ? batch.value : []));
1785
+ } catch (error: any) {
1786
+ logger.warn(
1787
+ `[historical-workitems] workitemsbatch failed${
1788
+ apiVersion ? ` (api-version=${apiVersion})` : ''
1789
+ }, falling back to per-item retrieval: ${error?.message || error}`,
1790
+ );
1791
+ const asOfParam = encodeURIComponent(asOf);
1792
+ const responses = await Promise.all(
1793
+ ids.map((id) =>
1794
+ this.limit(() => {
1795
+ const itemUrl = this.appendApiVersion(
1796
+ `${this.orgUrl}${project}/_apis/wit/workitems/${id}?$expand=Relations&asOf=${asOfParam}`,
1797
+ apiVersion,
1798
+ );
1799
+ return TFSServices.getItemContent(itemUrl, this.token);
1800
+ }),
1801
+ ),
1802
+ );
1803
+ return responses.filter((item) => item && typeof item === 'object');
1804
+ }
1805
+ }
1806
+
1807
+ private async executeHistoricalQueryAtAsOf(
1808
+ project: string,
1809
+ queryId: string,
1810
+ asOfIso: string,
1811
+ ): Promise<{ queryDefinition: any; queryResult: any; apiVersion: string | null }> {
1812
+ const { apiVersion, result } = await this.withHistoricalApiVersionFallback(
1813
+ 'historical-query-execution',
1814
+ async (resolvedApiVersion) => {
1815
+ const queryDefUrl = this.appendApiVersion(
1816
+ `${this.orgUrl}${project}/_apis/wit/queries/${encodeURIComponent(queryId)}?$expand=all`,
1817
+ resolvedApiVersion,
1818
+ );
1819
+ const queryDefinition = await TFSServices.getItemContent(queryDefUrl, this.token);
1820
+
1821
+ const wiqlText = String(queryDefinition?.wiql || '').trim();
1822
+ let queryResult: any = null;
1823
+ try {
1824
+ if (!wiqlText) {
1825
+ throw new Error(`Could not resolve WIQL text for query ${queryId}`);
1826
+ }
1827
+ const wiqlWithAsOf = this.appendAsOfToWiql(wiqlText, asOfIso);
1828
+ if (!wiqlWithAsOf) {
1829
+ throw new Error(`Could not build WIQL for historical query ${queryId}`);
1830
+ }
1831
+ const executeUrl = this.appendApiVersion(
1832
+ `${this.orgUrl}${project}/_apis/wit/wiql?$top=2147483646&timePrecision=true`,
1833
+ resolvedApiVersion,
1834
+ );
1835
+ queryResult = await TFSServices.getItemContent(executeUrl, this.token, 'post', {
1836
+ query: wiqlWithAsOf,
1837
+ });
1838
+ } catch (inlineWiqlError: any) {
1839
+ logger.warn(
1840
+ `[historical-query-execution] inline WIQL failed for query ${queryId}${
1841
+ resolvedApiVersion ? ` (api-version=${resolvedApiVersion})` : ''
1842
+ }, trying WIQL-by-id fallback: ${inlineWiqlError?.message || inlineWiqlError}`,
1843
+ );
1844
+ const executeByIdUrl = this.appendApiVersion(
1845
+ `${this.orgUrl}${project}/_apis/wit/wiql/${encodeURIComponent(
1846
+ queryId,
1847
+ )}?$top=2147483646&timePrecision=true&asOf=${encodeURIComponent(asOfIso)}`,
1848
+ resolvedApiVersion,
1849
+ );
1850
+ queryResult = await TFSServices.getItemContent(executeByIdUrl, this.token);
1851
+ }
1852
+ return { queryDefinition, queryResult };
1853
+ },
1854
+ );
1855
+ return { ...result, apiVersion };
1856
+ }
1857
+
1858
+ private toHistoricalWorkItemSnapshot(project: string, workItem: any): HistoricalWorkItemSnapshot {
1859
+ const fields = (workItem?.fields || {}) as Record<string, unknown>;
1860
+ const id = Number(workItem?.id || fields['System.Id'] || 0);
1861
+ const workItemType = this.normalizeHistoricalCompareValue(fields['System.WorkItemType']);
1862
+ const testPhaseRaw =
1863
+ fields['Elisra.TestPhase'] ??
1864
+ fields['Custom.TestPhase'] ??
1865
+ fields['Elisra.Testphase'] ??
1866
+ fields['Custom.Testphase'];
1867
+ const relatedLinkCount = Array.isArray(workItem?.relations) ? workItem.relations.length : 0;
1868
+
1869
+ return {
1870
+ id,
1871
+ workItemType,
1872
+ title: this.normalizeHistoricalCompareValue(fields['System.Title']),
1873
+ state: this.normalizeHistoricalCompareValue(fields['System.State']),
1874
+ areaPath: this.normalizeHistoricalCompareValue(fields['System.AreaPath']),
1875
+ iterationPath: this.normalizeHistoricalCompareValue(fields['System.IterationPath']),
1876
+ versionId: this.toHistoricalRevision(workItem?.rev ?? fields['System.Rev']),
1877
+ versionTimestamp: this.normalizeHistoricalCompareValue(fields['System.ChangedDate']),
1878
+ description: this.normalizeHistoricalCompareValue(fields['System.Description']),
1879
+ steps: this.normalizeHistoricalCompareValue(fields['Microsoft.VSTS.TCM.Steps']),
1880
+ testPhase: this.normalizeTestPhaseValue(testPhaseRaw),
1881
+ relatedLinkCount,
1882
+ workItemUrl: `${this.orgUrl}${project}/_workitems/edit/${id}`,
1883
+ };
1884
+ }
1885
+
1886
+ private async getHistoricalSnapshot(
1887
+ project: string,
1888
+ queryId: string,
1889
+ asOfInput: string,
1890
+ ): Promise<HistoricalSnapshotResult> {
1891
+ const asOf = this.normalizeHistoricalAsOf(asOfInput);
1892
+ const { queryDefinition, queryResult, apiVersion } = await this.executeHistoricalQueryAtAsOf(
1893
+ project,
1894
+ queryId,
1895
+ asOf,
1896
+ );
1897
+ const ids = this.extractHistoricalWorkItemIds(queryResult);
1898
+
1899
+ if (ids.length === 0) {
1900
+ return {
1901
+ queryId,
1902
+ queryName: String(queryDefinition?.name || queryId),
1903
+ asOf,
1904
+ total: 0,
1905
+ rows: [],
1906
+ snapshotMap: new Map<number, HistoricalWorkItemSnapshot>(),
1907
+ };
1908
+ }
1909
+
1910
+ const values = await this.fetchHistoricalWorkItemsBatch(project, ids, asOf, apiVersion);
1911
+ const rows = values
1912
+ .map((workItem: any) => this.toHistoricalWorkItemSnapshot(project, workItem))
1913
+ .sort((a: HistoricalWorkItemSnapshot, b: HistoricalWorkItemSnapshot) => a.id - b.id);
1914
+ const snapshotMap = new Map<number, HistoricalWorkItemSnapshot>();
1915
+ for (const row of rows) {
1916
+ snapshotMap.set(row.id, row);
1917
+ }
1918
+
1919
+ return {
1920
+ queryId,
1921
+ queryName: String(queryDefinition?.name || queryId),
1922
+ asOf,
1923
+ total: rows.length,
1924
+ rows,
1925
+ snapshotMap,
1926
+ };
1927
+ }
1928
+
1929
+ private collectHistoricalQueries(root: any, parentPath = ''): HistoricalQueryListItem[] {
1930
+ const items: HistoricalQueryListItem[] = [];
1931
+ if (!root) {
1932
+ return items;
1933
+ }
1934
+
1935
+ const name = String(root?.name || '').trim();
1936
+ const currentPath = parentPath && name ? `${parentPath}/${name}` : name || parentPath;
1937
+
1938
+ if (!root?.isFolder) {
1939
+ const id = String(root?.id || '').trim();
1940
+ if (id) {
1941
+ items.push({
1942
+ id,
1943
+ queryName: name || id,
1944
+ path: parentPath || 'Shared Queries',
1945
+ });
1946
+ }
1947
+ return items;
1948
+ }
1949
+
1950
+ const children = Array.isArray(root?.children) ? root.children : [];
1951
+ for (const child of children) {
1952
+ items.push(...this.collectHistoricalQueries(child, currentPath || 'Shared Queries'));
1953
+ }
1954
+ return items;
1955
+ }
1956
+
1957
+ /**
1958
+ * Returns a flat list of shared queries for historical/as-of execution.
1959
+ */
1960
+ async GetHistoricalQueries(project: string, path: string = 'shared'): Promise<HistoricalQueryListItem[]> {
1961
+ const normalizedPath = this.normalizeHistoricalQueryPath(path);
1962
+ // Azure DevOps WIT query tree endpoint enforces $depth range 0..2.
1963
+ const depth = 2;
1964
+ const { result: root } = await this.withHistoricalApiVersionFallback('historical-queries-list', (apiVersion) => {
1965
+ const url = this.appendApiVersion(
1966
+ `${this.orgUrl}${project}/_apis/wit/queries/${normalizedPath}?$depth=${depth}&$expand=all`,
1967
+ apiVersion,
1968
+ );
1969
+ return TFSServices.getItemContent(url, this.token);
1970
+ });
1971
+ const normalizedRoot = this.normalizeHistoricalQueryRoot(root);
1972
+ const items = this.collectHistoricalQueries(normalizedRoot).filter((query) => query.id !== '');
1973
+ items.sort((a, b) => {
1974
+ const byPath = a.path.localeCompare(b.path);
1975
+ if (byPath !== 0) return byPath;
1976
+ return a.queryName.localeCompare(b.queryName);
1977
+ });
1978
+ return items;
1979
+ }
1980
+
1981
+ /**
1982
+ * Runs a shared query as-of a specific date-time and returns a flat work-item table snapshot.
1983
+ */
1984
+ async GetHistoricalQueryResults(queryId: string, project: string, asOfInput: string): Promise<any> {
1985
+ const snapshot = await this.getHistoricalSnapshot(project, queryId, asOfInput);
1986
+ return {
1987
+ queryId: snapshot.queryId,
1988
+ queryName: snapshot.queryName,
1989
+ asOf: snapshot.asOf,
1990
+ total: snapshot.total,
1991
+ rows: snapshot.rows.map((row) => ({
1992
+ id: row.id,
1993
+ workItemType: row.workItemType,
1994
+ title: row.title,
1995
+ state: row.state,
1996
+ areaPath: row.areaPath,
1997
+ iterationPath: row.iterationPath,
1998
+ versionId: row.versionId,
1999
+ versionTimestamp: row.versionTimestamp,
2000
+ workItemUrl: row.workItemUrl,
2001
+ })),
2002
+ };
2003
+ }
2004
+
2005
+ /**
2006
+ * Compares a shared query between two date-time baselines using the feature's noise-control fields.
2007
+ */
2008
+ async CompareHistoricalQueryResults(
2009
+ queryId: string,
2010
+ project: string,
2011
+ baselineAsOfInput: string,
2012
+ compareToAsOfInput: string,
2013
+ ): Promise<any> {
2014
+ const [baseline, compareTo] = await Promise.all([
2015
+ this.getHistoricalSnapshot(project, queryId, baselineAsOfInput),
2016
+ this.getHistoricalSnapshot(project, queryId, compareToAsOfInput),
2017
+ ]);
2018
+
2019
+ const allIds = new Set<number>([
2020
+ ...Array.from(baseline.snapshotMap.keys()),
2021
+ ...Array.from(compareTo.snapshotMap.keys()),
2022
+ ]);
2023
+ const sortedIds = Array.from(allIds.values()).sort((a, b) => a - b);
2024
+
2025
+ const rows = sortedIds.map((id) => {
2026
+ const baselineRow = baseline.snapshotMap.get(id) || null;
2027
+ const compareToRow = compareTo.snapshotMap.get(id) || null;
2028
+ const workItemType = compareToRow?.workItemType || baselineRow?.workItemType || '';
2029
+ const isTestCase = this.isTestCaseType(workItemType);
2030
+
2031
+ if (baselineRow && !compareToRow) {
2032
+ return {
2033
+ id,
2034
+ workItemType,
2035
+ title: baselineRow.title,
2036
+ baselineRevisionId: baselineRow.versionId,
2037
+ compareToRevisionId: null,
2038
+ compareStatus: 'Deleted',
2039
+ changedFields: [],
2040
+ differences: [],
2041
+ workItemUrl: baselineRow.workItemUrl,
2042
+ };
2043
+ }
2044
+
2045
+ if (!baselineRow && compareToRow) {
2046
+ return {
2047
+ id,
2048
+ workItemType,
2049
+ title: compareToRow.title,
2050
+ baselineRevisionId: null,
2051
+ compareToRevisionId: compareToRow.versionId,
2052
+ compareStatus: 'Added',
2053
+ changedFields: [],
2054
+ differences: [],
2055
+ workItemUrl: compareToRow.workItemUrl,
2056
+ };
2057
+ }
2058
+
2059
+ const safeBaseline = baselineRow as HistoricalWorkItemSnapshot;
2060
+ const safeCompareTo = compareToRow as HistoricalWorkItemSnapshot;
2061
+ const changedFields: string[] = [];
2062
+
2063
+ if (safeBaseline.description !== safeCompareTo.description) {
2064
+ changedFields.push('Description');
2065
+ }
2066
+ if (safeBaseline.title !== safeCompareTo.title) {
2067
+ changedFields.push('Title');
2068
+ }
2069
+ if (safeBaseline.state !== safeCompareTo.state) {
2070
+ changedFields.push('State');
2071
+ }
2072
+ if (isTestCase && safeBaseline.steps !== safeCompareTo.steps) {
2073
+ changedFields.push('Steps');
2074
+ }
2075
+ if (safeBaseline.testPhase !== safeCompareTo.testPhase) {
2076
+ changedFields.push('Test Phase');
2077
+ }
2078
+ if (isTestCase && safeBaseline.relatedLinkCount !== safeCompareTo.relatedLinkCount) {
2079
+ changedFields.push('Related Link Count');
2080
+ }
2081
+
2082
+ const differences = changedFields.map((field) => {
2083
+ switch (field) {
2084
+ case 'Description':
2085
+ return { field, baseline: safeBaseline.description, compareTo: safeCompareTo.description };
2086
+ case 'Title':
2087
+ return { field, baseline: safeBaseline.title, compareTo: safeCompareTo.title };
2088
+ case 'State':
2089
+ return { field, baseline: safeBaseline.state, compareTo: safeCompareTo.state };
2090
+ case 'Steps':
2091
+ return { field, baseline: safeBaseline.steps, compareTo: safeCompareTo.steps };
2092
+ case 'Test Phase':
2093
+ return { field, baseline: safeBaseline.testPhase, compareTo: safeCompareTo.testPhase };
2094
+ case 'Related Link Count':
2095
+ return {
2096
+ field,
2097
+ baseline: String(safeBaseline.relatedLinkCount),
2098
+ compareTo: String(safeCompareTo.relatedLinkCount),
2099
+ };
2100
+ default:
2101
+ return { field, baseline: '', compareTo: '' };
2102
+ }
2103
+ });
2104
+
2105
+ return {
2106
+ id,
2107
+ workItemType,
2108
+ title: safeCompareTo.title || safeBaseline.title,
2109
+ baselineRevisionId: safeBaseline.versionId,
2110
+ compareToRevisionId: safeCompareTo.versionId,
2111
+ compareStatus: changedFields.length > 0 ? 'Changed' : 'No changes',
2112
+ changedFields,
2113
+ differences,
2114
+ workItemUrl: safeCompareTo.workItemUrl || safeBaseline.workItemUrl,
2115
+ };
2116
+ });
2117
+
2118
+ const summary = rows.reduce(
2119
+ (acc, row) => {
2120
+ if (row.compareStatus === 'Added') acc.addedCount += 1;
2121
+ else if (row.compareStatus === 'Deleted') acc.deletedCount += 1;
2122
+ else if (row.compareStatus === 'Changed') acc.changedCount += 1;
2123
+ else acc.noChangeCount += 1;
2124
+ return acc;
2125
+ },
2126
+ { addedCount: 0, deletedCount: 0, changedCount: 0, noChangeCount: 0 },
2127
+ );
2128
+
2129
+ return {
2130
+ queryId,
2131
+ queryName: compareTo.queryName || baseline.queryName || queryId,
2132
+ baseline: {
2133
+ asOf: baseline.asOf,
2134
+ total: baseline.total,
2135
+ },
2136
+ compareTo: {
2137
+ asOf: compareTo.asOf,
2138
+ total: compareTo.total,
2139
+ },
2140
+ summary: {
2141
+ ...summary,
2142
+ updatedCount: summary.changedCount,
2143
+ },
2144
+ rows,
2145
+ };
2146
+ }
2147
+
1445
2148
  async PopulateWorkItemsByIds(workItemsArray: any[] = [], projectName: string = ''): Promise<any[]> {
1446
2149
  let url = `${this.orgUrl}${projectName}/_apis/wit/workitemsbatch`;
1447
2150
  let res: any[] = [];
@@ -1585,7 +2288,7 @@ export default class TicketsDataProvider {
1585
2288
 
1586
2289
  async CreateNewWorkItem(projectName: string, wiBody: any, wiType: string, byPass: boolean) {
1587
2290
  let url = `${this.orgUrl}${projectName}/_apis/wit/workitems/$${wiType}?bypassRules=${String(
1588
- byPass
2291
+ byPass,
1589
2292
  ).toString()}`;
1590
2293
  return TFSServices.getItemContent(url, this.token, 'POST', wiBody, {
1591
2294
  'Content-Type': 'application/json-patch+json',
@@ -1605,7 +2308,7 @@ export default class TicketsDataProvider {
1605
2308
  attachment.downloadUrl = `${relation.url}/${relation.attributes.name}`;
1606
2309
  attachmentList.push(attachment);
1607
2310
  }
1608
- })
2311
+ }),
1609
2312
  );
1610
2313
  return attachmentList;
1611
2314
  } catch (e) {
@@ -1626,7 +2329,7 @@ export default class TicketsDataProvider {
1626
2329
  async UpdateWorkItem(projectName: string, wiBody: any, workItemId: number, byPass: boolean) {
1627
2330
  let res: any;
1628
2331
  let url: string = `${this.orgUrl}${projectName}/_apis/wit/workitems/${workItemId}?bypassRules=${String(
1629
- byPass
2332
+ byPass,
1630
2333
  ).toString()}`;
1631
2334
  res = await TFSServices.getItemContent(url, this.token, 'patch', wiBody, {
1632
2335
  'Content-Type': 'application/json-patch+json',
@@ -1680,7 +2383,7 @@ export default class TicketsDataProvider {
1680
2383
 
1681
2384
  // Process children recursively
1682
2385
  const childResults = await Promise.all(
1683
- rootQuery.children.map((child: any) => this.structureAllQueryPath(child, rootQuery.id))
2386
+ rootQuery.children.map((child: any) => this.structureAllQueryPath(child, rootQuery.id)),
1684
2387
  );
1685
2388
 
1686
2389
  // Build tree
@@ -1718,8 +2421,8 @@ export default class TicketsDataProvider {
1718
2421
  } catch (err: any) {
1719
2422
  logger.error(
1720
2423
  `Error occurred while constructing the query list ${err.message} with query ${JSON.stringify(
1721
- rootQuery
1722
- )}`
2424
+ rootQuery,
2425
+ )}`,
1723
2426
  );
1724
2427
  throw err;
1725
2428
  }
@@ -1757,7 +2460,7 @@ export default class TicketsDataProvider {
1757
2460
  includeTreeQueries: boolean = false,
1758
2461
  excludedFolderNames: string[] = [],
1759
2462
  includeFlatQueries: boolean = false,
1760
- workItemTypeCache?: Map<string, string | null>
2463
+ workItemTypeCache?: Map<string, string | null>,
1761
2464
  ): Promise<any> {
1762
2465
  try {
1763
2466
  // Per-invocation cache for ID->WorkItemType lookups; avoids global state and is safe for concurrency.
@@ -1765,7 +2468,7 @@ export default class TicketsDataProvider {
1765
2468
  const shouldSkipFolder =
1766
2469
  rootQuery?.isFolder &&
1767
2470
  excludedFolderNames.some(
1768
- (folderName) => folderName.toLowerCase() === (rootQuery.name || '').toLowerCase()
2471
+ (folderName) => folderName.toLowerCase() === (rootQuery.name || '').toLowerCase(),
1769
2472
  );
1770
2473
 
1771
2474
  if (shouldSkipFolder) {
@@ -1789,7 +2492,7 @@ export default class TicketsDataProvider {
1789
2492
  rootQuery,
1790
2493
  wiql,
1791
2494
  allTypes,
1792
- typeCache
2495
+ typeCache,
1793
2496
  );
1794
2497
 
1795
2498
  if (typesOk) {
@@ -1816,7 +2519,7 @@ export default class TicketsDataProvider {
1816
2519
  wiql,
1817
2520
  sources,
1818
2521
  targets,
1819
- typeCache
2522
+ typeCache,
1820
2523
  );
1821
2524
  }
1822
2525
  const matchesReverse = await this.matchesSourceTargetConditionAsync(
@@ -1824,7 +2527,7 @@ export default class TicketsDataProvider {
1824
2527
  wiql,
1825
2528
  targets,
1826
2529
  sources,
1827
- typeCache
2530
+ typeCache,
1828
2531
  );
1829
2532
 
1830
2533
  if (matchesForward) {
@@ -1871,7 +2574,7 @@ export default class TicketsDataProvider {
1871
2574
  includeTreeQueries,
1872
2575
  excludedFolderNames,
1873
2576
  includeFlatQueries,
1874
- typeCache
2577
+ typeCache,
1875
2578
  );
1876
2579
  }
1877
2580
 
@@ -1889,9 +2592,9 @@ export default class TicketsDataProvider {
1889
2592
  includeTreeQueries,
1890
2593
  excludedFolderNames,
1891
2594
  includeFlatQueries,
1892
- typeCache
1893
- )
1894
- )
2595
+ typeCache,
2596
+ ),
2597
+ ),
1895
2598
  );
1896
2599
 
1897
2600
  // Build tree1
@@ -1924,8 +2627,8 @@ export default class TicketsDataProvider {
1924
2627
  } catch (err: any) {
1925
2628
  logger.error(
1926
2629
  `Error occurred while constructing the query list ${err.message} with query ${JSON.stringify(
1927
- rootQuery
1928
- )}`
2630
+ rootQuery,
2631
+ )}`,
1929
2632
  );
1930
2633
  logger.error(`Error stack ${err.message}`);
1931
2634
  }
@@ -1941,7 +2644,7 @@ export default class TicketsDataProvider {
1941
2644
  private matchesAreaPathCondition(
1942
2645
  wiql: string,
1943
2646
  sourceAreaFilter: string,
1944
- targetAreaFilter: string
2647
+ targetAreaFilter: string,
1945
2648
  ): boolean {
1946
2649
  const wiqlLower = (wiql || '').toLowerCase();
1947
2650
  const srcFilter = (sourceAreaFilter || '').toLowerCase().trim();
@@ -1979,7 +2682,7 @@ export default class TicketsDataProvider {
1979
2682
  wiql: string,
1980
2683
  source: string[],
1981
2684
  target: string[],
1982
- workItemTypeCache: Map<string, string | null>
2685
+ workItemTypeCache: Map<string, string | null>,
1983
2686
  ): Promise<boolean> {
1984
2687
  /**
1985
2688
  * Matches source+target constraints for link WIQL.
@@ -1992,7 +2695,7 @@ export default class TicketsDataProvider {
1992
2695
  wiql,
1993
2696
  'Source',
1994
2697
  source,
1995
- workItemTypeCache
2698
+ workItemTypeCache,
1996
2699
  );
1997
2700
  if (!sourceOk) return false;
1998
2701
  const targetOk = await this.isLinkSideAllowedByTypeOrId(
@@ -2000,7 +2703,7 @@ export default class TicketsDataProvider {
2000
2703
  wiql,
2001
2704
  'Target',
2002
2705
  target,
2003
- workItemTypeCache
2706
+ workItemTypeCache,
2004
2707
  );
2005
2708
  return targetOk;
2006
2709
  }
@@ -2009,7 +2712,7 @@ export default class TicketsDataProvider {
2009
2712
  queryNode: any,
2010
2713
  wiql: string,
2011
2714
  allowedTypes: string[],
2012
- workItemTypeCache: Map<string, string | null>
2715
+ workItemTypeCache: Map<string, string | null>,
2013
2716
  ): Promise<boolean> {
2014
2717
  return this.isFlatQueryAllowedByTypeOrId(queryNode, wiql, allowedTypes, workItemTypeCache);
2015
2718
  }
@@ -2149,7 +2852,7 @@ export default class TicketsDataProvider {
2149
2852
  wiql: string,
2150
2853
  context: 'Source' | 'Target',
2151
2854
  allowedTypes: string[],
2152
- workItemTypeCache: Map<string, string | null>
2855
+ workItemTypeCache: Map<string, string | null>,
2153
2856
  ): Promise<boolean> {
2154
2857
  const wiqlStr = String(wiql || '');
2155
2858
 
@@ -2157,7 +2860,7 @@ export default class TicketsDataProvider {
2157
2860
  if (!allowedTypes || allowedTypes.length === 0) {
2158
2861
  const fieldPresenceRegex = new RegExp(
2159
2862
  `${this.buildWiqlFieldPattern(context, 'System.WorkItemType')}`,
2160
- 'i'
2863
+ 'i',
2161
2864
  );
2162
2865
  return fieldPresenceRegex.test(wiqlStr);
2163
2866
  }
@@ -2197,7 +2900,7 @@ export default class TicketsDataProvider {
2197
2900
  queryNode: any,
2198
2901
  wiql: string,
2199
2902
  allowedTypes: string[],
2200
- workItemTypeCache: Map<string, string | null>
2903
+ workItemTypeCache: Map<string, string | null>,
2201
2904
  ): Promise<boolean> {
2202
2905
  const wiqlStr = String(wiql || '');
2203
2906
 
@@ -2240,7 +2943,7 @@ export default class TicketsDataProvider {
2240
2943
  private async getWorkItemTypeById(
2241
2944
  project: string,
2242
2945
  id: string,
2243
- workItemTypeCache: Map<string, string | null>
2946
+ workItemTypeCache: Map<string, string | null>,
2244
2947
  ): Promise<string | null> {
2245
2948
  const cacheKey = `${project}:${id}`;
2246
2949
  if (workItemTypeCache.has(cacheKey)) {
@@ -2298,7 +3001,7 @@ export default class TicketsDataProvider {
2298
3001
  private filterFieldsByColumns(
2299
3002
  item: any,
2300
3003
  columnsToFilterMap: Map<string, string>,
2301
- resultedRefNameMap: Map<string, string>
3004
+ resultedRefNameMap: Map<string, string>,
2302
3005
  ) {
2303
3006
  try {
2304
3007
  const parsedFields: any = {};
@@ -2345,14 +3048,14 @@ export default class TicketsDataProvider {
2345
3048
  this.token,
2346
3049
  'get',
2347
3050
  {},
2348
- { Accept: accept }
3051
+ { Accept: accept },
2349
3052
  );
2350
3053
  if (iconDataUrl) break;
2351
3054
  } catch (error: any) {
2352
3055
  logger.warn(
2353
3056
  `Failed to download icon (${accept}) for work item type ${
2354
3057
  workItemType?.name ?? 'unknown'
2355
- }: ${error?.message || error}`
3058
+ }: ${error?.message || error}`,
2356
3059
  );
2357
3060
  }
2358
3061
  }
@@ -2361,8 +3064,8 @@ export default class TicketsDataProvider {
2361
3064
  const iconPayload = workItemType.icon
2362
3065
  ? { ...workItemType.icon, dataUrl: iconDataUrl }
2363
3066
  : iconDataUrl
2364
- ? { id: undefined, url: undefined, dataUrl: iconDataUrl }
2365
- : workItemType.icon;
3067
+ ? { id: undefined, url: undefined, dataUrl: iconDataUrl }
3068
+ : workItemType.icon;
2366
3069
 
2367
3070
  return {
2368
3071
  name: workItemType.name,
@@ -2371,7 +3074,7 @@ export default class TicketsDataProvider {
2371
3074
  icon: iconPayload,
2372
3075
  states: workItemType.states,
2373
3076
  };
2374
- })
3077
+ }),
2375
3078
  );
2376
3079
 
2377
3080
  return workItemTypesWithIcons;
@@ -2543,7 +3246,7 @@ export default class TicketsDataProvider {
2543
3246
  });
2544
3247
 
2545
3248
  logger.debug(
2546
- `Categorized ${workItemIds.length} work items into ${Object.keys(finalCategories).length} categories`
3249
+ `Categorized ${workItemIds.length} work items into ${Object.keys(finalCategories).length} categories`,
2547
3250
  );
2548
3251
 
2549
3252
  return {