diff --git a/__snapshots__/cli.js b/__snapshots__/cli.js new file mode 100644 index 000000000..3b3401d18 --- /dev/null +++ b/__snapshots__/cli.js @@ -0,0 +1,18 @@ +exports['CLI release-pr supports custom changelogSections 1'] = ` +[ + [ + "CHANGELOG.md", + { + "content": "# Changelog\\n\\n### [1.0.1](https://www.github.com/googleapis/release-please-cli/compare/v1.0.0...v1.0.1) (2020-10-04)\\n\\n\\n### Bug Fixes\\n\\n* **deps:** update dependency com.google.cloud:google-cloud-spanner to v1.50.0 ([1f9663c](https://www.github.com/googleapis/release-please-cli/commit/1f9663cf08ab1cf3b68d95dee4dc99b7c4aac373))\\n* **deps:** update dependency com.google.cloud:google-cloud-storage to v1.120.0 ([fcd1c89](https://www.github.com/googleapis/release-please-cli/commit/fcd1c890dc1526f4d62ceedad561f498195c8939))\\n\\n\\n### Miscellaneous Chores\\n\\n* update common templates ([3006009](https://www.github.com/googleapis/release-please-cli/commit/3006009a2b1b2cb4bd5108c0f469c410759f3a6a))\\n", + "mode": "100644" + } + ], + [ + "package.json", + { + "content": "{\\n \\"name\\": \\"simple-package\\",\\n \\"version\\": \\"1.0.1\\"\\n}\\n", + "mode": "100644" + } + ] +] +` diff --git a/src/bin/command.ts b/src/bin/command.ts new file mode 100644 index 000000000..0594cc441 --- /dev/null +++ b/src/bin/command.ts @@ -0,0 +1,192 @@ +import chalk = require('chalk'); +import {coerceOption} from '../util/coerce-option'; +import {GitHubRelease, GitHubReleaseOptions} from '../github-release'; +import {ReleasePROptions} from '../release-pr'; +import {ReleasePRFactory} from '../release-pr-factory'; +import {getReleaserNames} from '../releasers'; +import * as yargs from 'yargs'; + +export interface ErrorObject { + body?: object; + status?: number; + message: string; + stack: string; +} + +interface YargsOptions { + describe: string; + choices?: string[]; + demand?: boolean; + type?: string; + default?: string | boolean; +} + +interface YargsOptionsBuilder { + option(opt: string, options: YargsOptions): YargsOptionsBuilder; +} + +export default yargs + .config('config') + .command( + 'release-pr', + 'create or update a PR representing the next release', + (yargs: YargsOptionsBuilder) => { + yargs + .option('changelog-sections', { + describe: 'a JSON formatted string used to override the outputted changelog sections' + }) + .option('package-name', { + describe: 'name of package release is being minted for', + demand: true, + }) + .option('version-file', { + describe: 'path to version file to update, e.g., version.rb', + }) + .option('last-package-version', { + describe: 'last version # that package was released as', + }) + .option('repo-url', { + describe: 'GitHub URL to generate release for', + demand: true, + }) + .option('fork', { + describe: 'should the PR be created from a fork', + type: 'boolean', + default: false, + }) + .option('label', { + describe: 'label(s) to add to generated PR', + }) + .option('snapshot', { + describe: 'is it a snapshot (or pre-release) being generated?', + type: 'boolean', + default: false, + }) + .option('default-branch', { + describe: 'default branch to open release PR against', + type: 'string', + }) + .option('path', { + describe: 'release from path other than root directory', + type: 'string', + }) + .option('monorepo-tags', { + describe: 'include library name in tags and release branches', + type: 'boolean', + default: false, + }); + }, + (argv: ReleasePROptions & yargs.Arguments) => { + if (argv.noOperation) return; + const rp = ReleasePRFactory.build(argv.releaseType, argv); + return rp.run().catch((e) => handleError(e, argv)); + } + ) + .command( + 'github-release', + 'create a GitHub release from a release PR', + (yargs: YargsOptionsBuilder) => { + yargs + .option('package-name', { + describe: 'name of package release is being minted for', + }) + .option('repo-url', { + describe: 'GitHub URL to generate release for', + demand: true, + }) + .option('changelog-path', { + default: 'CHANGELOG.md', + describe: 'where can the CHANGELOG be found in the project?', + }) + .option('label', { + default: 'autorelease: pending', + describe: 'label to remove from release PR', + }) + .option('release-type', { + describe: 'what type of repo is a release being created for?', + choices: getReleaserNames(), + default: 'node', + }) + .option('path', { + describe: 'release from path other than root directory', + type: 'string', + }); + }, + (argv: GitHubReleaseOptions & yargs.Arguments) => { + const gr = new GitHubRelease(argv); + return gr.createRelease().catch((e) => handleError(e, argv)); + } + ) + .middleware((argv: (GitHubReleaseOptions | ReleasePROptions) & yargs.Arguments) => { + if (argv.changelogSections) argv.changelogSections = JSON.parse(argv.changelogSections as string); + + // allow secrets to be loaded from file path + // rather than being passed directly to the bin. + if (argv.token) argv.token = coerceOption(argv.token); + if (argv.apiUrl) argv.apiUrl = coerceOption(argv.apiUrl); + if (argv.proxyKey) argv.proxyKey = coerceOption(argv.proxyKey); + }) + .option('no-operation', { + alias: 'no-op', + type: 'boolean', + default: false + }) + .option('token', {describe: 'GitHub token with repo write permissions'}) + .option('release-as', { + describe: 'override the semantically determined release version', + type: 'string', + }) + .option('release-type', { + describe: 'what type of repo is a release being created for?', + choices: getReleaserNames(), + default: 'node', + }) + .option('bump-minor-pre-major', { + describe: + 'should we bump the semver minor prior to the first major release', + default: false, + type: 'boolean', + }) + .option('api-url', { + describe: 'URL to use when making API requests', + default: 'https://api.github.com', + type: 'string', + }) + .option('proxy-key', { + describe: 'key used by some GitHub proxies', + type: 'string', + }) + .option('debug', { + describe: 'print verbose errors (use only for local debugging).', + default: false, + type: 'boolean', + }) + .option('default-branch', { + describe: '', + type: 'string', + }) + .demandCommand(1) + .strict(true) + +// The errors returned by octokit currently contain the +// request object, this contains information we don't want to +// leak. For this reason, we capture exceptions and print +// a less verbose error message (run with --debug to output +// the request object, don't do this in CI/CD). +export function handleError(err: ErrorObject, argv: yargs.Arguments) { + let status = ''; + const command = argv._.length === 0 ? '' : argv._[0]; + if (err.status) { + status = '' + err.status; + } + console.error( + chalk.red( + `command ${command} failed${status ? ` with status ${status}` : ''}` + ) + ); + if (argv.debug) { + console.error('---------'); + console.error(err.stack); + } + process.exitCode = 1; +} \ No newline at end of file diff --git a/src/bin/release-please.ts b/src/bin/release-please.ts index dac297578..0357b0239 100644 --- a/src/bin/release-please.ts +++ b/src/bin/release-please.ts @@ -14,193 +14,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import chalk = require('chalk'); -import {coerceOption} from '../util/coerce-option'; -import {GitHubRelease, GitHubReleaseOptions} from '../github-release'; -import {ReleasePROptions} from '../release-pr'; -import {ReleasePRFactory} from '../release-pr-factory'; -import {getReleaserNames} from '../releasers'; -import * as yargs from 'yargs'; +import command, {handleError, ErrorObject} from './command'; -interface ErrorObject { - body?: object; - status?: number; - message: string; - stack: string; -} - -interface YargsOptions { - describe: string; - choices?: string[]; - demand?: boolean; - type?: string; - default?: string | boolean; -} - -interface YargsOptionsBuilder { - option(opt: string, options: YargsOptions): YargsOptionsBuilder; -} - -const argv = yargs - .command( - 'release-pr', - 'create or update a PR representing the next release', - (yargs: YargsOptionsBuilder) => { - yargs - .option('package-name', { - describe: 'name of package release is being minted for', - demand: true, - }) - .option('version-file', { - describe: 'path to version file to update, e.g., version.rb', - }) - .option('last-package-version', { - describe: 'last version # that package was released as', - }) - .option('repo-url', { - describe: 'GitHub URL to generate release for', - demand: true, - }) - .option('fork', { - describe: 'should the PR be created from a fork', - type: 'boolean', - default: false, - }) - .option('label', { - describe: 'label(s) to add to generated PR', - }) - .option('snapshot', { - describe: 'is it a snapshot (or pre-release) being generated?', - type: 'boolean', - default: false, - }) - .option('default-branch', { - describe: 'default branch to open release PR against', - type: 'string', - }) - .option('path', { - describe: 'release from path other than root directory', - type: 'string', - }) - .option('monorepo-tags', { - describe: 'include library name in tags and release branches', - type: 'boolean', - default: false, - }); - }, - (argv: ReleasePROptions) => { - const rp = ReleasePRFactory.build(argv.releaseType, argv); - rp.run().catch(handleError); - } - ) - .command( - 'github-release', - 'create a GitHub release from a release PR', - (yargs: YargsOptionsBuilder) => { - yargs - .option('package-name', { - describe: 'name of package release is being minted for', - }) - .option('repo-url', { - describe: 'GitHub URL to generate release for', - demand: true, - }) - .option('changelog-path', { - default: 'CHANGELOG.md', - describe: 'where can the CHANGELOG be found in the project?', - }) - .option('label', { - default: 'autorelease: pending', - describe: 'label to remove from release PR', - }) - .option('release-type', { - describe: 'what type of repo is a release being created for?', - choices: getReleaserNames(), - default: 'node', - }) - .option('path', { - describe: 'release from path other than root directory', - type: 'string', - }); - }, - (argv: GitHubReleaseOptions) => { - const gr = new GitHubRelease(argv); - gr.createRelease().catch(handleError); - } - ) - .middleware(_argv => { - const argv = _argv as GitHubReleaseOptions; - // allow secrets to be loaded from file path - // rather than being passed directly to the bin. - if (argv.token) argv.token = coerceOption(argv.token); - if (argv.apiUrl) argv.apiUrl = coerceOption(argv.apiUrl); - if (argv.proxyKey) argv.proxyKey = coerceOption(argv.proxyKey); - }) - .option('token', {describe: 'GitHub token with repo write permissions'}) - .option('release-as', { - describe: 'override the semantically determined release version', - type: 'string', - }) - .option('release-type', { - describe: 'what type of repo is a release being created for?', - choices: getReleaserNames(), - default: 'node', - }) - .option('bump-minor-pre-major', { - describe: - 'should we bump the semver minor prior to the first major release', - default: false, - type: 'boolean', - }) - .option('api-url', { - describe: 'URL to use when making API requests', - default: 'https://api.github.com', - type: 'string', - }) - .option('proxy-key', { - describe: 'key used by some GitHub proxies', - type: 'string', - }) - .option('debug', { - describe: 'print verbose errors (use only for local debugging).', - default: false, - type: 'boolean', - }) - .option('default-branch', { - describe: '', - type: 'string', - }) - .demandCommand(1) - .strict(true) - .parse(); - -// The errors returned by octokit currently contain the -// request object, this contains information we don't want to -// leak. For this reason, we capture exceptions and print -// a less verbose error message (run with --debug to output -// the request object, don't do this in CI/CD). -function handleError(err: ErrorObject) { - let status = ''; - const command = argv._.length === 0 ? '' : argv._[0]; - if (err.status) { - status = '' + err.status; - } - console.error( - chalk.red( - `command ${command} failed${status ? ` with status ${status}` : ''}` - ) - ); - if (argv.debug) { - console.error('---------'); - console.error(err.stack); - } - process.exitCode = 1; -} +const argv = command.parse(); process.on('unhandledRejection', err => { - handleError(err as ErrorObject); + handleError(err as ErrorObject, argv); }); process.on('uncaughtException', err => { - handleError(err as ErrorObject); -}); + handleError(err as ErrorObject, argv); +}); \ No newline at end of file diff --git a/test/cli.ts b/test/cli.ts new file mode 100644 index 000000000..45274db97 --- /dev/null +++ b/test/cli.ts @@ -0,0 +1,142 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import {readFileSync} from 'fs'; +import {resolve} from 'path'; + +import {describe, it, afterEach} from 'mocha'; +import {expect} from 'chai'; +import * as suggester from 'code-suggester'; +import * as sinon from 'sinon'; +import * as nock from 'nock'; +import * as snapshot from 'snap-shot-it'; + +import {ReleasePRFactory} from '../src/release-pr-factory'; +import {ReleasePROptions} from '../src/release-pr'; + +import command from '../src/bin/command'; +import yargs = require('yargs'); + +const sandbox = sinon.createSandbox(); + + +function getExampleConfigurationPath() { + return resolve('./test/fixtures', 'example-config.json'); +} + + +describe('CLI', () => { + + afterEach(() => { + sandbox.restore(); + }); + + describe('release-pr', () => { + + it('can be configured using flags', () => { + const argv = command.parse('release-pr --no-op=true --repo-url=googleapis/release-please-cli --package-name=cli-package ') as ReleasePROptions; + expect(argv).includes({ + repoUrl: 'googleapis/release-please-cli', + releaseType: 'node', + packageName: 'cli-package' + }); + }); + + + it('can be configured using a file', () => { + const argv = command.parse(`release-pr --no-op=true --config=${getExampleConfigurationPath()}`) as ReleasePROptions; + expect(argv).includes({ + repoUrl: 'googleapis/release-please-cli', + releaseType: 'node', + packageName: 'cli-package--config' + }); + }); + + it('supports custom changelogSections', async () => { + // Fake the createPullRequest step, and capture a set of files to assert against: + let expectedChanges = null; + sandbox.replace( + suggester, + 'createPullRequest', + (_octokit, changes): Promise => { + expectedChanges = [...(changes as Map)]; // Convert map to key/value pairs. + return Promise.resolve(22); + } + ); + + const graphql = JSON.parse( + readFileSync(resolve('./test/releasers/fixtures/node', 'commits.json'), 'utf8') + ); + + const existingPackageResponse = { + content: Buffer.from(JSON.stringify({ + name: 'simple-package', + version: '1.0.0' + }), 'utf8').toString('base64'), + sha: 'abc123', + }; + + const scope = nock('https://api.github.com') + // Check for in progress, merged release PRs: + .get('/repos/googleapis/release-please-cli/pulls?state=closed&per_page=100') + .reply(200, undefined) + // fetch semver tags, this will be used to determine + // the delta since the last release. + .get('/repos/googleapis/release-please-cli/tags?per_page=100') + .reply(200, [ + { + name: 'v1.0.0', + commit: { + sha: 'da6e52d956c1e35d19e75e0f2fdba439739ba364', + }, + }, + ]) + // now we fetch the commits via the graphql API; + // note they will be truncated to just before the tag's sha. + .post('/graphql') + .reply(200, { + data: graphql, + }) + .get('/repos/googleapis/release-please-cli/contents/package.json') + .reply(200, existingPackageResponse) + .get('/repos/googleapis/release-please-cli') + // eslint-disable-next-line @typescript-eslint/no-var-requires + .reply(200, require('../../test/fixtures/repo-get-1.json')) + .get('/repos/googleapis/release-please-cli/contents/CHANGELOG.md?ref=refs%2Fheads%2Fmaster') + .reply(404) + .get('/repos/googleapis/release-please-cli/contents/package-lock.json?ref=refs%2Fheads%2Fmaster') + .reply(404) + .get('/repos/googleapis/release-please-cli/contents/samples/package.json?ref=refs%2Fheads%2Fmaster') + .reply(404) + .get('/repos/googleapis/release-please-cli/contents/package.json?ref=refs%2Fheads%2Fmaster') + .reply(200, existingPackageResponse) + // this step tries to match any existing PRs; just return an empty list. + .get('/repos/googleapis/release-please-cli/pulls?state=open&per_page=100') + .reply(200, []) + // Add autorelease: pending label to release PR: + .post('/repos/googleapis/release-please-cli/issues/22/labels') + .reply(200) + // this step tries to close any existing PRs; just return an empty list. + .get('/repos/googleapis/release-please-cli/pulls?state=open&per_page=100') + .reply(200, []) + + const argv = command.parse(`release-pr --no-op=true --config=${getExampleConfigurationPath()}`) as ReleasePROptions; + + const rp = ReleasePRFactory.build(argv.releaseType, argv); + await rp.run(); + + scope.done(); + snapshot(JSON.stringify(expectedChanges, null, 2)); + }); + }); +}); diff --git a/test/fixtures/example-config.json b/test/fixtures/example-config.json new file mode 100644 index 000000000..7fb3a27ba --- /dev/null +++ b/test/fixtures/example-config.json @@ -0,0 +1,5 @@ +{ + "package-name": "cli-package--config", + "repo-url": "googleapis/release-please-cli", + "changelog-sections": "[{\"type\":\"feat\",\"section\":\"Features\"},{\"type\":\"fix\",\"section\":\"Bug Fixes\"},{\"type\":\"perf\",\"section\":\"Performance Improvements\"},{\"type\":\"deps\",\"section\":\"Dependencies\"},{\"type\":\"revert\",\"section\":\"Reverts\"},{\"type\":\"docs\",\"section\":\"Documentation\"},{\"type\":\"style\",\"section\":\"Styles\",\"hidden\":true},{\"type\":\"chore\",\"section\":\"Miscellaneous Chores\"},{\"type\":\"refactor\",\"section\":\"Code Refactoring\",\"hidden\":true},{\"type\":\"test\",\"section\":\"Tests\",\"hidden\":true},{\"type\":\"build\",\"section\":\"Build System\",\"hidden\":true},{\"type\":\"ci\",\"section\":\"Continuous Integration\",\"hidden\":true}]" +} \ No newline at end of file