@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.
- package/bin/modules/PipelinesDataProvider.d.ts +40 -3
- package/bin/modules/PipelinesDataProvider.js +166 -11
- package/bin/modules/PipelinesDataProvider.js.map +1 -1
- package/bin/tests/modules/pipelineDataProvider.test.js +111 -47
- package/bin/tests/modules/pipelineDataProvider.test.js.map +1 -1
- package/package.json +1 -1
- package/src/modules/PipelinesDataProvider.ts +207 -9
- package/src/tests/modules/pipelineDataProvider.test.ts +182 -70
|
@@ -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
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
|
135
|
+
const ancestryId = await this.findAncestryFallbackBuild(
|
|
131
136
|
teamProject,
|
|
132
137
|
definitionId,
|
|
133
138
|
toBuildId,
|
|
134
139
|
targetPipeline
|
|
135
140
|
);
|
|
136
|
-
if (
|
|
137
|
-
|
|
141
|
+
if (ancestryId !== undefined) {
|
|
142
|
+
return ancestryId;
|
|
138
143
|
}
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
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
|
|
1517
|
-
(TFSServices.getItemContentWithHeaders as jest.Mock)
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
.mockResolvedValueOnce({
|
|
1523
|
-
|
|
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).
|
|
1535
|
-
expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(
|
|
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
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
.mockResolvedValueOnce({
|
|
1551
|
-
|
|
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
|
|
1586
|
-
(TFSServices.getItemContentWithHeaders as jest.Mock)
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
.
|
|
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
|
-
|
|
1598
|
-
|
|
1599
|
-
'
|
|
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', () => {
|