@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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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(
|
|
135
|
-
case 'svd':
|
|
136
|
-
|
|
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
|
|
514
|
-
if (!
|
|
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;
|
|
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
|
-
|
|
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
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
//
|
|
1143
|
+
// Fetch all work items in parallel with concurrency limit
|
|
598
1144
|
const wiSet: Set<any> = new Set();
|
|
599
1145
|
if (workItems) {
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
|
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
|
|
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
|
|
1609
|
-
const categoryHeader = trimmedType
|
|
1610
|
-
? typeToHeaderMap[trimmedType]
|
|
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
|