@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.
@@ -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
 
@@ -3189,7 +3189,8 @@ export default class TicketsDataProvider {
3189
3189
 
3190
3190
  public async GetWorkItemTypeList(project: string) {
3191
3191
  try {
3192
- 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}`;
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: 10000, // Verify the actual timeout value is 10000ms
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
- // Configure mock to simulate timeout (will retry 3 times by default)
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(2); // Should retry DNS failures too
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 () => {