From 4974b78866faac3afec98ca83c46fe8d999cdf30 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Sun, 9 Jun 2019 23:44:23 +0100 Subject: [PATCH] feat(version): Add `--create-release=[gitlab|github]` option (#2073) Deprecates `--github-release`, replacing with `--create-release=github` --- commands/__mocks__/@lerna/gitlab-client.js | 9 ++ commands/version/README.md | 16 ++- .../__tests__/version-github-release.test.js | 4 +- .../__tests__/version-gitlab-release.test.js | 120 ++++++++++++++++++ commands/version/command.js | 18 ++- commands/version/index.js | 58 +++------ commands/version/lib/create-release.js | 48 +++++++ commands/version/package.json | 1 + .../extends-deprecated/lerna.json | 3 + core/project/__tests__/project.test.js | 5 +- core/project/lib/deprecate-config.js | 48 ++++++- integration/lerna-init.test.js | 104 +++++++-------- package-lock.json | 9 ++ utils/gitlab-client/CHANGELOG.md | 4 + utils/gitlab-client/README.md | 9 ++ .../__tests__/GitLabClient.test.js | 56 ++++++++ .../__tests__/gitlab-client.test.js | 41 ++++++ utils/gitlab-client/index.js | 25 ++++ utils/gitlab-client/lib/GitLabClient.js | 43 +++++++ utils/gitlab-client/package.json | 38 ++++++ 20 files changed, 547 insertions(+), 112 deletions(-) create mode 100644 commands/__mocks__/@lerna/gitlab-client.js create mode 100644 commands/version/__tests__/version-gitlab-release.test.js create mode 100644 commands/version/lib/create-release.js create mode 100644 utils/gitlab-client/CHANGELOG.md create mode 100644 utils/gitlab-client/README.md create mode 100644 utils/gitlab-client/__tests__/GitLabClient.test.js create mode 100644 utils/gitlab-client/__tests__/gitlab-client.test.js create mode 100644 utils/gitlab-client/index.js create mode 100644 utils/gitlab-client/lib/GitLabClient.js create mode 100644 utils/gitlab-client/package.json diff --git a/commands/__mocks__/@lerna/gitlab-client.js b/commands/__mocks__/@lerna/gitlab-client.js new file mode 100644 index 0000000000..dacc557a43 --- /dev/null +++ b/commands/__mocks__/@lerna/gitlab-client.js @@ -0,0 +1,9 @@ +"use strict"; + +const client = { + repos: { + createRelease: jest.fn(), + }, +}; + +module.exports = () => client; diff --git a/commands/version/README.md b/commands/version/README.md index cbdaed93c1..368f242bd6 100644 --- a/commands/version/README.md +++ b/commands/version/README.md @@ -51,7 +51,7 @@ Running `lerna version --conventional-commits` without the above flags will rele - [`--exact`](#--exact) - [`--force-publish`](#--force-publish) - [`--git-remote`](#--git-remote-name) -- [`--github-release`](#--github-release) +- [`--create-release`](#--create-release-type) - [`--ignore-changes`](#--ignore-changes) - [`--include-merged-tags`](#--include-merged-tags) - [`--message`](#--message-msg) @@ -194,19 +194,25 @@ lerna version --git-remote upstream When run with this flag, `lerna version` will push the git changes to the specified remote instead of `origin`. -### `--github-release` +### `--create-release ` ```sh -lerna version --github-release --conventional-commits +lerna version --conventional-commits --create-release github +lerna version --conventional-commits --create-release gitlab ``` -When run with this flag, `lerna version` will create an official GitHub release based on the changed packages. Requires `--conventional-commits` to be passed so that changelogs can be generated. +When run with this flag, `lerna version` will create an official GitHub or GitLab release based on the changed packages. Requires `--conventional-commits` to be passed so that changelogs can be generated. To authenticate with GitHub, the following environment variables can be defined. - `GH_TOKEN` (required) - Your GitHub authentication token (under Settings > Developer settings > Personal access tokens). - `GHE_API_URL` - When using GitHub Enterprise, an absolute URL to the API. - `GHE_VERSION` - When using GitHub Enterprise, the currently installed GHE version. [Supports the following versions](https://github.com/octokit/plugin-enterprise-rest.js). +- +To authenticate with GitLab, the following environment variables can be defined. + +- `GL_TOKEN` (required) - Your GitLab authentication token (under User Settings > Access Tokens). +- `GL_API_URL` - An absolute URL to the API, including the version. (Default: https://gitlab.com/api/v4) > NOTE: When using this option, you cannot pass [`--no-changelog`](#--no-changelog). @@ -288,7 +294,7 @@ lerna version --conventional-commits --no-changelog When using `conventional-commits`, do not generate any `CHANGELOG.md` files. -> NOTE: When using this option, you cannot pass [`--github-release`](#--github-release). +> NOTE: When using this option, you cannot pass [`--create-release`](#--create-release-type). ### `--no-commit-hooks` diff --git a/commands/version/__tests__/version-github-release.test.js b/commands/version/__tests__/version-github-release.test.js index 43ccf11f8c..dbcf6bc348 100644 --- a/commands/version/__tests__/version-github-release.test.js +++ b/commands/version/__tests__/version-github-release.test.js @@ -33,7 +33,7 @@ test("--github-release throws an error if --conventional-commits is not passed", try { await lernaVersion(cwd)("--github-release"); } catch (err) { - expect(err.message).toBe("To create a Github Release, you must enable --conventional-commits"); + expect(err.message).toBe("To create a release, you must enable --conventional-commits"); expect(client.repos.createRelease).not.toHaveBeenCalled(); } @@ -46,7 +46,7 @@ test("--github-release throws an error if --no-changelog also passed", async () try { await lernaVersion(cwd)("--github-release", "--conventional-commits", "--no-changelog"); } catch (err) { - expect(err.message).toBe("To create a Github Release, you cannot pass --no-changelog"); + expect(err.message).toBe("To create a release, you cannot pass --no-changelog"); expect(client.repos.createRelease).not.toHaveBeenCalled(); } diff --git a/commands/version/__tests__/version-gitlab-release.test.js b/commands/version/__tests__/version-gitlab-release.test.js new file mode 100644 index 0000000000..e922fd1421 --- /dev/null +++ b/commands/version/__tests__/version-gitlab-release.test.js @@ -0,0 +1,120 @@ +"use strict"; + +// local modules _must_ be explicitly mocked +jest.mock("../lib/git-add"); +jest.mock("../lib/git-commit"); +jest.mock("../lib/git-push"); +jest.mock("../lib/git-tag"); +jest.mock("../lib/is-anything-committed"); +jest.mock("../lib/is-behind-upstream"); +jest.mock("../lib/remote-branch-exists"); + +// mocked modules +const client = require("@lerna/gitlab-client")(); +const { recommendVersion } = require("@lerna/conventional-commits"); + +// helpers +const initFixture = require("@lerna-test/init-fixture")(__dirname); + +// test command +const lernaVersion = require("@lerna-test/command-runner")(require("../command")); + +test("--create-release=gitlab does not create a release if --no-push is passed", async () => { + const cwd = await initFixture("independent"); + + await lernaVersion(cwd)("--create-release=gitlab", "--conventional-commits", "--no-push"); + + expect(client.repos.createRelease).not.toHaveBeenCalled(); +}); + +test("--create-release=gitlab throws an error if --conventional-commits is not passed", async () => { + const cwd = await initFixture("independent"); + + try { + await lernaVersion(cwd)("--create-release=gitlab"); + } catch (err) { + expect(err.message).toBe("To create a release, you must enable --conventional-commits"); + expect(client.repos.createRelease).not.toHaveBeenCalled(); + } + + expect.hasAssertions(); +}); + +test("--create-release=gitlab throws an error if --no-changelog also passed", async () => { + const cwd = await initFixture("independent"); + + try { + await lernaVersion(cwd)("--create-release=gitlab", "--conventional-commits", "--no-changelog"); + } catch (err) { + expect(err.message).toBe("To create a release, you cannot pass --no-changelog"); + expect(client.repos.createRelease).not.toHaveBeenCalled(); + } + + expect.hasAssertions(); +}); + +test("--create-release=gitlab marks a version as a pre-release if it contains a valid part", async () => { + const cwd = await initFixture("normal"); + + recommendVersion.mockResolvedValueOnce("2.0.0-alpha.1"); + + await lernaVersion(cwd)("--create-release=gitlab", "--conventional-commits"); + + expect(client.repos.createRelease).toHaveBeenCalledTimes(1); + expect(client.repos.createRelease).toHaveBeenCalledWith({ + owner: "lerna", + repo: "lerna", + tag_name: "v2.0.0-alpha.1", + name: "v2.0.0-alpha.1", + body: "normal", + draft: false, + prerelease: true, + }); +}); + +test("--create-release=gitlab creates a release for every independent version", async () => { + const cwd = await initFixture("independent"); + const versionBumps = new Map([ + ["package-1", "1.0.1"], + ["package-2", "2.1.0"], + ["package-3", "4.0.0"], + ["package-4", "4.1.0"], + ["package-5", "5.0.1"], + ]); + + versionBumps.forEach(bump => recommendVersion.mockResolvedValueOnce(bump)); + + await lernaVersion(cwd)("--create-release=gitlab", "--conventional-commits"); + + expect(client.repos.createRelease).toHaveBeenCalledTimes(5); + versionBumps.forEach((version, name) => { + expect(client.repos.createRelease).toHaveBeenCalledWith({ + owner: "lerna", + repo: "lerna", + tag_name: `${name}@${version}`, + name: `${name}@${version}`, + body: `${name} - ${version}`, + draft: false, + prerelease: false, + }); + }); +}); + +test("--create-release=gitlab creates a single fixed release", async () => { + const cwd = await initFixture("normal"); + + recommendVersion.mockResolvedValueOnce("1.1.0"); + + await lernaVersion(cwd)("--create-release=gitlab", "--conventional-commits"); + + expect(client.repos.createRelease).toHaveBeenCalledTimes(1); + expect(client.repos.createRelease).toHaveBeenCalledWith({ + owner: "lerna", + repo: "lerna", + tag_name: "v1.1.0", + name: "v1.1.0", + body: "normal", + draft: false, + prerelease: false, + }); +}); diff --git a/commands/version/command.js b/commands/version/command.js index da9b1c00c2..9a2728ebe3 100644 --- a/commands/version/command.js +++ b/commands/version/command.js @@ -40,9 +40,10 @@ exports.builder = (yargs, composed) => { requiresArg: true, defaultDescription: "origin", }, - "github-release": { - describe: "Create an official GitHub release for every version.", - type: "boolean", + "create-release": { + describe: "Create an official GitHub or GitLab release for every version.", + type: "string", + choices: ["gitlab", "github"], }, "ignore-changes": { describe: [ @@ -171,6 +172,11 @@ exports.builder = (yargs, composed) => { hidden: true, type: "boolean", }) + .option("github-release", { + // TODO: remove in next major release + hidden: true, + type: "boolean", + }) .check(argv => { /* eslint-disable no-param-reassign */ if (argv.ignore) { @@ -201,6 +207,12 @@ exports.builder = (yargs, composed) => { delete argv["skip-git"]; log.warn("deprecated", "--skip-git has been replaced by --no-git-tag-version --no-push"); } + + if (argv.githubRelease) { + argv.createRelease = "github"; + delete argv.githubRelease; + log.warn("deprecated", "--release has been replaced by --create-release=github"); + } /* eslint-enable no-param-reassign */ return argv; diff --git a/commands/version/index.js b/commands/version/index.js index 24e6b497bd..bb553b9df9 100644 --- a/commands/version/index.js +++ b/commands/version/index.js @@ -19,7 +19,6 @@ const collectUpdates = require("@lerna/collect-updates"); const { createRunner } = require("@lerna/run-lifecycle"); const runTopologically = require("@lerna/run-topologically"); const ValidationError = require("@lerna/validation-error"); -const { createGitHubClient, parseGitRepo } = require("@lerna/github-client"); const prereleaseIdFromVersion = require("@lerna/prerelease-id-from-version"); const getCurrentBranch = require("./lib/get-current-branch"); @@ -32,6 +31,7 @@ const remoteBranchExists = require("./lib/remote-branch-exists"); const isBreakingChange = require("./lib/is-breaking-change"); const isAnythingCommitted = require("./lib/is-anything-committed"); const makePromptVersion = require("./lib/prompt-version"); +const createRelease = require("./lib/create-release"); const { collectPackages, getPackagesForOption } = collectUpdates; @@ -75,18 +75,15 @@ class VersionCommand extends Command { this.pushToRemote = gitTagVersion && amend !== true && push; // never automatically push to remote when amending a commit - this.createReleases = this.pushToRemote && this.options.githubRelease; + this.createRelease = this.pushToRemote && this.options.createRelease; this.releaseNotes = []; - if (this.createReleases && this.options.conventionalCommits !== true) { - throw new ValidationError( - "ERELEASE", - "To create a Github Release, you must enable --conventional-commits" - ); + if (this.createRelease && this.options.conventionalCommits !== true) { + throw new ValidationError("ERELEASE", "To create a release, you must enable --conventional-commits"); } - if (this.createReleases && this.options.changelog === false) { - throw new ValidationError("ERELEASE", "To create a Github Release, you cannot pass --no-changelog"); + if (this.createRelease && this.options.changelog === false) { + throw new ValidationError("ERELEASE", "To create a release, you cannot pass --no-changelog"); } this.gitOpts = { @@ -269,10 +266,17 @@ class VersionCommand extends Command { this.logger.info("execute", "Skipping git push"); } - if (this.createReleases) { - tasks.push(() => this.createGitHubReleases()); + if (this.createRelease) { + this.logger.info("execute", "Creating releases..."); + tasks.push(() => + createRelease( + this.options.createRelease, + { tags: this.tags, releaseNotes: this.releaseNotes }, + { gitRemote: this.options.gitRemote, execOpts: this.execOpts } + ) + ); } else { - this.logger.info("execute", "Skipping GitHub releases"); + this.logger.info("execute", "Skipping releases"); } return pWaterfall(tasks).then(() => { @@ -652,36 +656,6 @@ class VersionCommand extends Command { return gitPush(this.gitRemote, this.currentBranch, this.execOpts); } - - createGitHubReleases() { - this.logger.info("github", "Creating GitHub releases..."); - - const client = createGitHubClient(); - const repo = parseGitRepo(this.options.gitRemote, this.execOpts); - - return Promise.all( - this.releaseNotes.map(({ notes, name }) => { - const tag = name === "fixed" ? this.tags[0] : this.tags.find(t => t.startsWith(`${name}@`)); - - /* istanbul ignore if */ - if (!tag) { - return Promise.resolve(); - } - - const prereleaseParts = semver.prerelease(tag.replace(`${name}@`, "")) || []; - - return client.repos.createRelease({ - owner: repo.owner, - repo: repo.name, - tag_name: tag, - name: tag, - body: notes, - draft: false, - prerelease: prereleaseParts.length > 0, - }); - }) - ); - } } module.exports.VersionCommand = VersionCommand; diff --git a/commands/version/lib/create-release.js b/commands/version/lib/create-release.js new file mode 100644 index 0000000000..f9069d5f4a --- /dev/null +++ b/commands/version/lib/create-release.js @@ -0,0 +1,48 @@ +"use strict"; + +const semver = require("semver"); + +const createGitLabClient = require("@lerna/gitlab-client"); +const { createGitHubClient, parseGitRepo } = require("@lerna/github-client"); +const ValidationError = require("@lerna/validation-error"); + +module.exports = createRelease; + +function createClient(type) { + switch (type) { + case "gitlab": + return createGitLabClient(); + case "github": + return createGitHubClient(); + default: + throw new ValidationError("ERELEASE", "Invalid release client type"); + } +} + +function createRelease(type, { tags, releaseNotes }, { gitRemote, execOpts }) { + const repo = parseGitRepo(gitRemote, execOpts); + const client = createClient(type); + + return Promise.all( + releaseNotes.map(({ notes, name }) => { + const tag = name === "fixed" ? tags[0] : tags.find(t => t.startsWith(`${name}@`)); + + /* istanbul ignore if */ + if (!tag) { + return Promise.resolve(); + } + + const prereleaseParts = semver.prerelease(tag.replace(`${name}@`, "")) || []; + + return client.repos.createRelease({ + owner: repo.owner, + repo: repo.name, + tag_name: tag, + name: tag, + body: notes, + draft: false, + prerelease: prereleaseParts.length > 0, + }); + }) + ); +} diff --git a/commands/version/package.json b/commands/version/package.json index 82256c738d..2c1b60227a 100644 --- a/commands/version/package.json +++ b/commands/version/package.json @@ -40,6 +40,7 @@ "@lerna/command": "file:../../core/command", "@lerna/conventional-commits": "file:../../core/conventional-commits", "@lerna/github-client": "file:../../utils/github-client", + "@lerna/gitlab-client": "file:../../utils/gitlab-client", "@lerna/output": "file:../../utils/output", "@lerna/prerelease-id-from-version": "file:../../utils/prerelease-id-from-version", "@lerna/prompt": "file:../../core/prompt", diff --git a/core/project/__fixtures__/extends-deprecated/lerna.json b/core/project/__fixtures__/extends-deprecated/lerna.json index e822e27b7c..a7b418fcc8 100644 --- a/core/project/__fixtures__/extends-deprecated/lerna.json +++ b/core/project/__fixtures__/extends-deprecated/lerna.json @@ -3,6 +3,9 @@ "commands": { "bootstrap": { "hoist": true + }, + "version": { + "githubRelease": true } }, "npmTag": "next", diff --git a/core/project/__tests__/project.test.js b/core/project/__tests__/project.test.js index 91d6d0632f..001099b789 100644 --- a/core/project/__tests__/project.test.js +++ b/core/project/__tests__/project.test.js @@ -140,7 +140,7 @@ describe("Project", () => { }); }); - it("renames deprecated config recursively", async () => { + it("updates deprecated config recursively", async () => { const cwd = await initFixture("extends-deprecated"); const project = new Project(cwd); @@ -158,6 +158,9 @@ Object { ], "loglevel": "success", }, + "version": Object { + "createRelease": "github", + }, }, "packages": Array [ "recursive-pkgs/*", diff --git a/core/project/lib/deprecate-config.js b/core/project/lib/deprecate-config.js index 0280c8aa9a..0297e6b61b 100644 --- a/core/project/lib/deprecate-config.js +++ b/core/project/lib/deprecate-config.js @@ -6,6 +6,10 @@ const path = require("path"); module.exports = compose( // add new predicates HERE + remap("command.version.githubRelease", "command.version.createRelease", { + alsoRoot: true, + toValue: value => value && "github", + }), remap("command.publish.npmTag", "command.publish.distTag", { alsoRoot: true }), remap("command.publish.cdVersion", "command.publish.bump", { alsoRoot: true }), remap("command.publish.ignore", "command.publish.ignoreChanges"), @@ -21,9 +25,10 @@ module.exports = compose( * @param {String} target Path of renamed option * @param {Object} opts Optional configuration object * @param {Boolean} opts.alsoRoot Whether to check root config as well + * @param {Function} opts.toValue Return the new config value given the current value * @return {Function} predicate accepting (config, filepath) */ -function remap(search, target, { alsoRoot } = {}) { +function remap(search, target, { alsoRoot, toValue } = {}) { const pathsToSearch = [search]; if (alsoRoot) { @@ -34,14 +39,12 @@ function remap(search, target, { alsoRoot } = {}) { return obj => { for (const searchPath of pathsToSearch) { if (dotProp.has(obj.config, searchPath)) { - const localPath = path.relative(".", obj.filepath); + const fromVal = dotProp.get(obj.config, searchPath); + const toVal = toValue ? toValue(fromVal) : fromVal; - log.warn( - "project", - `Deprecated key "${searchPath}" found in ${localPath}\nPlease rename "${searchPath}" => "${target}"` - ); + log.warn("project", deprecationMessage(obj, target, searchPath, fromVal, toVal)); - dotProp.set(obj.config, target, dotProp.get(obj.config, searchPath)); + dotProp.set(obj.config, target, toVal); dotProp.delete(obj.config, searchPath); } } @@ -50,6 +53,37 @@ function remap(search, target, { alsoRoot } = {}) { }; } +/** + * Builds a deprecation message string that specifies + * a deprecated config option and suggests a correction. + * + * @param {Object} obj A config object + * @param {String} target Path of renamed option + * @param {String} searchSearch Path to deprecated option + * @param {Any} fromVal Current value of deprecated option + * @param {Any} toVal Corrected value of deprecated option + * @return {String} deprecation message + */ +function deprecationMessage(obj, target, searchPath, fromVal, toVal) { + const localPath = path.relative(".", obj.filepath); + + let from; + let to; + if (toVal === fromVal) { + from = `"${searchPath}"`; + to = `"${target}"`; + } else { + from = stringify({ [searchPath]: fromVal }); + to = stringify({ [target]: toVal }); + } + + return `Deprecated key "${searchPath}" found in ${localPath}\nPlease update ${from} => ${to}`; +} + +function stringify(obj) { + return JSON.stringify(obj).slice(1, -1); +} + function compose(...funcs) { return funcs.reduce((a, b) => (...args) => a(b(...args))); } diff --git a/integration/lerna-init.test.js b/integration/lerna-init.test.js index 7f36ff93c2..7312820106 100644 --- a/integration/lerna-init.test.js +++ b/integration/lerna-init.test.js @@ -17,32 +17,32 @@ describe("lerna init", () => { const { stderr } = await cliRunner(cwd)("init"); expect(stderr).toMatchInlineSnapshot(` -lerna notice cli __TEST_VERSION__ -lerna info Initializing Git repository -lerna info Creating package.json -lerna info Creating lerna.json -lerna info Creating packages directory -lerna success Initialized Lerna files -`); + lerna notice cli __TEST_VERSION__ + lerna info Initializing Git repository + lerna info Creating package.json + lerna info Creating lerna.json + lerna info Creating packages directory + lerna success Initialized Lerna files + `); const [packageJson, lernaJson] = await loadMetaData(cwd); expect(packageJson).toMatchInlineSnapshot(` -Object { - "devDependencies": Object { - "lerna": "^__TEST_VERSION__", - }, - "name": "root", - "private": true, -} -`); + Object { + "devDependencies": Object { + "lerna": "^__TEST_VERSION__", + }, + "name": "root", + "private": true, + } + `); expect(lernaJson).toMatchInlineSnapshot(` -Object { - "packages": Array [ - "packages/*", - ], - "version": "0.0.0", -} -`); + Object { + "packages": Array [ + "packages/*", + ], + "version": "0.0.0", + } + `); }); test("updates existing metadata", async () => { @@ -50,39 +50,39 @@ Object { const { stderr } = await cliRunner(cwd)("init", "--exact"); expect(stderr).toMatchInlineSnapshot(` -lerna notice cli __TEST_VERSION__ -lerna WARN project Deprecated key "commands" found in lerna.json -lerna WARN project Please rename "commands" => "command" -lerna info Updating package.json -lerna info Updating lerna.json -lerna info Creating packages directory -lerna success Initialized Lerna files -`); + lerna notice cli __TEST_VERSION__ + lerna WARN project Deprecated key "commands" found in lerna.json + lerna WARN project Please update "commands" => "command" + lerna info Updating package.json + lerna info Updating lerna.json + lerna info Creating packages directory + lerna success Initialized Lerna files + `); const [packageJson, lernaJson] = await loadMetaData(cwd); expect(packageJson).toMatchInlineSnapshot(` -Object { - "devDependencies": Object { - "lerna": "__TEST_VERSION__", - }, - "name": "updates", -} -`); + Object { + "devDependencies": Object { + "lerna": "__TEST_VERSION__", + }, + "name": "updates", + } + `); expect(lernaJson).toMatchInlineSnapshot(` -Object { - "command": Object { - "bootstrap": Object { - "hoist": true, - }, - "init": Object { - "exact": true, - }, - }, - "packages": Array [ - "packages/*", - ], - "version": "1.0.0", -} -`); + Object { + "command": Object { + "bootstrap": Object { + "hoist": true, + }, + "init": Object { + "exact": true, + }, + }, + "packages": Array [ + "packages/*", + ], + "version": "1.0.0", + } + `); }); }); diff --git a/package-lock.json b/package-lock.json index 6ed88a300d..f818ad2f0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -919,6 +919,14 @@ "npmlog": "^4.1.2" } }, + "@lerna/gitlab-client": { + "version": "file:utils/gitlab-client", + "requires": { + "node-fetch": "^2.5.0", + "npmlog": "^4.1.2", + "whatwg-url": "^7.0.0" + } + }, "@lerna/global-options": { "version": "file:core/global-options" }, @@ -1253,6 +1261,7 @@ "@lerna/command": "file:core/command", "@lerna/conventional-commits": "file:core/conventional-commits", "@lerna/github-client": "file:utils/github-client", + "@lerna/gitlab-client": "file:utils/gitlab-client", "@lerna/output": "file:utils/output", "@lerna/prerelease-id-from-version": "file:utils/prerelease-id-from-version", "@lerna/prompt": "file:core/prompt", diff --git a/utils/gitlab-client/CHANGELOG.md b/utils/gitlab-client/CHANGELOG.md new file mode 100644 index 0000000000..e4d87c4d45 --- /dev/null +++ b/utils/gitlab-client/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/utils/gitlab-client/README.md b/utils/gitlab-client/README.md new file mode 100644 index 0000000000..06b14571ff --- /dev/null +++ b/utils/gitlab-client/README.md @@ -0,0 +1,9 @@ +# `@lerna/gitlab-client` + +> An internal Lerna tool + +## Usage + +You probably shouldn't, at least directly. + +Install [lerna](https://www.npmjs.com/package/lerna) for access to the `lerna` CLI. diff --git a/utils/gitlab-client/__tests__/GitLabClient.test.js b/utils/gitlab-client/__tests__/GitLabClient.test.js new file mode 100644 index 0000000000..7508242f14 --- /dev/null +++ b/utils/gitlab-client/__tests__/GitLabClient.test.js @@ -0,0 +1,56 @@ +"use strict"; + +jest.mock("node-fetch"); + +const fetch = require("node-fetch"); + +const GitLabClient = require("../lib/GitLabClient"); + +describe("GitLabClient", () => { + describe("constructor", () => { + it("sets `baseUrl` and `token`", () => { + const client = new GitLabClient("http://some/host", "TOKEN"); + + expect(client.baseUrl).toEqual("http://some/host"); + expect(client.token).toEqual("TOKEN"); + }); + }); + + describe("releasesUrl", () => { + it("returns a GitLab releases API URL", () => { + const client = new GitLabClient("http://some/host", "TOKEN"); + const url = client.releasesUrl("the-namespace", "the-project"); + + expect(url).toEqual("http://some/host/projects/the-namespace%2Fthe-project/releases"); + }); + }); + + describe("createRelease", () => { + it("requests releases api with release", () => { + const client = new GitLabClient("http://some/host", "TOKEN"); + fetch.mockResolvedValue({ ok: true }); + const release = { + owner: "the-owner", + repo: "the-repo", + name: "the-name", + tag_name: "the-tag_name", + body: "the-body", + }; + + client.createRelease(release); + + expect(fetch).toHaveBeenCalledWith("http://some/host/projects/the-owner%2Fthe-repo/releases", { + method: "post", + body: JSON.stringify({ + name: "the-name", + tag_name: "the-tag_name", + description: "the-body", + }), + headers: { + "PRIVATE-TOKEN": "TOKEN", + "Content-Type": "application/json", + }, + }); + }); + }); +}); diff --git a/utils/gitlab-client/__tests__/gitlab-client.test.js b/utils/gitlab-client/__tests__/gitlab-client.test.js new file mode 100644 index 0000000000..9e931855dc --- /dev/null +++ b/utils/gitlab-client/__tests__/gitlab-client.test.js @@ -0,0 +1,41 @@ +"use strict"; + +jest.mock("../lib/GitLabClient"); + +const GitLabClient = require("../lib/GitLabClient"); +const createGitLabClient = require("../index"); + +describe("createGitLabClient", () => { + const oldEnv = Object.assign({}, process.env); + + afterEach(() => { + process.env = oldEnv; + }); + + it("errors if no GL_TOKEN env var", () => { + expect(() => { + createGitLabClient(); + }).toThrow("A GL_TOKEN environment variable is required."); + }); + + it("doesnt error if GL_TOKEN env var is set", () => { + process.env.GL_TOKEN = "TOKEN"; + + expect(() => { + createGitLabClient(); + }).not.toThrow(); + }); + + it("sets client `baseUrl` when GL_API_URL is set", () => { + process.env.GL_TOKEN = "TOKEN"; + process.env.GL_API_URL = "http://some/host"; + + createGitLabClient(); + + expect(GitLabClient).toHaveBeenCalledWith("http://some/host", "TOKEN"); + }); + + it("has a createRelease method like ocktokit", () => { + expect(createGitLabClient().repos.createRelease).toBeInstanceOf(Function); + }); +}); diff --git a/utils/gitlab-client/index.js b/utils/gitlab-client/index.js new file mode 100644 index 0000000000..fb39e3cf1e --- /dev/null +++ b/utils/gitlab-client/index.js @@ -0,0 +1,25 @@ +"use strict"; + +const log = require("npmlog"); + +const GitLabClient = require("./lib/GitLabClient"); + +module.exports = createGitLabClient; + +function OcktokitAdapter(client) { + return { repos: { createRelease: client.createRelease.bind(client) } }; +} + +function createGitLabClient() { + const { GL_API_URL, GL_TOKEN } = process.env; + + log.silly("Creating a GitLab client..."); + + if (!GL_TOKEN) { + throw new Error("A GL_TOKEN environment variable is required."); + } + + const client = new GitLabClient(GL_API_URL, GL_TOKEN); + + return OcktokitAdapter(client); +} diff --git a/utils/gitlab-client/lib/GitLabClient.js b/utils/gitlab-client/lib/GitLabClient.js new file mode 100644 index 0000000000..2e567aaba5 --- /dev/null +++ b/utils/gitlab-client/lib/GitLabClient.js @@ -0,0 +1,43 @@ +"use strict"; + +const path = require("path"); + +const { URL } = require("whatwg-url"); +const log = require("npmlog"); +const fetch = require("node-fetch"); + +class GitLabClient { + constructor(baseUrl = "https://gitlab.com/api/v4", token) { + this.baseUrl = baseUrl; + this.token = token; + } + + createRelease({ owner, repo, name, tag_name: tagName, body }) { + const releasesUrl = this.releasesUrl(owner, repo, "releases"); + + log.silly("Requesting GitLab releases", releasesUrl); + + return fetch(releasesUrl, { + method: "post", + body: JSON.stringify({ name, tag_name: tagName, description: body }), + headers: { + "PRIVATE-TOKEN": this.token, + "Content-Type": "application/json", + }, + }).then(({ ok, status, statusText }) => { + if (!ok) { + log.error("gitlab", `Failed to create release\nRequest returned ${status} ${statusText}`); + } else { + log.silly("gitlab", "Created release successfully."); + } + }); + } + + releasesUrl(namespace, project) { + return new URL( + `${this.baseUrl}/${path.join("projects", encodeURIComponent(`${namespace}/${project}`), "releases")}` + ).toString(); + } +} + +module.exports = GitLabClient; diff --git a/utils/gitlab-client/package.json b/utils/gitlab-client/package.json new file mode 100644 index 0000000000..f19616263f --- /dev/null +++ b/utils/gitlab-client/package.json @@ -0,0 +1,38 @@ +{ + "name": "@lerna/gitlab-client", + "version": "3.13.3", + "description": "An internal Lerna tool", + "keywords": [ + "lerna", + "utils" + ], + "homepage": "https://github.com/lerna/lerna/tree/master/utils/gitlab-client#readme", + "license": "MIT", + "author": { + "name": "Luke Bennett", + "url": "https://gitlab.com/lbennett" + }, + "files": [ + "lib" + ], + "main": "index.js", + "engines": { + "node": ">= 6.9.0" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://gitlab.com/lerna/lerna.git", + "directory": "utils/gitlab-client" + }, + "scripts": { + "test": "echo \"Run tests from root\" && exit 1" + }, + "dependencies": { + "node-fetch": "^2.5.0", + "npmlog": "^4.1.2", + "whatwg-url": "^7.0.0" + } +}