From 191ed7089892d0043b473b3a2a14a23da76fd600 Mon Sep 17 00:00:00 2001 From: Rhys Arkins <rhys@keylocation.sg> Date: Sat, 11 Feb 2017 08:14:19 +0100 Subject: [PATCH] Add Gitlab support (#83) Closes #65 --- docs/deployment.md | 1 + docs/design-decisions.md | 15 +- lib/api/gitlab.js | 303 ++++++++++++++++++++++++++++++++++++++ lib/config/definitions.js | 2 +- lib/index.js | 14 +- lib/worker.js | 6 +- package.json | 1 + readme.md | 11 +- yarn.lock | 7 + 9 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 lib/api/gitlab.js diff --git a/docs/deployment.md b/docs/deployment.md index 55f041b935..8d7baf049a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -40,6 +40,7 @@ You now need to set the token. ``` $ heroku config:set GITHUB_TOKEN=[YourGitHubToken] ``` +(or use `GITLAB_TOKEN` if appropriate) You should also set any other [Configuration Options](configuration.md) you need. diff --git a/docs/design-decisions.md b/docs/design-decisions.md index 39cd14dc9b..2cdb3c1394 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -4,17 +4,18 @@ This file documents the design choices as well as configuration options. #### Stateless -No state is needed on `renovate` or GitHub side apart from what you see publicly in GitHub (branches, Pull Requests). It therefore doesn't matter if you stop/restart the script and would even still work if you had it running from two different locations, as long as their configuration was the same. +No state storage is needed on `renovate` or GitHub/GitLab apart from what you see publicly in GitHub (branches, Pull Requests). It therefore doesn't matter if you stop/restart the script and would even still work if you had it running from two different locations, as long as their configuration was the same. #### API only -So far, nothing we need to do requires git itself. e.g. we do not need to perform a git clone of the entire repository. Therefore, all operations are performed via the API. +So far, nothing we need to do requires git directly. e.g. we do not need to perform a git clone of the entire repository. Therefore, all operations are performed via the API. ## Synchronous Operation The script current processes repositories, package files, and dependencies within them all synchronously. -- Greatly reduces chance of hitting GitHub API limits -- Implicitly enables any feature that results in multiple commits in the same branch + +- Greatly reduces chance of hitting simultaneous API rate limits +- Implicitly enables any configuration that results in multiple commits in the same branch - Simplifies logging Note: Initial queries to NPM are done in parallel. @@ -43,6 +44,8 @@ The following options apply per-package file: The following options apply per-repository: - Token +- Platform +- Endpoint The following options apply globally: @@ -50,6 +53,8 @@ The following options apply globally: ## Automatic discovery of package.json locations +Note: GitHub only. + Default behaviour is to auto-discover all `package.json` locations in a repository and process them all. Doing so means that "monorepos" are supported by default. This can be overridden by the configuration option `packageFiles`, where you list the file paths manually (e.g. limit to just `package.json` in root of repository). @@ -100,6 +105,8 @@ Perhaps this will be made configurable in future once requirements are understoo ## Rebasing Unmergeable Pull Requests +Note: GitHub only. GitLab does not expose enough low level git API to allow this. + With the default behaviour of one branch per dependency, it's often that case that a PR gets merge conflicts after an adjacent dependency update is merged. Although GitHub has added a web interface for simple merge conflicts, this is still annoying to resolve manually. `renovate` will rebase any unmergeable branches and add the latest necessary commit on top of the most recent `master` commit. diff --git a/lib/api/gitlab.js b/lib/api/gitlab.js new file mode 100644 index 0000000000..e0ce0904b9 --- /dev/null +++ b/lib/api/gitlab.js @@ -0,0 +1,303 @@ +const logger = require('winston'); +const glGot = require('gl-got'); + +const config = {}; + +module.exports = { + initRepo, + // Search + findFilePaths, + // Branch + branchExists, + getBranchPr, + // issue + addAssignees, + addReviewers, + addLabels, + // PR + findPr, + checkForClosedPr, + createPr, + getPr, + updatePr, + // file + commitFilesToBranch, + getFile, + getFileContent, + getFileJson, +}; + +// Initialize GitLab by getting base branch +async function initRepo(repoName, token, endpoint) { + logger.debug(`initRepo(${repoName})`); + if (token) { + process.env.GITLAB_TOKEN = token; + } else if (!process.env.GITLAB_TOKEN) { + throw new Error(`No token found for GitLab repository ${repoName}`); + } + if (token) { + process.env.GITLAB_TOKEN = token; + } + if (endpoint) { + process.env.GITLAB_ENDPOINT = endpoint; + } + config.repoName = repoName.replace('/', '%2F'); + try { + const res = await glGot(`projects/${config.repoName}`); + config.defaultBranch = res.body.default_branch; + logger.debug(`${repoName} default branch = ${config.defaultBranch}`); + } catch (err) { + logger.error(`GitLab init error: ${JSON.stringify(err)}`); + throw err; + } +} + +// Search + +// Returns an array of file paths in current repo matching the fileName +async function findFilePaths(fileName) { + logger.verbose('Can\'t find multiple package.json files in GitLab'); + return [fileName]; +} + +// Branch + +// Returns true if branch exists, otherwise false +async function branchExists(branchName) { + logger.debug(`Checking if branch exists: ${branchName}`); + try { + const url = `projects/${config.repoName}/repository/branches/${branchName}`; + const res = await glGot(url); + if (res.statusCode === 200) { + logger.debug('Branch exists'); + return true; + } + // This probably shouldn't happen + logger.debug('Branch doesn\'t exist'); + return false; + } catch (error) { + if (error.statusCode === 404) { + // If file not found, then return false + logger.debug('Branch doesn\'t exist'); + return false; + } + // Propagate if it's any other error + throw error; + } +} + +// Returns the Pull Request for a branch. Null if not exists. +async function getBranchPr(branchName) { + logger.debug(`getBranchPr(${branchName})`); + const urlString = `projects/${config.repoName}/merge_requests?state=opened`; + const res = await glGot(urlString); + logger.debug(`Got res with ${res.body.length} results`); + let pr = null; + res.body.forEach((result) => { + if (result.source_branch === branchName) { + pr = result; + } + }); + if (!pr) { + return null; + } + return getPr(pr.id); +} + +// Issue + +async function addAssignees(prNo, assignees) { + logger.debug(`Adding assignees ${assignees} to #${prNo}`); + if (assignees.length > 1) { + logger.error('Cannot assign more than one assignee to Merge Requests'); + } + let url = `projects/${config.repoName}/merge_requests/${prNo}`; + url = `${url}?assignee_id=${assignees[0]}`; + await glGot.put(url); +} + +async function addReviewers(prNo, reviewers) { + logger.debug(`addReviewers('${prNo}, '${reviewers})`); + logger.error('No reviewer functionality in GitLab'); +} + +async function addLabels(prNo, labels) { + logger.debug(`Adding labels ${labels} to #${prNo}`); + let url = `projects/${config.repoName}/merge_requests/${prNo}`; + url = `${url}?labels=${labels.join(',')}`; + await glGot.put(url); +} + +async function findPr(branchName, prTitle, state = 'all') { + logger.debug(`findPr(${branchName}, ${prTitle}, ${state})`); + const urlString = `projects/${config.repoName}/merge_requests?state=${state}`; + const res = await glGot(urlString); + let pr = null; + res.body.forEach((result) => { + if ((!prTitle || result.title === prTitle) && result.source_branch === branchName) { + pr = result; + // GitHub uses number, GitLab uses iid + pr.number = pr.id; + pr.body = pr.description; + pr.displayNumber = `Merge Request #${pr.iid}`; + if (pr.state !== 'opened') { + pr.isClosed = true; + } + } + }); + return pr; +} + +// Pull Request +async function checkForClosedPr(branchName, prTitle) { + const pr = await findPr(branchName, prTitle, 'closed'); + if (pr) { + return true; + } + return false; +} + +async function createPr(branchName, title, body) { + logger.debug(`Creating Merge Request: ${title}`); + const res = await glGot.post(`projects/${config.repoName}/merge_requests`, { + body: { + source_branch: branchName, + target_branch: config.defaultBranch, + title, + description: body, + }, + }); + const pr = res.body; + pr.number = pr.id; + pr.displayNumber = `Merge Request #${pr.iid}`; + return pr; +} + +async function getPr(prNo) { + logger.debug(`getPr(${prNo})`); + const url = `projects/${config.repoName}/merge_requests/${prNo}`; + const pr = (await glGot(url)).body; + // Harmonize fields with GitHub + pr.number = pr.id; + pr.displayNumber = `Merge Request #${pr.iid}`; + pr.body = pr.description; + if (pr.state === 'closed' || pr.state === 'merged') { + logger.debug('pr is closed'); + pr.isClosed = true; + } + if (pr.merge_status === 'cannot_be_merged') { + logger.debug('pr cannot be merged'); + pr.isUnmergeable = true; + } + // We can't rebase through GitLab API + pr.canRebase = false; + return pr; +} + +async function updatePr(prNo, title, body) { + await glGot.put(`projects/${config.repoName}/merge_requests/${prNo}`, { + body: { + title, + description: body, + }, + }); +} + +// Generic File operations + +async function getFile(filePath, branchName = config.defaultBranch) { + const res = await glGot(`projects/${config.repoName}/repository/files?file_path=${filePath}&ref=${branchName}`); + return res.body.content; +} + +async function getFileContent(filePath, branchName) { + try { + const file = await getFile(filePath, branchName); + return new Buffer(file, 'base64').toString(); + } catch (error) { + if (error.statusCode === 404) { + // If file not found, then return null JSON + return null; + } + // Propagate if it's any other error + throw error; + } +} + +async function getFileJson(filePath, branchName) { + try { + const fileContent = await getFileContent(filePath, branchName); + return JSON.parse(fileContent); + } catch (error) { + if (error.statusCode === 404) { + // If file not found, then return null JSON + return null; + } + // Propagate if it's any other error + throw error; + } +} + +async function createFile(branchName, filePath, fileContents, message) { + await glGot.post(`projects/${config.repoName}/repository/files`, { + body: { + file_path: filePath, + branch_name: branchName, + commit_message: message, + encoding: 'base64', + content: new Buffer(fileContents).toString('base64'), + }, + }); +} + +async function updateFile(branchName, filePath, fileContents, message) { + await glGot.put(`projects/${config.repoName}/repository/files`, { + body: { + file_path: filePath, + branch_name: branchName, + commit_message: message, + encoding: 'base64', + content: new Buffer(fileContents).toString('base64'), + }, + }); +} + +// Add a new commit, create branch if not existing +async function commitFilesToBranch( + branchName, + files, + message, + parentBranch = config.defaultBranch) { + logger.debug(`commitFilesToBranch('${branchName}', files, message, '${parentBranch})'`); + if (branchName !== parentBranch) { + const isBranchExisting = await branchExists(branchName); + if (isBranchExisting) { + logger.debug(`Branch ${branchName} already exists`); + } else { + logger.debug(`Creating branch ${branchName}`); + await createBranch(branchName); + } + } + for (const file of files) { + const existingFile = await getFileContent(file.name, branchName); + if (existingFile) { + logger.debug(`${file.name} exists - updating it`); + await updateFile(branchName, file.name, file.contents, message); + } else { + logger.debug(`Creating file ${file.name}`); + await createFile(branchName, file.name, file.contents, message); + } + } +} + +// Internal branch operations + +// Creates a new branch with provided commit +async function createBranch(branchName, ref = config.defaultBranch) { + await glGot.post(`projects/${config.repoName}/repository/branches`, { + body: { + branch_name: branchName, + ref, + }, + }); +} diff --git a/lib/config/definitions.js b/lib/config/definitions.js index 0c99b61efc..c00533c80f 100644 --- a/lib/config/definitions.js +++ b/lib/config/definitions.js @@ -111,7 +111,7 @@ const options = [ name: 'prBody', description: 'Pull Request body template', type: 'string', - default: 'This Pull Request updates dependency {{depName}} from version {{currentVersion}} to {{newVersion}}\n\n{{changelog}}', + default: 'This Pull Request updates dependency {{depName}} from version `{{currentVersion}}` to `{{newVersion}}`\n\n{{changelog}}', cli: false, env: false, }, diff --git a/lib/index.js b/lib/index.js index 75eff27c65..eb80b49bd6 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,6 +2,7 @@ const stringify = require('json-stringify-pretty-compact'); const logger = require('./logger'); const configParser = require('./config'); const githubApi = require('./api/github'); +const gitlabApi = require('./api/gitlab'); const defaultsParser = require('./config/defaults'); // Require main source @@ -35,7 +36,9 @@ async function processRepo(repo) { const config = Object.assign({}, repo); if (config.platform === 'github') { api = githubApi; - } else { // Gitlab will be added here + } else if (config.platform === 'gitlab') { + api = gitlabApi; + } else { logger.error(`Unknown platform ${config.platform} for repository ${repo.repository}`); return; } @@ -91,8 +94,7 @@ async function configureRepository(config) { const defaultConfig = defaultsParser.getConfig(); delete defaultConfig.token; delete defaultConfig.repositories; - const defaultConfigString = `${stringify(defaultConfig)}\n`; - const prBody = `Welcome to [Renovate](https://keylocation.sg/our-tech/renovate)! Once you close this Pull Request, we will begin keeping your dependencies up-to-date via automated Pull Requests. + let prBody = `Welcome to [Renovate](https://keylocation.sg/our-tech/renovate)! Once you close this Pull Request, we will begin keeping your dependencies up-to-date via automated Pull Requests. #### Important! @@ -100,9 +102,11 @@ You do not need to *merge* this Pull Request - renovate will begin even if it's In fact, you only need to add a \`renovate.json\` file to your repository if you wish to override any default settings. The file is included as part of this PR only in case you wish to change default settings before you start. If the default settings are all suitable for you, simply close this Pull Request unmerged and your first renovation will begin the next time the program is run.`; - if (config.platform === 'github') { - // Do nothing + if (config.platform === 'gitlab') { + defaultConfig.platform = 'gitlab'; + prBody = prBody.replace(/Pull Request/g, 'Merge Request'); } + const defaultConfigString = `${stringify(defaultConfig)}\n`; await api.commitFilesToBranch( 'renovate/configure', [{ diff --git a/lib/worker.js b/lib/worker.js index 5bebc01d1f..d9bc0dc125 100644 --- a/lib/worker.js +++ b/lib/worker.js @@ -6,6 +6,7 @@ const cp = require('child_process'); const tmp = require('tmp'); const stringify = require('json-stringify-pretty-compact'); const githubApi = require('./api/github'); +const gitlabApi = require('./api/gitlab'); const handlebars = require('./helpers/handlebars'); const versionsHelper = require('./helpers/versions'); const packageJson = require('./helpers/package-json'); @@ -20,7 +21,10 @@ module.exports = renovate; async function renovate(repoName, packageFile, packageConfig) { if (packageConfig.platform === 'github') { api = githubApi; - } // Other platforms like Gitlab will go here + } + if (packageConfig.platform === 'gitlab') { + api = gitlabApi; + } // Initialize globals config = Object.assign({}, packageConfig); config.packageFile = packageFile; diff --git a/package.json b/package.json index cf7dcb9878..e3032a7408 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "changelog": "dylang/changelog#v1.2.0", "commander": "2.9.0", "gh-got": "5.0.0", + "gl-got": "6.0.0", "got": "6.7.1", "handlebars": "4.0.6", "jest": "18.1.0", diff --git a/readme.md b/readme.md index 20a41e0bcf..b0f48546eb 100644 --- a/readme.md +++ b/readme.md @@ -9,6 +9,7 @@ - Supports multiple major versions per-dependency at once - Configurable via file, environment, CLI, and `package.json` - Supports `yarn.lock` files +- Supports GitHub and GitLab - Self-hosted ## Install @@ -19,12 +20,14 @@ $ npm install -g renovate ## Authentication -You need to select a GitHub user for `renovate` to assume the identity of. It's recommended that you use a dedicated "bot" account for this to avoid user confusion. +You need to select a repository user for `renovate` to assume the identity of, and generate a Personal Access Token. It's recommended that you use a dedicated "bot" account for this to avoid user confusion. -The script will need a GitHub Personal Access Token with "repo" permissions. You can find instructions for generating it here: https://help.github.com/articles/creating-an-access-token-for-command-line-use/ +You can find instructions for GitHub here (select "repo" permissions): https://help.github.com/articles/creating-an-access-token-for-command-line-use/ + +You can find instructions for GitLab here: https://docs.gitlab.com/ee/api/README.html#personal-access-tokens This token needs to be configured via file, environment variable, or CLI. See [docs/configuration.md](docs/configuration.md) for details. -The simplest way is to expose it as `GITHUB_TOKEN`. +The simplest way is to expose it as `GITHUB_TOKEN` or `GITLAB_TOKEN`. ## Usage @@ -61,7 +64,7 @@ $ node renovate --help $ renovate singapore/lint-condo singapore/package-test ``` -Note: The first time you run `renovate` on a repository, it will not upgrade any dependencies. Instead, it will create a PR called 'Configure Renovate' and commit a default `renovate.json` file to the repository. This PR can be close unmerged if the default settings are fine for you. Also, this behaviour can be disabled if you first disable the `onboarding` setting before running. +Note: The first time you run `renovate` on a repository, it will not upgrade any dependencies. Instead, it will create a Pull Request (Merge Request if GitLab) called 'Configure Renovate' and commit a default `renovate.json` file to the repository. This PR can be close unmerged if the default settings are fine for you. Also, this behaviour can be disabled if you set the `onboarding` configuration option to `false` before running. ## Deployment diff --git a/yarn.lock b/yarn.lock index eaab692337..14caf25497 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1325,6 +1325,13 @@ gh-got@5.0.0: got "^6.2.0" is-plain-obj "^1.1.0" +gl-got@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/gl-got/-/gl-got-6.0.0.tgz#6abbaa8cd07464eb4064cdeb9fcacb5e7251e1b1" + dependencies: + got "^6.2.0" + is-plain-obj "^1.1.0" + glob-base@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" -- GitLab