From 3313b72f1d34d174adf79d03e62aaaf3eb1a59ce Mon Sep 17 00:00:00 2001 From: Rob Jack Stewart Date: Sun, 10 Mar 2024 18:25:46 +0000 Subject: [PATCH] feat(manager/azure-pipelines): Add Azure DevOps project support (#27277) --- .../manager/azure-pipelines/extract.spec.ts | 192 +++++++++++++----- .../manager/azure-pipelines/extract.ts | 36 +++- lib/modules/manager/types.ts | 1 + 3 files changed, 167 insertions(+), 62 deletions(-) diff --git a/lib/modules/manager/azure-pipelines/extract.spec.ts b/lib/modules/manager/azure-pipelines/extract.spec.ts index e53b41639e5495..eba2d1114abaca 100644 --- a/lib/modules/manager/azure-pipelines/extract.spec.ts +++ b/lib/modules/manager/azure-pipelines/extract.spec.ts @@ -35,11 +35,14 @@ describe('modules/manager/azure-pipelines/extract', () => { describe('extractRepository()', () => { it('should extract repository information', () => { expect( - extractRepository({ - type: 'github', - name: 'user/repo', - ref: 'refs/tags/v1.0.0', - }), + extractRepository( + { + type: 'github', + name: 'user/repo', + ref: 'refs/tags/v1.0.0', + }, + 'user', + ), ).toMatchObject({ depName: 'user/repo', packageName: 'https://github.com/user/repo.git', @@ -48,30 +51,39 @@ describe('modules/manager/azure-pipelines/extract', () => { it('should return null when repository type is not github', () => { expect( - extractRepository({ - type: 'bitbucket', - name: 'user/repo', - ref: 'refs/tags/v1.0.0', - }), + extractRepository( + { + type: 'bitbucket', + name: 'user/repo', + ref: 'refs/tags/v1.0.0', + }, + 'user/repo', + ), ).toBeNull(); }); it('should return null when reference is not defined specified', () => { expect( - extractRepository({ - type: 'github', - name: 'user/repo', - }), + extractRepository( + { + type: 'github', + name: 'user/repo', + }, + 'user/repo', + ), ).toBeNull(); }); it('should return null when reference is invalid tag format', () => { expect( - extractRepository({ - type: 'github', - name: 'user/repo', - ref: 'refs/head/master', - }), + extractRepository( + { + type: 'github', + name: 'user/repo', + ref: 'refs/head/master', + }, + 'user/repo', + ), ).toBeNull(); }); @@ -82,43 +94,91 @@ describe('modules/manager/azure-pipelines/extract', () => { }); expect( - extractRepository({ - type: 'git', - name: 'project/repo', - ref: 'refs/tags/v1.0.0', - }), + extractRepository( + { + type: 'git', + name: 'project/repo', + ref: 'refs/tags/v1.0.0', + }, + 'otherProject/otherRepo', + ), ).toMatchObject({ depName: 'project/repo', packageName: 'https://dev.azure.com/renovate-org/project/_git/repo', }); }); - it('should return null if repository type is git and project not in name', () => { + it('should extract Azure repository information if project is not in name but is in the config repository', () => { GlobalConfig.set({ platform: 'azure', endpoint: 'https://dev.azure.com/renovate-org', }); expect( - extractRepository({ - type: 'git', - name: 'repo', - ref: 'refs/tags/v1.0.0', - }), + extractRepository( + { + type: 'git', + name: 'repo', + ref: 'refs/tags/v1.0.0', + }, + 'project/otherrepo', + ), + ).toMatchObject({ + depName: 'project/repo', + packageName: 'https://dev.azure.com/renovate-org/project/_git/repo', + }); + }); + + it('should return null if repository type is git and project not in name nor in config repository name', () => { + GlobalConfig.set({ + platform: 'azure', + endpoint: 'https://dev.azure.com/renovate-org', + }); + + expect( + extractRepository( + { + type: 'git', + name: 'repo', + ref: 'refs/tags/v1.0.0', + }, + '', + ), + ).toBeNull(); + }); + + it('should return null if repository type is git and currentRepository is undefined', () => { + GlobalConfig.set({ + platform: 'azure', + endpoint: 'https://dev.azure.com/renovate-org', + }); + + expect( + extractRepository( + { + type: 'git', + name: 'repo', + ref: 'refs/tags/v1.0.0', + }, + undefined, + ), ).toBeNull(); }); - it('should extract return null for git repo type if platform not Azure', () => { + it('should return null for git repo type if platform not Azure', () => { GlobalConfig.set({ platform: 'github', }); expect( - extractRepository({ - type: 'git', - name: 'project/repo', - ref: 'refs/tags/v1.0.0', - }), + extractRepository( + { + type: 'git', + name: 'project/repo', + ref: 'refs/tags/v1.0.0', + }, + '', + ), ).toBeNull(); }); }); @@ -153,11 +213,15 @@ describe('modules/manager/azure-pipelines/extract', () => { describe('extractPackageFile()', () => { it('returns null for invalid azure pipelines files', () => { - expect(extractPackageFile('}', azurePipelinesFilename)).toBeNull(); + expect( + extractPackageFile('}', azurePipelinesFilename, { repository: 'repo' }), + ).toBeNull(); }); it('extracts dependencies', () => { - const res = extractPackageFile(azurePipelines, azurePipelinesFilename); + const res = extractPackageFile(azurePipelines, azurePipelinesFilename, { + repository: 'repo', + }); expect(res?.deps).toMatchObject([ { depName: 'user/repo', @@ -180,7 +244,9 @@ describe('modules/manager/azure-pipelines/extract', () => { it('should return null when there is no dependency found', () => { expect( - extractPackageFile(azurePipelinesNoDependency, azurePipelinesFilename), + extractPackageFile(azurePipelinesNoDependency, azurePipelinesFilename, { + repository: 'repo', + }), ).toBeNull(); }); @@ -196,7 +262,9 @@ describe('modules/manager/azure-pipelines/extract', () => { inputs: script: 'echo Hello World' `; - const res = extractPackageFile(packageFile, azurePipelinesFilename); + const res = extractPackageFile(packageFile, azurePipelinesFilename, { + repository: 'repo', + }); expect(res?.deps).toEqual([ { depName: 'Bash', @@ -219,7 +287,9 @@ describe('modules/manager/azure-pipelines/extract', () => { inputs: script: 'echo Hello World' `; - const res = extractPackageFile(packageFile, azurePipelinesFilename); + const res = extractPackageFile(packageFile, azurePipelinesFilename, { + repository: 'repo', + }); expect(res?.deps).toEqual([ { depName: 'Bash', @@ -242,7 +312,9 @@ describe('modules/manager/azure-pipelines/extract', () => { inputs: script: 'echo Hello World' `; - const res = extractPackageFile(packageFile, azurePipelinesFilename); + const res = extractPackageFile(packageFile, azurePipelinesFilename, { + repository: 'repo', + }); expect(res?.deps).toEqual([ { depName: 'Bash', @@ -264,7 +336,9 @@ describe('modules/manager/azure-pipelines/extract', () => { inputs: script: 'echo Hello World' `; - const res = extractPackageFile(packageFile, azurePipelinesFilename); + const res = extractPackageFile(packageFile, azurePipelinesFilename, { + repository: 'repo', + }); expect(res?.deps).toEqual([ { depName: 'Bash', @@ -286,7 +360,9 @@ describe('modules/manager/azure-pipelines/extract', () => { inputs: script: 'echo Hello World' `; - const res = extractPackageFile(packageFile, azurePipelinesFilename); + const res = extractPackageFile(packageFile, azurePipelinesFilename, { + repository: 'repo', + }); expect(res?.deps).toEqual([ { depName: 'Bash', @@ -308,7 +384,9 @@ describe('modules/manager/azure-pipelines/extract', () => { inputs: script: 'echo Hello World' `; - const res = extractPackageFile(packageFile, azurePipelinesFilename); + const res = extractPackageFile(packageFile, azurePipelinesFilename, { + repository: 'repo', + }); expect(res?.deps).toEqual([ { depName: 'Bash', @@ -330,7 +408,9 @@ describe('modules/manager/azure-pipelines/extract', () => { inputs: script: 'echo Hello World' `; - const res = extractPackageFile(packageFile, azurePipelinesFilename); + const res = extractPackageFile(packageFile, azurePipelinesFilename, { + repository: 'repo', + }); expect(res?.deps).toEqual([ { depName: 'Bash', @@ -352,7 +432,9 @@ describe('modules/manager/azure-pipelines/extract', () => { inputs: script: 'echo Hello World' `; - const res = extractPackageFile(packageFile, azurePipelinesFilename); + const res = extractPackageFile(packageFile, azurePipelinesFilename, { + repository: 'repo', + }); expect(res?.deps).toEqual([ { depName: 'Bash', @@ -373,7 +455,9 @@ describe('modules/manager/azure-pipelines/extract', () => { inputs: script: 'echo Hello World' `; - const res = extractPackageFile(packageFile, azurePipelinesFilename); + const res = extractPackageFile(packageFile, azurePipelinesFilename, { + repository: 'repo', + }); expect(res?.deps).toEqual([ { depName: 'Bash', @@ -392,7 +476,9 @@ describe('modules/manager/azure-pipelines/extract', () => { inputs: script: 'echo Hello World' `; - const res = extractPackageFile(packageFile, azurePipelinesFilename); + const res = extractPackageFile(packageFile, azurePipelinesFilename, { + repository: 'repo', + }); expect(res?.deps).toEqual([ { depName: 'Bash', @@ -409,7 +495,9 @@ describe('modules/manager/azure-pipelines/extract', () => { inputs: script: 'echo Hello World' `; - const res = extractPackageFile(packageFile, azurePipelinesFilename); + const res = extractPackageFile(packageFile, azurePipelinesFilename, { + repository: 'repo', + }); expect(res?.deps).toEqual([ { depName: 'Bash', @@ -424,7 +512,9 @@ describe('modules/manager/azure-pipelines/extract', () => { steps: - bash: 'echo Hello World'; `; - const res = extractPackageFile(packageFile, azurePipelinesFilename); + const res = extractPackageFile(packageFile, azurePipelinesFilename, { + repository: 'repo', + }); expect(res).toBeNull(); }); }); diff --git a/lib/modules/manager/azure-pipelines/extract.ts b/lib/modules/manager/azure-pipelines/extract.ts index 433d4b7c2f987f..d87317a8c5d350 100644 --- a/lib/modules/manager/azure-pipelines/extract.ts +++ b/lib/modules/manager/azure-pipelines/extract.ts @@ -6,7 +6,11 @@ import { joinUrlParts } from '../../../util/url'; import { AzurePipelinesTasksDatasource } from '../../datasource/azure-pipelines-tasks'; import { GitTagsDatasource } from '../../datasource/git-tags'; import { getDep } from '../dockerfile/extract'; -import type { PackageDependency, PackageFileContent } from '../types'; +import type { + ExtractConfig, + PackageDependency, + PackageFileContent, +} from '../types'; import { AzurePipelines, AzurePipelinesYaml, @@ -23,22 +27,20 @@ const AzurePipelinesTaskRegex = regEx(/^(?[^@]+)@(?.*)$/); export function extractRepository( repository: Repository, + currentRepository?: string, ): PackageDependency | null { let repositoryUrl = null; + let depName = repository.name; + if (repository.type === 'github') { repositoryUrl = `https://github.com/${repository.name}.git`; } else if (repository.type === 'git') { - // "git" type indicates an AzureDevOps repository. - // The repository URL is only deducible if we are running on AzureDevOps (so can use the endpoint) - // and the name is of the form `Project/Repository`. - // The name could just be the repository name, in which case AzureDevOps defaults to the - // same project, which is not currently accessible here. It could be deduced later by exposing - // the repository URL to managers. - // https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/resources-repositories-repository?view=azure-pipelines#types const platform = GlobalConfig.get('platform'); const endpoint = GlobalConfig.get('endpoint'); + if (platform === 'azure' && endpoint) { + // extract the project name if the repository from which the pipline is referencing templates contains the Azure DevOps project name if (repository.name.includes('/')) { const [projectName, repoName] = repository.name.split('/'); repositoryUrl = joinUrlParts( @@ -47,9 +49,20 @@ export function extractRepository( '_git', encodeURIComponent(repoName), ); + + // if the repository from which the pipline is referencing templates does not contain the Azure DevOps project name, get the project name from the repository containing the pipeline file being process + } else if (currentRepository && currentRepository.includes('/')) { + const projectName = currentRepository.split('/')[0]; + depName = `${projectName}/${repository.name}`; + repositoryUrl = joinUrlParts( + endpoint, + encodeURIComponent(projectName), + '_git', + encodeURIComponent(repository.name), + ); } else { logger.debug( - 'Renovate cannot update repositories that do not include the project name', + 'Renovate cannot update Azure pipelines in git repositories when neither the current repository nor the target repository contains the Azure DevOps project name.', ); } } @@ -67,7 +80,7 @@ export function extractRepository( autoReplaceStringTemplate: 'refs/tags/{{newValue}}', currentValue: repository.ref.replace('refs/tags/', ''), datasource: GitTagsDatasource.id, - depName: repository.name, + depName, depType: 'gitTags', packageName: repositoryUrl, replaceString: repository.ref, @@ -168,6 +181,7 @@ function extractJobs(jobs: Jobs | undefined): PackageDependency[] { export function extractPackageFile( content: string, packageFile: string, + config: ExtractConfig, ): PackageFileContent | null { logger.trace(`azurePipelines.extractPackageFile(${packageFile})`); const deps: PackageDependency[] = []; @@ -178,7 +192,7 @@ export function extractPackageFile( } for (const repository of coerceArray(pkg.resources?.repositories)) { - const dep = extractRepository(repository); + const dep = extractRepository(repository, config.repository); if (dep) { deps.push(dep); } diff --git a/lib/modules/manager/types.ts b/lib/modules/manager/types.ts index cdbdfe272b3827..40e4586a5bdd4b 100644 --- a/lib/modules/manager/types.ts +++ b/lib/modules/manager/types.ts @@ -22,6 +22,7 @@ export interface ExtractConfig extends CustomExtractConfig { npmrc?: string; npmrcMerge?: boolean; skipInstalls?: boolean | null; + repository?: string; } export interface UpdateArtifactsConfig {