From c4389cb11b31bb34832fe1dc1462b944ae67a839 Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@arkins.net>
Date: Thu, 19 Jul 2018 09:14:34 +0200
Subject: [PATCH] feat(composer): composer.lock support (#2255)

Adds logic to support updating the accompanying `composer.lock` file whenever `composer.json` is updated.

Closes #2098
---
 lib/manager/composer/extract.js               |  13 +-
 lib/manager/composer/index.js                 |   2 +
 lib/manager/composer/lock-file.js             |  69 +++++
 lib/workers/branch/get-updated.js             |  25 +-
 .../__snapshots__/extract.spec.js.snap        | 253 +++++++++++++++++-
 test/manager/composer/extract.spec.js         |  23 +-
 test/manager/composer/lock-file.spec.js       |  42 +++
 .../__snapshots__/get-updated.spec.js.snap    |  20 ++
 test/workers/branch/get-updated.spec.js       |  16 ++
 9 files changed, 450 insertions(+), 13 deletions(-)
 create mode 100644 lib/manager/composer/lock-file.js
 create mode 100644 test/manager/composer/lock-file.spec.js

diff --git a/lib/manager/composer/extract.js b/lib/manager/composer/extract.js
index 7b0fc76580..4d62f63cc8 100644
--- a/lib/manager/composer/extract.js
+++ b/lib/manager/composer/extract.js
@@ -4,7 +4,7 @@ module.exports = {
   extractDependencies,
 };
 
-function extractDependencies(content, packageFile) {
+async function extractDependencies(content, packageFile) {
   logger.debug('composer.extractDependencies()');
   let packageJson;
   try {
@@ -50,5 +50,14 @@ function extractDependencies(content, packageFile) {
   if (!deps.length) {
     return null;
   }
-  return { deps };
+  let composerLock = false;
+  const filePath = packageFile.replace(/\.json$/, '.lock');
+  if (await platform.getFile(filePath)) {
+    logger.debug(
+      { packageFile: packageFile.packageFile },
+      'Found composer.lock'
+    );
+    composerLock = filePath;
+  }
+  return { deps, composerLock };
 }
diff --git a/lib/manager/composer/index.js b/lib/manager/composer/index.js
index 9fd44f840d..5d7233f247 100644
--- a/lib/manager/composer/index.js
+++ b/lib/manager/composer/index.js
@@ -1,10 +1,12 @@
 const { extractDependencies } = require('./extract');
 const { updateDependency } = require('../npm/update');
+const { getLockFile } = require('./lock-file');
 
 const language = 'php';
 
 module.exports = {
   extractDependencies,
+  getLockFile,
   language,
   updateDependency,
   // TODO: support this
diff --git a/lib/manager/composer/lock-file.js b/lib/manager/composer/lock-file.js
new file mode 100644
index 0000000000..003fa92e37
--- /dev/null
+++ b/lib/manager/composer/lock-file.js
@@ -0,0 +1,69 @@
+const { exec } = require('child-process-promise');
+const fs = require('fs-extra');
+const tmp = require('tmp-promise');
+const upath = require('upath');
+
+module.exports = {
+  getLockFile,
+};
+
+async function getLockFile(
+  packageFileName,
+  updatedDeps,
+  newPackageFileContent
+) {
+  logger.debug(`composer.getLockFile(${packageFileName})`);
+  const composerLockPath = packageFileName.replace(/\.json$/, '.lock');
+  const existingComposerLockContent = await platform.getFile(composerLockPath);
+  if (!existingComposerLockContent) {
+    logger.debug('No composer.lock found');
+    return null;
+  }
+  const tmpDir = await tmp.dir({ unsafeCleanup: true });
+  let stdout;
+  let stderr;
+  try {
+    const composerJsonFileName = upath.join(tmpDir.path, 'composer.json');
+    await fs.outputFile(composerJsonFileName, newPackageFileContent);
+    const composerLockFileName = upath.join(tmpDir.path, 'composer.lock');
+    await fs.outputFile(composerLockFileName, existingComposerLockContent);
+    const env = { HOME: process.env.HOME, PATH: process.env.PATH };
+    const startTime = process.hrtime();
+    const cmd = ('composer update ' + updatedDeps.join(' ')).trim();
+    logger.debug({ cmd });
+    ({ stdout, stderr } = await exec(cmd, {
+      cwd: tmpDir.path,
+      shell: true,
+      env,
+    }));
+    logger.debug(`composer stdout:\n${stdout}`);
+    logger.debug(`composer stderr:\n${stderr}`);
+    const duration = process.hrtime(startTime);
+    const seconds = Math.round(duration[0] + duration[1] / 1e9);
+    const newComposerLockContent = await fs.readFile(
+      composerLockFileName,
+      'utf8'
+    );
+    logger.info(
+      { seconds, type: 'composer.lock', stdout, stderr },
+      'Generated lockfile'
+    );
+    if (newComposerLockContent === existingComposerLockContent) {
+      logger.debug('composer.lock is unchanged');
+      return null;
+    }
+    logger.debug('Returning updated composer.lock');
+    return {
+      name: composerLockPath,
+      contents: newComposerLockContent,
+    };
+  } catch (err) {
+    logger.warn(
+      { err, message: err.message },
+      'Failed to generate composer.lock'
+    );
+    return null;
+  } finally {
+    tmpDir.cleanup();
+  }
+}
diff --git a/lib/workers/branch/get-updated.js b/lib/workers/branch/get-updated.js
index cf26300da3..bb58f94547 100644
--- a/lib/workers/branch/get-updated.js
+++ b/lib/workers/branch/get-updated.js
@@ -8,9 +8,15 @@ async function getUpdatedPackageFiles(config) {
   logger.debug('manager.getUpdatedPackageFiles()');
   logger.trace({ config });
   const updatedFileContents = {};
+  const packageFileManagers = {};
+  const packageFileUpdatedDeps = {};
 
   for (const upgrade of config.upgrades) {
-    const { manager, packageFile } = upgrade;
+    const { manager, packageFile, depName } = upgrade;
+    packageFileManagers[packageFile] = manager;
+    packageFileUpdatedDeps[packageFile] =
+      packageFileUpdatedDeps[packageFile] || [];
+    packageFileUpdatedDeps[packageFile].push(depName);
     if (upgrade.updateType !== 'lockFileMaintenance') {
       const existingContent =
         updatedFileContents[packageFile] ||
@@ -46,8 +52,25 @@ async function getUpdatedPackageFiles(config) {
     name,
     contents: updatedFileContents[name],
   }));
+  const updatedLockFiles = [];
+  for (const packageFile of updatedPackageFiles) {
+    const manager = packageFileManagers[packageFile.name];
+    const updatedDeps = packageFileUpdatedDeps[packageFile.name];
+    const getLockFile = get(manager, 'getLockFile');
+    if (getLockFile) {
+      const updatedLockFile = await getLockFile(
+        packageFile.name,
+        updatedDeps,
+        packageFile.contents
+      );
+      if (updatedLockFile) {
+        updatedLockFiles.push(updatedLockFile);
+      }
+    }
+  }
   return {
     parentBranch: config.parentBranch, // Need to overwrite original config
     updatedPackageFiles,
+    updatedLockFiles,
   };
 }
diff --git a/test/manager/composer/__snapshots__/extract.spec.js.snap b/test/manager/composer/__snapshots__/extract.spec.js.snap
index 0ca7e75d76..1cc39fd69b 100644
--- a/test/manager/composer/__snapshots__/extract.spec.js.snap
+++ b/test/manager/composer/__snapshots__/extract.spec.js.snap
@@ -1,7 +1,258 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`lib/manager/pip_requirements/extract extractDependencies() extracts dependencies 1`] = `
+exports[`lib/manager/composer/extract extractDependencies() extracts dependencies with lock file 1`] = `
 Object {
+  "composerLock": "composer.lock",
+  "deps": Array [
+    Object {
+      "currentValue": ">=5.3.2",
+      "depName": "php",
+      "depType": "require",
+      "purl": "pkg:packagist/php",
+      "skipReason": "unsupported",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "symfony/assetic-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/symfony/assetic-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "symfony/monolog-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/symfony/monolog-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "symfony/swiftmailer-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/symfony/swiftmailer-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "2.1.*",
+      "depName": "symfony/symfony",
+      "depType": "require",
+      "purl": "pkg:packagist/symfony/symfony",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "2.2.2",
+      "depName": "doctrine/common",
+      "depType": "require",
+      "purl": "pkg:packagist/doctrine/common",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "doctrine/doctrine-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/doctrine/doctrine-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "doctrine/doctrine-fixtures-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/doctrine/doctrine-fixtures-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "2.2.x-dev",
+      "depName": "doctrine/orm",
+      "depType": "require",
+      "purl": "pkg:packagist/doctrine/orm",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "exercise/elastica-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/exercise/elastica-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "friendsofsymfony/rest-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/friendsofsymfony/rest-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "*",
+      "depName": "friendsofsymfony/user-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/friendsofsymfony/user-bundle",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "*",
+      "depName": "fzaninotto/faker",
+      "depType": "require",
+      "purl": "pkg:packagist/fzaninotto/faker",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "1.0.1",
+      "depName": "jms/di-extra-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/jms/di-extra-bundle",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "*",
+      "depName": "jms/payment-core-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/jms/payment-core-bundle",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "1.1.0",
+      "depName": "jms/security-extra-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/jms/security-extra-bundle",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "knplabs/knp-menu-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/knplabs/knp-menu-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "knplabs/knp-paginator-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/knplabs/knp-paginator-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "liip/imagine-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/liip/imagine-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "merk/dough-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/merk/dough-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "sensio/distribution-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/sensio/distribution-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "sensio/framework-extra-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/sensio/framework-extra-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "sensio/generator-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/sensio/generator-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "simplethings/entity-audit-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/simplethings/entity-audit-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "stof/doctrine-extensions-bundle",
+      "depType": "require",
+      "purl": "pkg:packagist/stof/doctrine-extensions-bundle",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "dev-master",
+      "depName": "twig/extensions",
+      "depType": "require",
+      "purl": "pkg:packagist/twig/extensions",
+      "skipReason": "unsupported-constraint",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "2.3.*",
+      "depName": "behat/behat",
+      "depType": "require-dev",
+      "purl": "pkg:packagist/behat/behat",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "*",
+      "depName": "behat/behat-bundle",
+      "depType": "require-dev",
+      "purl": "pkg:packagist/behat/behat-bundle",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "*",
+      "depName": "behat/mink-bundle",
+      "depType": "require-dev",
+      "purl": "pkg:packagist/behat/mink-bundle",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "*",
+      "depName": "behat/sahi-client",
+      "depType": "require-dev",
+      "purl": "pkg:packagist/behat/sahi-client",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
+    },
+    Object {
+      "currentValue": "*",
+      "depName": "behat/common-contexts",
+      "depType": "require-dev",
+      "purl": "pkg:packagist/behat/common-contexts",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
+    },
+  ],
+}
+`;
+
+exports[`lib/manager/composer/extract extractDependencies() extracts dependencies with no lock file 1`] = `
+Object {
+  "composerLock": false,
   "deps": Array [
     Object {
       "currentValue": ">=5.3.2",
diff --git a/test/manager/composer/extract.spec.js b/test/manager/composer/extract.spec.js
index 62869a2054..cf4a7e5e01 100644
--- a/test/manager/composer/extract.spec.js
+++ b/test/manager/composer/extract.spec.js
@@ -8,20 +8,25 @@ const requirements1 = fs.readFileSync(
   'utf8'
 );
 
-describe('lib/manager/pip_requirements/extract', () => {
+describe('lib/manager/composer/extract', () => {
   describe('extractDependencies()', () => {
-    let config;
+    let packageFile;
     beforeEach(() => {
-      config = {};
+      packageFile = 'composer.json';
     });
-    it('returns null for invalid json', () => {
-      expect(extractDependencies('nothing here', config)).toBe(null);
+    it('returns null for invalid json', async () => {
+      expect(await extractDependencies('nothing here', packageFile)).toBe(null);
     });
-    it('returns null for empty deps', () => {
-      expect(extractDependencies('{}', config)).toBe(null);
+    it('returns null for empty deps', async () => {
+      expect(await extractDependencies('{}', packageFile)).toBe(null);
     });
-    it('extracts dependencies', () => {
-      const res = extractDependencies(requirements1, config);
+    it('extracts dependencies with no lock file', async () => {
+      const res = await extractDependencies(requirements1, packageFile);
+      expect(res).toMatchSnapshot();
+    });
+    it('extracts dependencies with lock file', async () => {
+      platform.getFile.mockReturnValueOnce('some content');
+      const res = await extractDependencies(requirements1, packageFile);
       expect(res).toMatchSnapshot();
     });
   });
diff --git a/test/manager/composer/lock-file.spec.js b/test/manager/composer/lock-file.spec.js
new file mode 100644
index 0000000000..95d52b4cf9
--- /dev/null
+++ b/test/manager/composer/lock-file.spec.js
@@ -0,0 +1,42 @@
+jest.mock('fs-extra');
+jest.mock('child-process-promise');
+
+const fs = require('fs-extra');
+const { exec } = require('child-process-promise');
+const composer = require('../../../lib/manager/composer/lock-file');
+
+describe('.getLockFile()', () => {
+  beforeEach(() => {
+    jest.resetAllMocks();
+  });
+  it('returns if no composer.lock found', async () => {
+    expect(await composer.getLockFile('composer.json', [], '{}')).toBeNull();
+  });
+  it('returns null if unchanged', async () => {
+    platform.getFile.mockReturnValueOnce('Current composer.lock');
+    exec.mockReturnValueOnce({
+      stdout: '',
+      stderror: '',
+    });
+    fs.readFile = jest.fn(() => 'Current composer.lock');
+    expect(await composer.getLockFile('composer.json', [], '{}')).toBeNull();
+  });
+  it('returns updated composer.lock', async () => {
+    platform.getFile.mockReturnValueOnce('Current composer.lock');
+    exec.mockReturnValueOnce({
+      stdout: '',
+      stderror: '',
+    });
+    fs.readFile = jest.fn(() => 'New composer.lock');
+    expect(
+      await composer.getLockFile('composer.json', [], '{}')
+    ).not.toBeNull();
+  });
+  it('catches errors', async () => {
+    platform.getFile.mockReturnValueOnce('Current composer.lock');
+    fs.outputFile = jest.fn(() => {
+      throw new Error('not found');
+    });
+    expect(await composer.getLockFile('composer.json', [], '{}')).toBeNull();
+  });
+});
diff --git a/test/workers/branch/__snapshots__/get-updated.spec.js.snap b/test/workers/branch/__snapshots__/get-updated.spec.js.snap
index 6cb392e106..8ac0c1c993 100644
--- a/test/workers/branch/__snapshots__/get-updated.spec.js.snap
+++ b/test/workers/branch/__snapshots__/get-updated.spec.js.snap
@@ -3,6 +3,7 @@
 exports[`workers/branch/get-updated getUpdatedPackageFiles() handles content change 1`] = `
 Object {
   "parentBranch": undefined,
+  "updatedLockFiles": Array [],
   "updatedPackageFiles": Array [
     Object {
       "contents": "some new content",
@@ -15,6 +16,25 @@ Object {
 exports[`workers/branch/get-updated getUpdatedPackageFiles() handles empty 1`] = `
 Object {
   "parentBranch": undefined,
+  "updatedLockFiles": Array [],
   "updatedPackageFiles": Array [],
 }
 `;
+
+exports[`workers/branch/get-updated getUpdatedPackageFiles() handles lock files 1`] = `
+Object {
+  "parentBranch": undefined,
+  "updatedLockFiles": Array [
+    Object {
+      "contents": "some contents",
+      "name": "composer.json",
+    },
+  ],
+  "updatedPackageFiles": Array [
+    Object {
+      "contents": "some new content",
+      "name": "undefined",
+    },
+  ],
+}
+`;
diff --git a/test/workers/branch/get-updated.spec.js b/test/workers/branch/get-updated.spec.js
index 744ea0f1d5..859380b14f 100644
--- a/test/workers/branch/get-updated.spec.js
+++ b/test/workers/branch/get-updated.spec.js
@@ -1,3 +1,4 @@
+const composer = require('../../../lib/manager/composer');
 const npm = require('../../../lib/manager/npm');
 const {
   getUpdatedPackageFiles,
@@ -12,6 +13,8 @@ describe('workers/branch/get-updated', () => {
         ...defaultConfig,
         upgrades: [],
       };
+      composer.updateDependency = jest.fn();
+      composer.getLockFile = jest.fn();
       npm.updateDependency = jest.fn();
     });
     it('handles empty', async () => {
@@ -40,5 +43,18 @@ describe('workers/branch/get-updated', () => {
       const res = await getUpdatedPackageFiles(config);
       expect(res).toMatchSnapshot();
     });
+    it('handles lock files', async () => {
+      config.parentBranch = 'some-branch';
+      config.upgrades.push({
+        manager: 'composer',
+      });
+      composer.updateDependency.mockReturnValue('some new content');
+      composer.getLockFile.mockReturnValue({
+        name: 'composer.json',
+        contents: 'some contents',
+      });
+      const res = await getUpdatedPackageFiles(config);
+      expect(res).toMatchSnapshot();
+    });
   });
 });
-- 
GitLab