@elisra-devops/docgen-data-provider 1.110.0 → 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.js +2 -1
- 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 +2 -0
- 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 +2 -1
- 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 +5 -0
|
@@ -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
|
|
|
@@ -3189,7 +3189,8 @@ export default class TicketsDataProvider {
|
|
|
3189
3189
|
|
|
3190
3190
|
public async GetWorkItemTypeList(project: string) {
|
|
3191
3191
|
try {
|
|
3192
|
-
|
|
3192
|
+
const query = new URLSearchParams({ 'api-version': '5.1' }).toString();
|
|
3193
|
+
let url = `${this.orgUrl}${encodeURIComponent(project)}/_apis/wit/workitemtypes?${query}`;
|
|
3193
3194
|
const { value: workItemTypes } = await TFSServices.getItemContent(url, this.token);
|
|
3194
3195
|
const workItemTypesWithIcons = await Promise.all(
|
|
3195
3196
|
workItemTypes.map(async (workItemType: any) => {
|
|
@@ -21,6 +21,7 @@ describe('TFSServices', () => {
|
|
|
21
21
|
|
|
22
22
|
beforeEach(() => {
|
|
23
23
|
jest.clearAllMocks();
|
|
24
|
+
mockAxiosInstance.request.mockReset();
|
|
24
25
|
// Mock Math.random to return a predictable value for tests with retry
|
|
25
26
|
Math.random = jest.fn().mockReturnValue(0.5);
|
|
26
27
|
});
|
|
@@ -191,7 +192,7 @@ describe('TFSServices', () => {
|
|
|
191
192
|
url: url.replace(/ /g, '%20'),
|
|
192
193
|
method: 'get',
|
|
193
194
|
auth: { username: '', password: pat },
|
|
194
|
-
timeout:
|
|
195
|
+
timeout: 30000,
|
|
195
196
|
})
|
|
196
197
|
);
|
|
197
198
|
});
|
|
@@ -228,7 +229,7 @@ describe('TFSServices', () => {
|
|
|
228
229
|
timeoutError.name = 'TimeoutError';
|
|
229
230
|
(timeoutError as any).code = 'ECONNABORTED';
|
|
230
231
|
|
|
231
|
-
//
|
|
232
|
+
// Timeout errors should get the standard retry budget.
|
|
232
233
|
mockAxiosInstance.request
|
|
233
234
|
.mockRejectedValueOnce(timeoutError)
|
|
234
235
|
.mockRejectedValueOnce(timeoutError)
|
|
@@ -290,7 +291,7 @@ describe('TFSServices', () => {
|
|
|
290
291
|
|
|
291
292
|
// Act & Assert
|
|
292
293
|
await expect(TFSServices.getItemContent(url, pat)).rejects.toThrow('ENOTFOUND');
|
|
293
|
-
expect(mockAxiosInstance.request).toHaveBeenCalledTimes(
|
|
294
|
+
expect(mockAxiosInstance.request).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
|
294
295
|
});
|
|
295
296
|
|
|
296
297
|
it('should handle spaces in URL by replacing them with %20', async () => {
|