@elisra-devops/docgen-data-provider 1.110.0 → 1.112.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,34 @@ export default class PipelinesDataProvider {
14
21
  this.token = token;
15
22
  }
16
23
 
24
+ /**
25
+ * Returns work item references associated with one completed build.
26
+ *
27
+ * Used by template-only first-run SVD baseline generation, where there is no
28
+ * previous successful build to compare against and the current target build's
29
+ * linked work items become the baseline content.
30
+ */
31
+ public async GetBuildWorkItems(teamProject: string, buildId: number): Promise<any[]> {
32
+ const encodedProject = encodeURIComponent(teamProject);
33
+ const url = `${this.orgUrl}${encodedProject}/_apis/build/builds/${buildId}/workitems?$top=2000&api-version=6.0`;
34
+ const result = await TFSServices.getItemContent(url, this.token, 'get');
35
+ return Array.isArray(result?.value) ? result.value : [];
36
+ }
37
+
38
+ /**
39
+ * Resolves the previous pipeline run used as the source side of an SVD pipeline range.
40
+ *
41
+ * For regular pipeline ranges, the Builds API is used first because it supports paging,
42
+ * completed/succeeded filters, and branch filtering. Stage-specific discovery keeps the
43
+ * older Pipelines Runs path because stage status must be checked from run details.
44
+ *
45
+ * @param teamProject Azure DevOps project name.
46
+ * @param pipelineId Pipeline/build definition id.
47
+ * @param toPipelineRunId Target run/build id. Candidates must be older than this id.
48
+ * @param targetPipeline Full target pipeline run details, used for repository/branch matching.
49
+ * @param searchPrevPipelineFromDifferentCommit When true, skip candidates from the same commit.
50
+ * @param fromStage Optional stage name. When set, only previous runs with this successful stage match.
51
+ */
17
52
  public async findPreviousPipeline(
18
53
  teamProject: string,
19
54
  pipelineId: string,
@@ -22,8 +57,21 @@ export default class PipelinesDataProvider {
22
57
  searchPrevPipelineFromDifferentCommit: boolean,
23
58
  fromStage: string = ''
24
59
  ) {
60
+ if (!fromStage) {
61
+ const previousBuildId = await this.findPreviousSuccessfulBuild(
62
+ teamProject,
63
+ pipelineId,
64
+ toPipelineRunId,
65
+ targetPipeline,
66
+ searchPrevPipelineFromDifferentCommit
67
+ );
68
+ if (previousBuildId) {
69
+ return previousBuildId;
70
+ }
71
+ }
72
+
25
73
  const pipelineRuns = await this.GetPipelineRunHistory(teamProject, pipelineId);
26
- if (!pipelineRuns.value) {
74
+ if (!pipelineRuns?.value) {
27
75
  return undefined;
28
76
  }
29
77
 
@@ -48,6 +96,338 @@ export default class PipelinesDataProvider {
48
96
  return undefined;
49
97
  }
50
98
 
99
+ /**
100
+ * Finds the previous successful completed build for a definition.
101
+ *
102
+ * Discovery prefers the target run's branch first. If no same-branch candidate exists, it
103
+ * falls back to the same repository on any branch so auto-discovery can still work for
104
+ * sparse branches or customer histories where the previous success is on another branch.
105
+ *
106
+ * @returns Previous build id, or undefined when no valid candidate is found.
107
+ */
108
+ public async findPreviousSuccessfulBuild(
109
+ teamProject: string,
110
+ definitionId: string,
111
+ toBuildId: number,
112
+ targetPipeline: any,
113
+ searchPrevPipelineFromDifferentCommit: boolean
114
+ ): Promise<number | undefined> {
115
+ const targetRepo = this.getPrimaryPipelineRepository(targetPipeline);
116
+ const targetBranch = this.normalizeBranchName(targetRepo?.refName);
117
+
118
+ if (targetBranch) {
119
+ const sameBranchResult = await this.findPreviousSuccessfulBuildPage(
120
+ teamProject,
121
+ definitionId,
122
+ toBuildId,
123
+ targetPipeline,
124
+ searchPrevPipelineFromDifferentCommit,
125
+ targetBranch
126
+ );
127
+ if (sameBranchResult.status === 'failed') {
128
+ throw sameBranchResult.error;
129
+ }
130
+ if (sameBranchResult.status === 'found') {
131
+ return sameBranchResult.id;
132
+ }
133
+ }
134
+
135
+ const anyBranchResult = await this.findPreviousSuccessfulBuildPage(
136
+ teamProject,
137
+ definitionId,
138
+ toBuildId,
139
+ targetPipeline,
140
+ searchPrevPipelineFromDifferentCommit
141
+ );
142
+ if (anyBranchResult.status === 'failed') {
143
+ throw anyBranchResult.error;
144
+ }
145
+ return anyBranchResult.status === 'found' ? anyBranchResult.id : undefined;
146
+ }
147
+
148
+ /**
149
+ * Pages the Builds API until a valid previous successful build is found.
150
+ *
151
+ * The API query already asks for completed/succeeded builds ordered by finish time, but
152
+ * candidates are validated locally as well to protect callers from incomplete API data,
153
+ * mocks, or future response-shape differences.
154
+ */
155
+ private async findPreviousSuccessfulBuildPage(
156
+ teamProject: string,
157
+ definitionId: string,
158
+ toBuildId: number,
159
+ targetPipeline: any,
160
+ searchPrevPipelineFromDifferentCommit: boolean,
161
+ branchName?: string
162
+ ): Promise<PreviousDiscoveryResult> {
163
+ const targetRepo = this.getPrimaryPipelineRepository(targetPipeline);
164
+ let continuationToken: string | undefined = undefined;
165
+ let pageCount = 0;
166
+ do {
167
+ let url = `${this.orgUrl}${teamProject}/_apis/build/builds?definitions=${encodeURIComponent(
168
+ String(definitionId)
169
+ )}&resultFilter=succeeded&statusFilter=completed&queryOrder=finishTimeDescending&$top=200&api-version=6.0`;
170
+ if (branchName) {
171
+ url += `&branchName=${encodeURIComponent(branchName)}`;
172
+ }
173
+ if (continuationToken) {
174
+ url += `&continuationToken=${encodeURIComponent(continuationToken)}`;
175
+ }
176
+
177
+ try {
178
+ const { data, headers } = await TFSServices.getItemContentWithHeaders(
179
+ url,
180
+ this.token,
181
+ 'get',
182
+ null,
183
+ null
184
+ );
185
+ pageCount++;
186
+ const builds: any[] = data?.value || [];
187
+ const match = builds.find((build: any) =>
188
+ this.isMatchingPreviousBuild(
189
+ build,
190
+ targetRepo,
191
+ toBuildId,
192
+ searchPrevPipelineFromDifferentCommit,
193
+ branchName
194
+ )
195
+ );
196
+ if (match?.id) {
197
+ return { status: 'found', id: Number(match.id) };
198
+ }
199
+ continuationToken = this.getContinuationToken(headers);
200
+ if (continuationToken && pageCount >= MAX_DISCOVERY_PAGES) {
201
+ return {
202
+ status: 'failed',
203
+ error: new Error(`Pipeline discovery exceeded ${MAX_DISCOVERY_PAGES} pages`),
204
+ };
205
+ }
206
+ } catch (err: unknown) {
207
+ logger.warn(`Could not fetch previous successful builds: ${this.getErrorMessage(err)}`);
208
+ return { status: 'failed', error: err };
209
+ }
210
+ } while (continuationToken);
211
+
212
+ return { status: 'not_found' };
213
+ }
214
+
215
+ private getContinuationToken(headers: any): string | undefined {
216
+ return headers?.['x-ms-continuationtoken'] || headers?.['x-ms-continuation-token'] || undefined;
217
+ }
218
+
219
+ /**
220
+ * Extracts the primary repository from a pipeline run details response.
221
+ *
222
+ * Newer YAML runs expose repository resources as an array with a `self` entry, while some
223
+ * designer/classic pipeline responses expose the repository under `__designer_repo`.
224
+ */
225
+ private getPrimaryPipelineRepository(pipeline: any): any {
226
+ const repositories = pipeline?.resources?.repositories;
227
+ if (!repositories) return undefined;
228
+ return repositories[0]?.self || repositories.__designer_repo;
229
+ }
230
+
231
+ /**
232
+ * Validates a Builds API candidate against the target run.
233
+ *
234
+ * A candidate must be older than the target, completed successfully, from the same
235
+ * repository, and optionally from the required branch. When the caller asks for a
236
+ * different commit, candidates with the same source version are excluded.
237
+ */
238
+ private isMatchingPreviousBuild(
239
+ build: any,
240
+ targetRepo: any,
241
+ toBuildId: number,
242
+ searchPrevPipelineFromDifferentCommit: boolean,
243
+ requiredBranch?: string
244
+ ): boolean {
245
+ const buildId = Number(build?.id);
246
+ if (!Number.isFinite(buildId) || buildId >= toBuildId) return false;
247
+ if (build?.status && build.status !== 'completed') return false;
248
+ if (build?.result && build.result !== 'succeeded') return false;
249
+
250
+ const buildRepoId = build?.repository?.id;
251
+ const targetRepoId = targetRepo?.repository?.id;
252
+ if (!buildRepoId || !targetRepoId || buildRepoId !== targetRepoId) return false;
253
+
254
+ if (requiredBranch && build?.sourceBranch !== requiredBranch) return false;
255
+
256
+ if (build?.sourceVersion && targetRepo?.version && build.sourceVersion === targetRepo.version) {
257
+ return !searchPrevPipelineFromDifferentCommit;
258
+ }
259
+
260
+ return true;
261
+ }
262
+
263
+ /**
264
+ * Finds the previous successful release/deployment for an SVD release range.
265
+ *
266
+ * Release discovery pages the Release List API and expands environments so candidates can
267
+ * be validated by deployment status. A matching release must be older than the target,
268
+ * active, and have at least one succeeded environment.
269
+ *
270
+ * @returns Previous release id, or undefined when no valid candidate is found.
271
+ */
272
+ public async findPreviousSuccessfulRelease(
273
+ projectName: string,
274
+ definitionId: string,
275
+ toReleaseId: number
276
+ ): Promise<number | undefined> {
277
+ let result = await this.findSuccessfulReleasePage(
278
+ projectName,
279
+ definitionId,
280
+ '7.1',
281
+ (release) => this.isPreviousSuccessfulRelease(release, toReleaseId),
282
+ 'previous'
283
+ );
284
+ if (result.status === 'failed' && this.isUnsupportedApiVersionError(result.error)) {
285
+ result = await this.findSuccessfulReleasePage(
286
+ projectName,
287
+ definitionId,
288
+ '6.0',
289
+ (release) => this.isPreviousSuccessfulRelease(release, toReleaseId),
290
+ 'previous'
291
+ );
292
+ }
293
+ if (result.status === 'failed') {
294
+ throw result.error;
295
+ }
296
+ return result.status === 'found' ? result.id : undefined;
297
+ }
298
+
299
+ /**
300
+ * Finds the latest successful release/deployment for an SVD release range.
301
+ *
302
+ * Used when the release template omits `toReleaseId`. The same candidate rule is used as
303
+ * previous-release discovery: active release with at least one succeeded environment.
304
+ *
305
+ * @returns Latest release id, or undefined when no valid candidate is found.
306
+ */
307
+ public async findLatestSuccessfulRelease(
308
+ projectName: string,
309
+ definitionId: string
310
+ ): Promise<number | undefined> {
311
+ let result = await this.findSuccessfulReleasePage(
312
+ projectName,
313
+ definitionId,
314
+ '7.1',
315
+ (release) => this.isSuccessfulRelease(release),
316
+ 'latest'
317
+ );
318
+ if (result.status === 'failed' && this.isUnsupportedApiVersionError(result.error)) {
319
+ result = await this.findSuccessfulReleasePage(
320
+ projectName,
321
+ definitionId,
322
+ '6.0',
323
+ (release) => this.isSuccessfulRelease(release),
324
+ 'latest'
325
+ );
326
+ }
327
+ if (result.status === 'failed') {
328
+ throw result.error;
329
+ }
330
+ return result.status === 'found' ? result.id : undefined;
331
+ }
332
+
333
+ private async findSuccessfulReleasePage(
334
+ projectName: string,
335
+ definitionId: string,
336
+ apiVersion: string,
337
+ isMatch: (release: any) => boolean,
338
+ discoveryLabel: 'previous' | 'latest'
339
+ ): Promise<PreviousDiscoveryResult> {
340
+ let baseUrl: string = `${this.orgUrl}${projectName}/_apis/release/releases?definitionId=${encodeURIComponent(
341
+ String(definitionId)
342
+ )}&queryOrder=descending&$top=200&$expand=environments&api-version=${apiVersion}`;
343
+ if (baseUrl.startsWith('https://dev.azure.com')) {
344
+ baseUrl = baseUrl.replace('https://dev.azure.com', 'https://vsrm.dev.azure.com');
345
+ }
346
+
347
+ let continuationToken: string | undefined = undefined;
348
+ let pageCount = 0;
349
+ do {
350
+ let url = baseUrl;
351
+ if (continuationToken) {
352
+ url += `&continuationToken=${encodeURIComponent(continuationToken)}`;
353
+ }
354
+
355
+ try {
356
+ const { data, headers } = await TFSServices.getItemContentWithHeaders(
357
+ url,
358
+ this.token,
359
+ 'get',
360
+ null,
361
+ null
362
+ );
363
+ pageCount++;
364
+ const releases: any[] = data?.value || [];
365
+ const match = releases.find(isMatch);
366
+ if (match?.id) {
367
+ return { status: 'found', id: Number(match.id) };
368
+ }
369
+ continuationToken = this.getContinuationToken(headers);
370
+ if (continuationToken && pageCount >= MAX_DISCOVERY_PAGES) {
371
+ return {
372
+ status: 'failed',
373
+ error: new Error(`Release discovery exceeded ${MAX_DISCOVERY_PAGES} pages`),
374
+ };
375
+ }
376
+ } catch (err: unknown) {
377
+ logger.warn(`Could not fetch ${discoveryLabel} successful releases: ${this.getErrorMessage(err)}`);
378
+ return { status: 'failed', error: err };
379
+ }
380
+ } while (continuationToken);
381
+
382
+ return { status: 'not_found' };
383
+ }
384
+
385
+ private isUnsupportedApiVersionError(err: unknown): boolean {
386
+ const error = err as any;
387
+ const responseData = error?.response?.data;
388
+ const message = [
389
+ error?.message,
390
+ responseData?.message,
391
+ typeof responseData === 'string' ? responseData : JSON.stringify(responseData || ''),
392
+ ]
393
+ .join(' ')
394
+ .toLowerCase();
395
+ return (
396
+ message.includes('api-version') &&
397
+ (message.includes('unsupported') ||
398
+ message.includes('not support') ||
399
+ message.includes('not supported'))
400
+ );
401
+ }
402
+
403
+ private getErrorMessage(err: unknown): string {
404
+ return err instanceof Error ? err.message : String(err);
405
+ }
406
+
407
+ /**
408
+ * Validates a release candidate for auto-discovery.
409
+ *
410
+ * A release is considered successful for this SVD range purpose when at least one
411
+ * deployment environment succeeded. This mirrors the existing release artifact flow,
412
+ * where a release may contain multiple environments but still provide usable artifacts.
413
+ */
414
+ private isPreviousSuccessfulRelease(release: any, toReleaseId: number): boolean {
415
+ const releaseId = Number(release?.id);
416
+ if (!Number.isFinite(releaseId) || releaseId >= toReleaseId) return false;
417
+ return this.isSuccessfulRelease(release);
418
+ }
419
+
420
+ private isSuccessfulRelease(release: any): boolean {
421
+ const releaseId = Number(release?.id);
422
+ if (!Number.isFinite(releaseId)) return false;
423
+ if (release?.status && String(release.status).toLowerCase() !== 'active') return false;
424
+
425
+ const environments = Array.isArray(release?.environments) ? release.environments : [];
426
+ return environments.some((environment: any) => {
427
+ return String(environment?.status || '').toLowerCase() === 'succeeded';
428
+ });
429
+ }
430
+
51
431
  /**
52
432
  * Determines if a pipeline run is invalid based on various conditions.
53
433
  *
@@ -483,8 +863,9 @@ export default class PipelinesDataProvider {
483
863
  `getPipelineResourcePipelinesFromObject: resolving ${pipelineEntries.length} pipeline resources`
484
864
  );
485
865
 
866
+ const concurrencyLimit = pLimit(8);
486
867
  await Promise.all(
487
- pipelineEntries.map(async ([resourcePipelineAlias, resource]) => {
868
+ pipelineEntries.map(([resourcePipelineAlias, resource]) => concurrencyLimit(async () => {
488
869
  const resourcePipelineObj = (resource as any)?.pipeline;
489
870
  const pipelineIdCandidate = Number(resourcePipelineObj?.id);
490
871
 
@@ -596,7 +977,7 @@ export default class PipelinesDataProvider {
596
977
  const key = `${resourcePipelineToAdd.teamProject}:${resourcePipelineToAdd.definitionId}:${resourcePipelineToAdd.buildId}:${resourcePipelineToAdd.name}`;
597
978
  if (!resourcePipelinesByKey.has(key)) resourcePipelinesByKey.set(key, resourcePipelineToAdd);
598
979
  }
599
- })
980
+ }))
600
981
  );
601
982
  return [...resourcePipelinesByKey.values()];
602
983
  }
@@ -757,6 +1138,12 @@ export default class PipelinesDataProvider {
757
1138
  }
758
1139
  }
759
1140
 
1141
+ /**
1142
+ * Fetches the first page of release history for a definition.
1143
+ *
1144
+ * Kept for backward compatibility with existing consumers. New range/discovery flows
1145
+ * should prefer GetAllReleaseHistory when full release history is required.
1146
+ */
760
1147
  async GetReleaseHistory(projectName: string, definitionId: string) {
761
1148
  let url: string = `${this.orgUrl}${projectName}/_apis/release/releases?definitionId=${definitionId}&$top=200`;
762
1149
  if (url.startsWith('https://dev.azure.com')) {
@@ -767,9 +1154,16 @@ export default class PipelinesDataProvider {
767
1154
  }
768
1155
 
769
1156
  /**
770
- * Fetch all releases for a definition using continuation tokens.
1157
+ * Fetches all releases for a definition using continuation tokens.
1158
+ *
1159
+ * This is used by SVD release range handling because the requested from/to releases may be
1160
+ * older than the first page returned by Azure DevOps.
1161
+ *
1162
+ * @param range When provided, pagination stops as soon as both fromId and toId are present in
1163
+ * the accumulated results. ADO returns releases in descending id order, so once the smallest
1164
+ * id seen is <= the lower requested id both endpoints are guaranteed to be loaded.
771
1165
  */
772
- async GetAllReleaseHistory(projectName: string, definitionId: string) {
1166
+ async GetAllReleaseHistory(projectName: string, definitionId: string, range?: { fromId: number; toId: number }) {
773
1167
  let baseUrl: string = `${this.orgUrl}${projectName}/_apis/release/releases?definitionId=${definitionId}&api-version=6.0`;
774
1168
  if (baseUrl.startsWith('https://dev.azure.com')) {
775
1169
  baseUrl = baseUrl.replace('https://dev.azure.com', 'https://vsrm.dev.azure.com');
@@ -793,14 +1187,25 @@ export default class PipelinesDataProvider {
793
1187
  );
794
1188
  const { value = [] } = data || {};
795
1189
  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
1190
  page++;
800
1191
  logger.debug(`GetAllReleaseHistory: fetched page ${page}, cumulative ${all.length} releases`);
1192
+
1193
+ // Stop-early: once the smallest id in the accumulated list is <= the lower
1194
+ // requested id, both endpoints of the range are present.
1195
+ if (range && all.length > 0) {
1196
+ const lowerBound = Math.min(range.fromId, range.toId);
1197
+ const minId = Math.min(...all.map((r: any) => Number(r.id)));
1198
+ if (minId <= lowerBound) {
1199
+ logger.debug(`GetAllReleaseHistory: stop-early at page ${page} (minId=${minId} <= lowerBound=${lowerBound})`);
1200
+ break;
1201
+ }
1202
+ }
1203
+
1204
+ // Azure DevOps returns continuation token header for next page
1205
+ continuationToken = this.getContinuationToken(headers);
801
1206
  } catch (err: any) {
802
1207
  logger.error(`GetAllReleaseHistory failed: ${err.message}`);
803
- break;
1208
+ throw err;
804
1209
  }
805
1210
  } while (continuationToken);
806
1211
 
@@ -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 () => {
@@ -2569,6 +2569,37 @@ describe('GitDataProvider - createLinkedRelatedItemsForSVD', () => {
2569
2569
  );
2570
2570
  });
2571
2571
 
2572
+ it('GetLinkedRelatedItemsForSVD should expose the shared linked WI enrichment for callers', async () => {
2573
+ jest.spyOn((gitDataProvider as any).ticketsDataProvider, 'GetWorkItemByUrl').mockResolvedValueOnce({
2574
+ id: 10,
2575
+ fields: { 'System.WorkItemType': 'Requirement', 'System.Title': 'Req' },
2576
+ _links: { html: { href: 'http://example.com/10' } },
2577
+ });
2578
+
2579
+ const res = await gitDataProvider.GetLinkedRelatedItemsForSVD(
2580
+ { isEnabled: true, linkedWiTypes: 'reqOnly', linkedWiRelationship: 'affectsOnly' },
2581
+ {
2582
+ id: 1,
2583
+ relations: [
2584
+ {
2585
+ url: 'https://example.com/_apis/wit/workItems/10',
2586
+ rel: 'Affects',
2587
+ attributes: { name: 'Affects' },
2588
+ },
2589
+ ],
2590
+ }
2591
+ );
2592
+
2593
+ expect(res).toHaveLength(1);
2594
+ expect(res[0]).toEqual(
2595
+ expect.objectContaining({
2596
+ id: 10,
2597
+ wiType: 'Requirement',
2598
+ relationType: 'Affects',
2599
+ })
2600
+ );
2601
+ });
2602
+
2572
2603
  it('should add Feature when linkedWiTypes=featureOnly and linkedWiRelationship=coversOnly', async () => {
2573
2604
  jest.spyOn((gitDataProvider as any).ticketsDataProvider, 'GetWorkItemByUrl').mockResolvedValueOnce({
2574
2605
  id: 11,