@elisra-devops/docgen-data-provider 1.63.13 → 1.68.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.
Files changed (92) hide show
  1. package/.github/workflows/ci.yml +26 -9
  2. package/.github/workflows/release.yml +9 -10
  3. package/README.md +50 -24
  4. package/bin/helpers/tfs.d.ts +3 -0
  5. package/bin/helpers/tfs.js +44 -7
  6. package/bin/helpers/tfs.js.map +1 -1
  7. package/bin/modules/GitDataProvider.d.ts +10 -0
  8. package/bin/modules/GitDataProvider.js +10 -0
  9. package/bin/modules/GitDataProvider.js.map +1 -1
  10. package/bin/modules/TestDataProvider.js +0 -1
  11. package/bin/modules/TestDataProvider.js.map +1 -1
  12. package/bin/modules/TicketsDataProvider.d.ts +63 -24
  13. package/bin/modules/TicketsDataProvider.js +216 -114
  14. package/bin/modules/TicketsDataProvider.js.map +1 -1
  15. package/bin/tests/helpers/helper.test.js +279 -0
  16. package/bin/tests/helpers/helper.test.js.map +1 -0
  17. package/bin/{helpers/test → tests/helpers}/tfs.test.js +312 -49
  18. package/bin/tests/helpers/tfs.test.js.map +1 -0
  19. package/bin/tests/index.test.js +25 -0
  20. package/bin/tests/index.test.js.map +1 -0
  21. package/bin/tests/models/tfs-data.test.js +160 -0
  22. package/bin/tests/models/tfs-data.test.js.map +1 -0
  23. package/bin/{modules/test → tests/modules}/JfrogDataProvider.test.js +9 -9
  24. package/bin/tests/modules/JfrogDataProvider.test.js.map +1 -0
  25. package/bin/tests/modules/ResultDataProvider.test.js +1942 -0
  26. package/bin/tests/modules/ResultDataProvider.test.js.map +1 -0
  27. package/bin/tests/modules/gitDataProvider.test.js +1888 -0
  28. package/bin/tests/modules/gitDataProvider.test.js.map +1 -0
  29. package/bin/{modules/test → tests/modules}/managmentDataProvider.test.js +13 -1
  30. package/bin/tests/modules/managmentDataProvider.test.js.map +1 -0
  31. package/bin/tests/modules/pipelineDataProvider.test.d.ts +1 -0
  32. package/bin/tests/modules/pipelineDataProvider.test.js +783 -0
  33. package/bin/tests/modules/pipelineDataProvider.test.js.map +1 -0
  34. package/bin/tests/modules/testDataProvider.test.d.ts +1 -0
  35. package/bin/tests/modules/testDataProvider.test.js +717 -0
  36. package/bin/tests/modules/testDataProvider.test.js.map +1 -0
  37. package/bin/tests/modules/ticketsDataProvider.test.d.ts +1 -0
  38. package/bin/tests/modules/ticketsDataProvider.test.js +1681 -0
  39. package/bin/tests/modules/ticketsDataProvider.test.js.map +1 -0
  40. package/bin/tests/utils/DataProviderUtils.test.d.ts +1 -0
  41. package/bin/tests/utils/DataProviderUtils.test.js +61 -0
  42. package/bin/tests/utils/DataProviderUtils.test.js.map +1 -0
  43. package/bin/tests/utils/testStepParserHelper.test.d.ts +1 -0
  44. package/bin/tests/utils/testStepParserHelper.test.js +359 -0
  45. package/bin/tests/utils/testStepParserHelper.test.js.map +1 -0
  46. package/package.json +9 -1
  47. package/src/helpers/tfs.ts +51 -7
  48. package/src/modules/GitDataProvider.ts +10 -0
  49. package/src/modules/TestDataProvider.ts +0 -1
  50. package/src/modules/TicketsDataProvider.ts +298 -141
  51. package/src/tests/helpers/helper.test.ts +337 -0
  52. package/src/tests/helpers/tfs.test.ts +1092 -0
  53. package/src/tests/index.test.ts +28 -0
  54. package/src/tests/models/tfs-data.test.ts +203 -0
  55. package/src/tests/modules/JfrogDataProvider.test.ts +167 -0
  56. package/src/tests/modules/ResultDataProvider.test.ts +2571 -0
  57. package/src/tests/modules/gitDataProvider.test.ts +2628 -0
  58. package/src/{modules/test → tests/modules}/managmentDataProvider.test.ts +33 -1
  59. package/src/tests/modules/pipelineDataProvider.test.ts +1038 -0
  60. package/src/tests/modules/testDataProvider.test.ts +1046 -0
  61. package/src/tests/modules/ticketsDataProvider.test.ts +2204 -0
  62. package/src/tests/utils/DataProviderUtils.test.ts +76 -0
  63. package/src/tests/utils/testStepParserHelper.test.ts +437 -0
  64. package/tsconfig.json +1 -0
  65. package/bin/helpers/test/tfs.test.js.map +0 -1
  66. package/bin/modules/test/JfrogDataProvider.test.js.map +0 -1
  67. package/bin/modules/test/ResultDataProvider.test.js +0 -444
  68. package/bin/modules/test/ResultDataProvider.test.js.map +0 -1
  69. package/bin/modules/test/gitDataProvider.test.js +0 -428
  70. package/bin/modules/test/gitDataProvider.test.js.map +0 -1
  71. package/bin/modules/test/managmentDataProvider.test.js.map +0 -1
  72. package/bin/modules/test/pipelineDataProvider.test.js +0 -237
  73. package/bin/modules/test/pipelineDataProvider.test.js.map +0 -1
  74. package/bin/modules/test/testDataProvider.test.js +0 -234
  75. package/bin/modules/test/testDataProvider.test.js.map +0 -1
  76. package/bin/modules/test/ticketsDataProvider.test.js +0 -348
  77. package/bin/modules/test/ticketsDataProvider.test.js.map +0 -1
  78. package/src/helpers/test/tfs.test.ts +0 -748
  79. package/src/modules/test/JfrogDataProvider.test.ts +0 -171
  80. package/src/modules/test/ResultDataProvider.test.ts +0 -542
  81. package/src/modules/test/gitDataProvider.test.ts +0 -645
  82. package/src/modules/test/pipelineDataProvider.test.ts +0 -292
  83. package/src/modules/test/testDataProvider.test.ts +0 -318
  84. package/src/modules/test/ticketsDataProvider.test.ts +0 -462
  85. /package/bin/{helpers/test/tfs.test.d.ts → tests/helpers/helper.test.d.ts} +0 -0
  86. /package/bin/{modules/test/JfrogDataProvider.test.d.ts → tests/helpers/tfs.test.d.ts} +0 -0
  87. /package/bin/{modules/test/ResultDataProvider.test.d.ts → tests/index.test.d.ts} +0 -0
  88. /package/bin/{modules/test/gitDataProvider.test.d.ts → tests/models/tfs-data.test.d.ts} +0 -0
  89. /package/bin/{modules/test/managmentDataProvider.test.d.ts → tests/modules/JfrogDataProvider.test.d.ts} +0 -0
  90. /package/bin/{modules/test/pipelineDataProvider.test.d.ts → tests/modules/ResultDataProvider.test.d.ts} +0 -0
  91. /package/bin/{modules/test/testDataProvider.test.d.ts → tests/modules/gitDataProvider.test.d.ts} +0 -0
  92. /package/bin/{modules/test/ticketsDataProvider.test.d.ts → tests/modules/managmentDataProvider.test.d.ts} +0 -0
@@ -0,0 +1,2628 @@
1
+ import axios from 'axios';
2
+ import { TFSServices } from '../../helpers/tfs';
3
+ import GitDataProvider from '../../modules/GitDataProvider';
4
+ import logger from '../../utils/logger';
5
+
6
+ jest.mock('../../helpers/tfs');
7
+ jest.mock('../../utils/logger');
8
+
9
+ describe('GitDataProvider - GetCommitForPipeline', () => {
10
+ let gitDataProvider: GitDataProvider;
11
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
12
+ const mockToken = 'mock-token';
13
+ const mockProjectId = 'project-123';
14
+ const mockBuildId = 456;
15
+
16
+ beforeEach(() => {
17
+ jest.clearAllMocks();
18
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
19
+ });
20
+
21
+ it('should return the sourceVersion from build information', async () => {
22
+ // Arrange
23
+ const mockCommitSha = 'abc123def456';
24
+ const mockResponse = {
25
+ id: mockBuildId,
26
+ sourceVersion: mockCommitSha,
27
+ status: 'completed',
28
+ };
29
+
30
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
31
+
32
+ // Act
33
+ const result = await gitDataProvider.GetCommitForPipeline(mockProjectId, mockBuildId);
34
+
35
+ // Assert
36
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
37
+ `${mockOrgUrl}${mockProjectId}/_apis/build/builds/${mockBuildId}`,
38
+ mockToken,
39
+ 'get'
40
+ );
41
+ expect(result).toBe(mockCommitSha);
42
+ });
43
+
44
+ it('should throw an error if the API call fails', async () => {
45
+ // Arrange
46
+ const expectedError = new Error('API call failed');
47
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(expectedError);
48
+
49
+ // Act & Assert
50
+ await expect(gitDataProvider.GetCommitForPipeline(mockProjectId, mockBuildId)).rejects.toThrow(
51
+ 'API call failed'
52
+ );
53
+
54
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
55
+ `${mockOrgUrl}${mockProjectId}/_apis/build/builds/${mockBuildId}`,
56
+ mockToken,
57
+ 'get'
58
+ );
59
+ });
60
+
61
+ it('should return undefined if the response does not contain sourceVersion', async () => {
62
+ // Arrange
63
+ const mockResponse = {
64
+ id: mockBuildId,
65
+ status: 'completed',
66
+ // No sourceVersion property
67
+ };
68
+
69
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
70
+
71
+ // Act
72
+ const result = await gitDataProvider.GetCommitForPipeline(mockProjectId, mockBuildId);
73
+
74
+ // Assert
75
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
76
+ `${mockOrgUrl}${mockProjectId}/_apis/build/builds/${mockBuildId}`,
77
+ mockToken,
78
+ 'get'
79
+ );
80
+ expect(result).toBeUndefined();
81
+ });
82
+
83
+ it('should correctly construct URL with given project ID and build ID', async () => {
84
+ // Arrange
85
+ const customProjectId = 'custom-project';
86
+ const customBuildId = 789;
87
+ const mockCommitSha = 'xyz789abc';
88
+ const mockResponse = { sourceVersion: mockCommitSha };
89
+
90
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
91
+
92
+ // Act
93
+ await gitDataProvider.GetCommitForPipeline(customProjectId, customBuildId);
94
+
95
+ // Assert
96
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
97
+ `${mockOrgUrl}${customProjectId}/_apis/build/builds/${customBuildId}`,
98
+ mockToken,
99
+ 'get'
100
+ );
101
+ });
102
+
103
+ it('should handle different organization URLs correctly', async () => {
104
+ // Arrange
105
+ const altOrgUrl = 'https://dev.azure.com/different-org/';
106
+ const altGitDataProvider = new GitDataProvider(altOrgUrl, mockToken);
107
+ const mockResponse = { sourceVersion: 'commit-sha' };
108
+
109
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
110
+
111
+ // Act
112
+ await altGitDataProvider.GetCommitForPipeline(mockProjectId, mockBuildId);
113
+
114
+ // Assert
115
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
116
+ `${altOrgUrl}${mockProjectId}/_apis/build/builds/${mockBuildId}`,
117
+ mockToken,
118
+ 'get'
119
+ );
120
+ });
121
+ });
122
+ describe('GitDataProvider - GetTeamProjectGitReposList', () => {
123
+ let gitDataProvider: GitDataProvider;
124
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
125
+ const mockToken = 'mock-token';
126
+ const mockTeamProject = 'project-123';
127
+
128
+ beforeEach(() => {
129
+ jest.clearAllMocks();
130
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
131
+ });
132
+
133
+ it('should return sorted repositories when API call succeeds', async () => {
134
+ // Arrange
135
+ const mockRepos = {
136
+ value: [
137
+ { id: 'repo2', name: 'ZRepo' },
138
+ { id: 'repo1', name: 'ARepo' },
139
+ { id: 'repo3', name: 'MRepo' },
140
+ ],
141
+ };
142
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockRepos);
143
+
144
+ // Act
145
+ const result = await gitDataProvider.GetTeamProjectGitReposList(mockTeamProject);
146
+
147
+ // Assert
148
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
149
+ `${mockOrgUrl}/${mockTeamProject}/_apis/git/repositories`,
150
+ mockToken,
151
+ 'get'
152
+ );
153
+ expect(result).toHaveLength(3);
154
+ expect(result[0].name).toBe('ARepo');
155
+ expect(result[1].name).toBe('MRepo');
156
+ expect(result[2].name).toBe('ZRepo');
157
+ expect(logger.debug).toHaveBeenCalledWith(
158
+ expect.stringContaining(`fetching repos list for team project - ${mockTeamProject}`)
159
+ );
160
+ });
161
+
162
+ it('should return empty array when no repositories exist', async () => {
163
+ // Arrange
164
+ const mockEmptyRepos = { value: [] };
165
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockEmptyRepos);
166
+
167
+ // Act
168
+ const result = await gitDataProvider.GetTeamProjectGitReposList(mockTeamProject);
169
+
170
+ // Assert
171
+ expect(result).toEqual([]);
172
+ });
173
+
174
+ it('should handle API errors appropriately', async () => {
175
+ // Arrange
176
+ const mockError = new Error('API Error');
177
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(mockError);
178
+
179
+ // Act & Assert
180
+ await expect(gitDataProvider.GetTeamProjectGitReposList(mockTeamProject)).rejects.toThrow('API Error');
181
+ });
182
+ });
183
+
184
+ describe('GitDataProvider - GetGitRepoFromRepoId', () => {
185
+ let gitDataProvider: GitDataProvider;
186
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
187
+ const mockToken = 'mock-token';
188
+
189
+ beforeEach(() => {
190
+ jest.clearAllMocks();
191
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
192
+ });
193
+
194
+ it('should fetch repository by id', async () => {
195
+ const mockRepoId = 'repo-123';
196
+ const mockResponse = { id: mockRepoId, name: 'Repo' };
197
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
198
+
199
+ const result = await gitDataProvider.GetGitRepoFromRepoId(mockRepoId);
200
+
201
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
202
+ `${mockOrgUrl}_apis/git/repositories/${mockRepoId}`,
203
+ mockToken,
204
+ 'get'
205
+ );
206
+ expect(result).toEqual(mockResponse);
207
+ });
208
+ });
209
+
210
+ describe('GitDataProvider - GetTag', () => {
211
+ let gitDataProvider: GitDataProvider;
212
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
213
+ const mockToken = 'mock-token';
214
+ const mockGitRepoUrl = 'https://dev.azure.com/orgname/project/_apis/git/repositories/repo-id';
215
+
216
+ beforeEach(() => {
217
+ jest.clearAllMocks();
218
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
219
+ });
220
+
221
+ it('should return tag info when tag exists', async () => {
222
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
223
+ value: [
224
+ {
225
+ name: 'refs/tags/v1.0.0',
226
+ objectId: 'abc123',
227
+ peeledObjectId: 'def456',
228
+ },
229
+ ],
230
+ });
231
+
232
+ const result = await gitDataProvider.GetTag(mockGitRepoUrl, 'v1.0.0');
233
+
234
+ expect(result).toEqual(
235
+ expect.objectContaining({
236
+ name: 'v1.0.0',
237
+ objectId: 'abc123',
238
+ peeledObjectId: 'def456',
239
+ })
240
+ );
241
+ });
242
+
243
+ it('should return null when no matching tag found', async () => {
244
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
245
+ value: [{ name: 'refs/tags/other-tag', objectId: 'abc123' }],
246
+ });
247
+
248
+ const result = await gitDataProvider.GetTag(mockGitRepoUrl, 'v1.0.0');
249
+ expect(result).toBeNull();
250
+ });
251
+ });
252
+
253
+ describe('GitDataProvider - GetFileFromGitRepo', () => {
254
+ let gitDataProvider: GitDataProvider;
255
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
256
+ const mockToken = 'mock-token';
257
+ const mockProjectName = 'project-123';
258
+ const mockRepoId = 'repo-456';
259
+ const mockFileName = 'README.md';
260
+ const mockVersion = { version: 'main', versionType: 'branch' };
261
+
262
+ beforeEach(() => {
263
+ jest.clearAllMocks();
264
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
265
+ });
266
+
267
+ it('should return file content when file exists', async () => {
268
+ // Arrange
269
+ const mockContent = 'This is a test readme file';
270
+ const mockResponse = { content: mockContent };
271
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
272
+
273
+ // Act
274
+ const result = await gitDataProvider.GetFileFromGitRepo(
275
+ mockProjectName,
276
+ mockRepoId,
277
+ mockFileName,
278
+ mockVersion
279
+ );
280
+
281
+ // Assert
282
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
283
+ expect.stringContaining(`${mockOrgUrl}${mockProjectName}/_apis/git/repositories/${mockRepoId}/items`),
284
+ mockToken,
285
+ 'get',
286
+ {},
287
+ {},
288
+ false
289
+ );
290
+ expect(result).toBe(mockContent);
291
+ });
292
+
293
+ it('should handle special characters in version by encoding them', async () => {
294
+ // Arrange
295
+ const specialVersion = { version: 'feature/branch#123', versionType: 'branch' };
296
+ const mockResponse = { content: 'content' };
297
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
298
+
299
+ // Act
300
+ await gitDataProvider.GetFileFromGitRepo(mockProjectName, mockRepoId, mockFileName, specialVersion);
301
+
302
+ // Assert
303
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
304
+ expect.stringContaining('versionDescriptor.version=feature%2Fbranch%23123'),
305
+ expect.anything(),
306
+ expect.anything(),
307
+ expect.anything(),
308
+ expect.anything(),
309
+ expect.anything()
310
+ );
311
+ });
312
+
313
+ it('should use custom gitRepoUrl if provided', async () => {
314
+ // Arrange
315
+ const mockCustomUrl = 'https://custom.git.url';
316
+ const mockResponse = { content: 'content' };
317
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
318
+
319
+ // Act
320
+ await gitDataProvider.GetFileFromGitRepo(
321
+ mockProjectName,
322
+ mockRepoId,
323
+ mockFileName,
324
+ mockVersion,
325
+ mockCustomUrl
326
+ );
327
+
328
+ // Assert
329
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
330
+ expect.stringContaining(mockCustomUrl),
331
+ expect.anything(),
332
+ expect.anything(),
333
+ expect.anything(),
334
+ expect.anything(),
335
+ expect.anything()
336
+ );
337
+ });
338
+
339
+ it('should return undefined when file does not exist', async () => {
340
+ // Arrange
341
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({});
342
+
343
+ // Act
344
+ const result = await gitDataProvider.GetFileFromGitRepo(
345
+ mockProjectName,
346
+ mockRepoId,
347
+ mockFileName,
348
+ mockVersion
349
+ );
350
+
351
+ // Assert
352
+ expect(result).toBeUndefined();
353
+ });
354
+
355
+ it('should log warning and return undefined when error occurs', async () => {
356
+ // Arrange
357
+ const mockError = new Error('File not found');
358
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(mockError);
359
+
360
+ // Act
361
+ const result = await gitDataProvider.GetFileFromGitRepo(
362
+ mockProjectName,
363
+ mockRepoId,
364
+ mockFileName,
365
+ mockVersion
366
+ );
367
+
368
+ // Assert
369
+ expect(logger.warn).toHaveBeenCalledWith(
370
+ expect.stringContaining(`File ${mockFileName} could not be read: ${mockError.message}`)
371
+ );
372
+ expect(result).toBeUndefined();
373
+ });
374
+ });
375
+
376
+ describe('GitDataProvider - CheckIfItemExist', () => {
377
+ let gitDataProvider: GitDataProvider;
378
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
379
+ const mockToken = 'mock-token';
380
+ const mockGitApiUrl = 'https://dev.azure.com/orgname/project/_apis/git/repositories/repo-id';
381
+ const mockItemPath = 'path/to/file.txt';
382
+ const mockVersion = { version: 'main', versionType: 'branch' };
383
+
384
+ beforeEach(() => {
385
+ jest.clearAllMocks();
386
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
387
+ });
388
+
389
+ it('should return true when item exists', async () => {
390
+ // Arrange
391
+ const mockResponse = { path: mockItemPath, content: 'content' };
392
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
393
+
394
+ // Act
395
+ const result = await gitDataProvider.CheckIfItemExist(mockGitApiUrl, mockItemPath, mockVersion);
396
+
397
+ // Assert
398
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
399
+ expect.stringContaining(`${mockGitApiUrl}/items?path=${mockItemPath}`),
400
+ mockToken,
401
+ 'get',
402
+ {},
403
+ {},
404
+ false
405
+ );
406
+ expect(result).toBe(true);
407
+ });
408
+
409
+ it('should return false when item does not exist', async () => {
410
+ // Arrange
411
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(new Error('Not found'));
412
+
413
+ // Act
414
+ const result = await gitDataProvider.CheckIfItemExist(mockGitApiUrl, mockItemPath, mockVersion);
415
+
416
+ // Assert
417
+ expect(result).toBe(false);
418
+ });
419
+
420
+ it('should return false when API returns null', async () => {
421
+ // Arrange
422
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(null);
423
+
424
+ // Act
425
+ const result = await gitDataProvider.CheckIfItemExist(mockGitApiUrl, mockItemPath, mockVersion);
426
+
427
+ // Assert
428
+ expect(result).toBe(false);
429
+ });
430
+
431
+ it('should handle special characters in version', async () => {
432
+ // Arrange
433
+ const specialVersion = { version: 'feature/branch#123', versionType: 'branch' };
434
+ const mockResponse = { path: mockItemPath };
435
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
436
+
437
+ // Act
438
+ await gitDataProvider.CheckIfItemExist(mockGitApiUrl, mockItemPath, specialVersion);
439
+
440
+ // Assert
441
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
442
+ expect.stringContaining('versionDescriptor.version=feature%2Fbranch%23123'),
443
+ expect.anything(),
444
+ expect.anything(),
445
+ expect.anything(),
446
+ expect.anything(),
447
+ expect.anything()
448
+ );
449
+ });
450
+ });
451
+
452
+ describe('GitDataProvider - GetPullRequestsInCommitRangeWithoutLinkedItems', () => {
453
+ let gitDataProvider: GitDataProvider;
454
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
455
+ const mockToken = 'mock-token';
456
+ const mockProjectId = 'project-123';
457
+ const mockRepoId = 'repo-456';
458
+
459
+ beforeEach(() => {
460
+ jest.clearAllMocks();
461
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
462
+ });
463
+
464
+ it('should return filtered pull requests matching commit ids', async () => {
465
+ // Arrange
466
+ const mockCommits = {
467
+ value: [{ commitId: 'commit-1' }, { commitId: 'commit-2' }],
468
+ };
469
+
470
+ const mockPullRequests = {
471
+ count: 3,
472
+ value: [
473
+ {
474
+ pullRequestId: 101,
475
+ title: 'PR 1',
476
+ createdBy: { displayName: 'User 1' },
477
+ creationDate: '2023-01-01',
478
+ closedDate: '2023-01-02',
479
+ description: 'Description 1',
480
+ lastMergeCommit: { commitId: 'commit-1' },
481
+ },
482
+ {
483
+ pullRequestId: 102,
484
+ title: 'PR 2',
485
+ createdBy: { displayName: 'User 2' },
486
+ creationDate: '2023-02-01',
487
+ closedDate: '2023-02-02',
488
+ description: 'Description 2',
489
+ lastMergeCommit: { commitId: 'commit-3' }, // Not in our commit range
490
+ },
491
+ {
492
+ pullRequestId: 103,
493
+ title: 'PR 3',
494
+ createdBy: { displayName: 'User 3' },
495
+ creationDate: '2023-03-01',
496
+ closedDate: '2023-03-02',
497
+ description: 'Description 3',
498
+ lastMergeCommit: { commitId: 'commit-2' },
499
+ },
500
+ ],
501
+ };
502
+
503
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockPullRequests);
504
+
505
+ // Act
506
+ const result = await gitDataProvider.GetPullRequestsInCommitRangeWithoutLinkedItems(
507
+ mockProjectId,
508
+ mockRepoId,
509
+ mockCommits
510
+ );
511
+
512
+ // Assert
513
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
514
+ expect.stringContaining(
515
+ `${mockOrgUrl}${mockProjectId}/_apis/git/repositories/${mockRepoId}/pullrequests`
516
+ ),
517
+ mockToken,
518
+ 'get'
519
+ );
520
+ expect(result).toHaveLength(2);
521
+ expect(result[0].pullRequestId).toBe(101);
522
+ expect(result[1].pullRequestId).toBe(103);
523
+ expect(logger.info).toHaveBeenCalledWith(
524
+ expect.stringContaining('filtered in commit range 2 pullrequests')
525
+ );
526
+ });
527
+
528
+ it('should return empty array when no matching pull requests', async () => {
529
+ // Arrange
530
+ const mockCommits = {
531
+ value: [
532
+ { commitId: 'commit-999' }, // Not matching any PRs
533
+ ],
534
+ };
535
+
536
+ const mockPullRequests = {
537
+ count: 2,
538
+ value: [
539
+ {
540
+ pullRequestId: 101,
541
+ lastMergeCommit: { commitId: 'commit-1' },
542
+ },
543
+ {
544
+ pullRequestId: 102,
545
+ lastMergeCommit: { commitId: 'commit-2' },
546
+ },
547
+ ],
548
+ };
549
+
550
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockPullRequests);
551
+
552
+ // Act
553
+ const result = await gitDataProvider.GetPullRequestsInCommitRangeWithoutLinkedItems(
554
+ mockProjectId,
555
+ mockRepoId,
556
+ mockCommits
557
+ );
558
+
559
+ // Assert
560
+ expect(result).toHaveLength(0);
561
+ });
562
+
563
+ it('should handle API errors appropriately', async () => {
564
+ // Arrange
565
+ const mockCommits = { value: [{ commitId: 'commit-1' }] };
566
+ const mockError = new Error('API Error');
567
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(mockError);
568
+
569
+ // Act & Assert
570
+ await expect(
571
+ gitDataProvider.GetPullRequestsInCommitRangeWithoutLinkedItems(mockProjectId, mockRepoId, mockCommits)
572
+ ).rejects.toThrow('API Error');
573
+ });
574
+ });
575
+
576
+ describe('GitDataProvider - GetBranch', () => {
577
+ let gitDataProvider: GitDataProvider;
578
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
579
+ const mockToken = 'mock-token';
580
+ const mockGitRepoUrl = 'https://dev.azure.com/orgname/project/_apis/git/repositories/repo-id';
581
+
582
+ beforeEach(() => {
583
+ jest.clearAllMocks();
584
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
585
+ });
586
+
587
+ it('should return branch info when branch exists', async () => {
588
+ // Arrange
589
+ const branchName = 'main';
590
+ const mockResponse = {
591
+ value: [{ name: 'refs/heads/main', objectId: 'abc123' }],
592
+ };
593
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
594
+
595
+ // Act
596
+ const result = await gitDataProvider.GetBranch(mockGitRepoUrl, branchName);
597
+
598
+ // Assert
599
+ expect(result).toEqual({ name: 'refs/heads/main', objectId: 'abc123' });
600
+ });
601
+
602
+ it('should return null when branch does not exist', async () => {
603
+ // Arrange
604
+ const mockResponse = { value: [] };
605
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
606
+
607
+ // Act
608
+ const result = await gitDataProvider.GetBranch(mockGitRepoUrl, 'nonexistent');
609
+
610
+ // Assert
611
+ expect(result).toBeNull();
612
+ });
613
+
614
+ it('should return null when response is empty', async () => {
615
+ // Arrange
616
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(null);
617
+
618
+ // Act
619
+ const result = await gitDataProvider.GetBranch(mockGitRepoUrl, 'main');
620
+
621
+ // Assert
622
+ expect(result).toBeNull();
623
+ });
624
+ });
625
+
626
+ describe('GitDataProvider - GetGitRepoFromPrId', () => {
627
+ let gitDataProvider: GitDataProvider;
628
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
629
+ const mockToken = 'mock-token';
630
+
631
+ beforeEach(() => {
632
+ jest.clearAllMocks();
633
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
634
+ });
635
+
636
+ it('should fetch PR by ID', async () => {
637
+ // Arrange
638
+ const prId = 123;
639
+ const mockResponse = { pullRequestId: prId, title: 'Test PR' };
640
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
641
+
642
+ // Act
643
+ const result = await gitDataProvider.GetGitRepoFromPrId(prId);
644
+
645
+ // Assert
646
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
647
+ `${mockOrgUrl}_apis/git/pullrequests/${prId}`,
648
+ mockToken,
649
+ 'get'
650
+ );
651
+ expect(result).toEqual(mockResponse);
652
+ });
653
+ });
654
+
655
+ describe('GitDataProvider - GetPullRequestCommits', () => {
656
+ let gitDataProvider: GitDataProvider;
657
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
658
+ const mockToken = 'mock-token';
659
+
660
+ beforeEach(() => {
661
+ jest.clearAllMocks();
662
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
663
+ });
664
+
665
+ it('should fetch PR commits', async () => {
666
+ // Arrange
667
+ const repoId = 'repo-123';
668
+ const prId = 456;
669
+ const mockResponse = { value: [{ commitId: 'abc123' }] };
670
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
671
+
672
+ // Act
673
+ const result = await gitDataProvider.GetPullRequestCommits(repoId, prId);
674
+
675
+ // Assert
676
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
677
+ `${mockOrgUrl}_apis/git/repositories/${repoId}/pullRequests/${prId}/commits`,
678
+ mockToken,
679
+ 'get'
680
+ );
681
+ expect(result).toEqual(mockResponse);
682
+ });
683
+ });
684
+
685
+ describe('GitDataProvider - GetCommitByCommitId', () => {
686
+ let gitDataProvider: GitDataProvider;
687
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
688
+ const mockToken = 'mock-token';
689
+
690
+ beforeEach(() => {
691
+ jest.clearAllMocks();
692
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
693
+ });
694
+
695
+ it('should fetch commit by SHA', async () => {
696
+ // Arrange
697
+ const projectId = 'project-123';
698
+ const repoId = 'repo-456';
699
+ const commitSha = 'abc123def456';
700
+ const mockResponse = { commitId: commitSha, comment: 'Test commit' };
701
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
702
+
703
+ // Act
704
+ const result = await gitDataProvider.GetCommitByCommitId(projectId, repoId, commitSha);
705
+
706
+ // Assert
707
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
708
+ `${mockOrgUrl}${projectId}/_apis/git/repositories/${repoId}/commits/${commitSha}`,
709
+ mockToken,
710
+ 'get'
711
+ );
712
+ expect(result).toEqual(mockResponse);
713
+ });
714
+ });
715
+
716
+ describe('GitDataProvider - GetItemsForPipelinesRange', () => {
717
+ let gitDataProvider: GitDataProvider;
718
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
719
+ const mockToken = 'mock-token';
720
+
721
+ beforeEach(() => {
722
+ jest.clearAllMocks();
723
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
724
+ });
725
+
726
+ it('should fetch items in pipeline range', async () => {
727
+ // Arrange
728
+ const projectId = 'project-123';
729
+ const fromBuildId = 100;
730
+ const toBuildId = 200;
731
+ const mockWorkItemsResponse = {
732
+ count: 1,
733
+ value: [{ id: 1 }],
734
+ };
735
+ const mockWorkItemResponse = { id: 1, fields: { 'System.Title': 'Test' } };
736
+
737
+ (TFSServices.getItemContent as jest.Mock)
738
+ .mockResolvedValueOnce(mockWorkItemsResponse)
739
+ .mockResolvedValueOnce(mockWorkItemResponse);
740
+
741
+ // Act
742
+ const result = await gitDataProvider.GetItemsForPipelinesRange(projectId, fromBuildId, toBuildId);
743
+
744
+ // Assert
745
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
746
+ expect.stringContaining(`fromBuildId=${fromBuildId}&toBuildId=${toBuildId}`),
747
+ mockToken,
748
+ 'get'
749
+ );
750
+ expect(result).toHaveLength(1);
751
+ });
752
+ });
753
+
754
+ describe('GitDataProvider - GetCommitsInDateRange', () => {
755
+ let gitDataProvider: GitDataProvider;
756
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
757
+ const mockToken = 'mock-token';
758
+
759
+ beforeEach(() => {
760
+ jest.clearAllMocks();
761
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
762
+ });
763
+
764
+ it('should fetch commits in date range without branch', async () => {
765
+ // Arrange
766
+ const projectId = 'project-123';
767
+ const repoId = 'repo-456';
768
+ const fromDate = '2023-01-01';
769
+ const toDate = '2023-12-31';
770
+ const mockResponse = { value: [{ commitId: 'abc123' }] };
771
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
772
+
773
+ // Act
774
+ const result = await gitDataProvider.GetCommitsInDateRange(projectId, repoId, fromDate, toDate);
775
+
776
+ // Assert
777
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
778
+ expect.stringContaining(`fromDate=${fromDate}`),
779
+ mockToken,
780
+ 'get'
781
+ );
782
+ expect(result).toEqual(mockResponse);
783
+ });
784
+
785
+ it('should fetch commits in date range with branch', async () => {
786
+ // Arrange
787
+ const projectId = 'project-123';
788
+ const repoId = 'repo-456';
789
+ const fromDate = '2023-01-01';
790
+ const toDate = '2023-12-31';
791
+ const branchName = 'main';
792
+ const mockResponse = { value: [{ commitId: 'abc123' }] };
793
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
794
+
795
+ // Act
796
+ const result = await gitDataProvider.GetCommitsInDateRange(
797
+ projectId,
798
+ repoId,
799
+ fromDate,
800
+ toDate,
801
+ branchName
802
+ );
803
+
804
+ // Assert
805
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
806
+ expect.stringContaining(`itemVersion.version=${branchName}`),
807
+ mockToken,
808
+ 'get'
809
+ );
810
+ });
811
+ });
812
+
813
+ describe('GitDataProvider - GetCommitsInCommitRange', () => {
814
+ let gitDataProvider: GitDataProvider;
815
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
816
+ const mockToken = 'mock-token';
817
+
818
+ beforeEach(() => {
819
+ jest.clearAllMocks();
820
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
821
+ });
822
+
823
+ it('should fetch commits between two commit SHAs', async () => {
824
+ // Arrange
825
+ const projectId = 'project-123';
826
+ const repoId = 'repo-456';
827
+ const fromSha = 'abc123';
828
+ const toSha = 'def456';
829
+ const mockResponse = { value: [{ commitId: 'xyz789' }] };
830
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
831
+
832
+ // Act
833
+ const result = await gitDataProvider.GetCommitsInCommitRange(projectId, repoId, fromSha, toSha);
834
+
835
+ // Assert
836
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
837
+ expect.stringContaining(`fromCommitId=${fromSha}&searchCriteria.toCommitId=${toSha}`),
838
+ mockToken,
839
+ 'get'
840
+ );
841
+ expect(result).toEqual(mockResponse);
842
+ });
843
+ });
844
+
845
+ describe('GitDataProvider - CreatePullRequestComment', () => {
846
+ let gitDataProvider: GitDataProvider;
847
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
848
+ const mockToken = 'mock-token';
849
+
850
+ beforeEach(() => {
851
+ jest.clearAllMocks();
852
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
853
+ });
854
+
855
+ it('should create a PR comment thread', async () => {
856
+ // Arrange
857
+ const projectName = 'project-123';
858
+ const repoId = 'repo-456';
859
+ const prId = 789;
860
+ const threads = { comments: [{ content: 'Test comment' }] };
861
+ const mockResponse = { id: 1, comments: threads.comments };
862
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
863
+
864
+ // Act
865
+ const result = await gitDataProvider.CreatePullRequestComment(projectName, repoId, prId, threads);
866
+
867
+ // Assert
868
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
869
+ expect.stringContaining(`pullRequests/${prId}/threads`),
870
+ mockToken,
871
+ 'post',
872
+ threads,
873
+ null
874
+ );
875
+ expect(result).toEqual(mockResponse);
876
+ });
877
+ });
878
+
879
+ describe('GitDataProvider - GetPullRequestComments', () => {
880
+ let gitDataProvider: GitDataProvider;
881
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
882
+ const mockToken = 'mock-token';
883
+
884
+ beforeEach(() => {
885
+ jest.clearAllMocks();
886
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
887
+ });
888
+
889
+ it('should fetch PR comments', async () => {
890
+ // Arrange
891
+ const projectName = 'project-123';
892
+ const repoId = 'repo-456';
893
+ const prId = 789;
894
+ const mockResponse = { value: [{ id: 1, comments: [] }] };
895
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
896
+
897
+ // Act
898
+ const result = await gitDataProvider.GetPullRequestComments(projectName, repoId, prId);
899
+
900
+ // Assert
901
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
902
+ expect.stringContaining(`pullRequests/${prId}/threads`),
903
+ mockToken,
904
+ 'get',
905
+ null,
906
+ null
907
+ );
908
+ expect(result).toEqual(mockResponse);
909
+ });
910
+ });
911
+
912
+ describe('GitDataProvider - GetCommitsForRepo', () => {
913
+ let gitDataProvider: GitDataProvider;
914
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
915
+ const mockToken = 'mock-token';
916
+
917
+ beforeEach(() => {
918
+ jest.clearAllMocks();
919
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
920
+ });
921
+
922
+ it('should fetch commits for repo with version identifier', async () => {
923
+ // Arrange
924
+ const projectName = 'project-123';
925
+ const repoId = 'repo-456';
926
+ const versionId = 'main';
927
+ const mockResponse = {
928
+ count: 2,
929
+ value: [
930
+ { commitId: 'abc123', comment: 'First commit', committer: { date: '2023-01-01' } },
931
+ { commitId: 'def456', comment: 'Second commit', author: { date: '2023-01-02' } },
932
+ ],
933
+ };
934
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
935
+
936
+ // Act
937
+ const result = await gitDataProvider.GetCommitsForRepo(projectName, repoId, versionId);
938
+
939
+ // Assert
940
+ expect(result).toHaveLength(2);
941
+ expect(result[0].name).toContain('abc123');
942
+ expect(result[0].value).toBe('abc123');
943
+ });
944
+
945
+ it('should return empty array when no commits', async () => {
946
+ // Arrange
947
+ const mockResponse = { count: 0, value: [] };
948
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
949
+
950
+ // Act
951
+ const result = await gitDataProvider.GetCommitsForRepo('project', 'repo');
952
+
953
+ // Assert
954
+ expect(result).toEqual([]);
955
+ });
956
+ });
957
+
958
+ describe('GitDataProvider - GetPullRequestsForRepo', () => {
959
+ let gitDataProvider: GitDataProvider;
960
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
961
+ const mockToken = 'mock-token';
962
+
963
+ beforeEach(() => {
964
+ jest.clearAllMocks();
965
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
966
+ });
967
+
968
+ it('should fetch PRs for repo', async () => {
969
+ // Arrange
970
+ const projectName = 'project-123';
971
+ const repoId = 'repo-456';
972
+ const mockResponse = { count: 2, value: [{ pullRequestId: 1 }, { pullRequestId: 2 }] };
973
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
974
+
975
+ // Act
976
+ const result = await gitDataProvider.GetPullRequestsForRepo(projectName, repoId);
977
+
978
+ // Assert
979
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
980
+ expect.stringContaining('pullrequests?status=completed'),
981
+ mockToken,
982
+ 'get',
983
+ null,
984
+ null
985
+ );
986
+ expect(result).toEqual(mockResponse);
987
+ });
988
+ });
989
+
990
+ describe('GitDataProvider - GetRepoBranches', () => {
991
+ let gitDataProvider: GitDataProvider;
992
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
993
+ const mockToken = 'mock-token';
994
+
995
+ beforeEach(() => {
996
+ jest.clearAllMocks();
997
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
998
+ });
999
+
1000
+ it('should fetch repo branches', async () => {
1001
+ // Arrange
1002
+ const projectName = 'project-123';
1003
+ const repoId = 'repo-456';
1004
+ const mockResponse = { value: [{ name: 'refs/heads/main' }, { name: 'refs/heads/develop' }] };
1005
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
1006
+
1007
+ // Act
1008
+ const result = await gitDataProvider.GetRepoBranches(projectName, repoId);
1009
+
1010
+ // Assert
1011
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
1012
+ expect.stringContaining('refs?searchCriteria.$top=1000&filter=heads'),
1013
+ mockToken,
1014
+ 'get',
1015
+ null,
1016
+ null
1017
+ );
1018
+ expect(result).toEqual(mockResponse);
1019
+ });
1020
+ });
1021
+
1022
+ describe('GitDataProvider - GetPullRequestsLinkedItemsInCommitRange', () => {
1023
+ let gitDataProvider: GitDataProvider;
1024
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
1025
+ const mockToken = 'mock-token';
1026
+
1027
+ beforeEach(() => {
1028
+ jest.clearAllMocks();
1029
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
1030
+ });
1031
+
1032
+ it('should fetch and filter PRs with linked work items', async () => {
1033
+ // Arrange
1034
+ const projectId = 'project-123';
1035
+ const repoId = 'repo-456';
1036
+ const commitRange = { value: [{ commitId: 'commit-1' }] };
1037
+
1038
+ const mockPRsResponse = {
1039
+ count: 1,
1040
+ value: [
1041
+ {
1042
+ pullRequestId: 101,
1043
+ lastMergeCommit: { commitId: 'commit-1' },
1044
+ _links: { workItems: { href: 'https://example.com/workitems' } },
1045
+ },
1046
+ ],
1047
+ };
1048
+
1049
+ const mockWorkItemsResponse = {
1050
+ count: 1,
1051
+ value: [{ id: 1 }],
1052
+ };
1053
+
1054
+ const mockPopulatedWI = { id: 1, fields: { 'System.Title': 'Test WI' } };
1055
+
1056
+ (TFSServices.getItemContent as jest.Mock)
1057
+ .mockResolvedValueOnce(mockPRsResponse)
1058
+ .mockResolvedValueOnce(mockWorkItemsResponse)
1059
+ .mockResolvedValueOnce(mockPopulatedWI);
1060
+
1061
+ // Act
1062
+ const result = await gitDataProvider.GetPullRequestsLinkedItemsInCommitRange(
1063
+ projectId,
1064
+ repoId,
1065
+ commitRange
1066
+ );
1067
+
1068
+ // Assert
1069
+ expect(result).toHaveLength(1);
1070
+ expect(result[0].workItem.id).toBe(1);
1071
+ expect(result[0].pullrequest.pullRequestId).toBe(101);
1072
+ });
1073
+
1074
+ it('should handle PRs without linked work items', async () => {
1075
+ // Arrange
1076
+ const projectId = 'project-123';
1077
+ const repoId = 'repo-456';
1078
+ const commitRange = { value: [{ commitId: 'commit-1' }] };
1079
+
1080
+ const mockPRsResponse = {
1081
+ count: 1,
1082
+ value: [
1083
+ {
1084
+ pullRequestId: 101,
1085
+ lastMergeCommit: { commitId: 'commit-1' },
1086
+ _links: {},
1087
+ },
1088
+ ],
1089
+ };
1090
+
1091
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockPRsResponse);
1092
+
1093
+ // Act
1094
+ const result = await gitDataProvider.GetPullRequestsLinkedItemsInCommitRange(
1095
+ projectId,
1096
+ repoId,
1097
+ commitRange
1098
+ );
1099
+
1100
+ // Assert
1101
+ expect(result).toHaveLength(0);
1102
+ });
1103
+ });
1104
+
1105
+ describe('GitDataProvider - GetItemsInCommitRange', () => {
1106
+ let gitDataProvider: GitDataProvider;
1107
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
1108
+ const mockToken = 'mock-token';
1109
+
1110
+ beforeEach(() => {
1111
+ jest.clearAllMocks();
1112
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
1113
+ });
1114
+
1115
+ it('should process commits with work items', async () => {
1116
+ // Arrange
1117
+ const projectId = 'project-123';
1118
+ const repoId = 'repo-456';
1119
+ const commitRange = {
1120
+ value: [
1121
+ {
1122
+ commitId: 'commit-1',
1123
+ workItems: [{ id: 1 }],
1124
+ },
1125
+ ],
1126
+ };
1127
+
1128
+ const mockPopulatedWI = { id: 1, fields: { 'System.Title': 'Test WI' } };
1129
+ const mockPRsResponse = { count: 0, value: [] };
1130
+
1131
+ (TFSServices.getItemContent as jest.Mock)
1132
+ .mockResolvedValueOnce(mockPopulatedWI)
1133
+ .mockResolvedValueOnce(mockPRsResponse);
1134
+
1135
+ // Act
1136
+ const result = await gitDataProvider.GetItemsInCommitRange(projectId, repoId, commitRange, null, false);
1137
+
1138
+ // Assert
1139
+ expect(result.commitChangesArray).toHaveLength(1);
1140
+ expect(result.commitsWithNoRelations).toHaveLength(0);
1141
+ });
1142
+
1143
+ it('should include unlinked commits when flag is true', async () => {
1144
+ // Arrange
1145
+ const projectId = 'project-123';
1146
+ const repoId = 'repo-456';
1147
+ const commitRange = {
1148
+ value: [
1149
+ {
1150
+ commitId: 'commit-1',
1151
+ workItems: [],
1152
+ committer: { date: '2023-01-01', name: 'Test User' },
1153
+ comment: 'Test commit',
1154
+ remoteUrl: 'https://example.com/commit/1',
1155
+ },
1156
+ ],
1157
+ };
1158
+
1159
+ const mockPRsResponse = { count: 0, value: [] };
1160
+
1161
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockPRsResponse);
1162
+
1163
+ // Act
1164
+ const result = await gitDataProvider.GetItemsInCommitRange(projectId, repoId, commitRange, null, true);
1165
+
1166
+ // Assert
1167
+ expect(result.commitChangesArray).toHaveLength(0);
1168
+ expect(result.commitsWithNoRelations).toHaveLength(1);
1169
+ expect(result.commitsWithNoRelations[0].commitId).toBe('commit-1');
1170
+ });
1171
+ });
1172
+
1173
+ describe('GitDataProvider - getItemsForPipelineRange', () => {
1174
+ let gitDataProvider: GitDataProvider;
1175
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
1176
+ const mockToken = 'mock-token';
1177
+
1178
+ beforeEach(() => {
1179
+ jest.clearAllMocks();
1180
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
1181
+ });
1182
+
1183
+ it('should throw error when extended commits is empty', async () => {
1184
+ // Arrange
1185
+ const teamProject = 'project-123';
1186
+ const extendedCommits: any[] = [];
1187
+ const targetRepo = { url: 'https://example.com/repo' };
1188
+ const addedWorkItemByIdSet = new Set<number>();
1189
+
1190
+ // Act
1191
+ const result = await gitDataProvider.getItemsForPipelineRange(
1192
+ teamProject,
1193
+ extendedCommits,
1194
+ targetRepo,
1195
+ addedWorkItemByIdSet
1196
+ );
1197
+
1198
+ // Assert - should log error and return empty arrays
1199
+ expect(logger.error).toHaveBeenCalledWith('extended commits cannot be empty');
1200
+ expect(result.commitChangesArray).toHaveLength(0);
1201
+ });
1202
+
1203
+ it('should process commits with work items', async () => {
1204
+ // Arrange
1205
+ const teamProject = 'project-123';
1206
+ const extendedCommits = [
1207
+ {
1208
+ commit: {
1209
+ commitId: 'commit-1',
1210
+ workItems: [{ id: 1 }],
1211
+ committer: { date: '2023-01-01', name: 'Test User' },
1212
+ comment: 'Test commit',
1213
+ },
1214
+ },
1215
+ ];
1216
+ const targetRepo = { url: 'https://example.com/repo' };
1217
+ const addedWorkItemByIdSet = new Set<number>();
1218
+
1219
+ const mockRepoData = {
1220
+ _links: { web: { href: 'https://example.com/repo-web' } },
1221
+ project: { id: 'project-id' },
1222
+ };
1223
+ const mockPopulatedWI = { id: 1, fields: { 'System.Title': 'Test WI' } };
1224
+
1225
+ (TFSServices.getItemContent as jest.Mock)
1226
+ .mockResolvedValueOnce(mockRepoData)
1227
+ .mockResolvedValueOnce(mockPopulatedWI);
1228
+
1229
+ // Act
1230
+ const result = await gitDataProvider.getItemsForPipelineRange(
1231
+ teamProject,
1232
+ extendedCommits,
1233
+ targetRepo,
1234
+ addedWorkItemByIdSet
1235
+ );
1236
+
1237
+ // Assert
1238
+ expect(result.commitChangesArray).toHaveLength(1);
1239
+ expect(result.commitChangesArray[0].workItem.id).toBe(1);
1240
+ });
1241
+
1242
+ it('should include unlinked commits when flag is true', async () => {
1243
+ // Arrange
1244
+ const teamProject = 'project-123';
1245
+ const extendedCommits = [
1246
+ {
1247
+ commit: {
1248
+ commitId: 'commit-1',
1249
+ workItems: [],
1250
+ committer: { date: '2023-01-01', name: 'Test User' },
1251
+ comment: 'Test commit',
1252
+ remoteUrl: 'https://example.com/commit/1',
1253
+ },
1254
+ },
1255
+ ];
1256
+ const targetRepo = { url: 'https://example.com/repo' };
1257
+ const addedWorkItemByIdSet = new Set<number>();
1258
+
1259
+ const mockRepoData = {
1260
+ _links: { web: { href: 'https://example.com/repo-web' } },
1261
+ project: { id: 'project-id' },
1262
+ };
1263
+
1264
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockRepoData);
1265
+
1266
+ // Act
1267
+ const result = await gitDataProvider.getItemsForPipelineRange(
1268
+ teamProject,
1269
+ extendedCommits,
1270
+ targetRepo,
1271
+ addedWorkItemByIdSet,
1272
+ undefined,
1273
+ true
1274
+ );
1275
+
1276
+ // Assert
1277
+ expect(result.commitsWithNoRelations).toHaveLength(1);
1278
+ expect(result.commitsWithNoRelations[0].commitId).toBe('commit-1');
1279
+ });
1280
+
1281
+ it('should not add duplicate work items', async () => {
1282
+ // Arrange
1283
+ const teamProject = 'project-123';
1284
+ const extendedCommits = [
1285
+ { commit: { commitId: 'commit-1', workItems: [{ id: 1 }] } },
1286
+ { commit: { commitId: 'commit-2', workItems: [{ id: 1 }] } }, // Same WI
1287
+ ];
1288
+ const targetRepo = { url: 'https://example.com/repo' };
1289
+ const addedWorkItemByIdSet = new Set<number>();
1290
+
1291
+ const mockRepoData = {
1292
+ _links: { web: { href: 'https://example.com/repo-web' } },
1293
+ project: { id: 'project-id' },
1294
+ };
1295
+ const mockPopulatedWI = { id: 1, fields: { 'System.Title': 'Test WI' } };
1296
+
1297
+ (TFSServices.getItemContent as jest.Mock)
1298
+ .mockResolvedValueOnce(mockRepoData)
1299
+ .mockResolvedValueOnce(mockPopulatedWI)
1300
+ .mockResolvedValueOnce(mockPopulatedWI);
1301
+
1302
+ // Act
1303
+ const result = await gitDataProvider.getItemsForPipelineRange(
1304
+ teamProject,
1305
+ extendedCommits,
1306
+ targetRepo,
1307
+ addedWorkItemByIdSet
1308
+ );
1309
+
1310
+ // Assert - should only have 1 work item (no duplicates)
1311
+ expect(result.commitChangesArray).toHaveLength(1);
1312
+ });
1313
+ });
1314
+
1315
+ describe('GitDataProvider - GetItemsInPullRequestRange', () => {
1316
+ let gitDataProvider: GitDataProvider;
1317
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
1318
+ const mockToken = 'mock-token';
1319
+
1320
+ beforeEach(() => {
1321
+ jest.clearAllMocks();
1322
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
1323
+ });
1324
+
1325
+ it('should fetch items from PRs in range', async () => {
1326
+ // Arrange
1327
+ const projectId = 'project-123';
1328
+ const repoId = 'repo-456';
1329
+ const prIds = [101];
1330
+
1331
+ const mockPRsResponse = {
1332
+ count: 1,
1333
+ value: [{ pullRequestId: 101, _links: { workItems: { href: 'https://example.com/wi1' } } }],
1334
+ };
1335
+
1336
+ const mockWIResponse = { count: 1, value: [{ id: 1 }] };
1337
+ const mockPopulatedWI = { id: 1, fields: { 'System.Title': 'WI 1' } };
1338
+
1339
+ (TFSServices.getItemContent as jest.Mock)
1340
+ .mockResolvedValueOnce(mockPRsResponse)
1341
+ .mockResolvedValueOnce(mockWIResponse)
1342
+ .mockResolvedValueOnce(mockPopulatedWI);
1343
+
1344
+ // Act
1345
+ const result = await gitDataProvider.GetItemsInPullRequestRange(projectId, repoId, prIds);
1346
+
1347
+ // Assert
1348
+ expect(result).toHaveLength(1);
1349
+ expect(result[0].workItem.id).toBe(1);
1350
+ });
1351
+ });
1352
+
1353
+ describe('GitDataProvider - GetRepoTagsWithCommits', () => {
1354
+ let gitDataProvider: GitDataProvider;
1355
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
1356
+ const mockToken = 'mock-token';
1357
+
1358
+ beforeEach(() => {
1359
+ jest.clearAllMocks();
1360
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
1361
+ });
1362
+
1363
+ it('should return empty array when response is null', async () => {
1364
+ // Arrange
1365
+ const repoApiUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1366
+
1367
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(null);
1368
+
1369
+ // Act
1370
+ const result = await gitDataProvider.GetRepoTagsWithCommits(repoApiUrl);
1371
+
1372
+ // Assert
1373
+ expect(result).toEqual([]);
1374
+ });
1375
+
1376
+ it('should return empty array when no tags exist', async () => {
1377
+ // Arrange
1378
+ const repoApiUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1379
+ const mockTagsResponse = { count: 0, value: [] };
1380
+
1381
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockTagsResponse);
1382
+
1383
+ // Act
1384
+ const result = await gitDataProvider.GetRepoTagsWithCommits(repoApiUrl);
1385
+
1386
+ // Assert
1387
+ expect(result).toEqual([]);
1388
+ });
1389
+ });
1390
+
1391
+ describe('GitDataProvider - GetCommitBatch', () => {
1392
+ let gitDataProvider: GitDataProvider;
1393
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
1394
+ const mockToken = 'mock-token';
1395
+
1396
+ beforeEach(() => {
1397
+ jest.clearAllMocks();
1398
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
1399
+ // Mock postRequest
1400
+ (TFSServices as any).postRequest = jest.fn();
1401
+ });
1402
+
1403
+ it('should fetch commits in batches', async () => {
1404
+ // Arrange
1405
+ const gitUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1406
+ const itemVersion = { version: 'main', versionType: 'branch' };
1407
+ const compareVersion = { version: 'v1.0.0', versionType: 'tag' };
1408
+
1409
+ const mockCommitsResponse = {
1410
+ data: {
1411
+ count: 1,
1412
+ value: [
1413
+ {
1414
+ commitId: 'abc123',
1415
+ committer: { name: 'Test User', date: '2023-01-01T00:00:00Z' },
1416
+ comment: 'Test commit',
1417
+ },
1418
+ ],
1419
+ },
1420
+ };
1421
+ const mockEmptyResponse = { data: { count: 0, value: [] } };
1422
+
1423
+ (TFSServices as any).postRequest
1424
+ .mockResolvedValueOnce(mockCommitsResponse)
1425
+ .mockResolvedValueOnce(mockEmptyResponse);
1426
+
1427
+ // Act
1428
+ const result = await gitDataProvider.GetCommitBatch(gitUrl, itemVersion, compareVersion);
1429
+
1430
+ // Assert
1431
+ expect(result).toHaveLength(1);
1432
+ expect(result[0].commit.commitId).toBe('abc123');
1433
+ expect(result[0].committerName).toBe('Test User');
1434
+ });
1435
+
1436
+ it('should handle empty commits response', async () => {
1437
+ // Arrange
1438
+ const gitUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1439
+ const itemVersion = { version: 'main', versionType: 'branch' };
1440
+ const compareVersion = { version: 'v1.0.0', versionType: 'tag' };
1441
+
1442
+ const mockEmptyResponse = { data: { count: 0, value: [] } };
1443
+
1444
+ (TFSServices as any).postRequest.mockResolvedValueOnce(mockEmptyResponse);
1445
+
1446
+ // Act
1447
+ const result = await gitDataProvider.GetCommitBatch(gitUrl, itemVersion, compareVersion);
1448
+
1449
+ // Assert
1450
+ expect(result).toHaveLength(0);
1451
+ });
1452
+
1453
+ it('should include specificItemPath when provided', async () => {
1454
+ // Arrange
1455
+ const gitUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1456
+ const itemVersion = { version: 'main', versionType: 'branch' };
1457
+ const compareVersion = { version: 'v1.0.0', versionType: 'tag' };
1458
+ const specificItemPath = '/src/file.ts';
1459
+
1460
+ const mockEmptyResponse = { data: { count: 0, value: [] } };
1461
+
1462
+ (TFSServices as any).postRequest.mockResolvedValueOnce(mockEmptyResponse);
1463
+
1464
+ // Act
1465
+ await gitDataProvider.GetCommitBatch(gitUrl, itemVersion, compareVersion, specificItemPath);
1466
+
1467
+ // Assert
1468
+ expect((TFSServices as any).postRequest).toHaveBeenCalledWith(
1469
+ expect.any(String),
1470
+ mockToken,
1471
+ undefined,
1472
+ expect.objectContaining({
1473
+ itemPath: specificItemPath,
1474
+ historyMode: 'fullHistory',
1475
+ })
1476
+ );
1477
+ });
1478
+ });
1479
+
1480
+ describe('GitDataProvider - getSubmodulesData', () => {
1481
+ let gitDataProvider: GitDataProvider;
1482
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
1483
+ const mockToken = 'mock-token';
1484
+
1485
+ beforeEach(() => {
1486
+ jest.clearAllMocks();
1487
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
1488
+ });
1489
+
1490
+ it('should return empty array when no .gitmodules file exists', async () => {
1491
+ // Arrange
1492
+ const projectName = 'project-123';
1493
+ const repoId = 'repo-456';
1494
+ const targetVersion = { version: 'main', versionType: 'branch' };
1495
+ const sourceVersion = { version: 'v1.0.0', versionType: 'tag' };
1496
+ const allCommitsExtended: any[] = [];
1497
+
1498
+ // Mock GetFileFromGitRepo to return undefined (no .gitmodules)
1499
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({});
1500
+
1501
+ // Act
1502
+ const result = await gitDataProvider.getSubmodulesData(
1503
+ projectName,
1504
+ repoId,
1505
+ targetVersion,
1506
+ sourceVersion,
1507
+ allCommitsExtended
1508
+ );
1509
+
1510
+ // Assert
1511
+ expect(result).toEqual([]);
1512
+ });
1513
+ });
1514
+
1515
+ describe('GitDataProvider - GetRepoTagsWithCommits (extended)', () => {
1516
+ let gitDataProvider: GitDataProvider;
1517
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
1518
+ const mockToken = 'mock-token';
1519
+
1520
+ beforeEach(() => {
1521
+ jest.clearAllMocks();
1522
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
1523
+ });
1524
+
1525
+ it('should fetch tags with commit dates', async () => {
1526
+ // Arrange
1527
+ const repoApiUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1528
+ const mockTagsResponse = {
1529
+ count: 1,
1530
+ value: [{ name: 'refs/tags/v1.0.0', objectId: 'abc123', peeledObjectId: 'def456' }],
1531
+ };
1532
+ const mockCommitResponse = { committer: { date: '2023-01-01' } };
1533
+
1534
+ (TFSServices.getItemContent as jest.Mock)
1535
+ .mockResolvedValueOnce(mockTagsResponse)
1536
+ .mockResolvedValueOnce(mockCommitResponse);
1537
+
1538
+ // Act
1539
+ const result = await gitDataProvider.GetRepoTagsWithCommits(repoApiUrl);
1540
+
1541
+ // Assert
1542
+ expect(result).toHaveLength(1);
1543
+ expect(result[0].name).toBe('v1.0.0');
1544
+ expect(result[0].commitId).toBe('def456');
1545
+ expect(result[0].date).toBe('2023-01-01');
1546
+ });
1547
+
1548
+ it('should skip tags without commitId', async () => {
1549
+ // Arrange
1550
+ const repoApiUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1551
+ const mockTagsResponse = {
1552
+ count: 2,
1553
+ value: [
1554
+ { name: 'refs/tags/v1.0.0' }, // No objectId or peeledObjectId
1555
+ { name: 'refs/tags/v2.0.0', objectId: 'abc123' },
1556
+ ],
1557
+ };
1558
+ const mockCommitResponse = { committer: { date: '2023-01-01' } };
1559
+
1560
+ (TFSServices.getItemContent as jest.Mock)
1561
+ .mockResolvedValueOnce(mockTagsResponse)
1562
+ .mockResolvedValueOnce(mockCommitResponse);
1563
+
1564
+ // Act
1565
+ const result = await gitDataProvider.GetRepoTagsWithCommits(repoApiUrl);
1566
+
1567
+ // Assert
1568
+ expect(result).toHaveLength(1);
1569
+ expect(result[0].name).toBe('v2.0.0');
1570
+ });
1571
+
1572
+ it('should handle commit fetch failure gracefully', async () => {
1573
+ // Arrange
1574
+ const repoApiUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1575
+ const mockTagsResponse = {
1576
+ count: 1,
1577
+ value: [{ name: 'refs/tags/v1.0.0', objectId: 'abc123' }],
1578
+ };
1579
+
1580
+ (TFSServices.getItemContent as jest.Mock)
1581
+ .mockResolvedValueOnce(mockTagsResponse)
1582
+ .mockRejectedValueOnce(new Error('Commit not found'));
1583
+
1584
+ // Act
1585
+ const result = await gitDataProvider.GetRepoTagsWithCommits(repoApiUrl);
1586
+
1587
+ // Assert
1588
+ expect(result).toHaveLength(1);
1589
+ expect(result[0].date).toBeUndefined();
1590
+ });
1591
+
1592
+ it('should use author date when committer date is missing', async () => {
1593
+ // Arrange
1594
+ const repoApiUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1595
+ const mockTagsResponse = {
1596
+ count: 1,
1597
+ value: [{ name: 'refs/tags/v1.0.0', objectId: 'abc123' }],
1598
+ };
1599
+ const mockCommitResponse = { author: { date: '2023-02-01' } }; // No committer
1600
+
1601
+ (TFSServices.getItemContent as jest.Mock)
1602
+ .mockResolvedValueOnce(mockTagsResponse)
1603
+ .mockResolvedValueOnce(mockCommitResponse);
1604
+
1605
+ // Act
1606
+ const result = await gitDataProvider.GetRepoTagsWithCommits(repoApiUrl);
1607
+
1608
+ // Assert
1609
+ expect(result[0].date).toBe('2023-02-01');
1610
+ });
1611
+ });
1612
+
1613
+ describe('GitDataProvider - createLinkedRelatedItemsForSVD (via GetItemsInCommitRange)', () => {
1614
+ let gitDataProvider: GitDataProvider;
1615
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
1616
+ const mockToken = 'mock-token';
1617
+
1618
+ beforeEach(() => {
1619
+ jest.clearAllMocks();
1620
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
1621
+ });
1622
+
1623
+ it('should create linked items for requirements with Affects relationship', async () => {
1624
+ // Arrange
1625
+ const projectId = 'project-123';
1626
+ const repoId = 'repo-456';
1627
+ const commitRange = {
1628
+ value: [
1629
+ {
1630
+ commitId: 'commit-1',
1631
+ workItems: [{ id: 1 }],
1632
+ },
1633
+ ],
1634
+ };
1635
+
1636
+ const linkedWiOptions = {
1637
+ isEnabled: true,
1638
+ linkedWiTypes: 'reqOnly',
1639
+ linkedWiRelationship: 'affectsOnly',
1640
+ };
1641
+
1642
+ const mockPopulatedWI = {
1643
+ id: 1,
1644
+ fields: { 'System.Title': 'Test WI' },
1645
+ relations: [
1646
+ {
1647
+ url: 'https://example.com/workItems/2',
1648
+ rel: 'System.LinkTypes.Affects-Forward',
1649
+ attributes: { name: 'Affects' },
1650
+ },
1651
+ ],
1652
+ };
1653
+
1654
+ const mockRelatedWI = {
1655
+ id: 2,
1656
+ fields: {
1657
+ 'System.WorkItemType': 'Requirement',
1658
+ 'System.Title': 'Related Requirement',
1659
+ },
1660
+ _links: { html: { href: 'https://example.com/wi/2' } },
1661
+ };
1662
+
1663
+ const mockPRsResponse = { count: 0, value: [] };
1664
+
1665
+ (TFSServices.getItemContent as jest.Mock)
1666
+ .mockResolvedValueOnce(mockPopulatedWI)
1667
+ .mockResolvedValueOnce(mockRelatedWI)
1668
+ .mockResolvedValueOnce(mockPRsResponse);
1669
+
1670
+ // Act
1671
+ const result = await gitDataProvider.GetItemsInCommitRange(
1672
+ projectId,
1673
+ repoId,
1674
+ commitRange,
1675
+ linkedWiOptions,
1676
+ false
1677
+ );
1678
+
1679
+ // Assert
1680
+ expect(result.commitChangesArray).toHaveLength(1);
1681
+ expect(result.commitChangesArray[0].linkedItems).toBeDefined();
1682
+ });
1683
+
1684
+ it('should filter out non-workItem relations', async () => {
1685
+ // Arrange
1686
+ const projectId = 'project-123';
1687
+ const repoId = 'repo-456';
1688
+ const commitRange = {
1689
+ value: [
1690
+ {
1691
+ commitId: 'commit-1',
1692
+ workItems: [{ id: 1 }],
1693
+ },
1694
+ ],
1695
+ };
1696
+
1697
+ const linkedWiOptions = {
1698
+ isEnabled: true,
1699
+ linkedWiTypes: 'both',
1700
+ linkedWiRelationship: 'both',
1701
+ };
1702
+
1703
+ const mockPopulatedWI = {
1704
+ id: 1,
1705
+ fields: { 'System.Title': 'Test WI' },
1706
+ relations: [
1707
+ {
1708
+ url: 'https://example.com/attachments/file.txt', // Not a workItem
1709
+ rel: 'AttachedFile',
1710
+ attributes: {},
1711
+ },
1712
+ ],
1713
+ };
1714
+
1715
+ const mockPRsResponse = { count: 0, value: [] };
1716
+
1717
+ (TFSServices.getItemContent as jest.Mock)
1718
+ .mockResolvedValueOnce(mockPopulatedWI)
1719
+ .mockResolvedValueOnce(mockPRsResponse);
1720
+
1721
+ // Act
1722
+ const result = await gitDataProvider.GetItemsInCommitRange(
1723
+ projectId,
1724
+ repoId,
1725
+ commitRange,
1726
+ linkedWiOptions,
1727
+ false
1728
+ );
1729
+
1730
+ // Assert
1731
+ expect(result.commitChangesArray).toHaveLength(1);
1732
+ expect(result.commitChangesArray[0].linkedItems).toEqual([]);
1733
+ });
1734
+
1735
+ it('should handle linkedWiTypes=none', async () => {
1736
+ // Arrange
1737
+ const projectId = 'project-123';
1738
+ const repoId = 'repo-456';
1739
+ const commitRange = {
1740
+ value: [
1741
+ {
1742
+ commitId: 'commit-1',
1743
+ workItems: [{ id: 1 }],
1744
+ },
1745
+ ],
1746
+ };
1747
+
1748
+ const linkedWiOptions = {
1749
+ isEnabled: true,
1750
+ linkedWiTypes: 'none',
1751
+ linkedWiRelationship: 'both',
1752
+ };
1753
+
1754
+ const mockPopulatedWI = {
1755
+ id: 1,
1756
+ fields: { 'System.Title': 'Test WI' },
1757
+ relations: [
1758
+ {
1759
+ url: 'https://example.com/workItems/2',
1760
+ rel: 'System.LinkTypes.Affects-Forward',
1761
+ attributes: { name: 'Affects' },
1762
+ },
1763
+ ],
1764
+ };
1765
+
1766
+ const mockPRsResponse = { count: 0, value: [] };
1767
+
1768
+ (TFSServices.getItemContent as jest.Mock)
1769
+ .mockResolvedValueOnce(mockPopulatedWI)
1770
+ .mockResolvedValueOnce(mockPRsResponse);
1771
+
1772
+ // Act
1773
+ const result = await gitDataProvider.GetItemsInCommitRange(
1774
+ projectId,
1775
+ repoId,
1776
+ commitRange,
1777
+ linkedWiOptions,
1778
+ false
1779
+ );
1780
+
1781
+ // Assert
1782
+ expect(result.commitChangesArray[0].linkedItems).toEqual([]);
1783
+ });
1784
+
1785
+ it('should handle Feature type with featureOnly option', async () => {
1786
+ // Arrange
1787
+ const projectId = 'project-123';
1788
+ const repoId = 'repo-456';
1789
+ const commitRange = {
1790
+ value: [
1791
+ {
1792
+ commitId: 'commit-1',
1793
+ workItems: [{ id: 1 }],
1794
+ },
1795
+ ],
1796
+ };
1797
+
1798
+ const linkedWiOptions = {
1799
+ isEnabled: true,
1800
+ linkedWiTypes: 'featureOnly',
1801
+ linkedWiRelationship: 'coversOnly',
1802
+ };
1803
+
1804
+ const mockPopulatedWI = {
1805
+ id: 1,
1806
+ fields: { 'System.Title': 'Test WI' },
1807
+ relations: [
1808
+ {
1809
+ url: 'https://example.com/workItems/2',
1810
+ rel: 'System.LinkTypes.CoveredBy-Forward',
1811
+ attributes: { name: 'CoveredBy' },
1812
+ },
1813
+ ],
1814
+ };
1815
+
1816
+ const mockRelatedWI = {
1817
+ id: 2,
1818
+ fields: {
1819
+ 'System.WorkItemType': 'Feature',
1820
+ 'System.Title': 'Related Feature',
1821
+ },
1822
+ _links: { html: { href: 'https://example.com/wi/2' } },
1823
+ };
1824
+
1825
+ const mockPRsResponse = { count: 0, value: [] };
1826
+
1827
+ (TFSServices.getItemContent as jest.Mock)
1828
+ .mockResolvedValueOnce(mockPopulatedWI)
1829
+ .mockResolvedValueOnce(mockRelatedWI)
1830
+ .mockResolvedValueOnce(mockPRsResponse);
1831
+
1832
+ // Act
1833
+ const result = await gitDataProvider.GetItemsInCommitRange(
1834
+ projectId,
1835
+ repoId,
1836
+ commitRange,
1837
+ linkedWiOptions,
1838
+ false
1839
+ );
1840
+
1841
+ // Assert
1842
+ expect(result.commitChangesArray).toHaveLength(1);
1843
+ });
1844
+ });
1845
+
1846
+ describe('GitDataProvider - GetRepoReferences (extended)', () => {
1847
+ let gitDataProvider: GitDataProvider;
1848
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
1849
+ const mockToken = 'mock-token';
1850
+
1851
+ beforeEach(() => {
1852
+ jest.clearAllMocks();
1853
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
1854
+ });
1855
+
1856
+ it('should sort tags by commit date (most recent first)', async () => {
1857
+ // Arrange
1858
+ const projectId = 'project-123';
1859
+ const repoId = 'repo-456';
1860
+ const mockTagsResponse = {
1861
+ count: 2,
1862
+ value: [
1863
+ { name: 'refs/tags/v1.0.0', objectId: 'abc123' },
1864
+ { name: 'refs/tags/v2.0.0', objectId: 'def456', peeledObjectId: 'ghi789' },
1865
+ ],
1866
+ };
1867
+ const mockCommit1 = { committer: { date: '2023-01-01' } };
1868
+ const mockCommit2 = { committer: { date: '2023-06-01' } };
1869
+
1870
+ (TFSServices.getItemContent as jest.Mock)
1871
+ .mockResolvedValueOnce(mockTagsResponse)
1872
+ .mockResolvedValueOnce(mockCommit1)
1873
+ .mockResolvedValueOnce(mockCommit2);
1874
+
1875
+ // Act
1876
+ const result = await gitDataProvider.GetRepoReferences(projectId, repoId, 'tag');
1877
+
1878
+ // Assert
1879
+ expect(result).toHaveLength(2);
1880
+ // v2.0.0 should be first (more recent)
1881
+ expect(result[0].name).toBe('v2.0.0');
1882
+ expect(result[1].name).toBe('v1.0.0');
1883
+ });
1884
+
1885
+ it('should sort branches by commit date', async () => {
1886
+ // Arrange
1887
+ const projectId = 'project-123';
1888
+ const repoId = 'repo-456';
1889
+ const mockBranchesResponse = {
1890
+ count: 2,
1891
+ value: [
1892
+ { name: 'refs/heads/main', objectId: 'abc123' },
1893
+ { name: 'refs/heads/develop', objectId: 'def456' },
1894
+ ],
1895
+ };
1896
+ const mockCommit1 = { committer: { date: '2023-01-01' } };
1897
+ const mockCommit2 = { committer: { date: '2023-06-01' } };
1898
+
1899
+ (TFSServices.getItemContent as jest.Mock)
1900
+ .mockResolvedValueOnce(mockBranchesResponse)
1901
+ .mockResolvedValueOnce(mockCommit1)
1902
+ .mockResolvedValueOnce(mockCommit2);
1903
+
1904
+ // Act
1905
+ const result = await gitDataProvider.GetRepoReferences(projectId, repoId, 'branch');
1906
+
1907
+ // Assert
1908
+ expect(result).toHaveLength(2);
1909
+ // develop should be first (more recent)
1910
+ expect(result[0].name).toBe('develop');
1911
+ expect(result[1].name).toBe('main');
1912
+ });
1913
+
1914
+ it('should handle commit resolution failure in tag sorting', async () => {
1915
+ // Arrange
1916
+ const projectId = 'project-123';
1917
+ const repoId = 'repo-456';
1918
+ const mockTagsResponse = {
1919
+ count: 1,
1920
+ value: [{ name: 'refs/tags/v1.0.0', objectId: 'abc123' }],
1921
+ };
1922
+
1923
+ (TFSServices.getItemContent as jest.Mock)
1924
+ .mockResolvedValueOnce(mockTagsResponse)
1925
+ .mockRejectedValueOnce(new Error('Commit not found'));
1926
+
1927
+ // Act
1928
+ const result = await gitDataProvider.GetRepoReferences(projectId, repoId, 'tag');
1929
+
1930
+ // Assert
1931
+ expect(result).toHaveLength(1);
1932
+ expect(result[0].date).toBeUndefined();
1933
+ });
1934
+
1935
+ it('should set date undefined when tag commit has no committer/author date', async () => {
1936
+ const projectId = 'project-123';
1937
+ const repoId = 'repo-456';
1938
+ const mockTagsResponse = {
1939
+ count: 1,
1940
+ value: [{ name: 'refs/tags/v0.0.1', objectId: 'abc123' }],
1941
+ };
1942
+
1943
+ (TFSServices.getItemContent as jest.Mock)
1944
+ .mockResolvedValueOnce(mockTagsResponse)
1945
+ .mockResolvedValueOnce({});
1946
+
1947
+ const result = await gitDataProvider.GetRepoReferences(projectId, repoId, 'tag');
1948
+
1949
+ expect(result).toHaveLength(1);
1950
+ expect(result[0]).toEqual(expect.objectContaining({ name: 'v0.0.1', date: undefined }));
1951
+ });
1952
+
1953
+ it('should set date undefined when branch commit cannot be resolved to a date', async () => {
1954
+ const projectId = 'project-123';
1955
+ const repoId = 'repo-456';
1956
+ const mockBranchesResponse = {
1957
+ count: 1,
1958
+ value: [{ name: 'refs/heads/feature', objectId: 'abc123' }],
1959
+ };
1960
+
1961
+ (TFSServices.getItemContent as jest.Mock)
1962
+ .mockResolvedValueOnce(mockBranchesResponse)
1963
+ .mockResolvedValueOnce(null);
1964
+
1965
+ const result = await gitDataProvider.GetRepoReferences(projectId, repoId, 'branch');
1966
+
1967
+ expect(result).toHaveLength(1);
1968
+ expect(result[0]).toEqual(expect.objectContaining({ name: 'feature', date: undefined }));
1969
+ });
1970
+ });
1971
+
1972
+ describe('GitDataProvider - duplicate work item removal', () => {
1973
+ let gitDataProvider: GitDataProvider;
1974
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
1975
+ const mockToken = 'mock-token';
1976
+
1977
+ beforeEach(() => {
1978
+ jest.clearAllMocks();
1979
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
1980
+ });
1981
+
1982
+ it('should remove duplicate work items in GetItemsInCommitRange', async () => {
1983
+ // Arrange
1984
+ const projectId = 'project-123';
1985
+ const repoId = 'repo-456';
1986
+ const commitRange = {
1987
+ value: [
1988
+ { commitId: 'commit-1', workItems: [{ id: 1 }] },
1989
+ { commitId: 'commit-2', workItems: [{ id: 1 }] },
1990
+ ],
1991
+ };
1992
+
1993
+ const mockPopulatedWI = { id: 1, fields: { 'System.Title': 'Test WI' } };
1994
+ const mockPRsResponse = { count: 0, value: [] };
1995
+
1996
+ (TFSServices.getItemContent as jest.Mock)
1997
+ .mockResolvedValueOnce(mockPopulatedWI)
1998
+ .mockResolvedValueOnce(mockPopulatedWI)
1999
+ .mockResolvedValueOnce(mockPRsResponse);
2000
+
2001
+ // Act
2002
+ const result = await gitDataProvider.GetItemsInCommitRange(projectId, repoId, commitRange, null, false);
2003
+
2004
+ // Assert
2005
+ expect(result.commitChangesArray.length).toBeLessThanOrEqual(2);
2006
+ });
2007
+ });
2008
+
2009
+ describe('GitDataProvider - version encoding edge cases', () => {
2010
+ let gitDataProvider: GitDataProvider;
2011
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
2012
+ const mockToken = 'mock-token';
2013
+
2014
+ beforeEach(() => {
2015
+ jest.clearAllMocks();
2016
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
2017
+ });
2018
+
2019
+ it('should handle version with null gracefully in GetFileFromGitRepo', async () => {
2020
+ // Arrange
2021
+ const projectName = 'project-123';
2022
+ const repoId = 'repo-456';
2023
+ const fileName = 'test.txt';
2024
+ const version = { version: null as any, versionType: 'branch' };
2025
+
2026
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({ content: 'file content' });
2027
+
2028
+ // Act
2029
+ const result = await gitDataProvider.GetFileFromGitRepo(projectName, repoId, fileName, version);
2030
+
2031
+ // Assert
2032
+ expect(result).toBe('file content');
2033
+ });
2034
+
2035
+ it('should handle version with null gracefully in CheckIfItemExist', async () => {
2036
+ // Arrange
2037
+ const gitApiUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
2038
+ const itemPath = 'path/to/file.txt';
2039
+ const version = { version: null as any, versionType: 'branch' };
2040
+
2041
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({ path: itemPath });
2042
+
2043
+ // Act
2044
+ const result = await gitDataProvider.CheckIfItemExist(gitApiUrl, itemPath, version);
2045
+
2046
+ // Assert
2047
+ expect(result).toBe(true);
2048
+ });
2049
+ });
2050
+
2051
+ describe('GitDataProvider - GetCommitsForRepo edge cases', () => {
2052
+ let gitDataProvider: GitDataProvider;
2053
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
2054
+ const mockToken = 'mock-token';
2055
+
2056
+ beforeEach(() => {
2057
+ jest.clearAllMocks();
2058
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
2059
+ });
2060
+
2061
+ it('should handle commits without committer date', async () => {
2062
+ // Arrange
2063
+ const projectName = 'project-123';
2064
+ const repoId = 'repo-456';
2065
+ const mockResponse = {
2066
+ count: 1,
2067
+ value: [{ commitId: 'abc123', comment: 'Test commit' }],
2068
+ };
2069
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
2070
+
2071
+ // Act
2072
+ const result = await gitDataProvider.GetCommitsForRepo(projectName, repoId);
2073
+
2074
+ // Assert
2075
+ expect(result).toHaveLength(1);
2076
+ expect(result[0].date).toBeUndefined();
2077
+ });
2078
+
2079
+ it('should fetch commits without version identifier', async () => {
2080
+ // Arrange
2081
+ const projectName = 'project-123';
2082
+ const repoId = 'repo-456';
2083
+ const mockResponse = {
2084
+ count: 1,
2085
+ value: [{ commitId: 'abc123', comment: 'Test', committer: { date: '2023-01-01' } }],
2086
+ };
2087
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
2088
+
2089
+ // Act
2090
+ const result = await gitDataProvider.GetCommitsForRepo(projectName, repoId, '');
2091
+
2092
+ // Assert
2093
+ expect(result).toHaveLength(1);
2094
+ });
2095
+ });
2096
+
2097
+ describe('GitDataProvider - GetItemsInPullRequestRange edge cases', () => {
2098
+ let gitDataProvider: GitDataProvider;
2099
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
2100
+ const mockToken = 'mock-token';
2101
+
2102
+ beforeEach(() => {
2103
+ jest.clearAllMocks();
2104
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
2105
+ });
2106
+
2107
+ it('should handle PR without workItems link', async () => {
2108
+ // Arrange
2109
+ const projectId = 'project-123';
2110
+ const repoId = 'repo-456';
2111
+ const prIds = [101];
2112
+
2113
+ const mockPRsResponse = {
2114
+ count: 1,
2115
+ value: [{ pullRequestId: 101, _links: {} }],
2116
+ };
2117
+
2118
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockPRsResponse);
2119
+
2120
+ // Act
2121
+ const result = await gitDataProvider.GetItemsInPullRequestRange(projectId, repoId, prIds);
2122
+
2123
+ // Assert
2124
+ expect(result).toHaveLength(0);
2125
+ });
2126
+
2127
+ it('should handle errors when fetching work items', async () => {
2128
+ // Arrange
2129
+ const projectId = 'project-123';
2130
+ const repoId = 'repo-456';
2131
+ const prIds = [101];
2132
+
2133
+ const mockPRsResponse = {
2134
+ count: 1,
2135
+ value: [{ pullRequestId: 101, _links: { workItems: { href: 'https://example.com/wi' } } }],
2136
+ };
2137
+
2138
+ (TFSServices.getItemContent as jest.Mock)
2139
+ .mockResolvedValueOnce(mockPRsResponse)
2140
+ .mockRejectedValueOnce(new Error('Failed to fetch'));
2141
+
2142
+ // Act
2143
+ const result = await gitDataProvider.GetItemsInPullRequestRange(projectId, repoId, prIds);
2144
+
2145
+ // Assert
2146
+ expect(result).toHaveLength(0);
2147
+ expect(logger.error).toHaveBeenCalled();
2148
+ });
2149
+ });
2150
+
2151
+ describe('GitDataProvider - getSubmodulesData', () => {
2152
+ let gitDataProvider: GitDataProvider;
2153
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
2154
+ const mockToken = 'mock-token';
2155
+
2156
+ beforeEach(() => {
2157
+ jest.clearAllMocks();
2158
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
2159
+ });
2160
+
2161
+ it('should parse .gitmodules file and return submodule data', async () => {
2162
+ // Arrange
2163
+ const projectName = 'project-123';
2164
+ const repoId = 'repo-456';
2165
+ const targetVersion = { version: 'main', versionType: 'branch' };
2166
+ const sourceVersion = { version: 'v1.0.0', versionType: 'tag' };
2167
+ const allCommitsExtended: any[] = [];
2168
+
2169
+ const gitModulesContent = `[submodule "libs/common"]
2170
+ path = libs/common
2171
+ url = https://dev.azure.com/org/project/_git/common-lib`;
2172
+
2173
+ // Mock GetFileFromGitRepo calls
2174
+ (TFSServices.getItemContent as jest.Mock)
2175
+ .mockResolvedValueOnce({ content: gitModulesContent }) // .gitmodules file
2176
+ .mockResolvedValueOnce({ content: 'target-sha-123' }) // target SHA
2177
+ .mockResolvedValueOnce({ content: 'source-sha-456' }); // source SHA
2178
+
2179
+ // Act
2180
+ const result = await gitDataProvider.getSubmodulesData(
2181
+ projectName,
2182
+ repoId,
2183
+ targetVersion,
2184
+ sourceVersion,
2185
+ allCommitsExtended
2186
+ );
2187
+
2188
+ // Assert
2189
+ expect(result).toHaveLength(1);
2190
+ expect(result[0].gitSubModuleName).toBe('libs_common');
2191
+ expect(result[0].targetSha1).toBe('target-sha-123');
2192
+ expect(result[0].sourceSha1).toBe('source-sha-456');
2193
+ });
2194
+
2195
+ it('should handle .gitmodules with CRLF line endings', async () => {
2196
+ // Arrange
2197
+ const projectName = 'project-123';
2198
+ const repoId = 'repo-456';
2199
+ const targetVersion = { version: 'main', versionType: 'branch' };
2200
+ const sourceVersion = { version: 'v1.0.0', versionType: 'tag' };
2201
+ const allCommitsExtended: any[] = [];
2202
+
2203
+ const gitModulesContent = `[submodule "libs/common"]\r\n\tpath = libs/common\r\n\turl = https://example.com/repo`;
2204
+
2205
+ (TFSServices.getItemContent as jest.Mock)
2206
+ .mockResolvedValueOnce({ content: gitModulesContent })
2207
+ .mockResolvedValueOnce({ content: 'target-sha' })
2208
+ .mockResolvedValueOnce({ content: 'source-sha' });
2209
+
2210
+ // Act
2211
+ const result = await gitDataProvider.getSubmodulesData(
2212
+ projectName,
2213
+ repoId,
2214
+ targetVersion,
2215
+ sourceVersion,
2216
+ allCommitsExtended
2217
+ );
2218
+
2219
+ // Assert
2220
+ expect(result).toHaveLength(1);
2221
+ });
2222
+
2223
+ it('should handle relative URL paths in submodules', async () => {
2224
+ // Arrange
2225
+ const projectName = 'project-123';
2226
+ const repoId = 'repo-456';
2227
+ const targetVersion = { version: 'main', versionType: 'branch' };
2228
+ const sourceVersion = { version: 'v1.0.0', versionType: 'tag' };
2229
+ const allCommitsExtended: any[] = [];
2230
+
2231
+ const gitModulesContent = `[submodule "libs/common"]
2232
+ path = libs/common
2233
+ url = ../common-lib`;
2234
+
2235
+ (TFSServices.getItemContent as jest.Mock)
2236
+ .mockResolvedValueOnce({ content: gitModulesContent })
2237
+ .mockResolvedValueOnce({ content: 'target-sha' })
2238
+ .mockResolvedValueOnce({ content: 'source-sha' });
2239
+
2240
+ // Act
2241
+ const result = await gitDataProvider.getSubmodulesData(
2242
+ projectName,
2243
+ repoId,
2244
+ targetVersion,
2245
+ sourceVersion,
2246
+ allCommitsExtended
2247
+ );
2248
+
2249
+ // Assert
2250
+ expect(result).toHaveLength(1);
2251
+ expect(result[0].gitSubRepoUrl).toContain('common-lib');
2252
+ });
2253
+
2254
+ it('should skip submodule when source SHA not found', async () => {
2255
+ // Arrange
2256
+ const projectName = 'project-123';
2257
+ const repoId = 'repo-456';
2258
+ const targetVersion = { version: 'main', versionType: 'branch' };
2259
+ const sourceVersion = { version: 'v1.0.0', versionType: 'tag' };
2260
+ const allCommitsExtended: any[] = [];
2261
+
2262
+ const gitModulesContent = `[submodule "libs/common"]
2263
+ path = libs/common
2264
+ url = https://example.com/repo`;
2265
+
2266
+ (TFSServices.getItemContent as jest.Mock)
2267
+ .mockResolvedValueOnce({ content: gitModulesContent })
2268
+ .mockResolvedValueOnce({ content: 'target-sha' })
2269
+ .mockResolvedValueOnce({ content: undefined }); // source not found
2270
+
2271
+ // Act
2272
+ const result = await gitDataProvider.getSubmodulesData(
2273
+ projectName,
2274
+ repoId,
2275
+ targetVersion,
2276
+ sourceVersion,
2277
+ allCommitsExtended
2278
+ );
2279
+
2280
+ // Assert
2281
+ expect(result).toHaveLength(0);
2282
+ expect(logger.warn).toHaveBeenCalled();
2283
+ });
2284
+
2285
+ it('should skip submodule when target SHA not found', async () => {
2286
+ // Arrange
2287
+ const projectName = 'project-123';
2288
+ const repoId = 'repo-456';
2289
+ const targetVersion = { version: 'main', versionType: 'branch' };
2290
+ const sourceVersion = { version: 'v1.0.0', versionType: 'tag' };
2291
+ const allCommitsExtended: any[] = [];
2292
+
2293
+ const gitModulesContent = `[submodule "libs/common"]
2294
+ path = libs/common
2295
+ url = https://example.com/repo`;
2296
+
2297
+ (TFSServices.getItemContent as jest.Mock)
2298
+ .mockResolvedValueOnce({ content: gitModulesContent })
2299
+ .mockResolvedValueOnce({ content: undefined }) // target not found
2300
+ .mockResolvedValueOnce({ content: 'source-sha' });
2301
+
2302
+ // Act
2303
+ const result = await gitDataProvider.getSubmodulesData(
2304
+ projectName,
2305
+ repoId,
2306
+ targetVersion,
2307
+ sourceVersion,
2308
+ allCommitsExtended
2309
+ );
2310
+
2311
+ // Assert
2312
+ expect(result).toHaveLength(0);
2313
+ expect(logger.warn).toHaveBeenCalled();
2314
+ });
2315
+
2316
+ it('should skip submodule when source and target SHA are the same', async () => {
2317
+ // Arrange
2318
+ const projectName = 'project-123';
2319
+ const repoId = 'repo-456';
2320
+ const targetVersion = { version: 'main', versionType: 'branch' };
2321
+ const sourceVersion = { version: 'v1.0.0', versionType: 'tag' };
2322
+ const allCommitsExtended: any[] = [];
2323
+
2324
+ const gitModulesContent = `[submodule "libs/common"]
2325
+ path = libs/common
2326
+ url = https://example.com/repo`;
2327
+
2328
+ (TFSServices.getItemContent as jest.Mock)
2329
+ .mockResolvedValueOnce({ content: gitModulesContent })
2330
+ .mockResolvedValueOnce({ content: 'same-sha' })
2331
+ .mockResolvedValueOnce({ content: 'same-sha' });
2332
+
2333
+ // Act
2334
+ const result = await gitDataProvider.getSubmodulesData(
2335
+ projectName,
2336
+ repoId,
2337
+ targetVersion,
2338
+ sourceVersion,
2339
+ allCommitsExtended
2340
+ );
2341
+
2342
+ // Assert
2343
+ expect(result).toHaveLength(0);
2344
+ expect(logger.warn).toHaveBeenCalled();
2345
+ });
2346
+
2347
+ it('should search commits for source SHA when not found initially', async () => {
2348
+ // Arrange
2349
+ const projectName = 'project-123';
2350
+ const repoId = 'repo-456';
2351
+ const targetVersion = { version: 'main', versionType: 'branch' };
2352
+ const sourceVersion = { version: 'v1.0.0', versionType: 'tag' };
2353
+ const allCommitsExtended = [{ commit: { commitId: 'commit-1' } }, { commit: { commitId: 'commit-2' } }];
2354
+
2355
+ const gitModulesContent = `[submodule "libs/common"]
2356
+ path = libs/common
2357
+ url = https://example.com/repo`;
2358
+
2359
+ (TFSServices.getItemContent as jest.Mock)
2360
+ .mockResolvedValueOnce({ content: gitModulesContent })
2361
+ .mockResolvedValueOnce({ content: 'target-sha' })
2362
+ .mockResolvedValueOnce({ content: undefined }) // source not found initially
2363
+ .mockResolvedValueOnce({ content: 'source-sha-from-commit' }); // found in commit search
2364
+
2365
+ // Act
2366
+ const result = await gitDataProvider.getSubmodulesData(
2367
+ projectName,
2368
+ repoId,
2369
+ targetVersion,
2370
+ sourceVersion,
2371
+ allCommitsExtended
2372
+ );
2373
+
2374
+ // Assert
2375
+ expect(result).toHaveLength(1);
2376
+ expect(result[0].sourceSha1).toBe('source-sha-from-commit');
2377
+ });
2378
+
2379
+ it('should handle commits with direct commitId property', async () => {
2380
+ // Arrange
2381
+ const projectName = 'project-123';
2382
+ const repoId = 'repo-456';
2383
+ const targetVersion = { version: 'main', versionType: 'branch' };
2384
+ const sourceVersion = { version: 'v1.0.0', versionType: 'tag' };
2385
+ const allCommitsExtended = [{ commitId: 'direct-commit-1' }, { commitId: 'direct-commit-2' }];
2386
+
2387
+ const gitModulesContent = `[submodule "libs/common"]
2388
+ path = libs/common
2389
+ url = https://example.com/repo`;
2390
+
2391
+ (TFSServices.getItemContent as jest.Mock)
2392
+ .mockResolvedValueOnce({ content: gitModulesContent })
2393
+ .mockResolvedValueOnce({ content: 'target-sha' })
2394
+ .mockResolvedValueOnce({ content: undefined })
2395
+ .mockResolvedValueOnce({ content: 'source-sha' });
2396
+
2397
+ // Act
2398
+ const result = await gitDataProvider.getSubmodulesData(
2399
+ projectName,
2400
+ repoId,
2401
+ targetVersion,
2402
+ sourceVersion,
2403
+ allCommitsExtended
2404
+ );
2405
+
2406
+ // Assert
2407
+ expect(result).toHaveLength(1);
2408
+ });
2409
+
2410
+ it('should warn when commit not found in extended commits', async () => {
2411
+ // Arrange
2412
+ const projectName = 'project-123';
2413
+ const repoId = 'repo-456';
2414
+ const targetVersion = { version: 'main', versionType: 'branch' };
2415
+ const sourceVersion = { version: 'v1.0.0', versionType: 'tag' };
2416
+ const allCommitsExtended = [
2417
+ { noCommitId: true }, // No commitId or commit property
2418
+ { noCommitId: true },
2419
+ ];
2420
+
2421
+ const gitModulesContent = `[submodule "libs/common"]
2422
+ path = libs/common
2423
+ url = https://example.com/repo`;
2424
+
2425
+ (TFSServices.getItemContent as jest.Mock)
2426
+ .mockResolvedValueOnce({ content: gitModulesContent })
2427
+ .mockResolvedValueOnce({ content: 'target-sha' })
2428
+ .mockResolvedValueOnce({ content: undefined });
2429
+
2430
+ // Act
2431
+ const result = await gitDataProvider.getSubmodulesData(
2432
+ projectName,
2433
+ repoId,
2434
+ targetVersion,
2435
+ sourceVersion,
2436
+ allCommitsExtended
2437
+ );
2438
+
2439
+ // Assert
2440
+ expect(result).toHaveLength(0);
2441
+ expect(logger.warn).toHaveBeenCalled();
2442
+ });
2443
+
2444
+ it('should handle errors gracefully', async () => {
2445
+ // Arrange
2446
+ const projectName = 'project-123';
2447
+ const repoId = 'repo-456';
2448
+ const targetVersion = { version: 'main', versionType: 'branch' };
2449
+ const sourceVersion = { version: 'v1.0.0', versionType: 'tag' };
2450
+ const allCommitsExtended: any[] = [];
2451
+
2452
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(new Error('API error'));
2453
+
2454
+ // Act
2455
+ const result = await gitDataProvider.getSubmodulesData(
2456
+ projectName,
2457
+ repoId,
2458
+ targetVersion,
2459
+ sourceVersion,
2460
+ allCommitsExtended
2461
+ );
2462
+
2463
+ // Assert - returns empty array when .gitmodules fetch fails
2464
+ expect(result).toEqual([]);
2465
+ });
2466
+
2467
+ it('should handle multiple submodules', async () => {
2468
+ // Arrange
2469
+ const projectName = 'project-123';
2470
+ const repoId = 'repo-456';
2471
+ const targetVersion = { version: 'main', versionType: 'branch' };
2472
+ const sourceVersion = { version: 'v1.0.0', versionType: 'tag' };
2473
+ const allCommitsExtended: any[] = [];
2474
+
2475
+ const gitModulesContent = `[submodule "libs/common"]
2476
+ path = libs/common
2477
+ url = https://example.com/common
2478
+ [submodule "libs/utils"]
2479
+ path = libs/utils
2480
+ url = https://example.com/utils`;
2481
+
2482
+ (TFSServices.getItemContent as jest.Mock)
2483
+ .mockResolvedValueOnce({ content: gitModulesContent })
2484
+ .mockResolvedValueOnce({ content: 'target-sha-1' })
2485
+ .mockResolvedValueOnce({ content: 'source-sha-1' })
2486
+ .mockResolvedValueOnce({ content: 'target-sha-2' })
2487
+ .mockResolvedValueOnce({ content: 'source-sha-2' });
2488
+
2489
+ // Act
2490
+ const result = await gitDataProvider.getSubmodulesData(
2491
+ projectName,
2492
+ repoId,
2493
+ targetVersion,
2494
+ sourceVersion,
2495
+ allCommitsExtended
2496
+ );
2497
+
2498
+ // Assert
2499
+ expect(result).toHaveLength(2);
2500
+ expect(result[0].gitSubModuleName).toBe('libs_common');
2501
+ expect(result[1].gitSubModuleName).toBe('libs_utils');
2502
+ });
2503
+ });
2504
+
2505
+ describe('GitDataProvider - createLinkedRelatedItemsForSVD', () => {
2506
+ let gitDataProvider: GitDataProvider;
2507
+ const mockOrgUrl = 'https://dev.azure.com/orgname/';
2508
+ const mockToken = 'mock-token';
2509
+
2510
+ beforeEach(() => {
2511
+ jest.clearAllMocks();
2512
+ gitDataProvider = new GitDataProvider(mockOrgUrl, mockToken);
2513
+ });
2514
+
2515
+ it('should add Requirement when linkedWiTypes=reqOnly and linkedWiRelationship=affectsOnly', async () => {
2516
+ jest.spyOn((gitDataProvider as any).ticketsDataProvider, 'GetWorkItemByUrl').mockResolvedValueOnce({
2517
+ id: 10,
2518
+ fields: { 'System.WorkItemType': 'Requirement', 'System.Title': 'Req' },
2519
+ _links: { html: { href: 'http://example.com/10' } },
2520
+ });
2521
+
2522
+ const res = await (gitDataProvider as any).createLinkedRelatedItemsForSVD(
2523
+ { isEnabled: true, linkedWiTypes: 'reqOnly', linkedWiRelationship: 'affectsOnly' },
2524
+ {
2525
+ id: 1,
2526
+ relations: [
2527
+ {
2528
+ url: 'https://example.com/_apis/wit/workItems/10',
2529
+ rel: 'Affects',
2530
+ attributes: { name: 'Affects' },
2531
+ },
2532
+ ],
2533
+ }
2534
+ );
2535
+
2536
+ expect(res).toHaveLength(1);
2537
+ expect(res[0]).toEqual(
2538
+ expect.objectContaining({
2539
+ id: 10,
2540
+ wiType: 'Requirement',
2541
+ relationType: 'Affects',
2542
+ title: 'Req',
2543
+ url: 'http://example.com/10',
2544
+ })
2545
+ );
2546
+ });
2547
+
2548
+ it('should add Feature when linkedWiTypes=featureOnly and linkedWiRelationship=coversOnly', async () => {
2549
+ jest.spyOn((gitDataProvider as any).ticketsDataProvider, 'GetWorkItemByUrl').mockResolvedValueOnce({
2550
+ id: 11,
2551
+ fields: { 'System.WorkItemType': 'Feature', 'System.Title': 'Feat' },
2552
+ _links: { html: { href: 'http://example.com/11' } },
2553
+ });
2554
+
2555
+ const res = await (gitDataProvider as any).createLinkedRelatedItemsForSVD(
2556
+ { isEnabled: true, linkedWiTypes: 'featureOnly', linkedWiRelationship: 'coversOnly' },
2557
+ {
2558
+ id: 2,
2559
+ relations: [
2560
+ {
2561
+ url: 'https://example.com/_apis/wit/workItems/11',
2562
+ rel: 'CoveredBy',
2563
+ attributes: { name: 'CoveredBy' },
2564
+ },
2565
+ ],
2566
+ }
2567
+ );
2568
+
2569
+ expect(res).toHaveLength(1);
2570
+ expect(res[0]).toEqual(
2571
+ expect.objectContaining({
2572
+ id: 11,
2573
+ wiType: 'Feature',
2574
+ relationType: 'CoveredBy',
2575
+ title: 'Feat',
2576
+ url: 'http://example.com/11',
2577
+ })
2578
+ );
2579
+ });
2580
+
2581
+ it('should add items when linkedWiTypes=both and linkedWiRelationship=both', async () => {
2582
+ jest.spyOn((gitDataProvider as any).ticketsDataProvider, 'GetWorkItemByUrl').mockResolvedValueOnce({
2583
+ id: 12,
2584
+ fields: { 'System.WorkItemType': 'Requirement', 'System.Title': 'Req 12' },
2585
+ _links: { html: { href: 'http://example.com/12' } },
2586
+ });
2587
+
2588
+ const res = await (gitDataProvider as any).createLinkedRelatedItemsForSVD(
2589
+ { isEnabled: true, linkedWiTypes: 'both', linkedWiRelationship: 'both' },
2590
+ {
2591
+ id: 3,
2592
+ relations: [
2593
+ {
2594
+ url: 'https://example.com/_apis/wit/workItems/12',
2595
+ rel: 'Affects',
2596
+ attributes: { name: 'Affects' },
2597
+ },
2598
+ ],
2599
+ }
2600
+ );
2601
+
2602
+ expect(res).toHaveLength(1);
2603
+ });
2604
+
2605
+ it('should skip when linkedWiTypes is enabled but item type does not match', async () => {
2606
+ jest.spyOn((gitDataProvider as any).ticketsDataProvider, 'GetWorkItemByUrl').mockResolvedValueOnce({
2607
+ id: 13,
2608
+ fields: { 'System.WorkItemType': 'Task', 'System.Title': 'Task 13' },
2609
+ _links: { html: { href: 'http://example.com/13' } },
2610
+ });
2611
+
2612
+ const res = await (gitDataProvider as any).createLinkedRelatedItemsForSVD(
2613
+ { isEnabled: true, linkedWiTypes: 'reqOnly', linkedWiRelationship: 'both' },
2614
+ {
2615
+ id: 4,
2616
+ relations: [
2617
+ {
2618
+ url: 'https://example.com/_apis/wit/workItems/13',
2619
+ rel: 'Affects',
2620
+ attributes: { name: 'Affects' },
2621
+ },
2622
+ ],
2623
+ }
2624
+ );
2625
+
2626
+ expect(res).toEqual([]);
2627
+ });
2628
+ });