@elisra-devops/docgen-data-provider 1.109.1 → 1.111.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.
@@ -3,6 +3,13 @@ import { TFSServices } from '../helpers/tfs';
3
3
 
4
4
  import logger from '../utils/logger';
5
5
  import GitDataProvider from './GitDataProvider';
6
+ const pLimit = require('p-limit');
7
+ const MAX_DISCOVERY_PAGES = 50;
8
+
9
+ type PreviousDiscoveryResult =
10
+ | { status: 'found'; id: number }
11
+ | { status: 'not_found' }
12
+ | { status: 'failed'; error: unknown };
6
13
 
7
14
  export default class PipelinesDataProvider {
8
15
  orgUrl: string = '';
@@ -14,6 +21,20 @@ export default class PipelinesDataProvider {
14
21
  this.token = token;
15
22
  }
16
23
 
24
+ /**
25
+ * Resolves the previous pipeline run used as the source side of an SVD pipeline range.
26
+ *
27
+ * For regular pipeline ranges, the Builds API is used first because it supports paging,
28
+ * completed/succeeded filters, and branch filtering. Stage-specific discovery keeps the
29
+ * older Pipelines Runs path because stage status must be checked from run details.
30
+ *
31
+ * @param teamProject Azure DevOps project name.
32
+ * @param pipelineId Pipeline/build definition id.
33
+ * @param toPipelineRunId Target run/build id. Candidates must be older than this id.
34
+ * @param targetPipeline Full target pipeline run details, used for repository/branch matching.
35
+ * @param searchPrevPipelineFromDifferentCommit When true, skip candidates from the same commit.
36
+ * @param fromStage Optional stage name. When set, only previous runs with this successful stage match.
37
+ */
17
38
  public async findPreviousPipeline(
18
39
  teamProject: string,
19
40
  pipelineId: string,
@@ -22,8 +43,21 @@ export default class PipelinesDataProvider {
22
43
  searchPrevPipelineFromDifferentCommit: boolean,
23
44
  fromStage: string = ''
24
45
  ) {
46
+ if (!fromStage) {
47
+ const previousBuildId = await this.findPreviousSuccessfulBuild(
48
+ teamProject,
49
+ pipelineId,
50
+ toPipelineRunId,
51
+ targetPipeline,
52
+ searchPrevPipelineFromDifferentCommit
53
+ );
54
+ if (previousBuildId) {
55
+ return previousBuildId;
56
+ }
57
+ }
58
+
25
59
  const pipelineRuns = await this.GetPipelineRunHistory(teamProject, pipelineId);
26
- if (!pipelineRuns.value) {
60
+ if (!pipelineRuns?.value) {
27
61
  return undefined;
28
62
  }
29
63
 
@@ -48,6 +82,338 @@ export default class PipelinesDataProvider {
48
82
  return undefined;
49
83
  }
50
84
 
85
+ /**
86
+ * Finds the previous successful completed build for a definition.
87
+ *
88
+ * Discovery prefers the target run's branch first. If no same-branch candidate exists, it
89
+ * falls back to the same repository on any branch so auto-discovery can still work for
90
+ * sparse branches or customer histories where the previous success is on another branch.
91
+ *
92
+ * @returns Previous build id, or undefined when no valid candidate is found.
93
+ */
94
+ public async findPreviousSuccessfulBuild(
95
+ teamProject: string,
96
+ definitionId: string,
97
+ toBuildId: number,
98
+ targetPipeline: any,
99
+ searchPrevPipelineFromDifferentCommit: boolean
100
+ ): Promise<number | undefined> {
101
+ const targetRepo = this.getPrimaryPipelineRepository(targetPipeline);
102
+ const targetBranch = this.normalizeBranchName(targetRepo?.refName);
103
+
104
+ if (targetBranch) {
105
+ const sameBranchResult = await this.findPreviousSuccessfulBuildPage(
106
+ teamProject,
107
+ definitionId,
108
+ toBuildId,
109
+ targetPipeline,
110
+ searchPrevPipelineFromDifferentCommit,
111
+ targetBranch
112
+ );
113
+ if (sameBranchResult.status === 'failed') {
114
+ throw sameBranchResult.error;
115
+ }
116
+ if (sameBranchResult.status === 'found') {
117
+ return sameBranchResult.id;
118
+ }
119
+ }
120
+
121
+ const anyBranchResult = await this.findPreviousSuccessfulBuildPage(
122
+ teamProject,
123
+ definitionId,
124
+ toBuildId,
125
+ targetPipeline,
126
+ searchPrevPipelineFromDifferentCommit
127
+ );
128
+ if (anyBranchResult.status === 'failed') {
129
+ throw anyBranchResult.error;
130
+ }
131
+ return anyBranchResult.status === 'found' ? anyBranchResult.id : undefined;
132
+ }
133
+
134
+ /**
135
+ * Pages the Builds API until a valid previous successful build is found.
136
+ *
137
+ * The API query already asks for completed/succeeded builds ordered by finish time, but
138
+ * candidates are validated locally as well to protect callers from incomplete API data,
139
+ * mocks, or future response-shape differences.
140
+ */
141
+ private async findPreviousSuccessfulBuildPage(
142
+ teamProject: string,
143
+ definitionId: string,
144
+ toBuildId: number,
145
+ targetPipeline: any,
146
+ searchPrevPipelineFromDifferentCommit: boolean,
147
+ branchName?: string
148
+ ): Promise<PreviousDiscoveryResult> {
149
+ const targetRepo = this.getPrimaryPipelineRepository(targetPipeline);
150
+ let continuationToken: string | undefined = undefined;
151
+ let pageCount = 0;
152
+ do {
153
+ let url = `${this.orgUrl}${teamProject}/_apis/build/builds?definitions=${encodeURIComponent(
154
+ String(definitionId)
155
+ )}&resultFilter=succeeded&statusFilter=completed&queryOrder=finishTimeDescending&$top=200&api-version=6.0`;
156
+ if (branchName) {
157
+ url += `&branchName=${encodeURIComponent(branchName)}`;
158
+ }
159
+ if (continuationToken) {
160
+ url += `&continuationToken=${encodeURIComponent(continuationToken)}`;
161
+ }
162
+
163
+ try {
164
+ const { data, headers } = await TFSServices.getItemContentWithHeaders(
165
+ url,
166
+ this.token,
167
+ 'get',
168
+ null,
169
+ null
170
+ );
171
+ pageCount++;
172
+ const builds: any[] = data?.value || [];
173
+ const match = builds.find((build: any) =>
174
+ this.isMatchingPreviousBuild(
175
+ build,
176
+ targetRepo,
177
+ toBuildId,
178
+ searchPrevPipelineFromDifferentCommit,
179
+ branchName
180
+ )
181
+ );
182
+ if (match?.id) {
183
+ return { status: 'found', id: Number(match.id) };
184
+ }
185
+ continuationToken = this.getContinuationToken(headers);
186
+ if (continuationToken && pageCount >= MAX_DISCOVERY_PAGES) {
187
+ return {
188
+ status: 'failed',
189
+ error: new Error(`Pipeline discovery exceeded ${MAX_DISCOVERY_PAGES} pages`),
190
+ };
191
+ }
192
+ } catch (err: unknown) {
193
+ logger.warn(`Could not fetch previous successful builds: ${this.getErrorMessage(err)}`);
194
+ return { status: 'failed', error: err };
195
+ }
196
+ } while (continuationToken);
197
+
198
+ return { status: 'not_found' };
199
+ }
200
+
201
+ private getContinuationToken(headers: any): string | undefined {
202
+ return headers?.['x-ms-continuationtoken'] || headers?.['x-ms-continuation-token'] || undefined;
203
+ }
204
+
205
+ /**
206
+ * Extracts the primary repository from a pipeline run details response.
207
+ *
208
+ * Newer YAML runs expose repository resources as an array with a `self` entry, while some
209
+ * designer/classic pipeline responses expose the repository under `__designer_repo`.
210
+ */
211
+ private getPrimaryPipelineRepository(pipeline: any): any {
212
+ const repositories = pipeline?.resources?.repositories;
213
+ if (!repositories) return undefined;
214
+ return repositories[0]?.self || repositories.__designer_repo;
215
+ }
216
+
217
+ /**
218
+ * Validates a Builds API candidate against the target run.
219
+ *
220
+ * A candidate must be older than the target, completed successfully, from the same
221
+ * repository, and optionally from the required branch. When the caller asks for a
222
+ * different commit, candidates with the same source version are excluded.
223
+ */
224
+ private isMatchingPreviousBuild(
225
+ build: any,
226
+ targetRepo: any,
227
+ toBuildId: number,
228
+ searchPrevPipelineFromDifferentCommit: boolean,
229
+ requiredBranch?: string
230
+ ): boolean {
231
+ const buildId = Number(build?.id);
232
+ if (!Number.isFinite(buildId) || buildId >= toBuildId) return false;
233
+ if (build?.status && build.status !== 'completed') return false;
234
+ if (build?.result && build.result !== 'succeeded') return false;
235
+
236
+ const buildRepoId = build?.repository?.id;
237
+ const targetRepoId = targetRepo?.repository?.id;
238
+ if (!buildRepoId || !targetRepoId || buildRepoId !== targetRepoId) return false;
239
+
240
+ if (requiredBranch && build?.sourceBranch !== requiredBranch) return false;
241
+
242
+ if (build?.sourceVersion && targetRepo?.version && build.sourceVersion === targetRepo.version) {
243
+ return !searchPrevPipelineFromDifferentCommit;
244
+ }
245
+
246
+ return true;
247
+ }
248
+
249
+ /**
250
+ * Finds the previous successful release/deployment for an SVD release range.
251
+ *
252
+ * Release discovery pages the Release List API and expands environments so candidates can
253
+ * be validated by deployment status. A matching release must be older than the target,
254
+ * active, and have at least one succeeded environment.
255
+ *
256
+ * @returns Previous release id, or undefined when no valid candidate is found.
257
+ */
258
+ public async findPreviousSuccessfulRelease(
259
+ projectName: string,
260
+ definitionId: string,
261
+ toReleaseId: number
262
+ ): Promise<number | undefined> {
263
+ let result = await this.findSuccessfulReleasePage(
264
+ projectName,
265
+ definitionId,
266
+ '7.1',
267
+ (release) => this.isPreviousSuccessfulRelease(release, toReleaseId),
268
+ 'previous'
269
+ );
270
+ if (result.status === 'failed' && this.isUnsupportedApiVersionError(result.error)) {
271
+ result = await this.findSuccessfulReleasePage(
272
+ projectName,
273
+ definitionId,
274
+ '6.0',
275
+ (release) => this.isPreviousSuccessfulRelease(release, toReleaseId),
276
+ 'previous'
277
+ );
278
+ }
279
+ if (result.status === 'failed') {
280
+ throw result.error;
281
+ }
282
+ return result.status === 'found' ? result.id : undefined;
283
+ }
284
+
285
+ /**
286
+ * Finds the latest successful release/deployment for an SVD release range.
287
+ *
288
+ * Used when the release template omits `toReleaseId`. The same candidate rule is used as
289
+ * previous-release discovery: active release with at least one succeeded environment.
290
+ *
291
+ * @returns Latest release id, or undefined when no valid candidate is found.
292
+ */
293
+ public async findLatestSuccessfulRelease(
294
+ projectName: string,
295
+ definitionId: string
296
+ ): Promise<number | undefined> {
297
+ let result = await this.findSuccessfulReleasePage(
298
+ projectName,
299
+ definitionId,
300
+ '7.1',
301
+ (release) => this.isSuccessfulRelease(release),
302
+ 'latest'
303
+ );
304
+ if (result.status === 'failed' && this.isUnsupportedApiVersionError(result.error)) {
305
+ result = await this.findSuccessfulReleasePage(
306
+ projectName,
307
+ definitionId,
308
+ '6.0',
309
+ (release) => this.isSuccessfulRelease(release),
310
+ 'latest'
311
+ );
312
+ }
313
+ if (result.status === 'failed') {
314
+ throw result.error;
315
+ }
316
+ return result.status === 'found' ? result.id : undefined;
317
+ }
318
+
319
+ private async findSuccessfulReleasePage(
320
+ projectName: string,
321
+ definitionId: string,
322
+ apiVersion: string,
323
+ isMatch: (release: any) => boolean,
324
+ discoveryLabel: 'previous' | 'latest'
325
+ ): Promise<PreviousDiscoveryResult> {
326
+ let baseUrl: string = `${this.orgUrl}${projectName}/_apis/release/releases?definitionId=${encodeURIComponent(
327
+ String(definitionId)
328
+ )}&queryOrder=descending&$top=200&$expand=environments&api-version=${apiVersion}`;
329
+ if (baseUrl.startsWith('https://dev.azure.com')) {
330
+ baseUrl = baseUrl.replace('https://dev.azure.com', 'https://vsrm.dev.azure.com');
331
+ }
332
+
333
+ let continuationToken: string | undefined = undefined;
334
+ let pageCount = 0;
335
+ do {
336
+ let url = baseUrl;
337
+ if (continuationToken) {
338
+ url += `&continuationToken=${encodeURIComponent(continuationToken)}`;
339
+ }
340
+
341
+ try {
342
+ const { data, headers } = await TFSServices.getItemContentWithHeaders(
343
+ url,
344
+ this.token,
345
+ 'get',
346
+ null,
347
+ null
348
+ );
349
+ pageCount++;
350
+ const releases: any[] = data?.value || [];
351
+ const match = releases.find(isMatch);
352
+ if (match?.id) {
353
+ return { status: 'found', id: Number(match.id) };
354
+ }
355
+ continuationToken = this.getContinuationToken(headers);
356
+ if (continuationToken && pageCount >= MAX_DISCOVERY_PAGES) {
357
+ return {
358
+ status: 'failed',
359
+ error: new Error(`Release discovery exceeded ${MAX_DISCOVERY_PAGES} pages`),
360
+ };
361
+ }
362
+ } catch (err: unknown) {
363
+ logger.warn(`Could not fetch ${discoveryLabel} successful releases: ${this.getErrorMessage(err)}`);
364
+ return { status: 'failed', error: err };
365
+ }
366
+ } while (continuationToken);
367
+
368
+ return { status: 'not_found' };
369
+ }
370
+
371
+ private isUnsupportedApiVersionError(err: unknown): boolean {
372
+ const error = err as any;
373
+ const responseData = error?.response?.data;
374
+ const message = [
375
+ error?.message,
376
+ responseData?.message,
377
+ typeof responseData === 'string' ? responseData : JSON.stringify(responseData || ''),
378
+ ]
379
+ .join(' ')
380
+ .toLowerCase();
381
+ return (
382
+ message.includes('api-version') &&
383
+ (message.includes('unsupported') ||
384
+ message.includes('not support') ||
385
+ message.includes('not supported'))
386
+ );
387
+ }
388
+
389
+ private getErrorMessage(err: unknown): string {
390
+ return err instanceof Error ? err.message : String(err);
391
+ }
392
+
393
+ /**
394
+ * Validates a release candidate for auto-discovery.
395
+ *
396
+ * A release is considered successful for this SVD range purpose when at least one
397
+ * deployment environment succeeded. This mirrors the existing release artifact flow,
398
+ * where a release may contain multiple environments but still provide usable artifacts.
399
+ */
400
+ private isPreviousSuccessfulRelease(release: any, toReleaseId: number): boolean {
401
+ const releaseId = Number(release?.id);
402
+ if (!Number.isFinite(releaseId) || releaseId >= toReleaseId) return false;
403
+ return this.isSuccessfulRelease(release);
404
+ }
405
+
406
+ private isSuccessfulRelease(release: any): boolean {
407
+ const releaseId = Number(release?.id);
408
+ if (!Number.isFinite(releaseId)) return false;
409
+ if (release?.status && String(release.status).toLowerCase() !== 'active') return false;
410
+
411
+ const environments = Array.isArray(release?.environments) ? release.environments : [];
412
+ return environments.some((environment: any) => {
413
+ return String(environment?.status || '').toLowerCase() === 'succeeded';
414
+ });
415
+ }
416
+
51
417
  /**
52
418
  * Determines if a pipeline run is invalid based on various conditions.
53
419
  *
@@ -483,8 +849,9 @@ export default class PipelinesDataProvider {
483
849
  `getPipelineResourcePipelinesFromObject: resolving ${pipelineEntries.length} pipeline resources`
484
850
  );
485
851
 
852
+ const concurrencyLimit = pLimit(8);
486
853
  await Promise.all(
487
- pipelineEntries.map(async ([resourcePipelineAlias, resource]) => {
854
+ pipelineEntries.map(([resourcePipelineAlias, resource]) => concurrencyLimit(async () => {
488
855
  const resourcePipelineObj = (resource as any)?.pipeline;
489
856
  const pipelineIdCandidate = Number(resourcePipelineObj?.id);
490
857
 
@@ -596,7 +963,7 @@ export default class PipelinesDataProvider {
596
963
  const key = `${resourcePipelineToAdd.teamProject}:${resourcePipelineToAdd.definitionId}:${resourcePipelineToAdd.buildId}:${resourcePipelineToAdd.name}`;
597
964
  if (!resourcePipelinesByKey.has(key)) resourcePipelinesByKey.set(key, resourcePipelineToAdd);
598
965
  }
599
- })
966
+ }))
600
967
  );
601
968
  return [...resourcePipelinesByKey.values()];
602
969
  }
@@ -757,6 +1124,12 @@ export default class PipelinesDataProvider {
757
1124
  }
758
1125
  }
759
1126
 
1127
+ /**
1128
+ * Fetches the first page of release history for a definition.
1129
+ *
1130
+ * Kept for backward compatibility with existing consumers. New range/discovery flows
1131
+ * should prefer GetAllReleaseHistory when full release history is required.
1132
+ */
760
1133
  async GetReleaseHistory(projectName: string, definitionId: string) {
761
1134
  let url: string = `${this.orgUrl}${projectName}/_apis/release/releases?definitionId=${definitionId}&$top=200`;
762
1135
  if (url.startsWith('https://dev.azure.com')) {
@@ -767,9 +1140,16 @@ export default class PipelinesDataProvider {
767
1140
  }
768
1141
 
769
1142
  /**
770
- * Fetch all releases for a definition using continuation tokens.
1143
+ * Fetches all releases for a definition using continuation tokens.
1144
+ *
1145
+ * This is used by SVD release range handling because the requested from/to releases may be
1146
+ * older than the first page returned by Azure DevOps.
1147
+ *
1148
+ * @param range When provided, pagination stops as soon as both fromId and toId are present in
1149
+ * the accumulated results. ADO returns releases in descending id order, so once the smallest
1150
+ * id seen is <= the lower requested id both endpoints are guaranteed to be loaded.
771
1151
  */
772
- async GetAllReleaseHistory(projectName: string, definitionId: string) {
1152
+ async GetAllReleaseHistory(projectName: string, definitionId: string, range?: { fromId: number; toId: number }) {
773
1153
  let baseUrl: string = `${this.orgUrl}${projectName}/_apis/release/releases?definitionId=${definitionId}&api-version=6.0`;
774
1154
  if (baseUrl.startsWith('https://dev.azure.com')) {
775
1155
  baseUrl = baseUrl.replace('https://dev.azure.com', 'https://vsrm.dev.azure.com');
@@ -793,14 +1173,25 @@ export default class PipelinesDataProvider {
793
1173
  );
794
1174
  const { value = [] } = data || {};
795
1175
  all.push(...value);
796
- // Azure DevOps returns continuation token header for next page
797
- continuationToken =
798
- headers?.['x-ms-continuationtoken'] || headers?.['x-ms-continuation-token'] || undefined;
799
1176
  page++;
800
1177
  logger.debug(`GetAllReleaseHistory: fetched page ${page}, cumulative ${all.length} releases`);
1178
+
1179
+ // Stop-early: once the smallest id in the accumulated list is <= the lower
1180
+ // requested id, both endpoints of the range are present.
1181
+ if (range && all.length > 0) {
1182
+ const lowerBound = Math.min(range.fromId, range.toId);
1183
+ const minId = Math.min(...all.map((r: any) => Number(r.id)));
1184
+ if (minId <= lowerBound) {
1185
+ logger.debug(`GetAllReleaseHistory: stop-early at page ${page} (minId=${minId} <= lowerBound=${lowerBound})`);
1186
+ break;
1187
+ }
1188
+ }
1189
+
1190
+ // Azure DevOps returns continuation token header for next page
1191
+ continuationToken = this.getContinuationToken(headers);
801
1192
  } catch (err: any) {
802
1193
  logger.error(`GetAllReleaseHistory failed: ${err.message}`);
803
- break;
1194
+ throw err;
804
1195
  }
805
1196
  } while (continuationToken);
806
1197
 
@@ -78,7 +78,7 @@ const HISTORICAL_WORK_ITEM_FIELDS = [
78
78
 
79
79
  /** Default fields fetched per work item in tree/flat query parsing. */
80
80
  const WI_DEFAULT_FIELDS =
81
- 'System.Description,System.Title,Microsoft.VSTS.TCM.ReproSteps,Microsoft.VSTS.CMMI.Symptom';
81
+ 'System.Description,System.Title,System.WorkItemType,Microsoft.VSTS.TCM.ReproSteps,Microsoft.VSTS.CMMI.Symptom';
82
82
 
83
83
  export default class TicketsDataProvider {
84
84
  orgUrl: string = '';
@@ -691,6 +691,58 @@ export default class TicketsDataProvider {
691
691
  return { systemRequirementsQueryTree };
692
692
  }
693
693
 
694
+ private async fetchCustomerRequirementQueries(queries: any) {
695
+ const systemRequirementsQueryTree = await this.structureCustomerRequirementQueries(queries);
696
+ return { systemRequirementsQueryTree };
697
+ }
698
+
699
+ private async structureCustomerRequirementQueries(
700
+ rootQuery: any,
701
+ parentId: any = null,
702
+ ): Promise<any> {
703
+ try {
704
+ if (!rootQuery?.hasChildren) {
705
+ const isExecutableQuery =
706
+ !rootQuery?.isFolder && ['flat', 'tree', 'oneHop'].includes(rootQuery?.queryType);
707
+ return isExecutableQuery ? this.buildQueryNode(rootQuery, parentId) : null;
708
+ }
709
+
710
+ if (!rootQuery.children) {
711
+ const queryUrl = `${rootQuery.url}?$depth=2&$expand=all`;
712
+ const currentQuery = await TFSServices.getItemContent(queryUrl, this.token);
713
+ if (!currentQuery) {
714
+ return null;
715
+ }
716
+ return await this.structureCustomerRequirementQueries(currentQuery, currentQuery.id);
717
+ }
718
+
719
+ const childResults = await Promise.all(
720
+ rootQuery.children.map((child: any) =>
721
+ this.structureCustomerRequirementQueries(child, rootQuery.id),
722
+ ),
723
+ );
724
+ const children = childResults.filter((child: any) => child !== null);
725
+
726
+ return children.length > 0
727
+ ? {
728
+ id: rootQuery.id,
729
+ pId: parentId,
730
+ value: rootQuery.name,
731
+ title: rootQuery.name,
732
+ children,
733
+ }
734
+ : null;
735
+ } catch (err: any) {
736
+ logger.error(
737
+ `Error occurred while constructing the customer query list ${err.message} with query ${JSON.stringify(
738
+ rootQuery,
739
+ )}`,
740
+ );
741
+ logger.error(`Error stack ${err.message}`);
742
+ return null;
743
+ }
744
+ }
745
+
694
746
  private async fetchSrsQueries(rootQueries: any) {
695
747
  const srsFolder = await this.findQueryFolderByName(rootQueries, 'srs');
696
748
  if (!srsFolder) {
@@ -735,55 +787,42 @@ export default class TicketsDataProvider {
735
787
  const { root: sysRsRoot, found: sysRsRootFound } = await this.getDocTypeRoot(rootQueries, 'sysrs');
736
788
  logger.debug(`[GetSharedQueries][sysrs] using ${sysRsRootFound ? 'dedicated folder' : 'root queries'}`);
737
789
 
738
- const systemToCustomerFolderNames = [
739
- 'system to customer',
740
- 'system-to-customer',
741
- 'system customer',
742
- 'subsystem to system',
743
- 'customer to system',
744
- ];
745
790
  const systemToSubsystemFolderNames = ['system to subsystem', 'system-to-subsystem', 'system subsystem'];
746
791
 
747
- const systemRequirementsQueries = await this.fetchSystemRequirementQueries(sysRsRoot, [
748
- ...systemToCustomerFolderNames,
749
- ...systemToSubsystemFolderNames,
750
- ]);
751
-
752
- const systemToCustomerFolder = await this.findChildFolderByPossibleNames(
792
+ const systemRequirementsQueries = await this.fetchSystemRequirementQueries(
753
793
  sysRsRoot,
754
- systemToCustomerFolderNames,
794
+ systemToSubsystemFolderNames,
755
795
  );
796
+
756
797
  const systemToSubsystemFolder = await this.findChildFolderByPossibleNames(
757
798
  sysRsRoot,
758
799
  systemToSubsystemFolderNames,
759
800
  );
760
-
761
- const subsystemToSystemRequirementsQueries =
762
- await this.fetchRequirementsTraceQueriesForFolder(systemToCustomerFolder);
763
801
  const systemToSubsystemRequirementsQueries =
764
802
  await this.fetchRequirementsTraceQueriesForFolder(systemToSubsystemFolder);
765
803
 
766
- // Customer/System requirements (traceability table) picker: only scan the dedicated
767
- // System-to-Customer folder. If it doesn't exist in the tenant, return null
768
- // rather than scanning the whole SysRS root, which would surface unrelated
769
- // flat queries into the picker.
804
+ // Customer/System requirements (traceability table) picker: scan the entire
805
+ // dedicated SysRS folder for executable query nodes. The selected query
806
+ // defines the customer-side candidate set, so discovery does not infer
807
+ // "customer" semantics from WIQL. Scope is intentionally limited to the
808
+ // dedicated `sysrs` folder so unrelated queries from the Shared Queries root
809
+ // do not leak into the picker.
770
810
  let customerRequirementsQueries: any = null;
771
- if (systemToCustomerFolder) {
772
- customerRequirementsQueries = await this.fetchFlatUpstreamQueries(
773
- systemToCustomerFolder,
774
- [],
775
- ['requirement'],
776
- );
811
+ if (sysRsRootFound) {
812
+ customerRequirementsQueries = await this.fetchCustomerRequirementQueries(sysRsRoot);
777
813
  } else {
778
814
  logger.debug(
779
- '[GetSharedQueries][sysrs] System-to-Customer folder not found; skipping customer-requirements picker',
815
+ '[GetSharedQueries][sysrs] dedicated sysrs folder not found; skipping customer-requirements picker',
780
816
  );
781
817
  }
782
818
 
783
819
  return {
784
820
  systemRequirementsQueries,
785
821
  customerRequirementsQueries,
786
- subsystemToSystemRequirementsQueries,
822
+ // Legacy field retained for backward compatibility with callers/tests.
823
+ // The sub-system -> system trace table is out of scope for v0 and no
824
+ // longer sourced from a hardcoded "System to Customer" folder.
825
+ subsystemToSystemRequirementsQueries: null,
787
826
  systemToSubsystemRequirementsQueries,
788
827
  };
789
828
  }
@@ -2256,42 +2295,46 @@ export default class TicketsDataProvider {
2256
2295
  }
2257
2296
 
2258
2297
  async PopulateWorkItemsByIds(workItemsArray: any[] = [], projectName: string = ''): Promise<any[]> {
2259
- let url = `${this.orgUrl}${projectName}/_apis/wit/workitemsbatch`;
2260
- let res: any[] = [];
2261
- let divByMax = Math.floor(workItemsArray.length / 200);
2262
- let modulusByMax = workItemsArray.length % 200;
2263
- //iterating
2264
- for (let i = 0; i < divByMax; i++) {
2265
- let from = i * 200;
2266
- let to = (i + 1) * 200;
2267
- let currentIds = workItemsArray.slice(from, to);
2268
- try {
2269
- let subRes = await TFSServices.getItemContent(url, this.token, 'post', {
2298
+ const baseUrl = `${this.orgUrl}${projectName}/_apis/wit/workitemsbatch`;
2299
+ // On-prem Azure DevOps Server rejects this POST with HTTP 400 unless an
2300
+ // api-version is explicitly set on the URL. Use the same fallback chain
2301
+ // (7.1 -> 5.1 -> no version) the historical batch hydration already uses,
2302
+ // so the helper stays compatible with ADO cloud and older on-prem tenants.
2303
+ const postBatch = (currentIds: any[]) =>
2304
+ this.withHistoricalApiVersionFallback('populate-workitems-batch', (apiVersion) =>
2305
+ TFSServices.getItemContent(this.appendApiVersion(baseUrl, apiVersion), this.token, 'post', {
2270
2306
  $expand: 'Relations',
2271
2307
  ids: currentIds,
2272
- });
2273
- res = [...res, ...subRes.value];
2308
+ }),
2309
+ ).then(({ result }) => result);
2310
+
2311
+ const res: any[] = [];
2312
+ const divByMax = Math.floor(workItemsArray.length / 200);
2313
+ const modulusByMax = workItemsArray.length % 200;
2314
+ for (let i = 0; i < divByMax; i++) {
2315
+ const from = i * 200;
2316
+ const to = (i + 1) * 200;
2317
+ const currentIds = workItemsArray.slice(from, to);
2318
+ try {
2319
+ const subRes = await postBatch(currentIds);
2320
+ res.push(...subRes.value);
2274
2321
  } catch (error) {
2275
2322
  logger.error(`error populating workitems array`);
2276
2323
  logger.error(JSON.stringify(error));
2277
2324
  return [];
2278
2325
  }
2279
2326
  }
2280
- //compliting the rimainder
2281
2327
  if (modulusByMax !== 0) {
2282
2328
  try {
2283
- let currentIds = workItemsArray.slice(workItemsArray.length - modulusByMax, workItemsArray.length);
2284
- let subRes = await TFSServices.getItemContent(url, this.token, 'post', {
2285
- $expand: 'Relations',
2286
- ids: currentIds,
2287
- });
2288
- res = [...res, ...subRes.value];
2329
+ const currentIds = workItemsArray.slice(workItemsArray.length - modulusByMax, workItemsArray.length);
2330
+ const subRes = await postBatch(currentIds);
2331
+ res.push(...subRes.value);
2289
2332
  } catch (error) {
2290
2333
  logger.error(`error populating workitems array`);
2291
2334
  logger.error(JSON.stringify(error));
2292
2335
  return [];
2293
2336
  }
2294
- } //if
2337
+ }
2295
2338
 
2296
2339
  return res;
2297
2340
  }
@@ -3146,7 +3189,8 @@ export default class TicketsDataProvider {
3146
3189
 
3147
3190
  public async GetWorkItemTypeList(project: string) {
3148
3191
  try {
3149
- let url = `${this.orgUrl}${project}/_apis/wit/workitemtypes?api-version=5.1`;
3192
+ const query = new URLSearchParams({ 'api-version': '5.1' }).toString();
3193
+ let url = `${this.orgUrl}${encodeURIComponent(project)}/_apis/wit/workitemtypes?${query}`;
3150
3194
  const { value: workItemTypes } = await TFSServices.getItemContent(url, this.token);
3151
3195
  const workItemTypesWithIcons = await Promise.all(
3152
3196
  workItemTypes.map(async (workItemType: any) => {