From 24ce56006569e8dd1197740d9ab9895896608b25 Mon Sep 17 00:00:00 2001 From: Pierre Vanduynslager Date: Sat, 7 Jul 2018 04:12:21 -0400 Subject: [PATCH] refactor: build plugin pipeline parameters at initialization In addition, factorize the pipeline config function to avoid code duplication. --- index.js | 88 ++++---------------------------- lib/definitions/plugins.js | 44 +++++++++++++++- lib/plugins/index.js | 48 +++++++++-------- lib/plugins/normalize.js | 1 + lib/plugins/pipeline.js | 13 ++--- test/definitions/plugins.test.js | 9 ++++ test/plugins/pipeline.test.js | 14 ++--- 7 files changed, 105 insertions(+), 112 deletions(-) diff --git a/index.js b/index.js index ca961e9a..250d5a5e 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,4 @@ -const {template, isPlainObject} = require('lodash'); +const {template} = require('lodash'); const marked = require('marked'); const TerminalRenderer = require('marked-terminal'); const envCi = require('env-ci'); @@ -15,7 +15,7 @@ const getGitAuthUrl = require('./lib/get-git-auth-url'); const logger = require('./lib/logger'); const {fetch, verifyAuth, isBranchUpToDate, gitHead: getGitHead, tag, push} = require('./lib/git'); const getError = require('./lib/get-error'); -const {COMMIT_NAME, COMMIT_EMAIL, RELEASE_NOTES_SEPARATOR} = require('./lib/definitions/constants'); +const {COMMIT_NAME, COMMIT_EMAIL} = require('./lib/definitions/constants'); marked.setOptions({renderer: new TerminalRenderer()}); @@ -72,21 +72,14 @@ async function run(options, plugins) { logger.log('Run automated release from branch %s', options.branch); - logger.log('Call plugin %s', 'verify-conditions'); - await plugins.verifyConditions({options, logger}, {settleAll: true}); + await plugins.verifyConditions({options, logger}); await fetch(options.repositoryUrl); const lastRelease = await getLastRelease(options.tagFormat, logger); const commits = await getCommits(lastRelease.gitHead, options.branch, logger); - logger.log('Call plugin %s', 'analyze-commits'); - const [type] = await plugins.analyzeCommits({ - options, - logger, - lastRelease, - commits: commits.filter(commit => !/\[skip\s+release\]|\[release\s+skip\]/i.test(commit.message)), - }); + const type = await plugins.analyzeCommits({options, logger, lastRelease, commits}); if (!type) { logger.log('There are no relevant changes, so no new version is released.'); return; @@ -94,87 +87,28 @@ async function run(options, plugins) { const version = getNextVersion(type, lastRelease, logger); const nextRelease = {type, version, gitHead: await getGitHead(), gitTag: template(options.tagFormat)({version})}; - logger.log('Call plugin %s', 'verify-release'); - await plugins.verifyRelease({options, logger, lastRelease, commits, nextRelease}, {settleAll: true}); + await plugins.verifyRelease({options, logger, lastRelease, commits, nextRelease}); const generateNotesParam = {options, logger, lastRelease, commits, nextRelease}; if (options.dryRun) { - logger.log('Call plugin %s', 'generate-notes'); - const notes = (await plugins.generateNotes(generateNotesParam, { - getNextInput: ({nextRelease, ...generateNotesParam}, notes) => ({ - ...generateNotesParam, - nextRelease: { - ...nextRelease, - notes: `${nextRelease.notes ? `${nextRelease.notes}${RELEASE_NOTES_SEPARATOR}` : ''}${notes}`, - }, - }), - })) - .filter(Boolean) - .join(RELEASE_NOTES_SEPARATOR); + const notes = await plugins.generateNotes(generateNotesParam); logger.log('Release note for version %s:\n', nextRelease.version); if (notes) { process.stdout.write(`${marked(notes)}\n`); } } else { - logger.log('Call plugin %s', 'generateNotes'); - nextRelease.notes = (await plugins.generateNotes(generateNotesParam, { - getNextInput: ({nextRelease, ...generateNotesParam}, notes) => ({ - ...generateNotesParam, - nextRelease: { - ...nextRelease, - notes: `${nextRelease.notes ? `${nextRelease.notes}${RELEASE_NOTES_SEPARATOR}` : ''}${notes}`, - }, - }), - })) - .filter(Boolean) - .join(RELEASE_NOTES_SEPARATOR); - - logger.log('Call plugin %s', 'prepare'); - await plugins.prepare( - {options, logger, lastRelease, commits, nextRelease}, - { - getNextInput: async ({nextRelease, ...prepareParam}) => { - const newGitHead = await getGitHead(); - // If previous prepare plugin has created a commit (gitHead changed) - if (nextRelease.gitHead !== newGitHead) { - nextRelease.gitHead = newGitHead; - // Regenerate the release notes - logger.log('Call plugin %s', 'generateNotes'); - nextRelease.notes = (await plugins.generateNotes( - {nextRelease, ...prepareParam}, - { - getNextInput: ({nextRelease, ...generateNotesParam}, notes) => ({ - ...generateNotesParam, - nextRelease: { - ...nextRelease, - notes: `${nextRelease.notes ? `${nextRelease.notes}${RELEASE_NOTES_SEPARATOR}` : ''}${notes}`, - }, - }), - } - )) - .filter(Boolean) - .join(RELEASE_NOTES_SEPARATOR); - } - // Call the next publish plugin with the updated `nextRelease` - return {...prepareParam, nextRelease}; - }, - } - ); + nextRelease.notes = await plugins.generateNotes(generateNotesParam); + await plugins.prepare({options, logger, lastRelease, commits, nextRelease}); // Create the tag before calling the publish plugins as some require the tag to exists logger.log('Create tag %s', nextRelease.gitTag); await tag(nextRelease.gitTag); await push(options.repositoryUrl, branch); - logger.log('Call plugin %s', 'publish'); - const releases = await plugins.publish( - {options, logger, lastRelease, commits, nextRelease}, - // Add nextRelease and plugin properties to published release - {transform: (release, step) => ({...(isPlainObject(release) ? release : {}), ...nextRelease, ...step})} - ); + const releases = await plugins.publish({options, logger, lastRelease, commits, nextRelease}); - await plugins.success({options, logger, lastRelease, commits, nextRelease, releases}, {settleAll: true}); + await plugins.success({options, logger, lastRelease, commits, nextRelease, releases}); logger.log('Published release: %s', nextRelease.version); } @@ -199,7 +133,7 @@ async function callFail(plugins, options, error) { const errors = extractErrors(error).filter(error => error.semanticRelease); if (errors.length > 0) { try { - await plugins.fail({options, logger, errors}, {settleAll: true}); + await plugins.fail({options, logger, errors}); } catch (err) { logErrors(err); } diff --git a/lib/definitions/plugins.js b/lib/definitions/plugins.js index 7d3df084..144197ee 100644 --- a/lib/definitions/plugins.js +++ b/lib/definitions/plugins.js @@ -1,5 +1,6 @@ const {isString, isFunction, isArray, isPlainObject} = require('lodash'); -const {RELEASE_TYPE} = require('./constants'); +const {gitHead} = require('../git'); +const {RELEASE_TYPE, RELEASE_NOTES_SEPARATOR} = require('./constants'); const validatePluginConfig = conf => isString(conf) || isString(conf.path) || isFunction(conf); @@ -7,36 +8,77 @@ module.exports = { verifyConditions: { default: ['@semantic-release/npm', '@semantic-release/github'], configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), + pipelineConfig: () => ({settleAll: true}), }, analyzeCommits: { default: '@semantic-release/commit-analyzer', configValidator: conf => Boolean(conf) && validatePluginConfig(conf), outputValidator: output => !output || RELEASE_TYPE.includes(output), + preprocess: ({commits, ...inputs}) => ({ + ...inputs, + commits: commits.filter(commit => !/\[skip\s+release\]|\[release\s+skip\]/i.test(commit.message)), + }), + postprocess: ([result]) => result, }, verifyRelease: { default: false, configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), + pipelineConfig: () => ({settleAll: true}), }, generateNotes: { default: ['@semantic-release/release-notes-generator'], configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), outputValidator: output => !output || isString(output), + pipelineConfig: () => ({ + getNextInput: ({nextRelease, ...generateNotesParam}, notes) => ({ + ...generateNotesParam, + nextRelease: { + ...nextRelease, + notes: `${nextRelease.notes ? `${nextRelease.notes}${RELEASE_NOTES_SEPARATOR}` : ''}${notes}`, + }, + }), + }), + postprocess: results => results.filter(Boolean).join(RELEASE_NOTES_SEPARATOR), }, prepare: { default: ['@semantic-release/npm'], configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), + pipelineConfig: ({generateNotes}, logger) => ({ + getNextInput: async ({nextRelease, ...prepareParam}) => { + const newGitHead = await gitHead(); + // If previous prepare plugin has created a commit (gitHead changed) + if (nextRelease.gitHead !== newGitHead) { + nextRelease.gitHead = newGitHead; + // Regenerate the release notes + logger.log('Call plugin %s', 'generateNotes'); + nextRelease.notes = await generateNotes({nextRelease, ...prepareParam}); + } + // Call the next publish plugin with the updated `nextRelease` + return {...prepareParam, nextRelease}; + }, + }), }, publish: { default: ['@semantic-release/npm', '@semantic-release/github'], configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), outputValidator: output => !output || isPlainObject(output), + pipelineConfig: () => ({ + // Add `nextRelease` and plugin properties to published release + transform: (release, step, {nextRelease}) => ({ + ...(isPlainObject(release) ? release : {}), + ...nextRelease, + ...step, + }), + }), }, success: { default: ['@semantic-release/github'], configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), + pipelineConfig: () => ({settleAll: true}), }, fail: { default: ['@semantic-release/github'], configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), + pipelineConfig: () => ({settleAll: true}), }, }; diff --git a/lib/plugins/index.js b/lib/plugins/index.js index 1f57ac81..7c482b16 100644 --- a/lib/plugins/index.js +++ b/lib/plugins/index.js @@ -1,4 +1,4 @@ -const {isPlainObject, omit, castArray, isUndefined} = require('lodash'); +const {identity, isPlainObject, omit, castArray, isUndefined} = require('lodash'); const AggregateError = require('aggregate-error'); const getError = require('../get-error'); const PLUGINS_DEFINITIONS = require('../definitions/plugins'); @@ -7,31 +7,37 @@ const normalize = require('./normalize'); module.exports = (options, pluginsPath, logger) => { const errors = []; - const plugins = Object.entries(PLUGINS_DEFINITIONS).reduce((plugins, [type, {configValidator, default: def}]) => { - let pluginConfs; + const plugins = Object.entries(PLUGINS_DEFINITIONS).reduce( + ( + plugins, + [type, {configValidator, default: def, pipelineConfig, postprocess = identity, preprocess = identity}] + ) => { + let pluginConfs; - if (isUndefined(options[type])) { - pluginConfs = def; - } else { - // If an object is passed and the path is missing, set the default one for single plugins - if (isPlainObject(options[type]) && !options[type].path && castArray(def).length === 1) { - options[type].path = def; + if (isUndefined(options[type])) { + pluginConfs = def; + } else { + // If an object is passed and the path is missing, set the default one for single plugins + if (isPlainObject(options[type]) && !options[type].path && castArray(def).length === 1) { + options[type].path = def; + } + if (configValidator && !configValidator(options[type])) { + errors.push(getError('EPLUGINCONF', {type, pluginConf: options[type]})); + return plugins; + } + pluginConfs = options[type]; } - if (configValidator && !configValidator(options[type])) { - errors.push(getError('EPLUGINCONF', {type, pluginConf: options[type]})); - return plugins; - } - pluginConfs = options[type]; - } - const globalOpts = omit(options, Object.keys(PLUGINS_DEFINITIONS)); + const globalOpts = omit(options, Object.keys(PLUGINS_DEFINITIONS)); + const steps = castArray(pluginConfs).map(conf => normalize(type, pluginsPath, globalOpts, conf, logger)); - plugins[type] = pipeline( - castArray(pluginConfs).map(conf => normalize(type, pluginsPath, globalOpts, conf, logger)) - ); + plugins[type] = async input => + postprocess(await pipeline(steps, pipelineConfig && pipelineConfig(plugins, logger))(await preprocess(input))); - return plugins; - }, {}); + return plugins; + }, + {} + ); if (errors.length > 0) { throw new AggregateError(errors); } diff --git a/lib/plugins/normalize.js b/lib/plugins/normalize.js index 0fac87df..1b7032e0 100644 --- a/lib/plugins/normalize.js +++ b/lib/plugins/normalize.js @@ -42,6 +42,7 @@ module.exports = (type, pluginsPath, globalOpts, pluginOpts, logger) => { const validator = async input => { const {outputValidator} = PLUGINS_DEFINITIONS[type] || {}; try { + logger.log('Call plugin "%s"', type); const result = await func(cloneDeep(input)); if (outputValidator && !outputValidator(result)) { throw getError(`E${type.toUpperCase()}OUTPUT`, {result, pluginName}); diff --git a/lib/plugins/pipeline.js b/lib/plugins/pipeline.js index ac29c035..6364c058 100644 --- a/lib/plugins/pipeline.js +++ b/lib/plugins/pipeline.js @@ -8,10 +8,6 @@ const {extractErrors} = require('../utils'); * * @typedef {Function} Pipeline * @param {Any} input Argument to pass to the first step in the pipeline. - * @param {Object} options Pipeline options. - * @param {Boolean} [options.settleAll=false] If `true` all the steps in the pipeline are executed, even if one rejects, if `false` the execution stops after a steps rejects. - * @param {Function} [options.getNextInput=identity] Function called after each step is executed, with the last step input and the current current step result; the returned value will be used as the input of the next step. - * @param {Function} [options.transform=identity] Function called after each step is executed, with the current step result and the step function; the returned value will be saved in the pipeline results. * * @return {Array<*>|*} An Array with the result of each step in the pipeline; if there is only 1 step in the pipeline, the result of this step is returned directly. * @@ -22,9 +18,14 @@ const {extractErrors} = require('../utils'); * Create a Pipeline with a list of Functions. * * @param {Array} steps The list of Function to execute. + * @param {Object} options Pipeline options. + * @param {Boolean} [options.settleAll=false] If `true` all the steps in the pipeline are executed, even if one rejects, if `false` the execution stops after a steps rejects. + * @param {Function} [options.getNextInput=identity] Function called after each step is executed, with the last step input and the current current step result; the returned value will be used as the input of the next step. + * @param {Function} [options.transform=identity] Function called after each step is executed, with the current step result, the step function and the last step input; the returned value will be saved in the pipeline results. + * * @return {Pipeline} A Function that execute the `steps` sequencially */ -module.exports = steps => async (input, {settleAll = false, getNextInput = identity, transform = identity} = {}) => { +module.exports = (steps, {settleAll = false, getNextInput = identity, transform = identity} = {}) => async input => { const results = []; const errors = []; await pReduce( @@ -33,7 +34,7 @@ module.exports = steps => async (input, {settleAll = false, getNextInput = ident let result; try { // Call the step with the input computed at the end of the previous iteration and save intermediary result - result = await transform(await step(lastInput), step); + result = await transform(await step(lastInput), step, lastInput); results.push(result); } catch (err) { if (settleAll) { diff --git a/test/definitions/plugins.test.js b/test/definitions/plugins.test.js index 9d37ae88..eedda95e 100644 --- a/test/definitions/plugins.test.js +++ b/test/definitions/plugins.test.js @@ -1,5 +1,6 @@ import test from 'ava'; import plugins from '../../lib/definitions/plugins'; +import {RELEASE_NOTES_SEPARATOR} from '../../lib/definitions/constants'; test('The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition', t => { t.false(plugins.verifyConditions.configValidator({})); @@ -118,3 +119,11 @@ test('The "publish" plugin output, if defined, must be an object', t => { t.true(plugins.publish.outputValidator(null)); t.true(plugins.publish.outputValidator('')); }); + +test('The "generateNotes" plugins output are concatenated with separator', t => { + t.is(plugins.generateNotes.postprocess(['note 1', 'note 2']), `note 1${RELEASE_NOTES_SEPARATOR}note 2`); + t.is(plugins.generateNotes.postprocess(['', 'note']), 'note'); + t.is(plugins.generateNotes.postprocess([undefined, 'note']), 'note'); + t.is(plugins.generateNotes.postprocess(['note 1', '', 'note 2']), `note 1${RELEASE_NOTES_SEPARATOR}note 2`); + t.is(plugins.generateNotes.postprocess(['note 1', undefined, 'note 2']), `note 1${RELEASE_NOTES_SEPARATOR}note 2`); +}); diff --git a/test/plugins/pipeline.test.js b/test/plugins/pipeline.test.js index da335059..2a673941 100644 --- a/test/plugins/pipeline.test.js +++ b/test/plugins/pipeline.test.js @@ -25,7 +25,7 @@ test('Execute each function in series passing a transformed input from "getNextI const step4 = stub().resolves(4); const getNextInput = (lastResult, result) => lastResult + result; - const result = await pipeline([step1, step2, step3, step4])(0, {settleAll: false, getNextInput}); + const result = await pipeline([step1, step2, step3, step4], {settleAll: false, getNextInput})(0); t.deepEqual(result, [1, 2, 3, 4]); t.true(step1.calledWith(0)); @@ -44,7 +44,7 @@ test('Execute each function in series passing the "lastResult" and "result" to " const step4 = stub().resolves(4); const getNextInput = stub().returnsArg(0); - const result = await pipeline([step1, step2, step3, step4])(5, {settleAll: false, getNextInput}); + const result = await pipeline([step1, step2, step3, step4], {settleAll: false, getNextInput})(5); t.deepEqual(result, [1, 2, 3, 4]); t.deepEqual(getNextInput.args, [[5, 1], [5, 2], [5, 3], [5, 4]]); @@ -58,7 +58,7 @@ test('Execute each function in series calling "transform" to modify the results' const getNextInput = stub().returnsArg(0); const transform = stub().callsFake(result => result + 1); - const result = await pipeline([step1, step2, step3, step4])(5, {getNextInput, transform}); + const result = await pipeline([step1, step2, step3, step4], {getNextInput, transform})(5); t.deepEqual(result, [1 + 1, 2 + 1, 3 + 1, 4 + 1]); t.deepEqual(getNextInput.args, [[5, 1 + 1], [5, 2 + 1], [5, 3 + 1], [5, 4 + 1]]); @@ -72,7 +72,7 @@ test('Execute each function in series calling "transform" to modify the results const getNextInput = stub().returnsArg(0); const transform = stub().callsFake(result => result + 1); - const result = await pipeline([step1, step2, step3, step4])(5, {settleAll: true, getNextInput, transform}); + const result = await pipeline([step1, step2, step3, step4], {settleAll: true, getNextInput, transform})(5); t.deepEqual(result, [1 + 1, 2 + 1, 3 + 1, 4 + 1]); t.deepEqual(getNextInput.args, [[5, 1 + 1], [5, 2 + 1], [5, 3 + 1], [5, 4 + 1]]); @@ -113,7 +113,7 @@ test('Execute all even if a Promise rejects', async t => { const step2 = stub().rejects(error1); const step3 = stub().rejects(error2); - const errors = await t.throws(pipeline([step1, step2, step3])(0, {settleAll: true})); + const errors = await t.throws(pipeline([step1, step2, step3], {settleAll: true})(0)); t.deepEqual([...errors], [error1, error2]); t.true(step1.calledWith(0)); @@ -129,7 +129,7 @@ test('Throw all errors from all steps throwing an AggregateError', async t => { const step1 = stub().rejects(new AggregateError([error1, error2])); const step2 = stub().rejects(new AggregateError([error3, error4])); - const errors = await t.throws(pipeline([step1, step2])(0, {settleAll: true})); + const errors = await t.throws(pipeline([step1, step2], {settleAll: true})(0)); t.deepEqual([...errors], [error1, error2, error3, error4]); t.true(step1.calledWith(0)); @@ -145,7 +145,7 @@ test('Execute each function in series passing a transformed input even if a step const step4 = stub().resolves(4); const getNextInput = (prevResult, result) => prevResult + result; - const errors = await t.throws(pipeline([step1, step2, step3, step4])(0, {settleAll: true, getNextInput})); + const errors = await t.throws(pipeline([step1, step2, step3, step4], {settleAll: true, getNextInput})(0)); t.deepEqual([...errors], [error2, error3]); t.true(step1.calledWith(0));