@elisra-devops/docgen-data-provider 1.61.0 → 1.63.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.
@@ -8,11 +8,30 @@ import { Column } from '../models/tfs-data';
8
8
  import { value } from '../models/tfs-data';
9
9
 
10
10
  import logger from '../utils/logger';
11
+ const pLimit = require('p-limit');
12
+
13
+ type FallbackFetchOutcome = {
14
+ result: any;
15
+ usedFolder: any;
16
+ };
17
+
18
+ type DocTypeBranchConfig = {
19
+ id: string;
20
+ label: string;
21
+ // Candidate folder names (case-insensitive). If none resolve, fallback starts from `fallbackStart`.
22
+ folderNames?: string[];
23
+ fetcher: (folder: any) => Promise<any>;
24
+ // Optional validator to determine whether the fetch produced any usable queries.
25
+ validator?: (result: any) => boolean;
26
+ // Optional explicit starting folder for the fallback chain.
27
+ fallbackStart?: any;
28
+ };
11
29
 
12
30
  export default class TicketsDataProvider {
13
31
  orgUrl: string = '';
14
32
  token: string = '';
15
33
  queriesList: Array<any> = new Array<any>();
34
+ private limit = pLimit(10);
16
35
 
17
36
  constructor(orgUrl: string, token: string) {
18
37
  this.orgUrl = orgUrl;
@@ -118,22 +137,197 @@ export default class TicketsDataProvider {
118
137
  else url = `${this.orgUrl}${project}/_apis/wit/queries/${path}?$depth=2&$expand=all`;
119
138
  let queries: any = await TFSServices.getItemContent(url, this.token);
120
139
  logger.debug(`doctype: ${docType}`);
121
- switch (docType?.toLowerCase()) {
122
- case 'std':
123
- const reqTestQueries = await this.fetchLinkedReqTestQueries(queries, false);
124
- const linkedMomQueries = await this.fetchLinkedMomQueries(queries);
140
+ const normalizedDocType = (docType || '').toLowerCase();
141
+ const queriesWithChildren = await this.ensureQueryChildren(queries);
142
+
143
+ switch (normalizedDocType) {
144
+ case 'std': {
145
+ const { root: stdRoot, found: stdRootFound } = await this.getDocTypeRoot(
146
+ queriesWithChildren,
147
+ 'std'
148
+ );
149
+ logger.debug(`[GetSharedQueries][std] using ${stdRootFound ? 'dedicated folder' : 'root queries'}`);
150
+ // Each branch describes the dedicated folder names, the fetch routine, and how to validate results.
151
+ const stdBranches = await this.fetchDocTypeBranches(queriesWithChildren, stdRoot, [
152
+ {
153
+ id: 'reqToTest',
154
+ label: '[GetSharedQueries][std][req-to-test]',
155
+ folderNames: [
156
+ 'requirement - test',
157
+ 'requirement to test case',
158
+ 'requirement to test',
159
+ 'req to test',
160
+ ],
161
+ fetcher: (folder: any) => this.fetchLinkedReqTestQueries(folder, false),
162
+ validator: (result: any) => this.hasAnyQueryTree(result?.reqTestTree),
163
+ },
164
+ {
165
+ id: 'testToReq',
166
+ label: '[GetSharedQueries][std][test-to-req]',
167
+ folderNames: [
168
+ 'test - requirement',
169
+ 'test to requirement',
170
+ 'test case to requirement',
171
+ 'test to req',
172
+ ],
173
+ fetcher: (folder: any) => this.fetchLinkedReqTestQueries(folder, true),
174
+ validator: (result: any) => this.hasAnyQueryTree(result?.testReqTree),
175
+ },
176
+ {
177
+ id: 'mom',
178
+ label: '[GetSharedQueries][std][mom]',
179
+ folderNames: ['linked mom', 'mom'],
180
+ fetcher: (folder: any) => this.fetchLinkedMomQueries(folder),
181
+ validator: (result: any) => this.hasAnyQueryTree(result?.linkedMomTree),
182
+ },
183
+ ]);
184
+
185
+ const reqToTestResult = stdBranches['reqToTest'];
186
+ const testToReqResult = stdBranches['testToReq'];
187
+ const momResult = stdBranches['mom'];
188
+
189
+ const reqTestQueries = {
190
+ reqTestTree: reqToTestResult?.result?.reqTestTree ?? null,
191
+ testReqTree: testToReqResult?.result?.testReqTree ?? reqToTestResult?.result?.testReqTree ?? null,
192
+ };
193
+
194
+ const linkedMomQueries = {
195
+ linkedMomTree: momResult?.result?.linkedMomTree ?? null,
196
+ };
197
+
125
198
  return { reqTestQueries, linkedMomQueries };
126
- case 'str':
127
- const reqTestTrees = await this.fetchLinkedReqTestQueries(queries, false);
128
- const openPcrTestTrees = await this.fetchLinkedOpenPcrTestQueries(queries, false);
199
+ }
200
+ case 'str': {
201
+ const { root: strRoot, found: strRootFound } = await this.getDocTypeRoot(
202
+ queriesWithChildren,
203
+ 'str'
204
+ );
205
+ logger.debug(`[GetSharedQueries][str] using ${strRootFound ? 'dedicated folder' : 'root queries'}`);
206
+ const strBranches = await this.fetchDocTypeBranches(queriesWithChildren, strRoot, [
207
+ {
208
+ id: 'reqToTest',
209
+ label: '[GetSharedQueries][str][req-to-test]',
210
+ folderNames: [
211
+ 'requirement - test',
212
+ 'requirement to test case',
213
+ 'requirement to test',
214
+ 'req to test',
215
+ ],
216
+ fetcher: (folder: any) => this.fetchLinkedReqTestQueries(folder, false),
217
+ validator: (result: any) => this.hasAnyQueryTree(result?.reqTestTree),
218
+ },
219
+ {
220
+ id: 'testToReq',
221
+ label: '[GetSharedQueries][str][test-to-req]',
222
+ folderNames: [
223
+ 'test - requirement',
224
+ 'test to requirement',
225
+ 'test case to requirement',
226
+ 'test to req',
227
+ ],
228
+ fetcher: (folder: any) => this.fetchLinkedReqTestQueries(folder, true),
229
+ validator: (result: any) => this.hasAnyQueryTree(result?.testReqTree),
230
+ },
231
+ {
232
+ id: 'openPcrToTest',
233
+ label: '[GetSharedQueries][str][open-pcr-to-test]',
234
+ folderNames: ['open pcr to test case', 'open pcr to test', 'open pcr - test', 'open pcr'],
235
+ fetcher: (folder: any) => this.fetchLinkedOpenPcrTestQueries(folder, false),
236
+ validator: (result: any) => this.hasAnyQueryTree(result?.OpenPcrToTestTree),
237
+ },
238
+ {
239
+ id: 'testToOpenPcr',
240
+ label: '[GetSharedQueries][str][test-to-open-pcr]',
241
+ folderNames: ['test case to open pcr', 'test to open pcr', 'test - open pcr', 'open pcr'],
242
+ fetcher: (folder: any) => this.fetchLinkedOpenPcrTestQueries(folder, true),
243
+ validator: (result: any) => this.hasAnyQueryTree(result?.TestToOpenPcrTree),
244
+ },
245
+ ]);
246
+
247
+ const strReqToTest = strBranches['reqToTest'];
248
+ const strTestToReq = strBranches['testToReq'];
249
+ const strOpenPcrToTest = strBranches['openPcrToTest'];
250
+ const strTestToOpenPcr = strBranches['testToOpenPcr'];
251
+
252
+ const reqTestTrees = {
253
+ reqTestTree: strReqToTest?.result?.reqTestTree ?? null,
254
+ testReqTree: strTestToReq?.result?.testReqTree ?? strReqToTest?.result?.testReqTree ?? null,
255
+ };
256
+
257
+ const openPcrTestTrees = {
258
+ OpenPcrToTestTree: strOpenPcrToTest?.result?.OpenPcrToTestTree ?? null,
259
+ TestToOpenPcrTree:
260
+ strTestToOpenPcr?.result?.TestToOpenPcrTree ??
261
+ strOpenPcrToTest?.result?.TestToOpenPcrTree ??
262
+ null,
263
+ };
264
+
129
265
  return { reqTestTrees, openPcrTestTrees };
130
- case 'test-reporter':
131
- const testAssociatedTree = await this.fetchTestReporterQueries(queries);
132
- return { testAssociatedTree };
266
+ }
267
+ case 'test-reporter': {
268
+ const { root: testReporterRoot, found: testReporterFound } = await this.getDocTypeRoot(
269
+ queriesWithChildren,
270
+ 'test-reporter'
271
+ );
272
+ logger.debug(
273
+ `[GetSharedQueries][test-reporter] using ${
274
+ testReporterFound ? 'dedicated folder' : 'root queries'
275
+ }`
276
+ );
277
+ const testReporterBranches = await this.fetchDocTypeBranches(
278
+ queriesWithChildren,
279
+ testReporterRoot,
280
+ [
281
+ {
282
+ id: 'testReporter',
283
+ label: '[GetSharedQueries][test-reporter]',
284
+ folderNames: ['test reporter', 'test-reporter'],
285
+ fetcher: (folder: any) => this.fetchTestReporterQueries(folder),
286
+ validator: (result: any) => this.hasAnyQueryTree(result?.testAssociatedTree),
287
+ },
288
+ ]
289
+ );
290
+ const testReporterFetch = testReporterBranches['testReporter'];
291
+ return testReporterFetch?.result ?? { testAssociatedTree: null };
292
+ }
133
293
  case 'srs':
134
- return await this.fetchSrsQueries(queries);
135
- case 'svd':
136
- return await this.fetchAnyQueries(queries);
294
+ return await this.fetchSrsQueries(queriesWithChildren);
295
+ case 'svd': {
296
+ const { root: svdRoot, found } = await this.getDocTypeRoot(queriesWithChildren, 'svd');
297
+ if (!found) {
298
+ logger.debug('[GetSharedQueries][svd] dedicated folder not found, using fallback tree');
299
+ }
300
+ const svdBranches = await this.fetchDocTypeBranches(queriesWithChildren, svdRoot, [
301
+ {
302
+ id: 'systemOverview',
303
+ label: '[GetSharedQueries][svd][system-overview]',
304
+ folderNames: ['system overview'],
305
+ fetcher: async (folder: any) => {
306
+ const { tree1 } = await this.structureAllQueryPath(folder);
307
+ return tree1;
308
+ },
309
+ validator: (result: any) => !!result,
310
+ },
311
+ {
312
+ id: 'knownBugs',
313
+ label: '[GetSharedQueries][svd][known-bugs]',
314
+ folderNames: ['known bugs', 'known bug'],
315
+ fetcher: async (folder: any) => {
316
+ const { tree2 } = await this.structureAllQueryPath(folder);
317
+ return tree2;
318
+ },
319
+ validator: (result: any) => !!result,
320
+ },
321
+ ]);
322
+
323
+ const systemOverviewFetch = svdBranches['systemOverview'];
324
+ const knownBugsFetch = svdBranches['knownBugs'];
325
+
326
+ return {
327
+ systemOverviewQueryTree: systemOverviewFetch?.result ?? null,
328
+ knownBugsQueryTree: knownBugsFetch?.result ?? null,
329
+ };
330
+ }
137
331
  default:
138
332
  break;
139
333
  }
@@ -229,6 +423,38 @@ export default class TicketsDataProvider {
229
423
  return { linkedMomTree };
230
424
  }
231
425
 
426
+ private hasAnyQueryTree(result: any): boolean {
427
+ const inspect = (value: any): boolean => {
428
+ if (!value) {
429
+ return false;
430
+ }
431
+
432
+ if (Array.isArray(value)) {
433
+ return value.some(inspect);
434
+ }
435
+
436
+ if (typeof value === 'object') {
437
+ if (value.isValidQuery || value.wiql || value.queryType) {
438
+ return true;
439
+ }
440
+
441
+ if ('roots' in value && Array.isArray(value.roots) && value.roots.length > 0) {
442
+ return true;
443
+ }
444
+
445
+ if ('children' in value && Array.isArray(value.children) && value.children.length > 0) {
446
+ return true;
447
+ }
448
+
449
+ return Object.values(value).some(inspect);
450
+ }
451
+
452
+ return false;
453
+ };
454
+
455
+ return inspect(result);
456
+ }
457
+
232
458
  /**
233
459
  * Fetches and structures linked queries related to open PCR (Problem Change Request) tests.
234
460
  *
@@ -269,13 +495,6 @@ export default class TicketsDataProvider {
269
495
  return { testAssociatedTree };
270
496
  }
271
497
 
272
- private async fetchAnyQueries(queries: any) {
273
- const { tree1: systemOverviewQueryTree, tree2: knownBugsQueryTree } = await this.structureAllQueryPath(
274
- queries
275
- );
276
- return { systemOverviewQueryTree, knownBugsQueryTree };
277
- }
278
-
279
498
  private async fetchSystemRequirementQueries(queries: any, excludedFolderNames: string[] = []) {
280
499
  const { tree1: systemRequirementsQueryTree } = await this.structureFetchedQueries(
281
500
  queries,
@@ -427,6 +646,278 @@ export default class TicketsDataProvider {
427
646
  );
428
647
  }
429
648
 
649
+ /**
650
+ * Performs a breadth-first walk starting at `parent` to locate the nearest folder whose
651
+ * name matches any of the provided candidates (case-insensitive). Exact matches win; if none
652
+ * are found the first partial match encountered is returned. When no candidates are located,
653
+ * the method yields `null`.
654
+ */
655
+ private async findChildFolderByPossibleNames(parent: any, possibleNames: string[]): Promise<any | null> {
656
+ if (!parent || !possibleNames?.length) {
657
+ return null;
658
+ }
659
+
660
+ const normalizedNames = possibleNames.map((name) => name.toLowerCase());
661
+
662
+ const isMatch = (candidate: string, value: string) => value === candidate;
663
+ const isPartialMatch = (candidate: string, value: string) => value.includes(candidate);
664
+
665
+ const tryMatch = (folder: any, matcher: (candidate: string, value: string) => boolean) => {
666
+ const folderName = (folder?.name || '').toLowerCase();
667
+ return normalizedNames.some((candidate) => matcher(candidate, folderName));
668
+ };
669
+
670
+ const parentWithChildren = await this.ensureQueryChildren(parent);
671
+ if (!parentWithChildren?.children?.length) {
672
+ return null;
673
+ }
674
+
675
+ const queue: any[] = [];
676
+ const visited = new Set<string>();
677
+ let partialCandidate: any = null;
678
+
679
+ // Seed the queue with direct children so we prefer closer matches before walking deeper.
680
+ for (const child of parentWithChildren.children) {
681
+ if (!child?.isFolder) {
682
+ continue;
683
+ }
684
+ const childId = child.id ?? `${child.name}-${Math.random()}`;
685
+ queue.push(child);
686
+ visited.add(childId);
687
+ }
688
+
689
+ const considerFolder = async (folder: any): Promise<any | null> => {
690
+ if (tryMatch(folder, isMatch)) {
691
+ return await this.ensureQueryChildren(folder);
692
+ }
693
+
694
+ if (!partialCandidate && tryMatch(folder, isPartialMatch)) {
695
+ partialCandidate = await this.ensureQueryChildren(folder);
696
+ }
697
+
698
+ return null;
699
+ };
700
+
701
+ for (const child of queue) {
702
+ const match = await considerFolder(child);
703
+ if (match) {
704
+ return match;
705
+ }
706
+ }
707
+
708
+ while (queue.length > 0) {
709
+ const current = queue.shift();
710
+ if (!current) {
711
+ continue;
712
+ }
713
+
714
+ const currentWithChildren = await this.ensureQueryChildren(current);
715
+ if (!currentWithChildren) {
716
+ continue;
717
+ }
718
+
719
+ const match = await considerFolder(currentWithChildren);
720
+ if (match) {
721
+ return match;
722
+ }
723
+
724
+ // Breadth-first expansion so we climb the hierarchy gradually.
725
+ if (currentWithChildren.children?.length) {
726
+ for (const child of currentWithChildren.children) {
727
+ if (!child?.isFolder) {
728
+ continue;
729
+ }
730
+ const childId = child.id ?? `${child.name}-${Math.random()}`;
731
+ if (!visited.has(childId)) {
732
+ visited.add(childId);
733
+ queue.push(child);
734
+ }
735
+ }
736
+ }
737
+ }
738
+
739
+ return partialCandidate;
740
+ }
741
+
742
+ /**
743
+ * Executes `fetcher` against `startingFolder` and, if the validator deems the result empty,
744
+ * climbs ancestor folders toward `rootQueries` until a satisfactory result is produced.
745
+ * The first successful folder short-circuits the search; otherwise the final attempt is
746
+ * returned to preserve legacy behavior.
747
+ */
748
+ private async fetchWithAncestorFallback(
749
+ rootQueries: any,
750
+ startingFolder: any,
751
+ fetcher: (folder: any) => Promise<any>,
752
+ logContext: string,
753
+ validator?: (result: any) => boolean
754
+ ): Promise<{ result: any; usedFolder: any }> {
755
+ const rootWithChildren = await this.ensureQueryChildren(rootQueries);
756
+ const candidates = await this.buildFallbackChain(rootWithChildren, startingFolder);
757
+ const evaluate = validator ?? ((res: any) => this.hasAnyQueryTree(res));
758
+
759
+ let lastResult: any = null;
760
+ let lastFolder: any = startingFolder ?? rootWithChildren;
761
+
762
+ for (const candidate of candidates) {
763
+ const enrichedCandidate = await this.ensureQueryChildren(candidate);
764
+ const candidateName = enrichedCandidate?.name ?? '<root>';
765
+ logger.debug(`${logContext} trying folder: ${candidateName}`);
766
+ lastResult = await fetcher(enrichedCandidate);
767
+ lastFolder = enrichedCandidate;
768
+ if (evaluate(lastResult)) {
769
+ logger.debug(`${logContext} using folder: ${candidateName}`);
770
+ return { result: lastResult, usedFolder: enrichedCandidate };
771
+ }
772
+ logger.debug(`${logContext} folder ${candidateName} produced no results, ascending`);
773
+ }
774
+
775
+ logger.debug(`${logContext} no folders yielded results, returning last attempt`);
776
+ return { result: lastResult, usedFolder: lastFolder };
777
+ }
778
+
779
+ /**
780
+ * Applies `fetchWithAncestorFallback` to each configured branch, resolving dedicated folders
781
+ * when available and emitting a map keyed by branch id. Each outcome includes both the
782
+ * resulting payload and the specific folder that satisfied the fallback chain.
783
+ */
784
+ private async fetchDocTypeBranches(
785
+ queriesWithChildren: any,
786
+ docRoot: any,
787
+ branches: DocTypeBranchConfig[]
788
+ ): Promise<Record<string, FallbackFetchOutcome>> {
789
+ const results: Record<string, FallbackFetchOutcome> = {};
790
+ const effectiveDocRoot = docRoot ?? queriesWithChildren;
791
+
792
+ for (const branch of branches) {
793
+ const fallbackStart = branch.fallbackStart ?? effectiveDocRoot;
794
+ let startingFolder = fallbackStart;
795
+ let startingName = startingFolder?.name ?? '<root>';
796
+
797
+ // Attempt to locate a more specific child folder, falling back to the provided root if absent.
798
+ if (branch.folderNames?.length && effectiveDocRoot) {
799
+ const resolvedFolder = await this.findChildFolderByPossibleNames(
800
+ effectiveDocRoot,
801
+ branch.folderNames
802
+ );
803
+ if (resolvedFolder) {
804
+ startingFolder = resolvedFolder;
805
+ startingName = resolvedFolder?.name ?? '<root>';
806
+ }
807
+ }
808
+
809
+ logger.debug(`${branch.label} starting folder: ${startingName}`);
810
+
811
+ const fetchOutcome = await this.fetchWithAncestorFallback(
812
+ queriesWithChildren,
813
+ startingFolder,
814
+ branch.fetcher,
815
+ branch.label,
816
+ branch.validator
817
+ );
818
+
819
+ logger.debug(`${branch.label} final folder: ${fetchOutcome.usedFolder?.name ?? '<root>'}`);
820
+
821
+ results[branch.id] = fetchOutcome;
822
+ }
823
+
824
+ return results;
825
+ }
826
+
827
+ /**
828
+ * Constructs an ordered list of folders to probe during fallback. The sequence starts at
829
+ * `startingFolder` (if provided) and walks upward through ancestors to the root query tree,
830
+ * ensuring no folder id appears twice.
831
+ */
832
+ private async buildFallbackChain(rootQueries: any, startingFolder: any): Promise<any[]> {
833
+ const chain: any[] = [];
834
+ const seen = new Set<string>();
835
+ const pushUnique = (node: any) => {
836
+ if (!node) {
837
+ return;
838
+ }
839
+ const id = node.id ?? '__root__';
840
+ if (seen.has(id)) {
841
+ return;
842
+ }
843
+ seen.add(id);
844
+ chain.push(node);
845
+ };
846
+
847
+ if (startingFolder?.id) {
848
+ const path = await this.findPathToNode(rootQueries, startingFolder.id);
849
+ if (path) {
850
+ for (let i = path.length - 1; i >= 0; i--) {
851
+ pushUnique(path[i]);
852
+ }
853
+ } else {
854
+ pushUnique(startingFolder);
855
+ }
856
+ } else if (startingFolder) {
857
+ pushUnique(startingFolder);
858
+ }
859
+
860
+ pushUnique(rootQueries);
861
+ return chain;
862
+ }
863
+
864
+ /**
865
+ * Recursively searches the query tree for the node with the provided id and returns the
866
+ * path (root → target). Nodes are enriched with children on demand and a visited set guards
867
+ * against cycles within malformed data.
868
+ */
869
+ private async findPathToNode(
870
+ currentNode: any,
871
+ targetId: string,
872
+ visited: Set<string> = new Set<string>()
873
+ ): Promise<any[] | null> {
874
+ if (!currentNode) {
875
+ return null;
876
+ }
877
+
878
+ const currentId = currentNode.id ?? '__root__';
879
+ if (visited.has(currentId)) {
880
+ return null;
881
+ }
882
+ visited.add(currentId);
883
+
884
+ if (currentNode.id === targetId) {
885
+ return [currentNode];
886
+ }
887
+
888
+ const enrichedNode = await this.ensureQueryChildren(currentNode);
889
+ const children = enrichedNode?.children;
890
+ if (!children?.length) {
891
+ return null;
892
+ }
893
+
894
+ for (const child of children) {
895
+ const path = await this.findPathToNode(child, targetId, visited);
896
+ if (path) {
897
+ return [enrichedNode, ...path];
898
+ }
899
+ }
900
+
901
+ return null;
902
+ }
903
+
904
+ private async getDocTypeRoot(
905
+ rootQueries: any,
906
+ docTypeName: string
907
+ ): Promise<{ root: any; found: boolean }> {
908
+ if (!rootQueries) {
909
+ return { root: rootQueries, found: false };
910
+ }
911
+
912
+ const docTypeFolder = await this.findQueryFolderByName(rootQueries, docTypeName);
913
+ if (docTypeFolder) {
914
+ const folderWithChildren = await this.ensureQueryChildren(docTypeFolder);
915
+ return { root: folderWithChildren, found: true };
916
+ }
917
+
918
+ return { root: rootQueries, found: false };
919
+ }
920
+
430
921
  private async ensureQueryChildren(node: any): Promise<any> {
431
922
  if (!node || !node.hasChildren || node.children) {
432
923
  return node;
@@ -505,40 +996,95 @@ export default class TicketsDataProvider {
505
996
  // Initialize maps
506
997
  const sourceTargetsMap: Map<any, any[]> = new Map();
507
998
  const lookupMap: Map<number, any> = new Map();
999
+
508
1000
  if (workItemRelations) {
1001
+ // Step 1: Collect all unique work item IDs that need to be fetched
1002
+ const sourceIds = new Set<number>();
1003
+ const targetIds = new Set<number>();
1004
+
1005
+ for (const relation of workItemRelations) {
1006
+ if (!relation.source) {
1007
+ // Root link - target is actually the source
1008
+ sourceIds.add(relation.target.id);
1009
+ } else {
1010
+ sourceIds.add(relation.source.id);
1011
+ if (relation.target) {
1012
+ targetIds.add(relation.target.id);
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ // Step 2: Fetch all work items in parallel with concurrency limit
1018
+ const allSourcePromises = Array.from(sourceIds).map((id) =>
1019
+ this.limit(() => {
1020
+ const relation = workItemRelations.find(
1021
+ (r) => (!r.source && r.target.id === id) || r.source?.id === id
1022
+ );
1023
+ return this.fetchWIForQueryResult(relation, columnsToShowMap, columnSourceMap, true);
1024
+ })
1025
+ );
1026
+
1027
+ const allTargetPromises = Array.from(targetIds).map((id) =>
1028
+ this.limit(() => {
1029
+ const relation = workItemRelations.find((r) => r.target?.id === id);
1030
+ return this.fetchWIForQueryResult(relation, columnsToShowMap, columnTargetsMap, true);
1031
+ })
1032
+ );
1033
+
1034
+ // Wait for all fetches to complete in parallel (with concurrency control)
1035
+ const [sourceWorkItems, targetWorkItems] = await Promise.all([
1036
+ Promise.all(allSourcePromises),
1037
+ Promise.all(allTargetPromises),
1038
+ ]);
1039
+
1040
+ // Build lookup maps
1041
+ const sourceWorkItemMap = new Map<number, any>();
1042
+ sourceWorkItems.forEach((wi) => {
1043
+ sourceWorkItemMap.set(wi.id, wi);
1044
+ if (!lookupMap.has(wi.id)) {
1045
+ lookupMap.set(wi.id, wi);
1046
+ }
1047
+ });
1048
+
1049
+ const targetWorkItemMap = new Map<number, any>();
1050
+ targetWorkItems.forEach((wi) => {
1051
+ targetWorkItemMap.set(wi.id, wi);
1052
+ if (!lookupMap.has(wi.id)) {
1053
+ lookupMap.set(wi.id, wi);
1054
+ }
1055
+ });
1056
+
1057
+ // Step 3: Build the sourceTargetsMap using the fetched work items
509
1058
  for (const relation of workItemRelations) {
510
- //if relation.Source is null and target has a valid value then the target is the source
511
1059
  if (!relation.source) {
512
1060
  // Root link
513
- const wi: any = await this.fetchWIForQueryResult(relation, columnsToShowMap, columnSourceMap, true);
514
- if (!lookupMap.has(wi.id)) {
1061
+ const wi = sourceWorkItemMap.get(relation.target.id);
1062
+ if (wi && !sourceTargetsMap.has(wi)) {
515
1063
  sourceTargetsMap.set(wi, []);
516
- lookupMap.set(wi.id, wi);
517
1064
  }
518
- continue; // Move to the next relation
1065
+ continue;
519
1066
  }
520
1067
 
521
1068
  if (!relation.target) {
522
1069
  throw new Error('Target relation is missing');
523
1070
  }
524
1071
 
525
- // Get relation source from lookup
526
- const sourceWorkItem = lookupMap.get(relation.source.id);
1072
+ const sourceWorkItem = sourceWorkItemMap.get(relation.source.id);
527
1073
  if (!sourceWorkItem) {
528
1074
  throw new Error('Source relation has no mapping');
529
1075
  }
530
1076
 
531
- const targetWi: any = await this.fetchWIForQueryResult(
532
- relation,
533
- columnsToShowMap,
534
- columnTargetsMap,
535
- true
536
- );
537
- //In case if source is a test case
1077
+ const targetWi = targetWorkItemMap.get(relation.target.id);
1078
+ if (!targetWi) {
1079
+ throw new Error('Target work item not found');
1080
+ }
1081
+
1082
+ // In case if source is a test case
538
1083
  this.mapTestCaseToRelatedItem(sourceWorkItem, targetWi, testCaseToRelatedWiMap);
539
1084
 
540
- //In case of target is a test case
1085
+ // In case of target is a test case
541
1086
  this.mapTestCaseToRelatedItem(targetWi, sourceWorkItem, testCaseToRelatedWiMap);
1087
+
542
1088
  const targets: any = sourceTargetsMap.get(sourceWorkItem) || [];
543
1089
  targets.push(targetWi);
544
1090
  sourceTargetsMap.set(sourceWorkItem, targets);
@@ -594,18 +1140,15 @@ export default class TicketsDataProvider {
594
1140
  }
595
1141
  });
596
1142
 
597
- // Initialize maps
1143
+ // Fetch all work items in parallel with concurrency limit
598
1144
  const wiSet: Set<any> = new Set();
599
1145
  if (workItems) {
600
- for (const workItem of workItems) {
601
- const wi: any = await this.fetchWIForQueryResult(
602
- workItem,
603
- columnsToShowMap,
604
- fieldsToIncludeMap,
605
- false
606
- );
607
- wiSet.add(wi);
608
- }
1146
+ const fetchPromises = workItems.map((workItem) =>
1147
+ this.limit(() => this.fetchWIForQueryResult(workItem, columnsToShowMap, fieldsToIncludeMap, false))
1148
+ );
1149
+
1150
+ const fetchedWorkItems = await Promise.all(fetchPromises);
1151
+ fetchedWorkItems.forEach((wi) => wiSet.add(wi));
609
1152
  }
610
1153
 
611
1154
  columnsToShowMap.clear();
@@ -1528,8 +2071,6 @@ export default class TicketsDataProvider {
1528
2071
  return { categories: {}, totalCount: 0 };
1529
2072
  }
1530
2073
 
1531
- logger.debug(`Found ${workItemIds.length} work items to categorize`);
1532
-
1533
2074
  // Define the mapping from requirement type keys to standard headers
1534
2075
  const typeToHeaderMap: Record<string, string> = {
1535
2076
  Adaptation: 'Adaptation Requirements',
@@ -1583,31 +2124,24 @@ export default class TicketsDataProvider {
1583
2124
  for (const workItemId of workItemIds) {
1584
2125
  try {
1585
2126
  // Fetch full work item with all fields
1586
- const wiUrl = `${this.orgUrl}_apis/wit/workitems/${workItemId}?$expand=All&api-version=6.0`;
2127
+ const wiUrl = `${this.orgUrl}_apis/wit/workitems/${workItemId}?$expand=All`;
1587
2128
  const fullWi = await TFSServices.getItemContent(wiUrl, this.token);
1588
2129
 
1589
2130
  // Check if it's a Requirement work item type
1590
2131
  const workItemType = fullWi.fields['System.WorkItemType'];
1591
- logger.debug(`Work item ${workItemId} type: ${workItemType}`);
1592
2132
  if (workItemType !== 'Requirement') {
1593
- logger.debug(`Skipping work item ${workItemId} - not a Requirement type`);
1594
2133
  continue; // Skip non-requirement work items
1595
2134
  }
1596
2135
 
1597
- // Get the requirement type field (check both possible reference names)
1598
- const rawRequirementType =
1599
- fullWi.fields['Custom.Requirement_Type'] ||
1600
- fullWi.fields['Microsoft.VSTS.CMMI.RequirementType'] ||
1601
- '';
1602
-
1603
- logger.debug(`Work item ${workItemId} requirement type: "${rawRequirementType}"`);
2136
+ // Get the requirement type field
2137
+ const rawRequirementType: string = fullWi.fields['Microsoft.VSTS.CMMI.RequirementType'] || '';
1604
2138
 
1605
2139
  // Normalize and trim the requirement type
1606
2140
  const trimmedType = String(rawRequirementType).trim();
1607
2141
 
1608
- // Map to standard header or use "Other Requirements" as default
1609
- const categoryHeader = trimmedType
1610
- ? typeToHeaderMap[trimmedType] || 'Other Requirements'
2142
+ // Map to the standard category header
2143
+ const categoryHeader = typeToHeaderMap[trimmedType]
2144
+ ? typeToHeaderMap[trimmedType]
1611
2145
  : 'Other Requirements';
1612
2146
 
1613
2147
  // Create the requirement object