From 5b0c431dceeee00f23790d44eeae1b6d0966efb4 Mon Sep 17 00:00:00 2001
From: Carlin St Pierre <cstpierre@atlassian.com>
Date: Tue, 4 Feb 2020 16:59:13 +1100
Subject: [PATCH] feat: post-upgrade tasks (#5202)

---
 docs/usage/configuration-options.md     | 27 ++++++++
 docs/usage/self-hosted-configuration.md | 12 ++++
 lib/config/common.ts                    |  7 +++
 lib/config/definitions.ts               | 41 +++++++++++-
 lib/workers/branch/index.ts             | 83 +++++++++++++++++++++++++
 renovate-schema.json                    | 42 +++++++++++++
 test/workers/branch/index.spec.ts       | 49 +++++++++++++++
 7 files changed, 260 insertions(+), 1 deletion(-)

diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index a4fd039f83..de9f0a9459 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -1100,6 +1100,33 @@ Warning: `pipenv` support is currently in beta, so it is not enabled by default.
 - `yarnDedupeFewer`: Run `yarn-deduplicate --strategy fewer` after `yarn.lock` updates
 - `yarnDedupeHighest`: Run `yarn-deduplicate --strategy highest` after `yarn.lock` updates
 
+## postUpgradeTasks
+
+Post-upgrade tasks are commands that are executed by Renovate after a dependency has been updated but before the commit is created. The intention is to run any additional command line tools that would modify existing files or generate new files when a dependency changes.
+
+This is only available on Renovate instances that have a `trustLevel` of 'high'. Each command must match at least one of the patterns defined in `allowedPostUpgradeTasks` in order to be executed. If the list of allowed tasks is empty then no tasks will be executed.
+
+e.g.
+
+```json
+{
+  "postUpgradeTasks": {
+    "commands": ["tslint --fix"],
+    "fileFilters": ["yarn.lock", "**/*.js"]
+  }
+}
+```
+
+The `postUpdateTasks` configuration consists of two fields:
+
+### commands
+
+A list of commands that are executed after Renovate has updated a dependency but before the commit it made
+
+### fileFilters
+
+A list of glob-style matchers that determine which files will be included in the final commit made by Renovate
+
 ## prBodyColumns
 
 Use this array to provide a list of column names you wish to include in the PR tables.
diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md
index 1df50e1478..9c86f9510f 100644
--- a/docs/usage/self-hosted-configuration.md
+++ b/docs/usage/self-hosted-configuration.md
@@ -7,6 +7,18 @@ description: Self-Hosted Configuration usable in renovate.json or package.json
 
 The below configuration options are applicable only if you are running your own instance ("bot") of Renovate.
 
+## allowedPostUpgradeCommands
+
+A list of regular expressions that determine which commands in `postUpgradeTasks` are allowed to be executed. If this list is empty then no tasks will be executed.
+
+e.g.
+
+```json
+{
+  "allowedPostUpgradeCommands": ["^tslint --fix$", "^tslint --[a-z]+$"]
+}
+```
+
 ## autodiscover
 
 Be cautious when using this option - it will run Renovate over _every_ repository that the bot account has access to. To filter this list, use `autodiscoverFilter`.
diff --git a/lib/config/common.ts b/lib/config/common.ts
index 1b91283eb7..8fc2d2baf6 100644
--- a/lib/config/common.ts
+++ b/lib/config/common.ts
@@ -29,8 +29,15 @@ export interface RenovateSharedConfig {
   statusCheckVerify?: boolean;
   suppressNotifications?: string[];
   timezone?: string;
+  allowedPostUpgradeCommands?: string[];
+  postUpgradeTasks?: PostUpgradeTasks;
 }
 
+export type PostUpgradeTasks = {
+  commands?: string[];
+  fileFilters?: string[];
+};
+
 type UpdateConfig<
   T extends RenovateSharedConfig = RenovateSharedConfig
 > = Partial<Record<UpdateType, T>>;
diff --git a/lib/config/definitions.ts b/lib/config/definitions.ts
index 9bf118b65f..b46650e7fe 100644
--- a/lib/config/definitions.ts
+++ b/lib/config/definitions.ts
@@ -45,7 +45,7 @@ export interface RenovateOptionBase {
 
   name: string;
 
-  parent?: 'hostRules' | 'packageRules';
+  parent?: 'hostRules' | 'packageRules' | 'postUpgradeTasks';
 
   // used by tests
   relatedOptions?: string[];
@@ -102,6 +102,45 @@ export type RenovateOptions =
   | RenovateObjectOption;
 
 const options: RenovateOptions[] = [
+  {
+    name: 'allowedPostUpgradeCommands',
+    description:
+      'A list of regular expressions that determine which post-upgrade tasks are allowed. A task has to match at least one of the patterns to be allowed to run',
+    type: 'array',
+    subType: 'string',
+    default: [],
+    admin: true,
+  },
+  {
+    name: 'postUpgradeTasks',
+    description:
+      'Post-upgrade tasks that are executed before a commit is made by Renovate',
+    type: 'object',
+    default: {
+      commands: [],
+      fileFilters: [],
+    },
+  },
+  {
+    name: 'commands',
+    description:
+      'A list of post-upgrade commands that are executed before a commit is made by Renovate',
+    type: 'array',
+    subType: 'string',
+    parent: 'postUpgradeTasks',
+    default: [],
+    cli: false,
+  },
+  {
+    name: 'fileFilters',
+    description:
+      'Files that match these glob patterns will be committed if they are present after running a post-upgrade task',
+    type: 'array',
+    subType: 'string',
+    parent: 'postUpgradeTasks',
+    default: [],
+    cli: false,
+  },
   {
     name: 'onboardingBranch',
     description:
diff --git a/lib/workers/branch/index.ts b/lib/workers/branch/index.ts
index 472fe374b0..e5bbc2c180 100644
--- a/lib/workers/branch/index.ts
+++ b/lib/workers/branch/index.ts
@@ -1,5 +1,10 @@
 import { DateTime } from 'luxon';
 
+import _ from 'lodash';
+import { readFile } from 'fs-extra';
+import is from '@sindresorhus/is';
+import minimatch from 'minimatch';
+import { join } from 'upath';
 import { logger } from '../../logger';
 import { isScheduledNow } from './schedule';
 import { getUpdatedPackageFiles } from './get-updated';
@@ -30,6 +35,7 @@ import {
   PLATFORM_FAILURE,
 } from '../../constants/error-messages';
 import { BRANCH_STATUS_FAILURE } from '../../constants/branch-constants';
+import { exec } from '../../util/exec';
 
 export type ProcessBranchResult =
   | 'already-existed'
@@ -320,6 +326,83 @@ export async function processBranch(
     } else {
       logger.debug('No updated lock files in branch');
     }
+
+    if (
+      global.trustLevel === 'high' &&
+      is.nonEmptyArray(config.allowedPostUpgradeCommands)
+    ) {
+      logger.debug(
+        {
+          tasks: config.postUpgradeTasks,
+          allowedCommands: config.allowedPostUpgradeCommands,
+        },
+        'Checking for post-upgrade tasks'
+      );
+      const commands = config.postUpgradeTasks.commands || [];
+      const fileFilters = config.postUpgradeTasks.fileFilters || [];
+
+      if (is.nonEmptyArray(commands)) {
+        for (const cmd of commands) {
+          if (
+            !_.some(config.allowedPostUpgradeCommands, (pattern: string) =>
+              cmd.match(pattern)
+            )
+          ) {
+            logger.warn(
+              {
+                cmd,
+                allowedPostUpgradeCommands: config.allowedPostUpgradeCommands,
+              },
+              'Post-upgrade task did not match any on allowed list'
+            );
+          } else {
+            logger.debug({ cmd }, 'Executing post-upgrade task');
+
+            const execResult = await exec(cmd, {
+              cwd: config.localDir,
+            });
+
+            logger.debug({ cmd, ...execResult }, 'Executed post-upgrade task');
+          }
+        }
+
+        const status = await platform.getRepoStatus();
+
+        for (const relativePath of status.modified.concat(status.not_added)) {
+          for (const pattern of fileFilters) {
+            if (minimatch(relativePath, pattern)) {
+              logger.debug(
+                { file: relativePath, pattern },
+                'Post-upgrade file saved'
+              );
+              const existingContent = await readFile(
+                join(config.localDir, relativePath)
+              );
+              config.updatedArtifacts.push({
+                name: relativePath,
+                contents: existingContent.toString(),
+              });
+            }
+          }
+        }
+
+        for (const relativePath of status.deleted || []) {
+          for (const pattern of fileFilters) {
+            if (minimatch(relativePath, pattern)) {
+              logger.debug(
+                { file: relativePath, pattern },
+                'Post-upgrade file removed'
+              );
+              config.updatedArtifacts.push({
+                name: '|delete|',
+                contents: relativePath,
+              });
+            }
+          }
+        }
+      }
+    }
+
     if (config.artifactErrors && config.artifactErrors.length) {
       if (config.releaseTimestamp) {
         logger.debug(`Branch timestamp: ` + config.releaseTimestamp);
diff --git a/renovate-schema.json b/renovate-schema.json
index 4a952be5df..1bb82903a1 100644
--- a/renovate-schema.json
+++ b/renovate-schema.json
@@ -3,6 +3,48 @@
   "$schema": "http://json-schema.org/draft-04/schema#",
   "type": "object",
   "properties": {
+    "allowedPostUpgradeCommands": {
+      "description": "A list of regular expressions that determine which post-upgrade tasks are allowed. A task has to match at least one of the patterns to be allowed to run",
+      "type": "array",
+      "items": {
+        "type": "string"
+      },
+      "default": []
+    },
+    "postUpgradeTasks": {
+      "description": "Post-upgrade tasks that are executed before a commit is made by Renovate",
+      "type": "object",
+      "default": {
+        "commands": [],
+        "fileFilters": []
+      },
+      "$ref": "#",
+      "items": {
+        "allOf": [
+          {
+            "type": "object",
+            "properties": {
+              "commands": {
+                "description": "A list of post-upgrade commands that are executed before a commit is made by Renovate",
+                "type": "array",
+                "items": {
+                  "type": "string"
+                },
+                "default": []
+              },
+              "fileFilters": {
+                "description": "Files that match these glob patterns will be committed if they are present after running a post-upgrade task",
+                "type": "array",
+                "items": {
+                  "type": "string"
+                },
+                "default": []
+              }
+            }
+          }
+        ]
+      }
+    },
     "onboardingBranch": {
       "description": "Change this value in order to override the default onboarding branch name.",
       "type": "string",
diff --git a/test/workers/branch/index.spec.ts b/test/workers/branch/index.spec.ts
index 0af6abf55e..5e670ed6d8 100644
--- a/test/workers/branch/index.spec.ts
+++ b/test/workers/branch/index.spec.ts
@@ -1,3 +1,4 @@
+import * as _fs from 'fs-extra';
 import * as branchWorker from '../../../lib/workers/branch';
 import * as _schedule from '../../../lib/workers/branch/schedule';
 import * as _checkExisting from '../../../lib/workers/branch/check-existing';
@@ -8,6 +9,7 @@ import * as _statusChecks from '../../../lib/workers/branch/status-checks';
 import * as _automerge from '../../../lib/workers/branch/automerge';
 import * as _prWorker from '../../../lib/workers/pr';
 import * as _getUpdated from '../../../lib/workers/branch/get-updated';
+import * as _exec from '../../../lib/util/exec';
 import { defaultConfig, platform, mocked } from '../../util';
 import { BranchConfig } from '../../../lib/workers/common';
 import {
@@ -15,6 +17,7 @@ import {
   REPOSITORY_CHANGED,
 } from '../../../lib/constants/error-messages';
 import { BRANCH_STATUS_PENDING } from '../../../lib/constants/branch-constants';
+import { StatusResult } from '../../../lib/platform/git/storage';
 
 jest.mock('../../../lib/workers/branch/get-updated');
 jest.mock('../../../lib/workers/branch/schedule');
@@ -25,6 +28,8 @@ jest.mock('../../../lib/workers/branch/status-checks');
 jest.mock('../../../lib/workers/branch/automerge');
 jest.mock('../../../lib/workers/branch/commit');
 jest.mock('../../../lib/workers/pr');
+jest.mock('../../../lib/util/exec');
+jest.mock('fs-extra');
 
 const getUpdated = mocked(_getUpdated);
 const schedule = mocked(_schedule);
@@ -35,6 +40,8 @@ const statusChecks = mocked(_statusChecks);
 const automerge = mocked(_automerge);
 const commit = mocked(_commit);
 const prWorker = mocked(_prWorker);
+const exec = mocked(_exec);
+const fs = mocked(_fs);
 
 describe('workers/branch', () => {
   describe('processBranch', () => {
@@ -524,5 +531,47 @@ describe('workers/branch', () => {
         })
       ).toEqual('done');
     });
+
+    it('executes post-upgrade tasks if trust is high', async () => {
+      getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce({
+        updatedPackageFiles: [{}],
+        artifactErrors: [],
+      } as never);
+      npmPostExtract.getAdditionalFiles.mockResolvedValueOnce({
+        artifactErrors: [],
+        updatedArtifacts: [{}],
+      } as never);
+      platform.branchExists.mockResolvedValueOnce(true);
+      platform.getBranchPr.mockResolvedValueOnce({
+        title: 'rebase!',
+        state: 'open',
+        body: `- [x] <!-- rebase-check -->`,
+        isModified: true,
+      } as never);
+      platform.getRepoStatus.mockResolvedValueOnce({
+        modified: ['modified_file'],
+        not_added: [],
+        deleted: ['deleted_file'],
+      } as StatusResult);
+      global.trustLevel = 'high';
+
+      fs.readFile.mockResolvedValueOnce(Buffer.from('modified file content'));
+
+      schedule.isScheduledNow.mockReturnValueOnce(false);
+      commit.commitFilesToBranch.mockResolvedValueOnce(false);
+
+      const result = await branchWorker.processBranch({
+        ...config,
+        postUpgradeTasks: {
+          commands: ['echo 1', 'disallowed task'],
+          fileFilters: ['modified_file', 'deleted_file'],
+        },
+        localDir: '/localDir',
+        allowedPostUpgradeCommands: ['^echo 1$'],
+      });
+
+      expect(result).toEqual('done');
+      expect(exec.exec).toHaveBeenCalledWith('echo 1', { cwd: '/localDir' });
+    });
   });
 });
-- 
GitLab