diff --git a/__snapshots__/runner.js b/__snapshots__/runner.js new file mode 100644 index 000000000..2aee43fc2 --- /dev/null +++ b/__snapshots__/runner.js @@ -0,0 +1,18 @@ +exports['Runner release-pr allows customization of changelog sections 1'] = ` +[ + [ + "CHANGELOG.md", + { + "content": "# Changelog\\n\\n### [1.0.1](https://www.github.com/googleapis/release-please/compare/v1.0.0...v1.0.1) (1983-10-10)\\n\\n\\n### Other\\n\\n* **deps:** update dependency com.google.cloud:google-cloud-spanner to v1.50.0 ([1f9663c](https://www.github.com/googleapis/release-please/commit/1f9663cf08ab1cf3b68d95dee4dc99b7c4aac373))\\n* **deps:** update dependency com.google.cloud:google-cloud-storage to v1.120.0 ([fcd1c89](https://www.github.com/googleapis/release-please/commit/fcd1c890dc1526f4d62ceedad561f498195c8939))\\n* update common templates ([3006009](https://www.github.com/googleapis/release-please/commit/3006009a2b1b2cb4bd5108c0f469c410759f3a6a))\\n", + "mode": "100644" + } + ], + [ + "package.json", + { + "content": "{\\n \\"name\\": \\"runner-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..2c0be8506 --- /dev/null +++ b/src/bin/command.ts @@ -0,0 +1,198 @@ +// 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 chalk = require('chalk'); +import {coerceOption} from '../util/coerce-option'; +import {GitHubReleaseOptions} from '../github-release'; +import {ReleasePROptions} from '../release-pr'; +import {getReleaserNames} from '../releasers'; +import main from '../runner'; +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('package-name', { + describe: 'name of package release is being minted for', + demand: true, + }) + .option('changelog-types', { + describe: + 'a JSON formatted string containing to override the outputted changelog sections', + }) + .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, + }); + }, + async (argv: ReleasePROptions & yargs.Arguments) => { + await main(argv, 'release-pr').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', + }); + }, + async (argv: GitHubReleaseOptions & yargs.Arguments) => { + await main(argv, 'github-release').catch(e => handleError(e, argv)); + } + ) + .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); + +// 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; +} diff --git a/src/bin/release-please.ts b/src/bin/release-please.ts index dac297578..cb732cc7b 100644 --- a/src/bin/release-please.ts +++ b/src/bin/release-please.ts @@ -1,206 +1,13 @@ #!/usr/bin/env node -// 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 command, {ErrorObject, handleError} from './command'; -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'; - -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); }); diff --git a/src/release-pr.ts b/src/release-pr.ts index 53a2ff293..10d2858dd 100644 --- a/src/release-pr.ts +++ b/src/release-pr.ts @@ -54,9 +54,15 @@ export interface BuildOptions { octokitAPIs?: OctokitAPIs; } +type ChangelogSection = { + type: string; + section: string; + hidden?: boolean; +}; + export interface ReleasePROptions extends BuildOptions { releaseType: string; - changelogSections?: []; + changelogSections?: Array; } export interface ReleaseCandidate { @@ -99,7 +105,7 @@ export class ReleasePR { proxyKey?: string; snapshot?: boolean; lastPackageVersion?: string; - changelogSections?: []; + changelogSections?: Array; constructor(options: ReleasePROptions) { this.bumpMinorPreMajor = options.bumpMinorPreMajor || false; diff --git a/src/runner.ts b/src/runner.ts new file mode 100644 index 000000000..330ab108d --- /dev/null +++ b/src/runner.ts @@ -0,0 +1,33 @@ +import {GitHubRelease, GitHubReleaseOptions} from './github-release'; +import {ReleasePRFactory} from './release-pr-factory'; +import {ReleasePROptions} from './release-pr'; + +type Commands = 'github-release' | 'release-pr'; + +interface MappedOptions { + changelogTypes?: string; +} + +type Options = (GitHubReleaseOptions | ReleasePROptions) & MappedOptions; + +export default async function main( + options: Options, + command?: Commands, + onReleaseCreated?: Function +) { + if (!command || command === 'github-release') { + const gr = new GitHubRelease(options as GitHubReleaseOptions); + const releaseCreated = await gr.createRelease(); + if (releaseCreated && onReleaseCreated) { + onReleaseCreated(releaseCreated); + } + } + if (!command || command === 'release-pr') { + const opts = options as ReleasePROptions; + if (options.changelogTypes) { + opts.changelogSections = JSON.parse(options.changelogTypes); + } + const release = ReleasePRFactory.buildStatic(opts.releaseType, opts); + await release.run(); + } +} diff --git a/test/cli.ts b/test/cli.ts new file mode 100644 index 000000000..609ce5588 --- /dev/null +++ b/test/cli.ts @@ -0,0 +1,53 @@ +import {resolve} from 'path'; + +import {describe, it} from 'mocha'; +import {expect, assert} from 'chai'; + +import command from '../src/bin/command'; + +function getExampleConfigurationPath() { + return resolve('./test/fixtures/config', 'simple.json'); +} + +describe('CLI', () => { + describe('release-pr', () => { + it('can be configured using flags', () => { + const argv = command.parse( + 'release-pr --repo-url=googleapis/release-please-cli --package-name=cli-package' + ); + 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 --config=${getExampleConfigurationPath()}` + ); + expect(argv).includes({ + repoUrl: 'googleapis/release-please-cli', + releaseType: 'node', + packageName: 'cli-package--config', + }); + expect(argv.changelogTypes).to.be.a('string').that.is.not.empty; + }); + + it('converts changelog-types => changelogSections', () => { + const argv = command.parse( + `release-pr --config=${getExampleConfigurationPath()}` + ); + expect(argv).includes({ + repoUrl: 'googleapis/release-please-cli', + releaseType: 'node', + packageName: 'cli-package--config', + }); + expect(argv.changelogTypes).to.be.a('string').that.is.not.empty; + assert.sameDeepMembers( + JSON.parse(argv.changelogTypes as string), + argv.changelogSections as [] + ); + }); + }); +}); diff --git a/test/fixtures/config/simple.json b/test/fixtures/config/simple.json new file mode 100644 index 000000000..ea3fcae20 --- /dev/null +++ b/test/fixtures/config/simple.json @@ -0,0 +1,5 @@ +{ + "package-name": "cli-package--config", + "repo-url": "googleapis/release-please-cli", + "changelog-types": "[{\"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 diff --git a/test/runner.ts b/test/runner.ts new file mode 100644 index 000000000..e55a8fa93 --- /dev/null +++ b/test/runner.ts @@ -0,0 +1,131 @@ +import {resolve} from 'path'; +import {readFileSync} from 'fs'; + +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 runner from '../src/runner'; + +const sandbox = sinon.createSandbox(); + +describe('Runner', () => { + afterEach(() => { + sandbox.restore(); + }); + + describe('release-pr', () => { + it('allows customization of changelog sections', 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: 'runner-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/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/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/contents/package.json') + .reply(200, existingPackageResponse) + .get('/repos/googleapis/release-please') + // eslint-disable-next-line @typescript-eslint/no-var-requires + .reply(200, require('../../test/fixtures/repo-get-1.json')) + .get( + '/repos/googleapis/release-please/contents/CHANGELOG.md?ref=refs%2Fheads%2Fmaster' + ) + .reply(404) + .get( + '/repos/googleapis/release-please/contents/package-lock.json?ref=refs%2Fheads%2Fmaster' + ) + .reply(404) + .get( + '/repos/googleapis/release-please/contents/samples/package.json?ref=refs%2Fheads%2Fmaster' + ) + .reply(404) + .get( + '/repos/googleapis/release-please/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/pulls?state=open&per_page=100') + .reply(200, []) + // Add autorelease: pending label to release PR: + .post('/repos/googleapis/release-please/issues/22/labels') + .reply(200) + // this step tries to close any existing PRs; just return an empty list. + .get('/repos/googleapis/release-please/pulls?state=open&per_page=100') + .reply(200, []); + + const onReleaseCreated = sinon.fake(); + + await runner( + { + apiUrl: 'https://api.github.com', + repoUrl: 'googleapis/release-please', + releaseType: 'node', + packageName: 'runner-package', + changelogSections: [ + {type: 'feat', section: 'Features'}, + {type: 'fix', section: 'Other'}, + {type: 'chore', section: 'Other'}, + ], + }, + 'release-pr', + onReleaseCreated + ); + + scope.done(); + + snapshot( + JSON.stringify(expectedChanges, null, 2) + // don't save a real date, this will break tests. + .replace(/[0-9]{4}-[0-9]{2}-[0-9]{2}/, '1983-10-10') + ); + expect(onReleaseCreated.callCount).to.equal(0); + }); + }); +});