diff --git a/lib/config/definitions.js b/lib/config/definitions.js index b133ce82179dd2a63b6a697141d5ea109707ea35..7104021cac3d70d6d4c8df538adcc1dd1d8f6f1b 100644 --- a/lib/config/definitions.js +++ b/lib/config/definitions.js @@ -1379,6 +1379,19 @@ const options = [ mergeable: true, cli: false, }, + { + name: 'maven', + description: 'Configuration object for when renovating Maven pom.xml files', + releaseStatus: 'alpha', + stage: 'package', + type: 'json', + default: { + enabled: false, + fileMatch: ['\\.pom.xml$', '(^|/)pom.xml$'], + }, + mergeable: true, + cli: false, + }, { name: 'gitlabci', description: diff --git a/lib/manager/index.js b/lib/manager/index.js index 41391e7e56522e7ddd5422992402b10c45f3e4c1..aa8feb497dcadfa8c79dc4bf51bf0d3378aba1b0 100644 --- a/lib/manager/index.js +++ b/lib/manager/index.js @@ -13,6 +13,7 @@ const managerList = [ 'gradle', 'gradle-wrapper', 'kubernetes', + 'maven', 'meteor', 'npm', 'nuget', diff --git a/lib/manager/maven/extract.js b/lib/manager/maven/extract.js new file mode 100644 index 0000000000000000000000000000000000000000..e6bc3fff346d36352e6c08dc3451457b0f5e239d --- /dev/null +++ b/lib/manager/maven/extract.js @@ -0,0 +1,91 @@ +const { XmlDocument } = require('xmldoc'); +const { isVersion } = require('../../versioning/maven'); + +function parsePom(raw) { + let project; + try { + project = new XmlDocument(raw); + } catch (e) { + return null; + } + const { name, attr } = project; + if (name !== 'project') return null; + if (attr.xmlns !== 'http://maven.apache.org/POM/4.0.0') return null; + return project; +} + +function containsPlaceholder(str) { + return /\${.*?}/g.test(str); +} + +function depFromNode(node) { + if (!node.valueWithPath) return null; + const groupId = node.valueWithPath('groupId'); + const artifactId = node.valueWithPath('artifactId'); + const currentValue = node.valueWithPath('version'); + if (groupId && artifactId && currentValue) { + const depName = `${groupId}/${artifactId}`; + const result = { + depName, + currentValue, + }; + if (containsPlaceholder(depName)) { + result.skipReason = 'name-placeholder'; + } else if (containsPlaceholder(currentValue)) { + result.skipReason = 'version-placeholder'; + } else if (!isVersion(currentValue)) { + result.skipReason = 'not-a-version'; + } else { + const versionNode = node.descendantWithPath('version'); + const offset = '<version>'.length - 1; + result.fileReplacePosition = versionNode.startTagPosition + offset; + result.purl = `pkg:maven/${ + result.depName + }?repository_url=https://repo.maven.apache.org/maven2`; + } + return result; + } + return null; +} + +function deepExtract(node, result = [], isRoot = true) { + const dep = depFromNode(node); + if (dep && !isRoot) { + result.push(dep); + } + if (node.children) { + for (const child of node.children) { + deepExtract(child, result, false); + } + } + return result; +} + +function extractDependencies(raw) { + if (!raw) return null; + + const project = parsePom(raw); + if (!project) return null; + + const result = { datasource: 'maven' }; + + const homepage = project.valueWithPath('url'); + if (homepage && !containsPlaceholder(homepage)) { + result.homepage = homepage; + } + + const sourceUrl = project.valueWithPath('scm.url'); + if (sourceUrl && !containsPlaceholder(sourceUrl)) { + result.sourceUrl = sourceUrl; + } + + result.deps = deepExtract(project); + + return result; +} + +module.exports = { + containsPlaceholder, + parsePom, + extractDependencies, +}; diff --git a/lib/manager/maven/index.js b/lib/manager/maven/index.js new file mode 100644 index 0000000000000000000000000000000000000000..550c51e6ec1a3a923a6026885a446bd70ae54819 --- /dev/null +++ b/lib/manager/maven/index.js @@ -0,0 +1,46 @@ +const { extractDependencies } = require('./extract'); + +async function extractAllPackageFiles(config, packageFiles) { + const mavenFiles = []; + for (const packageFile of packageFiles) { + const content = await platform.getFile(packageFile); + if (content) { + const deps = extractDependencies(content); + if (deps) { + mavenFiles.push({ + packageFile, + manager: 'maven', + ...deps, + }); + } else { + logger.info({ packageFile }, 'can not read dependencies'); + } + } else { + logger.info({ packageFile }, 'packageFile has no content'); + } + } + return mavenFiles; +} + +function updateDependency(fileContent, upgrade) { + const { currentValue, newValue, fileReplacePosition } = upgrade; + const leftPart = fileContent.slice(0, fileReplacePosition); + const rightPart = fileContent.slice(fileReplacePosition); + const versionClosePosition = rightPart.indexOf('</'); + const restPart = rightPart.slice(versionClosePosition); + const versionPart = rightPart.slice(0, versionClosePosition); + const version = versionPart.trim(); + if (version === newValue) { + return fileContent; + } + if (version === currentValue) { + const replacedPart = versionPart.replace(currentValue, newValue); + return leftPart + replacedPart + restPart; + } + return null; +} + +module.exports = { + extractAllPackageFiles, + updateDependency, +}; diff --git a/test/_fixtures/maven/simple.pom.xml b/test/_fixtures/maven/simple.pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..0151002b32a27b73f540ba902ff173b7c0278f45 --- /dev/null +++ b/test/_fixtures/maven/simple.pom.xml @@ -0,0 +1,127 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0"> + <parent> + <groupId>org.example</groupId> + <artifactId>parent</artifactId> + <version>42</version> + </parent> + + <modelVersion>4.0.0</modelVersion> + <groupId>org.example</groupId> + <artifactId>ExamplePomFile</artifactId> + + <name>Example</name> + <version>0.0.1</version> + <description>Minimal example</description> + <url>http://example.org/index.html</url> + + <scm> + <url>http://example.org/src.git</url> + </scm> + + <issueManagement> + <url>http://example.org/</url> + </issueManagement> + + <dependencyManagement> + <dependencies> + <dependency> + <groupId>org.example</groupId> + <artifactId>foo</artifactId> + <version>0.0.1</version> + </dependency> + <dependency> + <groupId>org.example</groupId> + <artifactId>bar</artifactId> + <version>1.0.0</version> + </dependency> + </dependencies> + </dependencyManagement> + + <build> + <finalName>SamplePomfile</finalName> + <plugins> + <plugin> + <artifactId>maven-release-plugin</artifactId> + <version>2.4.2</version> + <dependencies> + <dependency> + <groupId>org.apache.maven.scm</groupId> + <artifactId>maven-scm-provider-gitexe</artifactId> + <version>1.8.1</version> + </dependency> + </dependencies> + </plugin> + <plugin> + <artifactId>maven-compiler-plugin</artifactId> + <configuration> + <source>1.8</source> + <target>1.8</target> + </configuration> + </plugin> + <plugin> + <groupId>org.jasig.maven</groupId> + <artifactId>maven-notice-plugin</artifactId> + <configuration> + <noticeTemplate>NOTICE.template</noticeTemplate> + <generateChildNotices>false</generateChildNotices> + </configuration> + </plugin> + </plugins> + </build> + + + <dependencies> + <dependency> + <groupId>org.example</groupId> + <artifactId>${artifact-id-placeholder}</artifactId> + <version>0.0.1</version> + </dependency> + <dependency> + <groupId>${group-id-placeholder}</groupId> + <artifactId>baz</artifactId> + <version>0.0.1</version> + </dependency> + <dependency> + <groupId>org.example</groupId> + <artifactId>quux</artifactId> + <version>${resourceServerVersion}</version> + </dependency> + <dependency> + <groupId>org.example</groupId> + <artifactId>quuz</artifactId> + <version>1.2.3</version> + </dependency> + <dependency> + <groupId>org.example</groupId> + <artifactId>quuuz</artifactId> + <version>it's not a version</version> + </dependency> + </dependencies> + + <profiles> + <profile> + <id>profile-id</id> + <dependencies> + <dependency> + <groupId>org.example</groupId> + <artifactId>profile-artifact</artifactId> + <version>${profile-placeholder}</version> + </dependency> + </dependencies> + </profile> + </profiles> + + <reporting> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-checkstyle-plugin</artifactId> + <version>2.17</version> + <configuration> + <configLocation>google_checks.xml</configLocation> + </configuration> + </plugin> + </plugins> + </reporting> +</project> diff --git a/test/manager/maven/__snapshots__/extract.spec.js.snap b/test/manager/maven/__snapshots__/extract.spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..308875d11fbdaca357d57039d812895ed7d206da --- /dev/null +++ b/test/manager/maven/__snapshots__/extract.spec.js.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`manager/maven/extract extractDependencies extract dependencies from any XML position 1`] = ` +Object { + "datasource": "maven", + "deps": Array [ + Object { + "currentValue": "42", + "depName": "org.example/parent", + "fileReplacePosition": 186, + "purl": "pkg:maven/org.example/parent?repository_url=https://repo.maven.apache.org/maven2", + }, + Object { + "currentValue": "0.0.1", + "depName": "org.example/foo", + "fileReplacePosition": 757, + "purl": "pkg:maven/org.example/foo?repository_url=https://repo.maven.apache.org/maven2", + }, + Object { + "currentValue": "1.0.0", + "depName": "org.example/bar", + "fileReplacePosition": 905, + "purl": "pkg:maven/org.example/bar?repository_url=https://repo.maven.apache.org/maven2", + }, + Object { + "currentValue": "1.8.1", + "depName": "org.apache.maven.scm/maven-scm-provider-gitexe", + "fileReplacePosition": 1337, + "purl": "pkg:maven/org.apache.maven.scm/maven-scm-provider-gitexe?repository_url=https://repo.maven.apache.org/maven2", + }, + Object { + "currentValue": "0.0.1", + "depName": "org.example/\${artifact-id-placeholder}", + "skipReason": "name-placeholder", + }, + Object { + "currentValue": "0.0.1", + "depName": "\${group-id-placeholder}/baz", + "skipReason": "name-placeholder", + }, + Object { + "currentValue": "\${resourceServerVersion}", + "depName": "org.example/quux", + "skipReason": "version-placeholder", + }, + Object { + "currentValue": "1.2.3", + "depName": "org.example/quuz", + "fileReplacePosition": 2529, + "purl": "pkg:maven/org.example/quuz?repository_url=https://repo.maven.apache.org/maven2", + }, + Object { + "currentValue": "it's not a version", + "depName": "org.example/quuuz", + "skipReason": "not-a-version", + }, + Object { + "currentValue": "\${profile-placeholder}", + "depName": "org.example/profile-artifact", + "skipReason": "version-placeholder", + }, + Object { + "currentValue": "2.17", + "depName": "org.apache.maven.plugins/maven-checkstyle-plugin", + "fileReplacePosition": 3218, + "purl": "pkg:maven/org.apache.maven.plugins/maven-checkstyle-plugin?repository_url=https://repo.maven.apache.org/maven2", + }, + ], + "homepage": "http://example.org/index.html", + "sourceUrl": "http://example.org/src.git", +} +`; diff --git a/test/manager/maven/extract.spec.js b/test/manager/maven/extract.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..157cbff1883255ec3992efd9ac3f506c04ac2b30 --- /dev/null +++ b/test/manager/maven/extract.spec.js @@ -0,0 +1,25 @@ +/* eslint-disable no-template-curly-in-string */ +const fs = require('fs'); +const path = require('path'); +const { extractDependencies } = require('../../../lib/manager/maven/extract'); + +const simpleContent = fs.readFileSync( + path.resolve(__dirname, `../../_fixtures/maven/simple.pom.xml`), + 'utf8' +); + +describe('manager/maven/extract', () => { + describe('extractDependencies', () => { + it('returns null for invalid XML', () => { + expect(extractDependencies()).toBeNull(); + expect(extractDependencies('invalid xml content')).toBeNull(); + expect(extractDependencies('<foobar></foobar>')).toBeNull(); + expect(extractDependencies('<project></project>')).toBeNull(); + }); + + it('extract dependencies from any XML position', () => { + const res = extractDependencies(simpleContent); + expect(res).toMatchSnapshot(); + }); + }); +}); diff --git a/test/manager/maven/index.spec.js b/test/manager/maven/index.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f86894ca106e6e872aa3c61e55d178fa79a15aae --- /dev/null +++ b/test/manager/maven/index.spec.js @@ -0,0 +1,77 @@ +const fs = require('fs'); +const { extractDependencies } = require('../../../lib/manager/maven/extract'); +const { + extractAllPackageFiles, + updateDependency, +} = require('../../../lib/manager/maven/index'); + +const pomContent = fs.readFileSync( + 'test/_fixtures/maven/simple.pom.xml', + 'utf8' +); + +const findFn = ({ depName }) => depName === 'org.example/quuz'; + +describe('manager/maven', () => { + describe('extractAllPackageFiles', () => { + it('should return empty if package has no content', async () => { + platform.getFile.mockReturnValueOnce(null); + const res = await extractAllPackageFiles({}, ['random.pom.xml']); + expect(res).toEqual([]); + }); + + it('should return empty for packages with invalid content', async () => { + platform.getFile.mockReturnValueOnce('invalid content'); + const res = await extractAllPackageFiles({}, ['random.pom.xml']); + expect(res).toEqual([]); + }); + + it('should return package files info', async () => { + platform.getFile.mockReturnValueOnce(pomContent); + const packages = await extractAllPackageFiles({}, ['random.pom.xml']); + expect(packages.length).toEqual(1); + + const pkg = packages[0]; + expect(pkg.packageFile).toEqual('random.pom.xml'); + expect(pkg.manager).toEqual('maven'); + expect(pkg.deps).not.toBeNull(); + }); + }); + + describe('updateDependency', () => { + it('should update an existing dependency', () => { + const newValue = '9.9.9.9-final'; + + const { deps } = extractDependencies(pomContent); + const dep = deps.find(findFn); + const upgrade = { ...dep, newValue }; + const updatedContent = updateDependency(pomContent, upgrade); + const updatedDep = extractDependencies(updatedContent).deps.find(findFn); + + expect(updatedDep.currentValue).toEqual(newValue); + }); + + it('should not touch content if new and old versions are equal', () => { + const newValue = '1.2.3'; + + const { deps } = extractDependencies(pomContent); + const dep = deps.find(findFn); + const upgrade = { ...dep, newValue }; + const updatedContent = updateDependency(pomContent, upgrade); + + expect(pomContent).toBe(updatedContent); + }); + + it('should return null if current versions in content and upgrade are not same', () => { + const currentValue = '1.2.2'; + const newValue = '1.2.4'; + + const { deps } = extractDependencies(pomContent); + const dep = deps.find(findFn); + const upgrade = { ...dep, currentValue, newValue }; + const updatedContent = updateDependency(pomContent, upgrade); + + expect(updatedContent).toBeNull(); + }); + }); +}); diff --git a/test/workers/repository/extract/__snapshots__/index.spec.js.snap b/test/workers/repository/extract/__snapshots__/index.spec.js.snap index c4439c3eaad08d92e4edd63cdb6b285d49a93227..31bda27d591c18a9cdd182723342580425996164 100644 --- a/test/workers/repository/extract/__snapshots__/index.spec.js.snap +++ b/test/workers/repository/extract/__snapshots__/index.spec.js.snap @@ -44,6 +44,9 @@ Object { "kubernetes": Array [ Object {}, ], + "maven": Array [ + Object {}, + ], "meteor": Array [ Object {}, ], diff --git a/website/docs/configuration-options.md b/website/docs/configuration-options.md index 346f1b30af61521ac0b9281a5423d0ac20afc0a0..a107ce814b5afee3b3407cefcdce7e8b61e6fdd3 100644 --- a/website/docs/configuration-options.md +++ b/website/docs/configuration-options.md @@ -398,6 +398,8 @@ Add to this object if you wish to define rules that apply only to major updates. This value defaults to empty string, as historically no prefix was necessary for when Renovate was JS-only. Now - for example - we use `docker-` for Docker branches, so they may look like `renovate/docker-ubuntu-16.x`. +## maven + ## meteor Set enabled to `true` to enable meteor package updating.