@elisra-devops/docgen-data-provider 1.121.0 → 1.123.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.
@@ -1558,6 +1558,151 @@ describe('GitDataProvider - GetCommitBatch', () => {
1558
1558
  expect(result).toHaveLength(0);
1559
1559
  });
1560
1560
 
1561
+ it('should enrich commit workItems with WIs linked from WI development section', async () => {
1562
+ // commitsbatch returns only the commit-side linked WI (e.g. from commit message "#277476").
1563
+ // WIs linked from the WI side (ADO development section) must be merged via the per-commit
1564
+ // /workItems endpoint.
1565
+ const gitUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1566
+ const itemVersion = { version: 'main', versionType: 'branch' };
1567
+ const compareVersion = { version: 'v1.0.0', versionType: 'tag' };
1568
+
1569
+ const mockCommitsResponse = {
1570
+ data: {
1571
+ count: 1,
1572
+ value: [
1573
+ {
1574
+ commitId: 'd465de7abc',
1575
+ committer: { name: 'Dev', date: '2024-01-01T00:00:00Z' },
1576
+ comment: 'fix: link WI-277476',
1577
+ workItems: [{ id: 277476, url: 'https://tfs/wi/277476' }],
1578
+ },
1579
+ ],
1580
+ },
1581
+ };
1582
+ const mockEmptyPage = { data: { count: 0, value: [] } };
1583
+ // Per-commit endpoint returns the 2 WI-side linked items in addition to the commit-side one.
1584
+ const mockPerCommitWi = {
1585
+ value: [
1586
+ { id: 277476, url: 'https://tfs/wi/277476' }, // duplicate — must be deduplicated
1587
+ { id: 285957, url: 'https://tfs/wi/285957' },
1588
+ { id: 284959, url: 'https://tfs/wi/284959' },
1589
+ ],
1590
+ };
1591
+
1592
+ (TFSServices as any).postRequest
1593
+ .mockResolvedValueOnce(mockCommitsResponse)
1594
+ .mockResolvedValueOnce(mockEmptyPage);
1595
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockPerCommitWi);
1596
+
1597
+ const result = await gitDataProvider.GetCommitBatch(gitUrl, itemVersion, compareVersion);
1598
+
1599
+ expect(result).toHaveLength(1);
1600
+ const workItems = result[0].commit.workItems;
1601
+ const ids = workItems.map((wi: any) => wi.id).sort((a: number, b: number) => a - b);
1602
+ expect(ids).toEqual([277476, 284959, 285957]);
1603
+ // Per-commit endpoint was called with correct URL
1604
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
1605
+ `${gitUrl}/commits/d465de7abc/workItems?api-version=5.1`,
1606
+ mockToken
1607
+ );
1608
+ });
1609
+
1610
+ it('should handle per-commit WI enrichment failure gracefully and log a warning', async () => {
1611
+ const gitUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1612
+ const itemVersion = { version: 'main', versionType: 'branch' };
1613
+ const compareVersion = { version: 'v1.0.0', versionType: 'tag' };
1614
+
1615
+ const mockCommitsResponse = {
1616
+ data: {
1617
+ count: 1,
1618
+ value: [
1619
+ {
1620
+ commitId: 'aabbcc',
1621
+ committer: { name: 'Dev', date: '2024-01-01T00:00:00Z' },
1622
+ comment: 'fix',
1623
+ workItems: [{ id: 100, url: 'https://tfs/wi/100' }],
1624
+ },
1625
+ ],
1626
+ },
1627
+ };
1628
+ const mockEmptyPage = { data: { count: 0, value: [] } };
1629
+
1630
+ (TFSServices as any).postRequest
1631
+ .mockResolvedValueOnce(mockCommitsResponse)
1632
+ .mockResolvedValueOnce(mockEmptyPage);
1633
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(new Error('network error'));
1634
+
1635
+ const result = await gitDataProvider.GetCommitBatch(gitUrl, itemVersion, compareVersion);
1636
+
1637
+ expect(result).toHaveLength(1);
1638
+ expect(result[0].commit.workItems).toEqual([{ id: 100, url: 'https://tfs/wi/100' }]);
1639
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('per-commit WI enrichment failed'));
1640
+ });
1641
+
1642
+ it('should initialize workItems from absent field when per-commit endpoint returns WIs', async () => {
1643
+ // Commit arrives from commitsbatch with workItems absent (not [] but undefined).
1644
+ const gitUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1645
+ const itemVersion = { version: 'main', versionType: 'branch' };
1646
+ const compareVersion = { version: 'v1.0.0', versionType: 'tag' };
1647
+
1648
+ const mockCommitsResponse = {
1649
+ data: {
1650
+ count: 1,
1651
+ value: [
1652
+ {
1653
+ commitId: 'ccddee',
1654
+ committer: { name: 'Dev', date: '2024-01-01T00:00:00Z' },
1655
+ comment: 'no commit-side WI',
1656
+ // workItems intentionally absent
1657
+ },
1658
+ ],
1659
+ },
1660
+ };
1661
+ const mockEmptyPage = { data: { count: 0, value: [] } };
1662
+ const mockPerCommitWi = { value: [{ id: 999, url: 'https://tfs/wi/999' }] };
1663
+
1664
+ (TFSServices as any).postRequest
1665
+ .mockResolvedValueOnce(mockCommitsResponse)
1666
+ .mockResolvedValueOnce(mockEmptyPage);
1667
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockPerCommitWi);
1668
+
1669
+ const result = await gitDataProvider.GetCommitBatch(gitUrl, itemVersion, compareVersion);
1670
+
1671
+ expect(result).toHaveLength(1);
1672
+ expect(result[0].commit.workItems.map((wi: any) => wi.id)).toEqual([999]);
1673
+ });
1674
+
1675
+ it('should leave workItems unchanged when per-commit endpoint returns empty list', async () => {
1676
+ const gitUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
1677
+ const itemVersion = { version: 'main', versionType: 'branch' };
1678
+ const compareVersion = { version: 'v1.0.0', versionType: 'tag' };
1679
+
1680
+ const mockCommitsResponse = {
1681
+ data: {
1682
+ count: 1,
1683
+ value: [
1684
+ {
1685
+ commitId: 'ff0011',
1686
+ committer: { name: 'Dev', date: '2024-01-01T00:00:00Z' },
1687
+ comment: 'fix',
1688
+ workItems: [{ id: 42, url: 'https://tfs/wi/42' }],
1689
+ },
1690
+ ],
1691
+ },
1692
+ };
1693
+ const mockEmptyPage = { data: { count: 0, value: [] } };
1694
+
1695
+ (TFSServices as any).postRequest
1696
+ .mockResolvedValueOnce(mockCommitsResponse)
1697
+ .mockResolvedValueOnce(mockEmptyPage);
1698
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({ value: [] });
1699
+
1700
+ const result = await gitDataProvider.GetCommitBatch(gitUrl, itemVersion, compareVersion);
1701
+
1702
+ expect(result).toHaveLength(1);
1703
+ expect(result[0].commit.workItems.map((wi: any) => wi.id)).toEqual([42]);
1704
+ });
1705
+
1561
1706
  it('should include specificItemPath when provided', async () => {
1562
1707
  // Arrange
1563
1708
  const gitUrl = 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id';
@@ -247,7 +247,7 @@ describe('PipelinesDataProvider', () => {
247
247
 
248
248
  // Assert
249
249
  expect(TFSServices.getItemContent).toHaveBeenCalledWith(
250
- `${mockOrgUrl}${projectName}/_apis/pipelines/${pipelineId}/runs/${runId}`,
250
+ `${mockOrgUrl}${projectName}/_apis/pipelines/${pipelineId}/runs/${runId}?$expand=resources`,
251
251
  mockToken
252
252
  );
253
253
  expect(result).toEqual(mockResponse);
@@ -1319,12 +1319,14 @@ describe('PipelinesDataProvider', () => {
1319
1319
  data: { value: [] },
1320
1320
  headers: {},
1321
1321
  });
1322
- (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
1323
- value: [
1324
- { id: 100, result: 'succeeded' },
1325
- { id: 99, result: 'succeeded' },
1326
- ],
1327
- });
1322
+ (TFSServices.getItemContent as jest.Mock)
1323
+ .mockResolvedValueOnce({}) // getRepoDefaultBranch (no defaultBranch → ancestry short-circuits)
1324
+ .mockResolvedValueOnce({
1325
+ value: [
1326
+ { id: 100, result: 'succeeded' },
1327
+ { id: 99, result: 'succeeded' },
1328
+ ],
1329
+ });
1328
1330
 
1329
1331
  jest.spyOn(pipelinesDataProvider as any, 'getPipelineRunDetails').mockResolvedValueOnce({
1330
1332
  resources: {
@@ -1432,6 +1434,7 @@ describe('PipelinesDataProvider', () => {
1432
1434
  headers: {},
1433
1435
  });
1434
1436
  (TFSServices.getItemContent as jest.Mock)
1437
+ .mockResolvedValueOnce({}) // getRepoDefaultBranch (no defaultBranch → ancestry short-circuits)
1435
1438
  .mockResolvedValueOnce(mockRunHistory)
1436
1439
  .mockResolvedValueOnce(mockPipelineDetails);
1437
1440
 
@@ -1500,7 +1503,9 @@ describe('PipelinesDataProvider', () => {
1500
1503
  data: { value: [] },
1501
1504
  headers: {},
1502
1505
  });
1503
- (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({ value: [] });
1506
+ (TFSServices.getItemContent as jest.Mock)
1507
+ .mockResolvedValueOnce({}) // getRepoDefaultBranch — no defaultBranch, ancestry exits
1508
+ .mockResolvedValueOnce({ value: [] }); // GetPipelineRunHistory (legacy path)
1504
1509
 
1505
1510
  const result = await pipelinesDataProvider.findPreviousPipeline(
1506
1511
  'project1',
@@ -1521,7 +1526,9 @@ describe('PipelinesDataProvider', () => {
1521
1526
  data: { value: [buildCandidate(100, 'refs/heads/main'), buildCandidate(101, 'refs/heads/main')] },
1522
1527
  headers: {},
1523
1528
  });
1524
- (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({});
1529
+ (TFSServices.getItemContent as jest.Mock)
1530
+ .mockResolvedValueOnce({}) // getRepoDefaultBranch — no defaultBranch, ancestry exits
1531
+ .mockResolvedValueOnce({ value: [] }); // GetPipelineRunHistory (legacy path)
1525
1532
 
1526
1533
  const result = await pipelinesDataProvider.findPreviousPipeline(
1527
1534
  'project1',
@@ -1557,7 +1564,9 @@ describe('PipelinesDataProvider', () => {
1557
1564
  data: { value: [] },
1558
1565
  headers: {},
1559
1566
  });
1560
- (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({ value: [] });
1567
+ (TFSServices.getItemContent as jest.Mock)
1568
+ .mockResolvedValueOnce({}) // getRepoDefaultBranch — no defaultBranch, ancestry exits
1569
+ .mockResolvedValueOnce({ value: [] }); // GetPipelineRunHistory (legacy path)
1561
1570
 
1562
1571
  const result = await pipelinesDataProvider.findPreviousPipeline(
1563
1572
  'project1',
@@ -1595,6 +1604,139 @@ describe('PipelinesDataProvider', () => {
1595
1604
  pipelinesDataProvider.findPreviousPipeline('project1', '123', 100, targetPipelineRun)
1596
1605
  ).rejects.toThrow('Pipeline discovery exceeded 50 pages');
1597
1606
  });
1607
+
1608
+ describe('ancestry-walk fallback', () => {
1609
+ const featureTargetPipelineRun = {
1610
+ resources: {
1611
+ repositories: {
1612
+ self: {
1613
+ repository: { id: 'repo1' },
1614
+ version: 'C3-B',
1615
+ refName: 'refs/heads/feature/x',
1616
+ },
1617
+ },
1618
+ },
1619
+ } as unknown as PipelineRun;
1620
+
1621
+ beforeEach(() => {
1622
+ jest.clearAllMocks();
1623
+ });
1624
+
1625
+ it('should return ancestry-resolved build when same-branch has no match', async () => {
1626
+ // same-branch search: no candidates (empty)
1627
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1628
+ .mockResolvedValueOnce({ data: { value: [] }, headers: {} }) // same-branch
1629
+ .mockResolvedValueOnce({ // ancestry default-branch page
1630
+ data: {
1631
+ value: [
1632
+ buildCandidate(90, 'refs/heads/main'), // id=90, sourceVersion='sha-90'
1633
+ buildCandidate(70, 'refs/heads/main'), // id=70, sourceVersion='sha-70'
1634
+ ],
1635
+ },
1636
+ headers: {},
1637
+ });
1638
+
1639
+ // getRepoDefaultBranch
1640
+ (TFSServices.getItemContent as jest.Mock)
1641
+ .mockResolvedValueOnce({ defaultBranch: 'refs/heads/main' }) // getRepoDefaultBranch
1642
+ .mockResolvedValueOnce({ commonCommit: 'C3' }) // getMergeBase
1643
+ .mockResolvedValueOnce({ commonCommit: 'unrelated-sha' }) // isCommitAncestorOf sha-90 vs C3 => false
1644
+ .mockResolvedValueOnce({ commonCommit: 'sha-70' }); // isCommitAncestorOf sha-70 vs C3 => sha-70 == sha-70 -> true
1645
+
1646
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1647
+ 'project1',
1648
+ '123',
1649
+ 100,
1650
+ featureTargetPipelineRun
1651
+ );
1652
+
1653
+ expect(result).toBe(70);
1654
+ });
1655
+
1656
+ it('should return undefined when ancestry walk finds no ancestor match', async () => {
1657
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1658
+ .mockResolvedValueOnce({ data: { value: [] }, headers: {} }) // same-branch
1659
+ .mockResolvedValueOnce({ // ancestry default-branch page
1660
+ data: { value: [buildCandidate(90, 'refs/heads/main')] },
1661
+ headers: {},
1662
+ });
1663
+
1664
+ (TFSServices.getItemContent as jest.Mock)
1665
+ .mockResolvedValueOnce({ defaultBranch: 'refs/heads/main' }) // getRepoDefaultBranch
1666
+ .mockResolvedValueOnce({ commonCommit: 'C3' }) // getMergeBase
1667
+ .mockResolvedValueOnce({ commonCommit: 'unrelated-sha' }) // isCommitAncestorOf => false
1668
+ .mockResolvedValueOnce({ value: [] }); // GetPipelineRunHistory (legacy path)
1669
+
1670
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1671
+ 'project1',
1672
+ '123',
1673
+ 100,
1674
+ featureTargetPipelineRun
1675
+ );
1676
+
1677
+ expect(result).toBeUndefined();
1678
+ });
1679
+
1680
+ it('should return undefined (not throw) when getRepoDefaultBranch fails', async () => {
1681
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1682
+ .mockResolvedValueOnce({ data: { value: [] }, headers: {} }); // same-branch
1683
+
1684
+ (TFSServices.getItemContent as jest.Mock)
1685
+ .mockRejectedValueOnce(new Error('repo not found')) // getRepoDefaultBranch throws
1686
+ .mockResolvedValueOnce({ value: [] }); // GetPipelineRunHistory (legacy path)
1687
+
1688
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1689
+ 'project1',
1690
+ '123',
1691
+ 100,
1692
+ featureTargetPipelineRun
1693
+ );
1694
+
1695
+ expect(result).toBeUndefined();
1696
+ });
1697
+
1698
+ it('should not invoke ancestry walk when same-branch search finds a match', async () => {
1699
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1700
+ data: { value: [buildCandidate(90, 'refs/heads/feature/x')] },
1701
+ headers: {},
1702
+ });
1703
+
1704
+ const getItemContentSpy = jest.spyOn(TFSServices, 'getItemContent');
1705
+
1706
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1707
+ 'project1',
1708
+ '123',
1709
+ 100,
1710
+ featureTargetPipelineRun
1711
+ );
1712
+
1713
+ expect(result).toBe(90);
1714
+ // getRepoDefaultBranch / getMergeBase are called via getItemContent — none should be called
1715
+ const ancestryCalls = getItemContentSpy.mock.calls.filter(
1716
+ ([url]) => typeof url === 'string' && (url.includes('git/repositories') && !url.includes('_apis/build'))
1717
+ );
1718
+ expect(ancestryCalls).toHaveLength(0);
1719
+ });
1720
+
1721
+ it('should return undefined (not throw) when getMergeBase returns no commonCommit', async () => {
1722
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1723
+ .mockResolvedValueOnce({ data: { value: [] }, headers: {} }); // same-branch
1724
+
1725
+ (TFSServices.getItemContent as jest.Mock)
1726
+ .mockResolvedValueOnce({ defaultBranch: 'refs/heads/main' }) // getRepoDefaultBranch
1727
+ .mockResolvedValueOnce({}) // getMergeBase returns no commonCommit
1728
+ .mockResolvedValueOnce({ value: [] }); // GetPipelineRunHistory (legacy path)
1729
+
1730
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1731
+ 'project1',
1732
+ '123',
1733
+ 100,
1734
+ featureTargetPipelineRun
1735
+ );
1736
+
1737
+ expect(result).toBeUndefined();
1738
+ });
1739
+ });
1598
1740
  });
1599
1741
 
1600
1742
  describe('private helper methods', () => {