@elisra-devops/docgen-data-provider 1.120.0 → 1.122.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.
@@ -96,9 +96,14 @@ export default class PipelinesDataProvider {
96
96
  /**
97
97
  * Finds the previous successful completed build for a definition.
98
98
  *
99
- * Discovery prefers the target run's branch first. If no same-branch candidate exists, it
100
- * falls back to the same repository on any branch so auto-discovery can still work for
101
- * sparse branches or customer histories where the previous success is on another branch.
99
+ * Discovery order:
100
+ * 1. Same-branch search (preferred): pages the Builds API filtered to succeeded builds on the
101
+ * same branch as the target run.
102
+ * 2. Ancestry-walk fallback: if no same-branch result, finds the merge-base between the
103
+ * target commit and the default branch, then returns the latest default-branch build whose
104
+ * sourceVersion is an ancestor of that merge-base. Useful for feature-branch first builds
105
+ * that have never had a prior same-branch success.
106
+ * 3. Returns undefined if neither step finds a candidate (caller falls through to baseline).
102
107
  *
103
108
  * @returns Previous build id, or undefined when no valid candidate is found.
104
109
  */
@@ -127,16 +132,17 @@ export default class PipelinesDataProvider {
127
132
  }
128
133
  }
129
134
 
130
- const anyBranchResult = await this.findPreviousSuccessfulBuildPage(
135
+ const ancestryId = await this.findAncestryFallbackBuild(
131
136
  teamProject,
132
137
  definitionId,
133
138
  toBuildId,
134
139
  targetPipeline
135
140
  );
136
- if (anyBranchResult.status === 'failed') {
137
- throw anyBranchResult.error;
141
+ if (ancestryId !== undefined) {
142
+ return ancestryId;
138
143
  }
139
- return anyBranchResult.status === 'found' ? anyBranchResult.id : undefined;
144
+
145
+ return undefined;
140
146
  }
141
147
 
142
148
  /**
@@ -204,6 +210,187 @@ export default class PipelinesDataProvider {
204
210
  return { status: 'not_found' };
205
211
  }
206
212
 
213
+ /**
214
+ * Ancestry-walk fallback for findPreviousSuccessfulBuild.
215
+ *
216
+ * Used when no same-branch successful build exists. Resolves the merge-base between the
217
+ * target commit and the repo's default branch, then finds the latest default-branch build
218
+ * whose sourceVersion is an ancestor of that merge-base.
219
+ *
220
+ * Returns undefined (never throws) so the caller can fall through to baseline SVD mode.
221
+ */
222
+ private async findAncestryFallbackBuild(
223
+ teamProject: string,
224
+ definitionId: string,
225
+ toBuildId: number,
226
+ targetPipeline: any
227
+ ): Promise<number | undefined> {
228
+ try {
229
+ const targetRepo = this.getPrimaryPipelineRepository(targetPipeline);
230
+ const repoId = targetRepo?.repository?.id;
231
+ const targetSha = targetRepo?.version;
232
+
233
+ if (!repoId || !targetSha) {
234
+ return undefined;
235
+ }
236
+
237
+ const defaultBranch = await this.getRepoDefaultBranch(teamProject, repoId);
238
+ if (!defaultBranch) {
239
+ return undefined;
240
+ }
241
+ const normalizedDefault = this.normalizeBranchName(defaultBranch);
242
+ if (!normalizedDefault) {
243
+ return undefined;
244
+ }
245
+
246
+ const mergeBase = await this.getMergeBase(teamProject, repoId, defaultBranch, targetSha);
247
+ if (!mergeBase) {
248
+ return undefined;
249
+ }
250
+
251
+ logger.debug(
252
+ `[ancestry] target=${targetSha.substring(0, 7)} defaultBranch=${defaultBranch} mergeBase=${mergeBase.substring(0, 7)}`
253
+ );
254
+
255
+ let continuationToken: string | undefined;
256
+ let pageCount = 0;
257
+
258
+ do {
259
+ // encodeURIComponent(teamProject) is used here (and in getRepoDefaultBranch / getMergeBase)
260
+ // for consistency within the ancestry helpers. findPreviousSuccessfulBuildPage uses a bare
261
+ // teamProject segment — both forms are accepted by ADO, but they should be unified in a
262
+ // future cleanup pass.
263
+ let url =
264
+ `${this.orgUrl}${encodeURIComponent(teamProject)}/_apis/build/builds` +
265
+ `?definitions=${encodeURIComponent(String(definitionId))}` +
266
+ `&resultFilter=succeeded&statusFilter=completed` +
267
+ `&queryOrder=finishTimeDescending&$top=200&api-version=6.0` +
268
+ `&branchName=${encodeURIComponent(normalizedDefault)}`;
269
+ if (continuationToken) {
270
+ url += `&continuationToken=${encodeURIComponent(continuationToken)}`;
271
+ }
272
+
273
+ const { data, headers } = await TFSServices.getItemContentWithHeaders(
274
+ url,
275
+ this.token,
276
+ 'get',
277
+ null,
278
+ null
279
+ );
280
+ pageCount++;
281
+ const builds: any[] = data?.value || [];
282
+
283
+ for (const build of builds) {
284
+ if (!this.isMatchingPreviousBuild(build, targetRepo, toBuildId, normalizedDefault)) {
285
+ continue;
286
+ }
287
+ const candidateSha: string | undefined = build.sourceVersion;
288
+ if (!candidateSha) continue;
289
+
290
+ const isAncestor = await this.isCommitAncestorOf(
291
+ teamProject,
292
+ repoId,
293
+ candidateSha,
294
+ mergeBase
295
+ );
296
+ if (isAncestor) {
297
+ logger.debug(
298
+ `[ancestry] selected build ${build.id} sourceVersion=${candidateSha.substring(0, 7)}`
299
+ );
300
+ return Number(build.id);
301
+ }
302
+ }
303
+
304
+ continuationToken = this.getContinuationToken(headers);
305
+ if (continuationToken && pageCount >= MAX_DISCOVERY_PAGES) {
306
+ logger.warn(`[ancestry] fallback exceeded ${MAX_DISCOVERY_PAGES} pages without match`);
307
+ return undefined;
308
+ }
309
+ } while (continuationToken);
310
+
311
+ return undefined;
312
+ } catch (err: unknown) {
313
+ logger.warn(`[ancestry] fallback failed: ${this.getErrorMessage(err)}`);
314
+ return undefined;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Returns the default branch name (e.g. "refs/heads/main") for a Git repository.
320
+ * Returns undefined if the repository cannot be fetched.
321
+ */
322
+ private async getRepoDefaultBranch(
323
+ teamProject: string,
324
+ repoId: string
325
+ ): Promise<string | undefined> {
326
+ const url = `${this.orgUrl}${encodeURIComponent(teamProject)}/_apis/git/repositories/${repoId}?api-version=6.0`;
327
+ try {
328
+ const result = await TFSServices.getItemContent(url, this.token, 'get', null, null, false);
329
+ return typeof result?.defaultBranch === 'string' && result.defaultBranch
330
+ ? result.defaultBranch
331
+ : undefined;
332
+ } catch {
333
+ return undefined;
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Returns the merge-base commit SHA between a branch tip and a target commit SHA.
339
+ *
340
+ * Uses the ADO Git diffs/commits API:
341
+ * baseVersion=<branchShortName> (branch type), targetVersion=<commitSha> (commit type)
342
+ * The returned commonCommit is the merge-base.
343
+ */
344
+ private async getMergeBase(
345
+ teamProject: string,
346
+ repoId: string,
347
+ defaultBranch: string,
348
+ targetSha: string
349
+ ): Promise<string | undefined> {
350
+ const branchShort = defaultBranch.replace(/^refs\/heads\//, '');
351
+ const url =
352
+ `${this.orgUrl}${encodeURIComponent(teamProject)}/_apis/git/repositories/${repoId}/diffs/commits` +
353
+ `?baseVersion=${encodeURIComponent(branchShort)}&baseVersionType=branch` +
354
+ `&targetVersion=${encodeURIComponent(targetSha)}&targetVersionType=commit` +
355
+ `&$top=1&api-version=6.0`;
356
+ try {
357
+ const result = await TFSServices.getItemContent(url, this.token, 'get', null, null, false);
358
+ return typeof result?.commonCommit === 'string' && result.commonCommit
359
+ ? result.commonCommit
360
+ : undefined;
361
+ } catch {
362
+ return undefined;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Returns true when candidateSha is an ancestor of (or equal to) targetSha.
368
+ *
369
+ * Uses the ADO Git diffs/commits API:
370
+ * baseVersion=candidateSha (commit), targetVersion=targetSha (commit)
371
+ * If candidateSha is an ancestor of targetSha, it IS the common ancestor of the two,
372
+ * so commonCommit === candidateSha.
373
+ */
374
+ private async isCommitAncestorOf(
375
+ teamProject: string,
376
+ repoId: string,
377
+ candidateSha: string,
378
+ targetSha: string
379
+ ): Promise<boolean> {
380
+ if (candidateSha === targetSha) return true;
381
+ const url =
382
+ `${this.orgUrl}${encodeURIComponent(teamProject)}/_apis/git/repositories/${repoId}/diffs/commits` +
383
+ `?baseVersion=${encodeURIComponent(candidateSha)}&baseVersionType=commit` +
384
+ `&targetVersion=${encodeURIComponent(targetSha)}&targetVersionType=commit` +
385
+ `&$top=1&api-version=6.0`;
386
+ try {
387
+ const result = await TFSServices.getItemContent(url, this.token, 'get', null, null, false);
388
+ return result?.commonCommit === candidateSha;
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
393
+
207
394
  private getContinuationToken(headers: any): string | undefined {
208
395
  return headers?.['x-ms-continuationtoken'] || headers?.['x-ms-continuation-token'] || undefined;
209
396
  }
@@ -219,7 +406,13 @@ export default class PipelinesDataProvider {
219
406
  private getPrimaryPipelineRepository(pipeline: any): any {
220
407
  const repositories = pipeline?.resources?.repositories;
221
408
  if (!repositories) return undefined;
222
- return repositories.self || repositories[0]?.self || repositories.__designer_repo;
409
+ if (repositories.self) return repositories.self;
410
+ if (repositories.__designer_repo) return repositories.__designer_repo;
411
+ // resources.repositories is a plain named-key object (not an array), so [0] is always
412
+ // undefined. Fall back to the first value for pipelines using a custom checkout alias.
413
+ // Some older ADO pipeline formats wrap the repo under a nested .self key; unwrap if present.
414
+ const first = Object.values(repositories)[0] as any;
415
+ return first?.self ?? first ?? undefined;
223
416
  }
224
417
 
225
418
  /**
@@ -1007,6 +1200,11 @@ export default class PipelinesDataProvider {
1007
1200
  if (!repoId) continue;
1008
1201
 
1009
1202
  const repo: Repository = await gitDataProviderInstance.GetGitRepoFromRepoId(repoId);
1203
+ const rawProjectName = repo.project?.name || repo.project?.id;
1204
+ const resolvedProjectName = await this.normalizeProjectName(rawProjectName);
1205
+ if (resolvedProjectName && repo.project) {
1206
+ repo.project.name = resolvedProjectName;
1207
+ }
1010
1208
  const repoApiUrl = this.buildRepoApiUrl(repo);
1011
1209
  const resourceRepository: ResourceRepository = {
1012
1210
  repoName: repo.name,
@@ -1043,7 +1241,7 @@ export default class PipelinesDataProvider {
1043
1241
  * @returns A promise that resolves to the content of the pipeline run.
1044
1242
  */
1045
1243
  async getPipelineRunDetails(projectName: string, pipelineId: number, runId: number): Promise<PipelineRun> {
1046
- let url = `${this.orgUrl}${projectName}/_apis/pipelines/${pipelineId}/runs/${runId}`;
1244
+ let url = `${this.orgUrl}${projectName}/_apis/pipelines/${pipelineId}/runs/${runId}?$expand=resources`;
1047
1245
  return TFSServices.getItemContent(url, this.token);
1048
1246
  }
1049
1247
 
@@ -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);
@@ -1291,10 +1291,6 @@ describe('PipelinesDataProvider', () => {
1291
1291
  const pipelineId = '123';
1292
1292
  const toPipelineRunId = 100;
1293
1293
  const targetPipeline = {} as PipelineRun;
1294
- (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1295
- data: { value: [] },
1296
- headers: {},
1297
- });
1298
1294
  (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({});
1299
1295
 
1300
1296
  // Act
@@ -1319,21 +1315,18 @@ describe('PipelinesDataProvider', () => {
1319
1315
  },
1320
1316
  } as any;
1321
1317
 
1322
- (TFSServices.getItemContentWithHeaders as jest.Mock)
1323
- .mockResolvedValueOnce({
1324
- data: { value: [] },
1325
- headers: {},
1326
- })
1318
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1319
+ data: { value: [] },
1320
+ headers: {},
1321
+ });
1322
+ (TFSServices.getItemContent as jest.Mock)
1323
+ .mockResolvedValueOnce({}) // getRepoDefaultBranch (no defaultBranch → ancestry short-circuits)
1327
1324
  .mockResolvedValueOnce({
1328
- data: { value: [] },
1329
- headers: {},
1325
+ value: [
1326
+ { id: 100, result: 'succeeded' },
1327
+ { id: 99, result: 'succeeded' },
1328
+ ],
1330
1329
  });
1331
- (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
1332
- value: [
1333
- { id: 100, result: 'succeeded' },
1334
- { id: 99, result: 'succeeded' },
1335
- ],
1336
- });
1337
1330
 
1338
1331
  jest.spyOn(pipelinesDataProvider as any, 'getPipelineRunDetails').mockResolvedValueOnce({
1339
1332
  resources: {
@@ -1357,10 +1350,6 @@ describe('PipelinesDataProvider', () => {
1357
1350
  const toPipelineRunId = 100;
1358
1351
  const targetPipeline = {} as PipelineRun;
1359
1352
 
1360
- (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1361
- data: { value: [] },
1362
- headers: {},
1363
- });
1364
1353
  (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
1365
1354
  value: [{ id: 99, result: 'succeeded' }],
1366
1355
  });
@@ -1440,16 +1429,12 @@ describe('PipelinesDataProvider', () => {
1440
1429
  },
1441
1430
  };
1442
1431
 
1443
- (TFSServices.getItemContentWithHeaders as jest.Mock)
1444
- .mockResolvedValueOnce({
1445
- data: { value: [] },
1446
- headers: {},
1447
- })
1448
- .mockResolvedValueOnce({
1449
- data: { value: [] },
1450
- headers: {},
1451
- });
1432
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1433
+ data: { value: [] },
1434
+ headers: {},
1435
+ });
1452
1436
  (TFSServices.getItemContent as jest.Mock)
1437
+ .mockResolvedValueOnce({}) // getRepoDefaultBranch (no defaultBranch → ancestry short-circuits)
1453
1438
  .mockResolvedValueOnce(mockRunHistory)
1454
1439
  .mockResolvedValueOnce(mockPipelineDetails);
1455
1440
 
@@ -1513,16 +1498,14 @@ describe('PipelinesDataProvider', () => {
1513
1498
  );
1514
1499
  });
1515
1500
 
1516
- it('should fall back to a different branch only when same-branch discovery fails', async () => {
1517
- (TFSServices.getItemContentWithHeaders as jest.Mock)
1518
- .mockResolvedValueOnce({
1519
- data: { value: [] },
1520
- headers: {},
1521
- })
1522
- .mockResolvedValueOnce({
1523
- data: { value: [buildCandidate(95, 'refs/heads/release')] },
1524
- headers: {},
1525
- });
1501
+ it('should return undefined when no same-branch build found (no cross-branch fallback)', async () => {
1502
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1503
+ data: { value: [] },
1504
+ headers: {},
1505
+ });
1506
+ (TFSServices.getItemContent as jest.Mock)
1507
+ .mockResolvedValueOnce({}) // getRepoDefaultBranch — no defaultBranch, ancestry exits
1508
+ .mockResolvedValueOnce({ value: [] }); // GetPipelineRunHistory (legacy path)
1526
1509
 
1527
1510
  const result = await pipelinesDataProvider.findPreviousPipeline(
1528
1511
  'project1',
@@ -1531,27 +1514,21 @@ describe('PipelinesDataProvider', () => {
1531
1514
  targetPipelineRun
1532
1515
  );
1533
1516
 
1534
- expect(result).toBe(95);
1535
- expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(2);
1517
+ expect(result).toBeUndefined();
1518
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(1);
1536
1519
  expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
1537
1520
  'branchName=refs%2Fheads%2Fmain'
1538
1521
  );
1539
- expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).not.toContain(
1540
- 'branchName='
1541
- );
1542
1522
  });
1543
1523
 
1544
1524
  it('should query completed successful builds and ignore non-previous candidates', async () => {
1545
- (TFSServices.getItemContentWithHeaders as jest.Mock)
1546
- .mockResolvedValueOnce({
1547
- data: { value: [buildCandidate(100, 'refs/heads/main'), buildCandidate(101, 'refs/heads/main')] },
1548
- headers: {},
1549
- })
1550
- .mockResolvedValueOnce({
1551
- data: { value: [] },
1552
- headers: {},
1553
- });
1554
- (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({});
1525
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1526
+ data: { value: [buildCandidate(100, 'refs/heads/main'), buildCandidate(101, 'refs/heads/main')] },
1527
+ headers: {},
1528
+ });
1529
+ (TFSServices.getItemContent as jest.Mock)
1530
+ .mockResolvedValueOnce({}) // getRepoDefaultBranch — no defaultBranch, ancestry exits
1531
+ .mockResolvedValueOnce({ value: [] }); // GetPipelineRunHistory (legacy path)
1555
1532
 
1556
1533
  const result = await pipelinesDataProvider.findPreviousPipeline(
1557
1534
  'project1',
@@ -1582,22 +1559,24 @@ describe('PipelinesDataProvider', () => {
1582
1559
  expect(TFSServices.getItemContent).not.toHaveBeenCalled();
1583
1560
  });
1584
1561
 
1585
- it('should throw when cross-branch Builds API fallback fails after same-branch no-match', async () => {
1586
- (TFSServices.getItemContentWithHeaders as jest.Mock)
1587
- .mockResolvedValueOnce({
1588
- data: { value: [] },
1589
- headers: {},
1590
- })
1591
- .mockRejectedValueOnce(new Error('fallback failed'));
1592
-
1593
- await expect(
1594
- pipelinesDataProvider.findPreviousPipeline('project1', '123', 100, targetPipelineRun)
1595
- ).rejects.toThrow('fallback failed');
1562
+ it('should not attempt cross-branch Builds API after same-branch no-match', async () => {
1563
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1564
+ data: { value: [] },
1565
+ headers: {},
1566
+ });
1567
+ (TFSServices.getItemContent as jest.Mock)
1568
+ .mockResolvedValueOnce({}) // getRepoDefaultBranch — no defaultBranch, ancestry exits
1569
+ .mockResolvedValueOnce({ value: [] }); // GetPipelineRunHistory (legacy path)
1596
1570
 
1597
- expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(2);
1598
- expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).not.toContain(
1599
- 'branchName='
1571
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1572
+ 'project1',
1573
+ '123',
1574
+ 100,
1575
+ targetPipelineRun
1600
1576
  );
1577
+
1578
+ expect(result).toBeUndefined();
1579
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(1);
1601
1580
  });
1602
1581
 
1603
1582
  it('should throw when a later Builds API page fails', async () => {
@@ -1625,6 +1604,139 @@ describe('PipelinesDataProvider', () => {
1625
1604
  pipelinesDataProvider.findPreviousPipeline('project1', '123', 100, targetPipelineRun)
1626
1605
  ).rejects.toThrow('Pipeline discovery exceeded 50 pages');
1627
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
+ });
1628
1740
  });
1629
1741
 
1630
1742
  describe('private helper methods', () => {