326 lines
10 KiB
JavaScript

#!/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 = '<!-- UNVERSIONED -->';
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;