From 116939ffb51d460beffcb206d7e45057adffa0ca Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@arkins.net>
Date: Mon, 19 Feb 2018 14:21:45 +0100
Subject: [PATCH] feat: renovate node engine in package.json (#1519)

Adds support for upgrading `node` version in `package.json` > `engines` if the current version is pinned.
- Does not convert from range to pin
- Ignores ranges
- Does not upgrade major versions
---
 lib/config/definitions.js                     |  9 ++++
 lib/manager/npm/engines.js                    | 43 +++++++++++++++++++
 lib/manager/npm/package.js                    |  4 ++
 lib/workers/package-file/index.js             |  1 +
 lib/workers/pr/index.js                       | 13 +++---
 test/manager/npm/engines.spec.js              | 37 ++++++++++++++++
 test/manager/npm/package.spec.js              |  5 +++
 .../2017-10-05-configuration-options.md       | 11 +++++
 8 files changed, 118 insertions(+), 5 deletions(-)
 create mode 100644 lib/manager/npm/engines.js
 create mode 100644 test/manager/npm/engines.spec.js

diff --git a/lib/config/definitions.js b/lib/config/definitions.js
index aee81a704c..d13666d615 100644
--- a/lib/config/definitions.js
+++ b/lib/config/definitions.js
@@ -298,6 +298,15 @@ const options = [
     mergeable: true,
     cli: false,
   },
+  {
+    name: 'engines',
+    description: 'Configuration specifically for `package.json`>`engines`',
+    stage: 'packageFile',
+    type: 'json',
+    default: {},
+    mergeable: true,
+    cli: false,
+  },
   // depType
   {
     name: 'ignoreDeps',
diff --git a/lib/manager/npm/engines.js b/lib/manager/npm/engines.js
new file mode 100644
index 0000000000..6ae10e628a
--- /dev/null
+++ b/lib/manager/npm/engines.js
@@ -0,0 +1,43 @@
+const semver = require('semver');
+const { getRepoReleases, semverSort } = require('../../datasource/github');
+
+async function renovateEngines(config) {
+  const { currentVersion, depName: dependency } = config;
+  logger.debug({ dependency, currentVersion }, 'Found engines');
+  if (config.depName !== 'node') {
+    logger.debug('Skipping non-node engine');
+    return [];
+  }
+  logger.info('Checking node engine');
+  if (!semver.valid(currentVersion)) {
+    logger.info({ currentVersion }, 'Skipping non-pinned node version');
+    return [];
+  }
+  const newReleases = (await getRepoReleases('nodejs/node'))
+    .map(release => release.replace(/^v/, ''))
+    .filter(release => semver.major(currentVersion) === semver.major(release))
+    .filter(release => semver.gt(release, currentVersion))
+    .sort(semverSort);
+  if (newReleases.length) {
+    logger.info({ newReleases }, 'Found newer Node releases');
+  } else {
+    return [];
+  }
+  const newVersion = newReleases.pop();
+  return [
+    {
+      type:
+        semver.major(currentVersion) !== semver.major(newVersion)
+          ? 'major'
+          : 'minor',
+      newVersion,
+      newVersionMajor: semver.major(newVersion),
+      newVersionMinor: semver.minor(newVersion),
+      changeLogFromVersion: currentVersion,
+      changeLogToVersion: newVersion,
+      repositoryUrl: 'https://github.com/nodejs/node',
+    },
+  ];
+}
+
+module.exports = { renovateEngines };
diff --git a/lib/manager/npm/package.js b/lib/manager/npm/package.js
index 5255a5c095..c0a653635c 100644
--- a/lib/manager/npm/package.js
+++ b/lib/manager/npm/package.js
@@ -1,11 +1,15 @@
 const npmApi = require('./registry');
 const versions = require('../../workers/package/versions');
+const { renovateEngines } = require('./engines');
 
 module.exports = {
   getPackageUpdates,
 };
 
 async function getPackageUpdates(config) {
+  if (config.depType === 'engines') {
+    return renovateEngines(config);
+  }
   let results = [];
   if (config.currentVersion.startsWith('file:')) {
     logger.debug(
diff --git a/lib/workers/package-file/index.js b/lib/workers/package-file/index.js
index caf91e3176..76dd9a8ef1 100644
--- a/lib/workers/package-file/index.js
+++ b/lib/workers/package-file/index.js
@@ -96,6 +96,7 @@ async function renovatePackageFile(packageFileConfig) {
     'devDependencies',
     'optionalDependencies',
     'peerDependencies',
+    'engines',
   ];
   const depTypeConfigs = depTypes.map(depType => {
     const depTypeConfig = configParser.mergeChildConfig(config, {
diff --git a/lib/workers/pr/index.js b/lib/workers/pr/index.js
index cccab2c7ce..c0b4d1153f 100644
--- a/lib/workers/pr/index.js
+++ b/lib/workers/pr/index.js
@@ -110,11 +110,14 @@ async function ensurePr(prConfig) {
     }
     processedUpgrades.push(upgradeKey);
 
-    const logJSON = await changelogHelper.getChangeLogJSON(
-      upgrade.depName,
-      upgrade.changeLogFromVersion,
-      upgrade.changeLogToVersion
-    );
+    let logJSON;
+    if (upgrade.depType !== 'engines') {
+      logJSON = await changelogHelper.getChangeLogJSON(
+        upgrade.depName,
+        upgrade.changeLogFromVersion,
+        upgrade.changeLogToVersion
+      );
+    }
     if (logJSON) {
       upgrade.githubName = logJSON.project.github;
       upgrade.hasReleaseNotes = logJSON.hasReleaseNotes;
diff --git a/test/manager/npm/engines.spec.js b/test/manager/npm/engines.spec.js
new file mode 100644
index 0000000000..2c65d9c9e7
--- /dev/null
+++ b/test/manager/npm/engines.spec.js
@@ -0,0 +1,37 @@
+const engines = require('../../../lib/manager/npm/engines');
+const { getRepoReleases } = require('../../../lib/datasource/github');
+
+jest.mock('../../../lib/datasource/github');
+
+describe('manager/npm/engines', () => {
+  let config;
+  beforeEach(() => {
+    config = {
+      depName: 'node',
+    };
+  });
+  it('skips non-pinned versions', async () => {
+    config.currentVersion = '8';
+    const res = await engines.renovateEngines(config);
+    expect(res).toEqual([]);
+  });
+  it('returns empty', async () => {
+    config.currentVersion = '8.9.0';
+    getRepoReleases.mockReturnValueOnce([]);
+    const res = await engines.renovateEngines(config);
+    expect(res).toEqual([]);
+  });
+  it('filters v', async () => {
+    config.currentVersion = '8.9.0';
+    getRepoReleases.mockReturnValueOnce(['v8.0.0', 'v8.9.1']);
+    const res = await engines.renovateEngines(config);
+    expect(res).toHaveLength(1);
+    expect(res[0].newVersion).toEqual('8.9.1');
+  });
+  it('skips major versions', async () => {
+    config.currentVersion = '8.9.0';
+    getRepoReleases.mockReturnValueOnce(['v9.4.0']);
+    const res = await engines.renovateEngines(config);
+    expect(res).toHaveLength(0);
+  });
+});
diff --git a/test/manager/npm/package.spec.js b/test/manager/npm/package.spec.js
index eb77e142bb..ac1a4b0c8c 100644
--- a/test/manager/npm/package.spec.js
+++ b/test/manager/npm/package.spec.js
@@ -17,6 +17,11 @@ describe('lib/workers/package/npm', () => {
         currentVersion: '1.0.0',
       };
     });
+    it('calls engines function', async () => {
+      config.depType = 'engines';
+      const res = await npm.getPackageUpdates(config);
+      expect(res).toHaveLength(0);
+    });
     it('returns if using a file reference', async () => {
       config.currentVersion = 'file:../sibling/package.json';
       const res = await npm.getPackageUpdates(config);
diff --git a/website/docs/_posts/2017-10-05-configuration-options.md b/website/docs/_posts/2017-10-05-configuration-options.md
index 70191f4e31..b26149b3d3 100644
--- a/website/docs/_posts/2017-10-05-configuration-options.md
+++ b/website/docs/_posts/2017-10-05-configuration-options.md
@@ -262,6 +262,17 @@ A configuration object containing strings encrypted with Renovate's public key.
 
 See https://renovateapp.com/docs/deep-dives/private-modules for details on how this is used to encrypt npm tokens.
 
+## engines
+
+Configuration specific for `package.json > engines`.
+
+| name    | value  |
+| ------- | ------ |
+| type    | object |
+| default | {}     |
+
+Extend this if you wish to configure rules specifically for `engines` definitions. Currently only `node` is supported.
+
 ## excludePackageNames
 
 A list of package names inside a package rule which are to be excluded/ignored.
-- 
GitLab