@elisra-devops/docgen-data-provider 1.69.14 → 1.69.15

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.
@@ -218,6 +218,21 @@ export default class PipelinesDataProvider {
218
218
  return `refs/heads/${b}`;
219
219
  }
220
220
 
221
+ private tryBuildBuildApiUrlFromPipelinesApiUrl(url?: string): string | undefined {
222
+ if (!url) return undefined;
223
+ try {
224
+ const parsed = new URL(url);
225
+ parsed.search = '';
226
+ const before = parsed.pathname;
227
+ const after = before.replace('/_apis/pipelines/', '/_apis/build/builds/');
228
+ if (after === before) return undefined;
229
+ parsed.pathname = after;
230
+ return parsed.toString();
231
+ } catch {
232
+ return undefined;
233
+ }
234
+ }
235
+
221
236
  /**
222
237
  * Searches for a build by build number without restricting by definition id.
223
238
  *
@@ -272,8 +287,7 @@ export default class PipelinesDataProvider {
272
287
  if (projectName) {
273
288
  try {
274
289
  return await this.getPipelineBuildByBuildId(projectName, buildId);
275
- } catch (e1: any) {
276
- }
290
+ } catch (e1: any) {}
277
291
  }
278
292
 
279
293
  // Some ADO instances allow build lookup without the {project} path segment.
@@ -350,7 +364,9 @@ export default class PipelinesDataProvider {
350
364
  return undefined;
351
365
  } catch (e: any) {
352
366
  logger.error(
353
- `Error resolving runId by runName for pipeline ${pipelineId} (${projectName}/${desired}): ${e?.message || e}`
367
+ `Error resolving runId by runName for pipeline ${pipelineId} (${projectName}/${desired}): ${
368
+ e?.message || e
369
+ }`
354
370
  );
355
371
  return undefined;
356
372
  }
@@ -431,9 +447,7 @@ export default class PipelinesDataProvider {
431
447
  const definitionType = buildResponse?.definition?.type;
432
448
  const repoType = buildResponse?.repository?.type;
433
449
  return (
434
- !!buildResponse &&
435
- definitionType === 'build' &&
436
- (repoType === 'TfsGit' || repoType === 'azureReposGit')
450
+ !!buildResponse && definitionType === 'build' && (repoType === 'TfsGit' || repoType === 'azureReposGit')
437
451
  );
438
452
  }
439
453
 
@@ -461,17 +475,18 @@ export default class PipelinesDataProvider {
461
475
  const resourcePipelinesByKey: Map<string, any> = new Map();
462
476
 
463
477
  if (!inPipeline?.resources?.pipelines) {
464
- return new Set();
478
+ logger.debug('getPipelineResourcePipelinesFromObject: no pipeline resources on run');
479
+ return [];
465
480
  }
466
481
  const pipelineEntries = Object.entries(inPipeline.resources.pipelines);
482
+ logger.debug(
483
+ `getPipelineResourcePipelinesFromObject: resolving ${pipelineEntries.length} pipeline resources`
484
+ );
467
485
 
468
486
  await Promise.all(
469
487
  pipelineEntries.map(async ([resourcePipelineAlias, resource]) => {
470
488
  const resourcePipelineObj = (resource as any)?.pipeline;
471
489
  const pipelineIdCandidate = Number(resourcePipelineObj?.id);
472
- const source = (resource as any)?.source;
473
- const version = (resource as any)?.version;
474
- const branch = (resource as any)?.branch;
475
490
 
476
491
  const rawProjectName =
477
492
  (resource as any)?.project?.name ||
@@ -480,38 +495,101 @@ export default class PipelinesDataProvider {
480
495
  const projectName = await this.normalizeProjectName(rawProjectName);
481
496
 
482
497
  if (!Number.isFinite(pipelineIdCandidate)) {
498
+ logger.warn(
499
+ `getPipelineResourcePipelinesFromObject: resource ${resourcePipelineAlias} missing numeric pipeline id`
500
+ );
483
501
  return;
484
502
  }
485
503
 
486
- let runId: number | undefined = this.inferRunIdFromPipelineResource(resource, resourcePipelineObj?.url);
487
- if (typeof runId !== 'number' || !Number.isFinite(runId)) {
488
- runId = await this.resolveRunIdFromVersion({
489
- projectName,
490
- pipelineIdCandidate,
491
- version,
492
- branch,
493
- source,
494
- });
504
+ let buildResponse: any;
505
+ const fixedUrl = this.tryBuildBuildApiUrlFromPipelinesApiUrl(resourcePipelineObj?.url);
506
+ if (fixedUrl) {
507
+ try {
508
+ const fixedBuildResponse = await TFSServices.getItemContent(
509
+ fixedUrl,
510
+ this.token,
511
+ 'get',
512
+ null,
513
+ null
514
+ );
515
+ if (this.isSupportedResourcePipelineBuild(fixedBuildResponse)) {
516
+ buildResponse = fixedBuildResponse;
517
+ }
518
+ } catch (err: any) {
519
+ logger.error(
520
+ `Error fetching pipeline ${resourcePipelineAlias} via fixed build url ${fixedUrl} : ${err.message}`
521
+ );
522
+ }
523
+ } else {
524
+ logger.warn(
525
+ `getPipelineResourcePipelinesFromObject: could not convert pipelines url to builds url for ${resourcePipelineAlias}: ${String(
526
+ resourcePipelineObj?.url || ''
527
+ )}`
528
+ );
495
529
  }
496
530
 
497
- if (typeof runId !== 'number' || !Number.isFinite(runId)) {
531
+ // PowerShell-style logic: do not fall back to runId/buildNumber/run-history resolution.
532
+ if (!this.isSupportedResourcePipelineBuild(buildResponse)) {
533
+ logger.debug(
534
+ `getPipelineResourcePipelinesFromObject: skipping ${resourcePipelineAlias} because resolved build is missing/unsupported (project=${String(
535
+ projectName || ''
536
+ )}, pipelineIdCandidate=${pipelineIdCandidate})`
537
+ );
498
538
  return;
499
539
  }
500
540
 
501
- let buildResponse: any;
502
- try {
503
- buildResponse = await this.tryGetBuildByIdWithFallback(projectName, runId);
504
- } catch (err: any) {
505
- logger.error(`Error fetching pipeline ${resourcePipelineAlias} run ${runId} : ${err.message}`);
506
- }
507
541
  if (this.isSupportedResourcePipelineBuild(buildResponse)) {
542
+ const buildNumber = String(buildResponse.buildNumber || '').trim();
543
+ if (!buildNumber) {
544
+ logger.warn(
545
+ `Resource pipeline ${resourcePipelineAlias} resolved to buildId=${String(
546
+ buildResponse?.id
547
+ )} but buildNumber is missing, skipping`
548
+ );
549
+ return;
550
+ }
551
+
552
+ const expectedProjectName = String(projectName || '').trim();
553
+ const actualProjectName = String(buildResponse?.project?.name || '').trim();
554
+ if (
555
+ expectedProjectName &&
556
+ actualProjectName &&
557
+ expectedProjectName.toLowerCase() !== actualProjectName.toLowerCase()
558
+ ) {
559
+ logger.warn(
560
+ `Resource pipeline ${resourcePipelineAlias} resolved to buildId=${String(
561
+ buildResponse?.id
562
+ )} but project mismatch expected=${expectedProjectName} actual=${actualProjectName}, skipping`
563
+ );
564
+ return;
565
+ }
566
+
567
+ const expectedPipelineName = String(resourcePipelineObj?.name || '').trim();
568
+ const actualPipelineName = String(buildResponse?.definition?.name || '').trim();
569
+ if (
570
+ expectedPipelineName &&
571
+ actualPipelineName &&
572
+ expectedPipelineName.toLowerCase() !== actualPipelineName.toLowerCase()
573
+ ) {
574
+ logger.warn(
575
+ `Resource pipeline ${resourcePipelineAlias} resolved to buildId=${String(
576
+ buildResponse?.id
577
+ )} but pipeline name mismatch expected=${expectedPipelineName} actual=${actualPipelineName}, skipping`
578
+ );
579
+ return;
580
+ }
508
581
  const definitionId = buildResponse.definition?.id ?? pipelineIdCandidate;
509
- const buildId = buildResponse.id ?? runId;
582
+ const buildId = buildResponse.id;
583
+ logger.debug(
584
+ `getPipelineResourcePipelinesFromObject: resolved ${resourcePipelineAlias} -> ${String(
585
+ buildResponse?.project?.name || projectName || ''
586
+ )}/${definitionId} runId=${buildId} repoType=${String(buildResponse?.repository?.type || '')}`
587
+ );
510
588
  const resourcePipelineToAdd = {
511
- name: resourcePipelineAlias,
589
+ name: resourcePipelineObj?.name ?? resourcePipelineAlias,
512
590
  buildId,
513
591
  definitionId,
514
- buildNumber: buildResponse.buildNumber ?? (resource as any)?.runName ?? (resource as any)?.version,
592
+ buildNumber,
515
593
  teamProject: buildResponse.project?.name ?? projectName,
516
594
  provider: buildResponse.repository?.type,
517
595
  };
@@ -543,7 +621,7 @@ export default class PipelinesDataProvider {
543
621
 
544
622
  for (const prop in repositories) {
545
623
  const resourceRepo = repositories[prop];
546
- if (resourceRepo?.repository?.type !== 'azureReposGit') {
624
+ if (!['azureReposGit', 'TfsGit'].includes(resourceRepo?.repository?.type)) {
547
625
  continue;
548
626
  }
549
627
  const repoId = resourceRepo.repository.id;
@@ -706,11 +784,18 @@ export default class PipelinesDataProvider {
706
784
  url += `&continuationToken=${encodeURIComponent(continuationToken)}`;
707
785
  }
708
786
  try {
709
- const { data, headers } = await TFSServices.getItemContentWithHeaders(url, this.token, 'get', null, null);
787
+ const { data, headers } = await TFSServices.getItemContentWithHeaders(
788
+ url,
789
+ this.token,
790
+ 'get',
791
+ null,
792
+ null
793
+ );
710
794
  const { value = [] } = data || {};
711
795
  all.push(...value);
712
796
  // Azure DevOps returns continuation token header for next page
713
- continuationToken = headers?.['x-ms-continuationtoken'] || headers?.['x-ms-continuation-token'] || undefined;
797
+ continuationToken =
798
+ headers?.['x-ms-continuationtoken'] || headers?.['x-ms-continuation-token'] || undefined;
714
799
  page++;
715
800
  logger.debug(`GetAllReleaseHistory: fetched page ${page}, cumulative ${all.length} releases`);
716
801
  } catch (err: any) {
@@ -681,7 +681,7 @@ describe('PipelinesDataProvider', () => {
681
681
  });
682
682
 
683
683
  describe('getPipelineResourcePipelinesFromObject', () => {
684
- it('should return empty set when no pipeline resources exist', async () => {
684
+ it('should return empty array when no pipeline resources exist', async () => {
685
685
  // Arrange
686
686
  const inPipeline = {
687
687
  resources: {},
@@ -691,7 +691,7 @@ describe('PipelinesDataProvider', () => {
691
691
  const result = await pipelinesDataProvider.getPipelineResourcePipelinesFromObject(inPipeline);
692
692
 
693
693
  // Assert
694
- expect(result).toEqual(new Set());
694
+ expect(result).toEqual([]);
695
695
  });
696
696
 
697
697
  it('should extract pipeline resources from pipeline object', async () => {
@@ -714,11 +714,11 @@ describe('PipelinesDataProvider', () => {
714
714
  id: 789,
715
715
  definition: { id: 123, type: 'build' },
716
716
  buildNumber: '20231201.1',
717
- project: { name: 'project1' },
717
+ project: { name: 'project' },
718
718
  repository: { type: 'TfsGit' },
719
719
  };
720
720
 
721
- (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockBuildResponse);
721
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockBuildResponse); // fixed-url attempt
722
722
 
723
723
  // Act
724
724
  const result = await pipelinesDataProvider.getPipelineResourcePipelinesFromObject(inPipeline);
@@ -730,7 +730,7 @@ describe('PipelinesDataProvider', () => {
730
730
  buildId: 789,
731
731
  definitionId: 123,
732
732
  buildNumber: '20231201.1',
733
- teamProject: 'project1',
733
+ teamProject: 'project',
734
734
  provider: 'TfsGit',
735
735
  });
736
736
  });
@@ -760,226 +760,35 @@ describe('PipelinesDataProvider', () => {
760
760
  expect(result).toEqual([]);
761
761
  });
762
762
 
763
- it('should resolve resource pipeline by buildNumber when runId is missing and version is semantic', async () => {
764
- // Arrange
765
- const inPipeline = {
766
- url: 'https://dev.azure.com/org/project1/_apis/pipelines/10/runs/200',
767
- resources: {
768
- pipelines: {
769
- SOME_PACKAGE: {
770
- pipeline: {
771
- id: 123,
772
- url: 'https://dev.azure.com/org/project1/_apis/pipelines/123?revision=1',
773
- },
774
- project: { name: 'project1' },
775
- source: 'project1-system-package',
776
- version: '1.0.56',
777
- branch: 'main',
778
- },
779
- },
780
- },
781
- } as unknown as PipelineRun;
782
-
783
- const mockListBuildsResponse = {
784
- value: [
785
- {
786
- id: 789,
787
- },
788
- ],
789
- };
790
- const mockBuildResponse = {
791
- id: 789,
792
- definition: { id: 123, type: 'build' },
793
- buildNumber: '1.0.56',
794
- project: { name: 'project1' },
795
- repository: { type: 'TfsGit' },
796
- };
797
-
798
- (TFSServices.getItemContent as jest.Mock)
799
- .mockResolvedValueOnce(mockListBuildsResponse) // findBuildByDefinitionAndBuildNumber
800
- .mockResolvedValueOnce(mockBuildResponse); // getPipelineBuildByBuildId
801
-
802
- // Act
803
- const result = await pipelinesDataProvider.getPipelineResourcePipelinesFromObject(inPipeline);
804
-
805
- // Assert
806
- expect(result).toHaveLength(1);
807
- expect((result as any[])[0]).toEqual({
808
- name: 'SOME_PACKAGE',
809
- buildId: 789,
810
- definitionId: 123,
811
- buildNumber: '1.0.56',
812
- teamProject: 'project1',
813
- provider: 'TfsGit',
814
- });
815
-
816
- // Ensure branch normalization was applied in the build search URL
817
- expect((TFSServices.getItemContent as jest.Mock).mock.calls[0][0]).toContain('branchName=refs%2Fheads%2Fmain');
818
- expect((TFSServices.getItemContent as jest.Mock).mock.calls[0][0]).toContain('buildNumber=1.0.56');
819
- expect((TFSServices.getItemContent as jest.Mock).mock.calls[0][0]).toContain('definitions=123');
820
- });
821
-
822
- it('should fall back to pipeline run history when buildNumber lookup returns no builds', async () => {
823
- // Arrange
824
- const inPipeline = {
825
- url: 'https://dev.azure.com/org/project1/_apis/pipelines/10/runs/200',
826
- resources: {
827
- pipelines: {
828
- SOME_PACKAGE: {
829
- pipeline: {
830
- id: 123,
831
- url: 'https://dev.azure.com/org/project1/_apis/pipelines/123?revision=1',
832
- },
833
- project: { name: 'project1' },
834
- source: 'project1-system-package',
835
- version: '20251109.1',
836
- branch: 'main',
837
- },
838
- },
839
- },
840
- } as unknown as PipelineRun;
841
-
842
- const mockListBuildsResponseEmpty = { value: [] };
843
- const mockRunHistoryResponse = {
844
- value: [{ id: 789, name: '20251109.1', result: 'succeeded' }],
845
- };
846
- const mockBuildResponse = {
847
- id: 789,
848
- definition: { id: 123, type: 'build' },
849
- buildNumber: '20251109.1',
850
- project: { name: 'project1' },
851
- repository: { type: 'TfsGit' },
852
- };
853
-
854
- (TFSServices.getItemContent as jest.Mock)
855
- .mockResolvedValueOnce(mockListBuildsResponseEmpty) // findBuildByDefinitionAndBuildNumber
856
- .mockResolvedValueOnce(mockListBuildsResponseEmpty) // findBuildByBuildNumber (no definition)
857
- .mockResolvedValueOnce(mockRunHistoryResponse) // GetPipelineRunHistory
858
- .mockResolvedValueOnce(mockBuildResponse); // getPipelineBuildByBuildId
859
-
860
- // Act
861
- const result = await pipelinesDataProvider.getPipelineResourcePipelinesFromObject(inPipeline);
862
-
863
- // Assert
864
- expect(result).toHaveLength(1);
865
- expect((result as any[])[0]).toEqual({
866
- name: 'SOME_PACKAGE',
867
- buildId: 789,
868
- definitionId: 123,
869
- buildNumber: '20251109.1',
870
- teamProject: 'project1',
871
- provider: 'TfsGit',
872
- });
873
- });
874
-
875
- it('should fall back to buildNumber-only lookup when definition-based lookup returns no builds', async () => {
876
- const inPipeline = {
877
- url: 'https://dev.azure.com/org/project1/_apis/pipelines/10/runs/200',
878
- resources: {
879
- pipelines: {
880
- SOME_PACKAGE: {
881
- pipeline: {
882
- id: 770,
883
- url: 'https://dev.azure.com/org/project1/_apis/pipelines/770?revision=1',
884
- },
885
- project: { name: 'project1' },
886
- source: 'project1-system-package',
887
- version: '20251109.1',
888
- branch: 'main',
889
- },
890
- },
891
- },
892
- } as unknown as PipelineRun;
893
-
894
- const mockListBuildsResponseEmpty = { value: [] };
895
- const mockListBuildsByNumber = {
896
- value: [
897
- {
898
- id: 789,
899
- definition: { id: 123, name: 'project1-system-package' },
900
- },
901
- ],
902
- };
903
- const mockBuildResponse = {
904
- id: 789,
905
- definition: { id: 123, type: 'build' },
906
- buildNumber: '20251109.1',
907
- project: { name: 'project1' },
908
- repository: { type: 'TfsGit' },
909
- };
910
-
911
- (TFSServices.getItemContent as jest.Mock)
912
- .mockResolvedValueOnce(mockListBuildsResponseEmpty) // findBuildByDefinitionAndBuildNumber
913
- .mockResolvedValueOnce(mockListBuildsByNumber) // findBuildByBuildNumber (no definition)
914
- .mockResolvedValueOnce(mockBuildResponse); // getPipelineBuildByBuildId
915
-
916
- const result = await pipelinesDataProvider.getPipelineResourcePipelinesFromObject(inPipeline);
917
-
918
- expect(result).toHaveLength(1);
919
- expect((result as any[])[0]).toEqual({
920
- name: 'SOME_PACKAGE',
921
- buildId: 789,
922
- definitionId: 123,
923
- buildNumber: '20251109.1',
924
- teamProject: 'project1',
925
- provider: 'TfsGit',
926
- });
927
-
928
- // Ensure the second call is the buildNumber-only lookup (no definitions= filter)
929
- expect((TFSServices.getItemContent as jest.Mock).mock.calls[1][0]).toContain('buildNumber=20251109.1');
930
- expect((TFSServices.getItemContent as jest.Mock).mock.calls[1][0]).not.toContain('definitions=');
931
- });
932
-
933
- it('should resolve project name when pipeline resource provides project id (GUID)', async () => {
763
+ it('should skip resource when fixed-url returns a build from a different project/pipeline', async () => {
934
764
  const inPipeline = {
935
- url: 'https://dev.azure.com/org/project1/_apis/pipelines/10/runs/200',
936
765
  resources: {
937
766
  pipelines: {
938
- SOME_PACKAGE: {
767
+ myPipeline: {
939
768
  pipeline: {
940
769
  id: 123,
941
- url: 'https://dev.azure.com/org/1488cb19-7369-4afc-92bf-251d368b85be/_apis/pipelines/123?revision=1',
770
+ name: 'ExpectedPipeline',
771
+ url: 'https://dev.azure.com/org/project/_apis/pipelines/123?revision=1',
942
772
  },
943
- project: { name: '1488cb19-7369-4afc-92bf-251d368b85be' },
944
- source: 'project1-system-package',
945
- version: '20251109.1',
946
- branch: 'main',
947
773
  },
948
774
  },
949
775
  },
950
776
  } as unknown as PipelineRun;
951
777
 
952
- const mockProjectResponse = { id: '1488cb19-7369-4afc-92bf-251d368b85be', name: 'Test CMMI' };
953
- const mockListBuildsResponse = { value: [{ id: 789 }] };
954
778
  const mockBuildResponse = {
955
779
  id: 789,
956
- definition: { id: 123, type: 'build' },
957
- buildNumber: '20251109.1',
958
- project: { name: 'Test CMMI' },
780
+ definition: { id: 999, name: 'DifferentPipeline', type: 'build' },
781
+ buildNumber: '20231201.1',
782
+ project: { name: 'DifferentProject' },
959
783
  repository: { type: 'TfsGit' },
960
784
  };
961
785
 
962
- (TFSServices.getItemContent as jest.Mock)
963
- .mockResolvedValueOnce(mockProjectResponse) // normalizeProjectName
964
- .mockResolvedValueOnce(mockListBuildsResponse) // findBuildByDefinitionAndBuildNumber
965
- .mockResolvedValueOnce(mockBuildResponse); // getPipelineBuildByBuildId
786
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockBuildResponse);
966
787
 
967
788
  const result = await pipelinesDataProvider.getPipelineResourcePipelinesFromObject(inPipeline);
968
789
 
969
- expect(result).toHaveLength(1);
970
- expect((result as any[])[0]).toEqual({
971
- name: 'SOME_PACKAGE',
972
- buildId: 789,
973
- definitionId: 123,
974
- buildNumber: '20251109.1',
975
- teamProject: 'Test CMMI',
976
- provider: 'TfsGit',
977
- });
978
-
979
- // Ensure project-id normalization API was called
980
- expect((TFSServices.getItemContent as jest.Mock).mock.calls[0][0]).toContain(
981
- `${mockOrgUrl}_apis/projects/1488cb19-7369-4afc-92bf-251d368b85be`
982
- );
790
+ expect(result).toEqual([]);
791
+ expect(TFSServices.getItemContent).toHaveBeenCalledTimes(1);
983
792
  });
984
793
  });
985
794
 
@@ -1227,6 +1036,126 @@ describe('PipelinesDataProvider', () => {
1227
1036
  });
1228
1037
  });
1229
1038
 
1039
+ describe('private helper methods', () => {
1040
+ it('tryGetTeamProjectFromAzureDevOpsUrl should extract project segment before _apis', () => {
1041
+ const fn = (pipelinesDataProvider as any).tryGetTeamProjectFromAzureDevOpsUrl.bind(
1042
+ pipelinesDataProvider
1043
+ );
1044
+ expect(fn('https://dev.azure.com/org/Test/_apis/pipelines/123?revision=1')).toBe('Test');
1045
+ expect(fn('https://dev.azure.com/_apis/pipelines/123?revision=1')).toBeUndefined();
1046
+ expect(fn('https://dev.azure.com/org/_apis/pipelines/123?revision=1')).toBe('org');
1047
+ expect(fn('not-a-url')).toBeUndefined();
1048
+ });
1049
+
1050
+ it('tryBuildBuildApiUrlFromPipelinesApiUrl should rewrite pipelines url to builds url and drop query', () => {
1051
+ const fn = (pipelinesDataProvider as any).tryBuildBuildApiUrlFromPipelinesApiUrl.bind(
1052
+ pipelinesDataProvider
1053
+ );
1054
+ expect(fn('https://dev.azure.com/org/Test/_apis/pipelines/123?revision=1')).toBe(
1055
+ 'https://dev.azure.com/org/Test/_apis/build/builds/123'
1056
+ );
1057
+ expect(fn('https://dev.azure.com/org/Test/_apis/build/builds/123')).toBeUndefined();
1058
+ expect(fn('not-a-url')).toBeUndefined();
1059
+ });
1060
+
1061
+ it('tryParseRunIdFromUrl should parse run id from pipelines runs URL and build id from builds URL', () => {
1062
+ const fn = (pipelinesDataProvider as any).tryParseRunIdFromUrl.bind(pipelinesDataProvider);
1063
+ expect(fn('https://dev.azure.com/org/Test/_apis/pipelines/10/runs/555')).toBe(555);
1064
+ expect(fn('https://dev.azure.com/org/Test/_apis/build/builds/777')).toBe(777);
1065
+ expect(fn('https://dev.azure.com/org/Test/_apis/pipelines/10/runs/not-a-number')).toBeUndefined();
1066
+ expect(fn('not-a-url')).toBeUndefined();
1067
+ });
1068
+
1069
+ it('normalizeBranchName should normalize branch to refs/heads form', () => {
1070
+ const fn = (pipelinesDataProvider as any).normalizeBranchName.bind(pipelinesDataProvider);
1071
+ expect(fn('main')).toBe('refs/heads/main');
1072
+ expect(fn('heads/main')).toBe('refs/heads/main');
1073
+ expect(fn('refs/heads/main')).toBe('refs/heads/main');
1074
+ expect(fn('')).toBeUndefined();
1075
+ expect(fn(undefined)).toBeUndefined();
1076
+ });
1077
+
1078
+ it('normalizeProjectName should resolve GUID project id to name and cache it', async () => {
1079
+ const guid = '009c6fae-b000-47fe-994e-be3354b78fbc';
1080
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({ name: 'ResolvedProjectName' });
1081
+
1082
+ const fn = (pipelinesDataProvider as any).normalizeProjectName.bind(pipelinesDataProvider);
1083
+ const first = await fn(guid);
1084
+ const second = await fn(guid);
1085
+
1086
+ expect(first).toBe('ResolvedProjectName');
1087
+ expect(second).toBe('ResolvedProjectName');
1088
+ expect(TFSServices.getItemContent).toHaveBeenCalledTimes(1);
1089
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
1090
+ `${mockOrgUrl}_apis/projects/${encodeURIComponent(guid)}?api-version=6.0`,
1091
+ mockToken,
1092
+ 'get',
1093
+ null,
1094
+ null,
1095
+ false
1096
+ );
1097
+ });
1098
+
1099
+ it('normalizeProjectName should return raw GUID when project resolution fails', async () => {
1100
+ const guid = '009c6fae-b000-47fe-994e-be3354b78fbc';
1101
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(new Error('boom'));
1102
+ const fn = (pipelinesDataProvider as any).normalizeProjectName.bind(pipelinesDataProvider);
1103
+ const result = await fn(guid);
1104
+ expect(result).toBe(guid);
1105
+ });
1106
+
1107
+ it('findBuildByBuildNumber should prefer matching definition name when provided', async () => {
1108
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
1109
+ value: [
1110
+ { id: 1, definition: { name: 'Other' } },
1111
+ { id: 2, definition: { name: 'Expected' } },
1112
+ ],
1113
+ });
1114
+
1115
+ const fn = (pipelinesDataProvider as any).findBuildByBuildNumber.bind(pipelinesDataProvider);
1116
+ const result = await fn('project1', '20251225.2', 'main', 'Expected');
1117
+ expect(result?.id).toBe(2);
1118
+ });
1119
+
1120
+ it('tryGetBuildByIdWithFallback should fall back to non-project-scoped URL when project-scoped lookup fails', async () => {
1121
+ jest
1122
+ .spyOn(pipelinesDataProvider as any, 'getPipelineBuildByBuildId')
1123
+ .mockRejectedValueOnce(new Error('not found'));
1124
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({ id: 123 });
1125
+
1126
+ const fn = (pipelinesDataProvider as any).tryGetBuildByIdWithFallback.bind(pipelinesDataProvider);
1127
+ const result = await fn('project1', 123);
1128
+
1129
+ expect(result).toEqual({ id: 123 });
1130
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
1131
+ `${mockOrgUrl}_apis/build/builds/123`,
1132
+ mockToken,
1133
+ 'get',
1134
+ null,
1135
+ null
1136
+ );
1137
+ });
1138
+
1139
+ it('getPipelineResourcePipelinesFromObject should skip resource when pipelines url cannot be converted to builds url', async () => {
1140
+ const inPipeline = {
1141
+ resources: {
1142
+ pipelines: {
1143
+ myPipeline: {
1144
+ pipeline: {
1145
+ id: 123,
1146
+ url: 'https://dev.azure.com/org/project/_apis/build/builds/123',
1147
+ },
1148
+ },
1149
+ },
1150
+ },
1151
+ } as unknown as PipelineRun;
1152
+
1153
+ const result = await pipelinesDataProvider.getPipelineResourcePipelinesFromObject(inPipeline);
1154
+ expect(result).toEqual([]);
1155
+ expect(TFSServices.getItemContent).not.toHaveBeenCalled();
1156
+ });
1157
+ });
1158
+
1230
1159
  describe('isInvalidPipelineRun', () => {
1231
1160
  const invokeIsInvalidPipelineRun = (
1232
1161
  pipelineRun: any,