@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.
- package/bin/helpers/tfs.js +9 -6
- package/bin/helpers/tfs.js.map +1 -1
- package/bin/modules/PipelinesDataProvider.d.ts +97 -2
- package/bin/modules/PipelinesDataProvider.js +281 -9
- package/bin/modules/PipelinesDataProvider.js.map +1 -1
- package/bin/modules/TicketsDataProvider.d.ts +2 -0
- package/bin/modules/TicketsDataProvider.js +74 -44
- package/bin/modules/TicketsDataProvider.js.map +1 -1
- package/bin/tests/helpers/tfs.test.js +4 -3
- package/bin/tests/helpers/tfs.test.js.map +1 -1
- package/bin/tests/modules/pipelineDataProvider.test.js +336 -4
- package/bin/tests/modules/pipelineDataProvider.test.js.map +1 -1
- package/bin/tests/modules/ticketsDataProvider.test.js +152 -34
- package/bin/tests/modules/ticketsDataProvider.test.js.map +1 -1
- package/package.json +1 -1
- package/src/helpers/tfs.ts +11 -6
- package/src/modules/PipelinesDataProvider.ts +400 -9
- package/src/modules/TicketsDataProvider.ts +96 -52
- package/src/tests/helpers/tfs.test.ts +4 -3
- package/src/tests/modules/pipelineDataProvider.test.ts +512 -5
- package/src/tests/modules/ticketsDataProvider.test.ts +177 -39
|
@@ -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
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
748
|
-
...systemToCustomerFolderNames,
|
|
749
|
-
...systemToSubsystemFolderNames,
|
|
750
|
-
]);
|
|
751
|
-
|
|
752
|
-
const systemToCustomerFolder = await this.findChildFolderByPossibleNames(
|
|
792
|
+
const systemRequirementsQueries = await this.fetchSystemRequirementQueries(
|
|
753
793
|
sysRsRoot,
|
|
754
|
-
|
|
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:
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
//
|
|
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 (
|
|
772
|
-
customerRequirementsQueries = await this.
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
//
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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) => {
|