#!/usr/bin/env node /* eslint-disable no-restricted-globals, no-console */ const path = require('path'); const { promises: fs } = require('fs'); const util = require('util'); const chalk = require('chalk'); const inquirer = require('inquirer'); const lodash = require('lodash'); const glob = util.promisify(require('glob')); const moment = require('moment'); const yargs = require('yargs/yargs'); const { hideBin } = require('yargs/helpers'); const hotConfig = require('../hot.config'); const isTTY = process.stdin.isTTY; const changelogMarkDownMark = ''; const changelogPath = path.join(__dirname, '../CHANGELOG.md'); const changelogsDirectoryName = '.changelogs'; // The order of the items affects the order of the categories that are generated in the CHANGELOG.md file. const changelogEntryTypes = ['added', 'changed', 'deprecated', 'removed', 'fixed', 'security']; const changelogFrameworkTypes = ['none', 'react', 'vue', 'angular']; const changelogIssuesOriginTypes = ['private', 'public']; const uppercaseFirstLetter = s => s.slice(0, 1).toUpperCase() + s.slice(1); const entryDestination = name => path.relative( process.cwd(), path.join(__dirname, `../${changelogsDirectoryName}/${name}.json`) ); const fileExists = p => fs.access(p).then(() => true).catch(() => false); const stringifyChangelogEntryObject = ({ issuesOrigin, title, issueOrPR, framework, breaking }) => { const breakingFragment = breaking ? '**Breaking change**: ' : ''; const frameworkFragment = framework === 'none' ? '' : `${uppercaseFirstLetter(framework)}: `; const linkType = issuesOrigin === 'private' ? 'pull' : 'issues'; const issueLinkFragment = `[#${issueOrPR}](https://github.com/handsontable/handsontable/${linkType}/${issueOrPR})`; return `${breakingFragment}${frameworkFragment}${title} ${issueLinkFragment}`; }; const askToProceedOrExit = async(message = 'Proceed?') => { const { shouldProceed } = isTTY ? await inquirer.prompt({ name: 'shouldProceed', type: 'confirm', message, default: true }) : true; if (shouldProceed === false) { process.exit(1); } }; /** * Checks if the changelog entry has all the necessary properties. Returns a * new object with only the changelog entry properties that can be safely * stringified and put into a file. If the input object is missing properties * or they're in invalid format, throws an error. * * @param {object} input A valid changelog entry object. * @returns {object} The `input` object but only with valid changelog entry properties. */ const assertChangelogEntryFormat = (input) => { if (typeof input !== 'object' || input === null) { throw new TypeError(`input must be an object and not be null (got: ${input})`); } if (typeof input.issuesOrigin !== 'string' || !changelogIssuesOriginTypes.includes(input.issuesOrigin)) { throw new TypeError( `input.issuesOrigin must be one of: ${changelogIssuesOriginTypes} (got: ${input.issuesOrigin})`); } if (typeof input.title !== 'string' || input.title.length === 0) { throw new TypeError(`input.title must be a non-empty string (got: ${input.title})`); } if (typeof input.type !== 'string' || !changelogEntryTypes.includes(input.type)) { throw new TypeError(`input.type must be one of: ${changelogEntryTypes} (got: ${input.type})`); } if (typeof input.issueOrPR !== 'number' || Number.isNaN(input.issueOrPR)) { throw new TypeError(`input.issueOrPR must be a non-NaN number (got: ${input.issueOrPR})`); } if (typeof input.breaking !== 'boolean') { throw new TypeError(`input.breaking must be a boolean (got: ${typeof input.breaking})`); } if (typeof input.framework !== 'string' || !changelogFrameworkTypes.includes(input.framework)) { throw new TypeError(`input.framework must be one of: ${changelogFrameworkTypes} (got: ${input.framework})`); } return { issuesOrigin: input.issuesOrigin, title: input.title, type: input.type, issueOrPR: input.issueOrPR, breaking: input.breaking, framework: input.framework }; }; const createEntryCommand = async(args) => { const argTitle = args.title ? args.title.join(' ') : undefined; const answers = isTTY ? await inquirer.prompt([ { name: 'issuesOrigin', type: 'list', message: 'Issue\'s origin', choices: changelogIssuesOriginTypes, default: changelogIssuesOriginTypes[0], when: typeof args.issuesOrigin === 'undefined' }, { name: 'title', type: 'input', message: 'Title of the entry (e.g. "Fixed an issue with...")', validate: s => s.length > 0, default: argTitle, when: typeof argTitle === 'undefined' }, { name: 'issueOrPR', type: 'input', message: ({ issuesOrigin }) => { if (issuesOrigin === 'private') { return '# of the related public PR (e.g. 512)'; } return '# of the related public issue (e.g. 512)'; }, validate: s => (Number.isInteger(s) ? true : 'The provided input is not a number'), filter: s => (Number.isNaN(parseInt(s, 10)) ? '' : parseInt(s, 10)), when: typeof args.issueOrPR === 'undefined' }, { name: 'breaking', type: 'confirm', message: 'Is this a breaking change?', default: false, when: typeof args.breaking === 'undefined' }, { name: 'type', type: 'list', message: 'Type of the change', choices: changelogEntryTypes, default: ({ title }) => // Attempts to find a suitable default choice based on the title. // e.g. the title "Fixed every bug" would return the type "fixed", // and "Added 10 features" would return "added". changelogEntryTypes.find( type => (title || argTitle).toLowerCase().includes(type) ) || 'changed', when: typeof args.framework === 'undefined' }, { name: 'framework', type: 'list', message: 'Framework', choices: changelogFrameworkTypes, default: 'none', when: typeof args.framework === 'undefined' } ]) : {}; const changelogEntry = assertChangelogEntryFormat({ ...args, title: argTitle, ...answers }); const filename = changelogEntry.issueOrPR.toString(); const destination = entryDestination(filename); console.log(` ${chalk.dim( `Your entry will be saved in ${chalk.reset.blue.underline(destination)}. This is how it will look like compiled to markdown in ${chalk.reset.blue.underline('CHANGELOG.md')}:` )} ${chalk.yellow.italic(`### ${uppercaseFirstLetter(changelogEntry.type)} - ${stringifyChangelogEntryObject(changelogEntry)}`)} `); await askToProceedOrExit(); if (await fileExists(destination)) { console.log(`The file \`${destination}\` already exists.`); await askToProceedOrExit('Overwrite?'); } await fs.writeFile(destination, `${JSON.stringify(changelogEntry, null, 2)}\n`); console.log(); console.log(`Entry created! Run ${chalk.blackBright(`\`rm ${destination}\``)} to undo.`); }; const consumeCommand = async(args) => { const { dryRun } = args; const formattedDefault = args.date === undefined ? moment(hotConfig.HOT_RELEASE_DATE, 'DD/MM/YYYY').format('YYYY-MM-DD') : args.date; const { date } = isTTY ? await inquirer.prompt([ { name: 'date', type: 'input', message: 'When should the version be released?', default: formattedDefault } ]) : { date: formattedDefault }; const existingChangelogContents = await fs.readFile(changelogPath, 'utf8'); if (!existingChangelogContents.includes(changelogMarkDownMark)) { throw new Error( `The existing changelog file (${ changelogPath }) does not include the mark (\`${ changelogMarkDownMark }\`) to put new contents into.` ); } const changelogEntryFiles = await glob('*.json', { absolute: true, cwd: path.join(__dirname, '..', changelogsDirectoryName) }); const ungroupedEntries = (await Promise.all(changelogEntryFiles.map(p => fs.readFile(p, 'utf8')))) .map(contents => assertChangelogEntryFormat(JSON.parse(contents))); const groupedEntries = lodash.groupBy(ungroupedEntries, 'type'); const groupedPairs = lodash.sortBy(lodash .toPairs(groupedEntries), ([key]) => changelogEntryTypes.indexOf(key)); const compiledEntries = groupedPairs .map(([type, entries]) => `${`### ${uppercaseFirstLetter(type)}\n`}${ lodash.chain(entries) .partition('breaking') .map( x => lodash.sortBy(x, ({ framework }) => (framework === 'none' ? 0 : framework.length)) ) .flatten() .map(entry => `- ${stringifyChangelogEntryObject(entry)}`) .join('\n') .value() }` ) .join('\n\n'); const compiled = ` ## [${hotConfig.HOT_VERSION}] - ${date} ${compiledEntries} `.trim(); console.log(` ${chalk.dim( 'You are about to update the changelog file, that will include the following text in the appropriate place:' )} ${chalk.yellow.italic(compiled)} `); if (dryRun) { console.log('Dry run, skipping write.'); process.exit(0); } await askToProceedOrExit(`Write the new ${path.basename(changelogPath)} and delete the changelog entry .json files?`); const newChangelogContents = existingChangelogContents.replace( changelogMarkDownMark, `${changelogMarkDownMark}\n\n${compiled}` ); console.log(); console.log(chalk.dim('Writing the new changelog...')); await fs.writeFile(changelogPath, newChangelogContents); console.log(chalk.dim('Deleting changelog entries...')); await Promise.all(changelogEntryFiles.map(p => fs.unlink(p))); console.log(); console.log(`Changelog updated! Run ${ chalk.blackBright(`\`git checkout ${path.relative(process.cwd(), changelogPath) } ${path.relative( process.cwd(), path.join(__dirname, '..', changelogsDirectoryName))}\``) } to undo.`); }; // eslint-disable-next-line no-unused-expressions yargs(hideBin(process.argv)) .command('entry [title..]', 'create a new changelog entry', (y) => { return y .option('type', { choices: changelogEntryTypes }) .option('issue', { type: 'number' }) .option('breaking', { type: 'boolean' }) .option('framework', { choices: changelogFrameworkTypes }); }, argv => createEntryCommand(argv)) .command('consume', `compile all \`${changelogsDirectoryName}/*.json\` files into changelog.md`, (y) => { return y .option('date', { type: 'string' }) .option('dry-run', { type: 'boolean' }); }, argv => consumeCommand(argv)) .help() .demandCommand() .version(false) .argv;