From 97d2a545618f7b1281977030aeca4665c3bf74e4 Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@keylocation.sg>
Date: Fri, 21 Apr 2017 07:00:26 +0200
Subject: [PATCH] Add repository autodiscovery (#156)

Closes #146
---
 docs/configuration.md                      |  2 +
 lib/api/github.js                          | 21 ++++++
 lib/api/gitlab.js                          | 21 ++++++
 lib/config/definitions.js                  |  6 ++
 lib/config/index.js                        | 22 ++++--
 lib/index.js                               |  2 +-
 readme.md                                  |  1 +
 test/api/__snapshots__/github.spec.js.snap | 30 ++++++++
 test/api/github.spec.js                    | 37 ++++++++++
 test/config/index.spec.js                  | 79 +++++++++++++++++-----
 10 files changed, 200 insertions(+), 21 deletions(-)

diff --git a/docs/configuration.md b/docs/configuration.md
index 889e728231..3f8528dba0 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -75,6 +75,7 @@ $ node renovate --help
     --platform <string>                  Platform type of repository
     --endpoint <string>                  Custom endpoint to use
     --token <string>                     Repository Auth Token
+    --autodiscover [boolean]             Autodiscover all repositories
     --package-files <list>               Package file paths
     --dep-types <list>                   Dependency types
     --separate-major-releases [boolean]  If set to false, it will upgrade dependencies to latest release only, and not separate major/minor branches
@@ -130,6 +131,7 @@ Obviously, you can't set repository or package file location with this method.
 | `platform` | Platform type of repository | string | `"github"` | `RENOVATE_PLATFORM` | `--platform` |
 | `endpoint` | Custom endpoint to use | string | `null` | `RENOVATE_ENDPOINT` | `--endpoint` |
 | `token` | Repository Auth Token | string | `null` | `RENOVATE_TOKEN` | `--token` |
+| `autodiscover` | Autodiscover all repositories | boolean | `false` | `RENOVATE_AUTODISCOVER` | `--autodiscover` |
 | `repositories` | List of Repositories | list | `[]` | `RENOVATE_REPOSITORIES` |  |
 | `packageFiles` | Package file paths | list | `[]` | `RENOVATE_PACKAGE_FILES` | `--package-files` |
 | `depTypes` | Dependency types | list | `["dependencies", "devDependencies", "optionalDependencies"]` | `RENOVATE_DEP_TYPES` | `--dep-types` |
diff --git a/lib/api/github.js b/lib/api/github.js
index c33c3fd7d1..b7ab35fced 100644
--- a/lib/api/github.js
+++ b/lib/api/github.js
@@ -4,6 +4,7 @@ const ghGot = require('gh-got');
 const config = {};
 
 module.exports = {
+  getRepos,
   initRepo,
   // Search
   findFilePaths,
@@ -29,6 +30,26 @@ module.exports = {
   getFileJson,
 };
 
+// Get all repositories that the user has access to
+async function getRepos(token, endpoint) {
+  logger.debug('getRepos(token, endpoint)');
+  if (token) {
+    process.env.GITHUB_TOKEN = token;
+  } else if (!process.env.GITHUB_TOKEN) {
+    throw new Error('No token found for getRepos');
+  }
+  if (endpoint) {
+    process.env.GITHUB_ENDPOINT = endpoint;
+  }
+  try {
+    const res = await ghGot('user/repos');
+    return res.body.map(repo => repo.full_name);
+  } catch (err) /* istanbul ignore next */ {
+    logger.error(`GitHub getRepos error: ${JSON.stringify(err)}`);
+    throw err;
+  }
+}
+
 // Initialize GitHub by getting base branch and SHA
 async function initRepo(repoName, token, endpoint) {
   logger.debug(`initRepo(${repoName})`);
diff --git a/lib/api/gitlab.js b/lib/api/gitlab.js
index 64889312b3..b884235c75 100644
--- a/lib/api/gitlab.js
+++ b/lib/api/gitlab.js
@@ -4,6 +4,7 @@ const glGot = require('gl-got');
 const config = {};
 
 module.exports = {
+  getRepos,
   initRepo,
   // Search
   findFilePaths,
@@ -29,6 +30,26 @@ module.exports = {
   getFileJson,
 };
 
+// Get all repositories that the user has access to
+async function getRepos(token, endpoint) {
+  logger.debug('getRepos(token, endpoint)');
+  if (token) {
+    process.env.GITLAB_TOKEN = token;
+  } else if (!process.env.GITLAB_TOKEN) {
+    throw new Error('No token found for getRepos');
+  }
+  if (endpoint) {
+    process.env.GITLAB_ENDPOINT = endpoint;
+  }
+  try {
+    const res = await glGot('projects');
+    return res.body.map(repo => repo.path_with_namespace);
+  } catch (err) {
+    logger.error(`GitLab getRepos error: ${JSON.stringify(err)}`);
+    throw err;
+  }
+}
+
 // Initialize GitLab by getting base branch
 async function initRepo(repoName, token, endpoint) {
   logger.debug(`initRepo(${repoName})`);
diff --git a/lib/config/definitions.js b/lib/config/definitions.js
index 700a375859..bbb07e5af7 100644
--- a/lib/config/definitions.js
+++ b/lib/config/definitions.js
@@ -31,6 +31,12 @@ const options = [
     description: 'Repository Auth Token',
     type: 'string',
   },
+  {
+    name: 'autodiscover',
+    description: 'Autodiscover all repositories',
+    type: 'boolean',
+    default: false,
+  },
   {
     name: 'repositories',
     description: 'List of Repositories',
diff --git a/lib/config/index.js b/lib/config/index.js
index 450aa58e82..5993002ab6 100644
--- a/lib/config/index.js
+++ b/lib/config/index.js
@@ -16,7 +16,7 @@ module.exports = {
   getRepositories,
 };
 
-function parseConfigs(env, argv) {
+async function parseConfigs(env, argv) {
   logger.debug('Parsing configs');
 
   // Get configs
@@ -53,9 +53,23 @@ function parseConfigs(env, argv) {
     throw new Error(`Unsupported platform: ${config.platform}.`);
   }
 
-  // We need at least one repository defined
-  if (!config.repositories || config.repositories.length === 0) {
-    throw new Error('At least one repository must be configured');
+  // Autodiscover
+  if (config.autodiscover) {
+    if (config.platform === 'github') {
+      logger.info('Autodiscovering GitHub repositories');
+      config.repositories = await githubApi.getRepos(config.token, config.endpoint);
+    } else if (config.platform === 'gitlab') {
+      logger.info('Autodiscovering GitLab repositories');
+      config.repositories = await gitlabApi.getRepos(config.token, config.endpoint);
+    }
+    if (!config.repositories || config.repositories.length === 0) {
+      // Soft fail (no error thrown) if no accessible repositories
+      logger.info('The account associated with your token does not have access to any repos');
+      return;
+    }
+  } else if (!config.repositories || config.repositories.length === 0) {
+    // We need at least one repository defined
+    throw new Error('At least one repository must be configured, or use --autodiscover');
   }
 
   // Configure each repository
diff --git a/lib/index.js b/lib/index.js
index 82c9725cf0..5595905d32 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -19,7 +19,7 @@ let api;
 async function start() {
   // Parse config
   try {
-    configParser.parseConfigs(process.env, process.argv);
+    await configParser.parseConfigs(process.env, process.argv);
     // Iterate through repositories sequentially
     for (const repo of configParser.getRepositories()) {
       await processRepo(repo);
diff --git a/readme.md b/readme.md
index f50a48db38..b57d7da84a 100644
--- a/readme.md
+++ b/readme.md
@@ -44,6 +44,7 @@ $ node renovate --help
     --platform <string>                  Platform type of repository
     --endpoint <string>                  Custom endpoint to use
     --token <string>                     Repository Auth Token
+    --autodiscover [boolean]             Autodiscover all repositories
     --package-files <list>               Package file paths
     --dep-types <list>                   Dependency types
     --separate-major-releases [boolean]  If set to false, it will upgrade dependencies to latest release only, and not separate major/minor branches
diff --git a/test/api/__snapshots__/github.spec.js.snap b/test/api/__snapshots__/github.spec.js.snap
index f3308236b9..9f42efd0b9 100644
--- a/test/api/__snapshots__/github.spec.js.snap
+++ b/test/api/__snapshots__/github.spec.js.snap
@@ -633,6 +633,36 @@ Object {
 }
 `;
 
+exports[`api/github getRepos should return an array of repos 1`] = `
+Array [
+  Array [
+    "user/repos",
+  ],
+]
+`;
+
+exports[`api/github getRepos should return an array of repos 2`] = `
+Array [
+  "a/b",
+  "c/d",
+]
+`;
+
+exports[`api/github getRepos should support a custom endpoint 1`] = `
+Array [
+  Array [
+    "user/repos",
+  ],
+]
+`;
+
+exports[`api/github getRepos should support a custom endpoint 2`] = `
+Array [
+  "a/b",
+  "c/d",
+]
+`;
+
 exports[`api/github initRepo should initialise the config for the repo - 0 1`] = `
 Array [
   Array [
diff --git a/test/api/github.spec.js b/test/api/github.spec.js
index 0cbae5e55d..f8d74a5cbf 100644
--- a/test/api/github.spec.js
+++ b/test/api/github.spec.js
@@ -13,6 +13,43 @@ describe('api/github', () => {
     ghGot = require('gh-got');
   });
 
+  async function getRepos(...args) {
+    // repo info
+    ghGot.mockImplementationOnce(() => ({
+      body: [
+        {
+          full_name: 'a/b',
+        },
+        {
+          full_name: 'c/d',
+        },
+      ],
+    }));
+    return github.getRepos(...args);
+  }
+
+  describe('getRepos', () => {
+    it('should throw an error if no token is provided', async () => {
+      let err;
+      try {
+        await github.getRepos();
+      } catch (e) {
+        err = e;
+      }
+      expect(err.message).toBe('No token found for getRepos');
+    });
+    it('should return an array of repos', async () => {
+      const repos = await getRepos('sometoken');
+      expect(ghGot.mock.calls).toMatchSnapshot();
+      expect(repos).toMatchSnapshot();
+    });
+    it('should support a custom endpoint', async () => {
+      const repos = await getRepos('sometoken', 'someendpoint');
+      expect(ghGot.mock.calls).toMatchSnapshot();
+      expect(repos).toMatchSnapshot();
+    });
+  });
+
   async function initRepo(...args) {
     // repo info
     ghGot.mockImplementationOnce(() => ({
diff --git a/test/config/index.spec.js b/test/config/index.spec.js
index ff4c40cab0..05d3b198d5 100644
--- a/test/config/index.spec.js
+++ b/test/config/index.spec.js
@@ -5,75 +5,122 @@ describe('config/index', () => {
   describe('.parseConfigs(env, defaultArgv)', () => {
     let configParser;
     let defaultArgv;
+    let ghGot;
+    let glGot;
     beforeEach(() => {
       jest.resetModules();
       configParser = require('../../lib/config/index.js');
       defaultArgv = argv();
+      jest.mock('gh-got');
+      jest.mock('gl-got');
+      ghGot = require('gh-got');
+      glGot = require('gl-got');
     });
-    it('throws for invalid platform', () => {
+    it('throws for invalid platform', async () => {
       const env = {};
       defaultArgv.push('--platform=foo');
       let err;
       try {
-        configParser.parseConfigs(env, defaultArgv);
+        await configParser.parseConfigs(env, defaultArgv);
       } catch (e) {
         err = e;
       }
       expect(err.message).toBe('Unsupported platform: foo.');
     });
-    it('throws for no GitHub token', () => {
+    it('throws for no GitHub token', async () => {
       const env = {};
       let err;
       try {
-        configParser.parseConfigs(env, defaultArgv);
+        await configParser.parseConfigs(env, defaultArgv);
       } catch (e) {
         err = e;
       }
       expect(err.message).toBe('You need to supply a GitHub token.');
     });
-    it('throws for no GitLab token', () => {
+    it('throws for no GitLab token', async () => {
       const env = { RENOVATE_PLATFORM: 'gitlab' };
       let err;
       try {
-        configParser.parseConfigs(env, defaultArgv);
+        await configParser.parseConfigs(env, defaultArgv);
       } catch (e) {
         err = e;
       }
       expect(err.message).toBe('You need to supply a GitLab token.');
     });
-    it('supports token in env', () => {
+    it('supports token in env', async () => {
       const env = { GITHUB_TOKEN: 'abc' };
       let err;
       try {
-        configParser.parseConfigs(env, defaultArgv);
+        await configParser.parseConfigs(env, defaultArgv);
       } catch (e) {
         err = e;
       }
-      expect(err.message).toBe('At least one repository must be configured');
+      expect(err.message).toBe('At least one repository must be configured, or use --autodiscover');
     });
-    it('supports token in CLI options', () => {
+    it('supports token in CLI options', async () => {
       defaultArgv = defaultArgv.concat(['--token=abc']);
       const env = {};
       let err;
       try {
-        configParser.parseConfigs(env, defaultArgv);
+        await configParser.parseConfigs(env, defaultArgv);
       } catch (e) {
         err = e;
       }
-      expect(err.message).toBe('At least one repository must be configured');
+      expect(err.message).toBe('At least one repository must be configured, or use --autodiscover');
     });
-    it('supports repositories in CLI', () => {
+    it('autodiscovers github platform', async () => {
+      const env = {};
+      defaultArgv = defaultArgv.concat(['--autodiscover', '--token=abc']);
+      ghGot.mockImplementationOnce(() => ({
+        body: [
+          {
+            full_name: 'a/b',
+          },
+          {
+            full_name: 'c/d',
+          },
+        ],
+      }));
+      await configParser.parseConfigs(env, defaultArgv);
+      expect(ghGot.mock.calls.length).toBe(1);
+      expect(glGot.mock.calls.length).toBe(0);
+    });
+    it('autodiscovers gitlab platform', async () => {
+      const env = {};
+      defaultArgv = defaultArgv.concat(['--autodiscover', '--platform=gitlab', '--token=abc']);
+      glGot.mockImplementationOnce(() => ({
+        body: [
+          {
+            path_with_namespace: 'a/b',
+          },
+        ],
+      }));
+      await configParser.parseConfigs(env, defaultArgv);
+      expect(ghGot.mock.calls.length).toBe(0);
+      expect(glGot.mock.calls.length).toBe(1);
+    });
+    it('logs if no autodiscovered repositories', async () => {
+      const env = { GITHUB_TOKEN: 'abc' };
+      defaultArgv = defaultArgv.concat(['--autodiscover']);
+      ghGot.mockImplementationOnce(() => ({
+        body: [],
+      }));
+      await configParser.parseConfigs(env, defaultArgv);
+      expect(ghGot.mock.calls.length).toBe(1);
+      expect(glGot.mock.calls.length).toBe(0);
+    });
+    it('supports repositories in CLI', async () => {
       const env = {};
       defaultArgv = defaultArgv.concat(['--token=abc', 'foo']);
-      configParser.parseConfigs(env, defaultArgv);
+      await configParser.parseConfigs(env, defaultArgv);
       const repos = configParser.getRepositories();
       should.exist(repos);
       repos.should.have.length(1);
       repos[0].repository.should.eql('foo');
     });
-    it('gets cascaded config', () => {
+    it('gets cascaded config', async () => {
       const env = { RENOVATE_CONFIG_FILE: 'test/_fixtures/config/file.js' };
-      configParser.parseConfigs(env, defaultArgv);
+      await configParser.parseConfigs(env, defaultArgv);
       const repo = configParser.getRepositories().pop();
       should.exist(repo);
       const cascadedConfig = configParser.getCascadedConfig(repo, null);
-- 
GitLab