UNPKG

@twyr/announce

Version:

CLI Tool and NPM Library for announcing a release on Github / Gitlab / etc. and on NPM

1,369 lines (1,161 loc) 44.9 kB
/* eslint-disable curly */ /* eslint-disable security/detect-object-injection */ /* eslint-disable security-node/detect-non-literal-require-calls */ /* eslint-disable security/detect-non-literal-require */ 'use strict'; /** * Module dependencies, required for ALL Twy'r modules * @ignore */ /** * Module dependencies, required for this module * @ignore */ /** * @class ReleaseCommandClass * @classdesc The command class that creates a release on a git host (Github / GitLab / BitBucket / etc.). * * @param {object} mode - Set the current run mode - CLI or API * * @description * The command class that implements the "release" step of the workflow. * Please see README.md for the details of what this step involves. * */ class ReleaseCommandClass { // #region Constructor constructor(mode) { this.#execMode = mode; } // #endregion // #region Public Methods /** * @async * @function * @instance * @memberof ReleaseCommandClass * @name execute * * @param {object} configOptions - Options via cosmiConfig, or via API * @param {object} cliOptions - Options via the CLI * * @return {null} Nothing. * * @summary The main method to tag/release the codebase on Github / GitLab / etc. * * This method does 3 things: * - Generates the changelog - features/fixes added to the code since the last tag/release * - Commits, tags, pushes to Github / GitLab / etc. * - Creates a release using the tag and the generated changelog * */ async execute(configOptions, cliOptions) { // Step 1: Setup sane defaults for the options const mergedOptions = this?._mergeOptions?.(configOptions, cliOptions); // Step 2: Set up the logger according to the options passed in const logger = this?._setupLogger?.(mergedOptions); mergedOptions.logger = logger; // Step 3: Setup the task list const taskList = this?._setupTasks?.(); // Step 4: Run the tasks in sequence // eslint-disable-next-line security-node/detect-crlf console.log(`Releasing the codebase:`); await taskList?.run?.({ 'options': mergedOptions, 'execError': null }); } // #endregion // #region Private Methods /** * @function * @instance * @memberof ReleaseCommandClass * @name _mergeOptions * * @param {object} configOptions - Options passed in from cosmiConfig / calling module * @param {object} cliOptions - Options passed in from the CLI * * @return {object} Merged options - input options > configured options. * * @summary Merges options passed in with configured ones - and puts in sane defaults if neither is available. * */ _mergeOptions(configOptions, cliOptions) { const path = require('path'); const mergedOptions = Object?.assign?.({}, configOptions, cliOptions); // Process upstreams mergedOptions.upstream = mergedOptions?.upstream ?.split?.(',') ?.map?.((remote) => { return remote?.trim?.(); }) ?.filter?.((remote) => { return !!remote.length; }); // Process release notes storage output formats mergedOptions.outputFormat = mergedOptions?.outputFormat ?.split?.(',') ?.map?.((format) => { return format?.trim?.(); }) ?.filter?.((format) => { return !!format.length; }); if(mergedOptions?.outputFormat?.includes?.('all')) mergedOptions.outputFormat = ['json', 'pdf']; // Process release notes storage path let outputPath = mergedOptions?.outputPath?.trim?.() ?? ''; if(outputPath === '') outputPath = './buildresults/release-notes'; if(!path.isAbsolute(outputPath)) outputPath = path.join(mergedOptions?.currentWorkingDirectory, outputPath); mergedOptions.outputPath = outputPath; // Process release notes ejs template path let releaseMessagePath = mergedOptions?.releaseMessage ?? ''; if(releaseMessagePath === '') releaseMessagePath = './templates/release-notes.ejs'; if(!path.isAbsolute(releaseMessagePath)) releaseMessagePath = path.join(mergedOptions?.currentWorkingDirectory, releaseMessagePath); mergedOptions.releaseMessage = releaseMessagePath; // Process old tag name mergedOptions.useTag = mergedOptions?.useTag?.trim?.(); return mergedOptions; } /** * @function * @instance * @memberof ReleaseCommandClass * @name _setupLogger * * @param {object} options - merged options object returned by the _mergeOptions method * * @return {object} Logger object with info / error functions. * * @summary Logger for API mode, otherwise null * */ _setupLogger(options) { if(this.#execMode === 'api') return options?.logger; return null; } /** * @function * @instance * @memberof ReleaseCommandClass * @name _setupTasks * * @return {object} Tasks as Listr. * * @summary Setup the list of tasks to be run * */ _setupTasks() { const Listr = require('listr'); const taskList = new Listr([{ 'title': 'Initializing Git Client...', 'task': this?._initializeGit?.bind?.(this) }, { 'title': 'Stash / Commit...', 'task': this?._stashOrCommit?.bind?.(this), 'skip': (ctxt) => { if(ctxt?.options?.git) return false; return `No Git client found.`; } }, { 'title': 'Generating Changelog...', 'task': this?._generateChangelog?.bind?.(this), 'skip': (ctxt) => { if(ctxt?.execError) return `Error in previous step`; if(!ctxt?.options?.tag) return `--no-tag option specified.`; if(ctxt?.options?.useTag?.length) return `Using previous tag ${ctxt?.options?.useTag}`; if(!ctxt?.options?.git) return `No Git client found.`; return false; } }, { 'title': 'Tagging the commit...', 'task': this?._tagCode?.bind?.(this), 'skip': (ctxt) => { if(ctxt?.execError) return `Error in one of the previous steps`; if(!ctxt?.options?.git) return `No Git client found.`; if(!ctxt?.options?.tag) return `--no-tag option specified.`; if(ctxt?.options?.useTag?.length) return `Using previous tag ${ctxt?.options?.useTag}`; return false; } }, { 'title': 'Pushing upstream...', 'task': this?._pushUpstream?.bind?.(this), 'skip': (ctxt) => { if(ctxt?.execError) return `Error in one of the previous steps`; if(!ctxt?.options?.git) return `No Git client found.`; if(!ctxt?.options?.tag) return `--no-tag option specified.`; if(ctxt?.options?.upstream?.length < 1) return `No upstreams specified`; if(ctxt?.options?.useTag?.length) return `Using previous tag ${ctxt?.options?.useTag}`; return false; } }, { 'title': 'Generating Release...', 'task': this?._generateRelease?.bind?.(this), 'skip': (ctxt) => { if(ctxt?.execError) return `Error in previous step`; if(!ctxt?.options?.git) return `No Git client found.`; if(!ctxt?.options?.release) return `--no-release option specified.`; return false; } }, { 'title': 'Restoring code...', 'task': this?._restoreCode?.bind?.(this), 'enabled': (ctxt) => { return ctxt?.options?.shouldPop; }, 'skip': (ctxt) => { if(ctxt?.options?.git) return false; return `No Git client found.`; } }, { 'title': 'Summarizing...', 'task': this?._summarize?.bind?.(this) }], { 'collapse': false }); return taskList; } /** * @function * @instance * @memberof ReleaseCommandClass * @name _initializeGit * * @param {object} ctxt - Task context containing the options object returned by the _mergeOptions method * @param {object} task - Reference to the task that is running * * @return {null} Nothing. * * @summary Creates a Git client instance for the current project repository and sets it on the context. * */ _initializeGit(ctxt, task) { const simpleGit = require('simple-git'); const git = simpleGit?.({ 'baseDir': ctxt?.options?.currentWorkingDirectory }); ctxt?.options?.logger?.info?.(`Initialized Git for the repository @ ${ctxt?.options?.currentWorkingDirectory}`); task.title = `Initialize Git for the repository @ ${ctxt?.options?.currentWorkingDirectory}: Done`; ctxt.options.git = git; } /** * @async * @function * @instance * @memberof ReleaseCommandClass * @name _stashOrCommit * * @param {object} ctxt - Task context containing the options object returned by the _mergeOptions method * @param {object} task - Reference to the task that is running * * @return {null} Nothing. * * @summary Depending on the configuration, stashes/commits code in the current branch - if required. * */ async _stashOrCommit(ctxt, task) { try { const gitOperation = ctxt?.options?.commit ? 'commit' : 'stash'; const branchStatus = await ctxt?.options?.git?.status?.(); if(!branchStatus?.files?.length) { ctxt?.options?.logger?.info?.(`${branchStatus.current} branch clean - ${gitOperation} operation not required.`); task.title = `${gitOperation} operation not required.`; return; } ctxt?.options?.logger?.debug?.(`${branchStatus.current} branch dirty - proceeding with ${gitOperation} operation`); task.title = `${gitOperation} in progress...`; if(gitOperation === 'stash') { await ctxt?.options?.git?.stash?.(['push']); ctxt.options.shouldPop = true; } else { const path = require('path'); let trailerMessages = await ctxt?.options?.git?.raw?.('interpret-trailers', path.join(__dirname, '../.gitkeep')); trailerMessages = trailerMessages?.replace?.(/\\n/g, '\n')?.replace(/\\t/g, '\t'); const consolidatedMessage = `${(ctxt?.options?.commitMessage ?? '')} ${(trailerMessages ?? '')}`; await ctxt?.options?.git?.commit?.(consolidatedMessage, ['--no-edit', '--no-verify', '--signoff', '--quiet']); } ctxt?.options?.logger?.info?.(`"${branchStatus.current}" branch ${gitOperation} done`); task.title = `"${branchStatus.current}" branch ${gitOperation}: Done.`; } catch(err) { task.title = 'Stash / Commit: Error'; ctxt.execError = err; } } /** * @async * @function * @instance * @memberof ReleaseCommandClass * @name _generateChangelog * * @param {object} ctxt - Task context containing the options object returned by the _mergeOptions method * @param {object} task - Reference to the task that is running * * @return {null} Nothing. * * @summary Generates a CHANGELOG from the relevant Git Log events, and commits the modified file. * */ async _generateChangelog(ctxt, task) { ctxt?.options?.logger?.info?.(`Generating CHANGELOG containing significant Git log events from the last tag onwards`); const Listr = require('listr'); const taskList = new Listr([{ 'title': 'Fetching git log events...', 'task': this?._fetchGitLogsForChangelog?.bind?.(this) }, { 'title': 'Filtering git log events...', 'task': this?._filterGitLogs?.bind?.(this), 'skip': () => { if(ctxt?.execError) return `Error in one of the previous steps`; if(ctxt?.options?.gitLogsInRange?.all?.length) return false; return `No relevant git logs.`; } }, { 'title': 'Formatting git log events...', 'task': this?._formatGitLogsForChangelog?.bind?.(this), 'skip': () => { if(ctxt?.execError) return `Error in one of the previous steps`; if(ctxt?.options?.gitLogsInRange?.length) return false; return `No relevant git logs.`; } }, { 'title': 'Creating / Modifying changelog...', 'task': this?._modifyChangelog?.bind?.(this), 'skip': () => { if(ctxt?.execError) return `Error in one of the previous steps`; if(ctxt?.options?.changelogText?.length) return false; return `No change log to add.`; } }, { 'title': 'Commiting changelog...', 'task': this?._commitChangelog?.bind?.(this), 'skip': async () => { if(ctxt?.execError) return `Error in one of the previous steps`; const git = ctxt?.options?.git; const branchStatus = await git?.status?.(); if(branchStatus?.files?.length) return false; return `Nothing to commit.`; } }, { 'title': 'Cleaning up...', 'task': this?._cleanupChangelog?.bind?.(this) }, { 'title': 'Teardown...', 'task': (thisCtxt, thisTask) => { thisTask.title = 'Teardown: Done'; task.title = ctxt?.execError ? `Changelog Generation: Error` : `Changelog Generation: Done`; } }], { 'collapse': true }); return taskList; } /** * @async * @function * @instance * @memberof ReleaseCommandClass * @name _tagCode * * @param {object} ctxt - Task context containing the options object returned by the _mergeOptions method * @param {object} task - Reference to the task that is running * * @return {null} Nothing. * * @summary Tags the codebase. * */ async _tagCode(ctxt, task) { try { ctxt?.options?.logger?.info(`Tagging commit with the CHANGELOG`); const git = ctxt?.options?.git; let lastCommit = await git?.raw?.(['rev-parse', 'HEAD']); lastCommit = lastCommit?.replace?.(/\\n/g, '')?.trim?.(); if(!lastCommit) { task.title = 'Tag the commit: No commits found'; return; } await git?.tag?.(['-a', '-f', '-m', ctxt?.options?.tagMessage, ctxt?.options?.tagName, lastCommit]); task.title = 'Tag the commit: Done'; } catch(err) { task.title = 'Tag the commit: Error'; ctxt.execError = err; } } /** * @async * @function * @instance * @memberof ReleaseCommandClass * @name _pushUpstream * * @param {object} ctxt - Task context containing the options object returned by the _mergeOptions method * @param {object} task - Reference to the task that is running * * @return {null} Nothing. * * @summary Pushes new commits/tags to the configured upstream git remote. * */ async _pushUpstream(ctxt, task) { try { ctxt?.options?.logger?.info?.(`Pushing commits and tag upstream`); const git = ctxt?.options?.git; const upstreamRemoteList = ctxt?.options?.upstream; task.title = 'Pushing upstream: Pulling from configured upstreams...'; await git?.remote?.(['update', '-p']?.concat?.(upstreamRemoteList)); const branchStatus = await git?.status?.(); for(let idx = 0; idx < upstreamRemoteList.length; idx++) { const thisUpstreamRemote = upstreamRemoteList[idx]; let canPush = await git?.raw?.(['rev-list', `HEAD...${thisUpstreamRemote}/${branchStatus?.current}`, '--ignore-submodules', '--count']); canPush = Number(canPush.replace(`\n`, '')); if(!canPush) continue; task.title = `Pushing upstream: Pushing to ${thisUpstreamRemote}...`; await git?.push?.(thisUpstreamRemote, branchStatus?.current, { '--atomic': true, '--progress': true, '--signed': 'if-asked' }); await git?.pushTags?.(thisUpstreamRemote, { '--atomic': true, '--force': true, '--progress': true, '--signed': 'if-asked' }); } task.title = 'Push upstream: Done'; } catch(err) { task.title = 'Push Upstream: Error'; ctxt.execError = err; } } /** * @async * @function * @instance * @memberof ReleaseCommandClass * @name _generateRelease * * @param {object} ctxt - Task context containing the options object returned by the _mergeOptions method * @param {object} task - Reference to the task that is running * * @return {null} Nothing. * * @summary Creates releases for each of the configured upstreams. * */ async _generateRelease(ctxt, task) { try { ctxt?.options?.logger?.info?.(`Generating ${ctxt?.options?.upstream?.length > 1 ? 'Releases' : 'Release'} for ${ctxt?.options?.upstream?.join?.(', ')}`); const releaseSteps = [{ 'title': `Fetching git logs for release...`, 'task': this?._fetchGitLogsForRelease?.bind?.(this), 'skip': (subTaskCtxt) => { if(subTaskCtxt?.releaseError) return `Error in one of the previous steps`; return false; } }, { 'title': 'Filtering git log events...', 'task': this?._filterGitLogs?.bind?.(this), 'skip': (subTaskCtxt) => { if(subTaskCtxt?.releaseError) return `Error in one of the previous steps`; if(ctxt?.options?.gitLogsInRange?.all?.length) return false; return `No relevant git logs.`; } }, { 'title': `Fetching author information for the relevant git log events...`, 'task': this?._fetchAuthorInformationForRelease?.bind?.(this), 'skip': (subTaskCtxt) => { if(subTaskCtxt?.releaseError) return `Error in one of the previous steps`; if(ctxt?.options?.gitLogsInRange?.length) return false; return `No relevant git logs.`; } }, { 'title': `Generating release notes...`, 'task': this?._generateReleaseNotes?.bind(this), 'skip': (subTaskCtxt) => { if(subTaskCtxt?.releaseError) return `Error in one of the previous steps`; if(ctxt?.options?.gitLogsInRange?.length) return false; if(ctxt?.options?.authorProfiles?.length) return false; return `No relevant git logs, or no information about their authors.`; } }, { 'title': `Pushing release...`, 'task': this?._createRelease?.bind?.(this), 'skip': (subTaskCtxt) => { if(subTaskCtxt?.releaseError) return `Error in one of the previous steps`; if(!ctxt?.options?.releaseData?.['RELEASE_NOTES']) return `Release notes not generated`; return false; } }, { 'title': `Storing release notes...`, 'task': this?._storeReleaseNotes?.bind?.(this), 'skip': (subTaskCtxt) => { if(subTaskCtxt?.releaseError) return `Error in one of the previous steps`; if(!ctxt?.options?.releaseData?.['RELEASE_NOTES']) return `Release notes not generated`; return false; } }]; const Listr = require('listr'); if(ctxt?.options?.upstream?.length > 1) { const taskArray = []; ctxt?.options?.upstream?.forEach?.((upstream) => { taskArray?.push?.({ 'title': `Releasing on ${upstream}...`, 'task': (thisCtxt, thisTask) => { const thisReleaseSteps = [{ 'title': `Setting up...`, 'task': (subTaskCtxt, subTaskTask) => { subTaskCtxt.options.currentReleaseUpstream = `${upstream}`; subTaskTask.title = `Setup: Done`; } }].concat(releaseSteps); thisReleaseSteps?.push?.({ 'title': `Cleaning up...`, 'task': (subTaskCtxt, subTaskTask) => { ctxt.execError = subTaskCtxt?.releaseError; ctxt.options.gitLogsInRange = null; ctxt.options.authorProfiles = null; ctxt.options.releaseData = null; subTaskTask.title = `Clean up: Done`; thisTask.title = ctxt?.execError ? `${upstream} release: Error` : `${upstream} release: Done`; } }); return new Listr(thisReleaseSteps, { 'collapse': true }); }, 'skip': () => { if(ctxt?.execError) return `Error in one of the previous steps`; return false; } }); }); taskArray?.push?.({ 'title': `Teardown...`, 'task': (thisCtxt, thisTask) => { thisTask.title = `Teardown: Done`; task.title = ctxt?.execError ? `Generate Release: Error` : `Generate Release: Done`; } }); const taskList = new Listr(taskArray); return taskList; } else { const thisReleaseSteps = [{ 'title': `Setting up...`, 'task': (subTaskCtxt, subTaskTask) => { subTaskCtxt.options.currentReleaseUpstream = `${ctxt?.options?.upstream?.[0]}`; subTaskTask.title = `Setup: Done`; } }].concat(releaseSteps); thisReleaseSteps?.push?.({ 'title': `Cleaning up...`, 'task': (subTaskCtxt, subTaskTask) => { ctxt.execError = subTaskCtxt?.releaseError; ctxt.options.gitLogsInRange = null; ctxt.options.authorProfiles = null; ctxt.options.releaseData = null; subTaskTask.title = `Clean up: Done`; } }); return new Listr(thisReleaseSteps, { 'collapse': true }); } } catch(err) { task.title = 'Generate Release: Error'; ctxt.execError = err; } return null; } /** * @async * @function * @instance * @memberof ReleaseCommandClass * @name _restoreCode * * @param {object} ctxt - Task context containing the options object returned by the _mergeOptions method * @param {object} task - Reference to the task that is running * * @return {null} Nothing. * * @summary If code was stashed earlier in the cycle, pops it out. * */ async _restoreCode(ctxt, task) { await ctxt?.options?.git?.stash?.(['pop']); task.title = 'Restore code: Done.'; return; } /** * @async * @function * @instance * @memberof ReleaseCommandClass * @name _summarize * * @param {object} ctxt - Task context containing the options object returned by the _mergeOptions method * @param {object} task - Reference to the task that is running * * @return {null} Nothing. * * @summary Print success if everything went through, else print error information. * */ async _summarize(ctxt, task) { if(this.#execMode !== 'cli') { if(ctxt?.execError) ctxt?.options?.logger?.error?.(ctxt?.execError); else ctxt?.options?.logger?.info?.(`Release: Done`); return; } if(!ctxt?.execError) { task.title = `Release: Done!`; return; } task.title = `Release process had errors:`; setTimeout?.(() => { console?.error?.(`\n Message:${ctxt?.execError?.message}\n Stack:${ctxt?.execError?.stack?.replace(/\\n/g, `\n `)}\n\n`); }, 1000); } // #endregion // #region Git Log processing methods async _fetchGitLogs(git, from, to) { let gitLogsInRange = null; if(from && to) gitLogsInRange = await git?.log?.({ 'from': from, 'to': to }); if(!from && to) gitLogsInRange = await git?.log?.({ 'to': to }); if(from && !to) gitLogsInRange = await git?.log?.({ 'from': from }); if(!from && !to) gitLogsInRange = { 'all': [] }; return gitLogsInRange; } async _filterGitLogs(ctxt, task) { try { const gitLogsInRange = ctxt?.options?.gitLogsInRange; const relevantGitLogs = []; gitLogsInRange?.all?.forEach?.((commitLog) => { if(commitLog?.message?.startsWith?.('feat(') || commitLog?.message?.startsWith?.('feat:') || commitLog?.message?.startsWith?.('fix(') || commitLog?.message?.startsWith?.('fix:') || commitLog?.message?.startsWith?.('docs(') || commitLog?.message?.startsWith?.('docs:') ) { relevantGitLogs?.push?.({ 'hash': commitLog?.hash, 'date': commitLog?.date, 'message': commitLog?.message, 'author_name': commitLog?.author_name, 'author_email': commitLog?.author_email }); } const commitLogBody = commitLog?.body?.replace?.(/\\r\\n/g, '\n')?.replace(/\\n/g, '\n')?.split?.('\n'); commitLogBody?.forEach?.((commitBody) => { if(commitBody?.startsWith?.('feat(') || commitBody?.startsWith?.('feat:') || commitBody?.startsWith?.('fix(') || commitBody?.startsWith?.('fix:') || commitBody?.startsWith?.('docs(') || commitBody?.startsWith?.('docs:') ) { relevantGitLogs.push?.({ 'hash': commitLog?.hash, 'date': commitLog?.date, 'message': commitBody?.trim(), 'author_name': commitLog?.author_name, 'author_email': commitLog?.author_email }); } }); }); ctxt.options.gitLogsInRange = relevantGitLogs; task.title = 'Filter git log events: Done'; } catch(err) { ctxt.options.gitLogsInRange = []; task.title = 'Filter git log events: Error'; ctxt.execError = err; } } // #endregion // #region Changelog generation methods async _fetchGitLogsForChangelog(ctxt, task) { try { const git = ctxt?.options?.git; // Step 1: Get the last tag, the commit for the last tag, and the last commit let lastTag = await git?.tag?.(['--sort=-creatordate']); lastTag = lastTag?.split?.('\n')?.shift()?.replace?.(/\\n/g, '')?.trim?.(); let lastTaggedCommit = null; if(lastTag) { lastTaggedCommit = await git?.raw?.(['rev-list', '-n', '1', `tags/${lastTag}`]); lastTaggedCommit = lastTaggedCommit?.replace?.(/\\n/g, '')?.trim?.(); } let lastCommit = await git?.raw?.(['rev-parse', 'HEAD']); lastCommit = lastCommit?.replace?.(/\\n/g, '')?.trim?.(); // Step 2: Get the Git Log events from the last commit to the commit of the last tag ctxt.options.gitLogsInRange = await this?._fetchGitLogs?.(git, lastTaggedCommit, lastCommit); task.title = 'Fetch git log events: Done'; } catch(err) { ctxt.options.gitLogsInRange = null; task.title = 'Fetch git log events: Error'; ctxt.execError = err; } } async _formatGitLogsForChangelog(ctxt, task) { try { const git = ctxt?.options?.git; const relevantGitLogs = ctxt?.options?.gitLogsInRange; // Step 1: Get the upstream to use... const upstreamRemoteList = ctxt?.options?.upstream; const upstreamForLinks = upstreamRemoteList?.[0]; // Step 2: Get the upstream type... const hostedGitInfo = require('hosted-git-info'); const gitRemote = await git?.remote?.(['get-url', '--push', upstreamForLinks]); const repository = hostedGitInfo?.fromUrl?.(gitRemote); repository.project = repository?.project?.replace?.('.git\n', ''); // Step 3: Instantiate the relevant Git Host Wrapper const GitHostWrapper = require(`./../git_host_utilities/${repository.type}`).GitHostWrapper; const gitHostWrapper = new GitHostWrapper(ctxt?.options?.githubToken); // Step 4: Convert the relevant Git Logs into a textual array, and add links to the hosted commit hash for insertion into the file const changeLogText = [`#### CHANGE LOG`]; const processedDates = []; const dateFormat = require('date-fns/format'); relevantGitLogs?.forEach?.((commitLog) => { const commitDate = dateFormat?.(new Date(commitLog?.date), 'dd-MMM-yyyy'); if(!processedDates?.includes?.(commitDate)) { processedDates?.push?.(commitDate); changeLogText?.push?.(`\n\n##### ${commitDate}`); } const commitLink = gitHostWrapper?.getCommitLink?.(repository, commitLog); changeLogText?.push?.(`\n${commitLog?.message} ([${commitLog?.hash}](${commitLink})`); }); ctxt.options.changelogText = changeLogText; task.title = 'Format git log events: Done'; } catch(err) { ctxt.options.changelogText = null; task.title = 'Format git log events: Error'; ctxt.execError = err; } } async _modifyChangelog(ctxt, task) { const path = require('path'); const prependFile = require('prepend-file'); const replaceInFile = require('replace-in-file'); try { const changeLogText = ctxt?.options?.changelogText; while(changeLogText?.length) { const thisChangeSet = []; // Step 1: Get all Git Logs for a particular date let thisChangeLog = changeLogText?.pop?.(); while(changeLogText?.length && !thisChangeLog?.startsWith?.(`\n\n####`)) { thisChangeSet?.unshift?.(thisChangeLog); thisChangeLog = changeLogText?.pop?.(); } thisChangeSet?.unshift?.(thisChangeLog); // Step 2: Add to existing entries for that date, if any already present in the file const replaceOptions = { 'files': path?.join?.(ctxt?.options?.currentWorkingDirectory, 'CHANGELOG.md'), 'from': thisChangeLog, 'to': thisChangeSet?.join?.(`\n`) }; // If the file has changed, continue to start processing the next date entries let changelogResult = await replaceInFile?.(replaceOptions); if(changelogResult?.[0]?.['hasChanged']) continue; // File hasn't changed, and there are no more relevant Git Logs. Break if(!changeLogText?.length) continue; // Step 3: File hasn't changed, but there are relevant Git Logs? That date is new // So simply add everything remaining to the top of the CHANGELOG while(thisChangeSet?.length) changeLogText?.push?.(thisChangeSet?.shift?.()); replaceOptions['from'] = changeLogText?.[0]; replaceOptions['to'] = `${changeLogText?.join?.('\n')}\n`; changelogResult = await replaceInFile?.(replaceOptions); if(changelogResult?.[0]?.['hasChanged']) break; // Step 4: The last resort... simply prepend everything // This should happen only if the CHANGELOG.md file is absolutely empty await prependFile?.(path.join(ctxt?.options?.currentWorkingDirectory, 'CHANGELOG.md'), `${changeLogText?.join?.('\n')}\n`); break; } task.title = 'Create / Modify changelog: Done'; } catch(err) { task.title = 'Create / Modify changelog: Error'; ctxt.execError = err; } } async _commitChangelog(ctxt, task) { try { const path = require('path'); const projectPackageJson = path.join(ctxt?.options?.currentWorkingDirectory, 'package.json'); const pkg = require(projectPackageJson); const git = ctxt?.options?.git; let trailerMessages = await git?.raw?.('interpret-trailers', path.join(__dirname, '../.gitkeep')); trailerMessages = trailerMessages?.replace?.(/\\n/g, '\n')?.replace(/\\t/g, '\t'); const consolidatedMessage = `Changelog for release ${pkg?.version}\n${trailerMessages ?? ''}`; await git?.add?.('.'); await git?.commit?.(consolidatedMessage, ['--no-edit', '--no-verify', '--signoff', '--quiet']); task.title = 'Commit changelog: Done'; } catch(err) { task.title = 'Commit changelog: Error'; ctxt.execError = err; } } _cleanupChangelog(ctxt, task) { ctxt.options.changelogText = null; ctxt.options.gitLogsInRange = null; task.title = 'Clean up: Done'; return; } // #endregion // #region Release Generation Methods async _fetchGitLogsForRelease(ctxt, task) { try { const git = ctxt?.options?.git; // Step 1: Get the repo info for the currentReleaseUpstream const gitRemote = await git?.remote?.(['get-url', '--push', ctxt?.options?.currentReleaseUpstream]); const hostedGitInfo = require('hosted-git-info'); const repository = hostedGitInfo?.fromUrl?.(gitRemote); repository.project = repository?.project?.replace?.('.git\n', ''); // Step 2: Instantiate the relevant Git Host Wrapper const GitHostWrapper = require(`./../git_host_utilities/${repository?.type}`)?.GitHostWrapper; const gitHostWrapper = new GitHostWrapper(ctxt?.options?.[`${repository?.type}Token`]); const lastRelease = await gitHostWrapper?.fetchReleaseInformation?.(repository); // Step 3: Get the commit associated with that last release, if there is one let lastReleasedCommit = null; if(lastRelease) { lastReleasedCommit = await git?.raw?.(['rev-list', '-n', '1', `tags/${lastRelease?.tag}`]); lastReleasedCommit = lastReleasedCommit?.replace?.(/\\n/g, '')?.trim?.(); } // Step 4: Get the commit associated with either the last tag, or the tag specified in the options let lastTag = ctxt?.options?.useTag ?? ''; if(!lastTag?.length) { lastTag = await git?.tag?.(['--sort=-creatordate']); lastTag = lastTag?.split?.(`\n`)?.shift?.()?.trim?.(); } let lastTaggedCommit = null; if(lastTag?.length) { lastTaggedCommit = await git?.raw?.(['rev-list', '-n', '1', `tags/${lastTag}`]); lastTaggedCommit = lastTaggedCommit?.replace?.(/\\n/g, '')?.trim?.(); } ctxt.options.gitLogsInRange = await this?._fetchGitLogs(git, lastReleasedCommit, lastTaggedCommit); task.title = 'Fetch git logs for release: Done'; } catch(err) { task.title = 'Fetch git logs for release: Error'; ctxt.options.gitLogsInRange = { 'all': [] }; ctxt.releaseError = err; } } async _fetchAuthorInformationForRelease(ctxt, task) { try { const git = ctxt?.options?.git; // Step 1: Get the repo info for the currentReleaseUpstream const gitRemote = await git?.remote?.(['get-url', '--push', ctxt?.options?.currentReleaseUpstream]); const hostedGitInfo = require('hosted-git-info'); const repository = hostedGitInfo?.fromUrl?.(gitRemote); repository.project = repository?.project?.replace?.('.git\n', ''); // Step 2: Instantiate the relevant Git Host Wrapper const GitHostWrapper = require(`./../git_host_utilities/${repository?.type}`)?.GitHostWrapper; const gitHostWrapper = new GitHostWrapper(ctxt?.options?.[`${repository?.type}Token`]); const contributorSet = []; let authorProfiles = []; ctxt?.options?.gitLogsInRange?.forEach?.((commitLog) => { if(!contributorSet?.includes?.(commitLog?.['author_email'])) { contributorSet?.push?.(commitLog['author_email']); authorProfiles?.push?.(gitHostWrapper?.fetchCommitAuthorInformation?.(repository, commitLog)); } }); authorProfiles = await Promise?.allSettled?.(authorProfiles); authorProfiles = authorProfiles.map((authorProfile) => { return authorProfile?.value; }) .filter((authorProfile) => { return !!authorProfile?.email?.trim?.()?.length; }); ctxt.options.authorProfiles = authorProfiles; task.title = 'Fetch author information for the relevant git log events: Done'; } catch(err) { task.title = 'Fetch author information for the relevant git log events: Error'; ctxt.options.authorProfiles = []; ctxt.releaseError = err; } } async _generateReleaseNotes(ctxt, task) { try { // Step 1: Bucket the Git Log events based on the Conventional Changelog fields const featureSet = []; const bugfixSet = []; const documentationSet = []; const humanizeString = require('humanize-string'); ctxt?.options?.gitLogsInRange?.forEach?.((commitLog) => { const commitObject = { 'hash': commitLog?.hash, 'component': '', 'message': commitLog?.message, 'author_name': commitLog?.['author_name'], 'author_email': commitLog?.['author_email'], 'author_profile': ctxt?.options?.authorProfiles?.filter?.((author) => { return author?.email === commitLog?.author_email; })?.[0]?.['profile'], 'date': commitLog?.date }; let set = null; if(commitLog?.message?.startsWith?.('feat')) { commitObject.message = commitObject?.message?.replace?.('feat', ''); set = featureSet; } if(commitLog?.message?.startsWith?.('fix')) { commitObject.message = commitObject?.message?.replace?.('fix', ''); set = bugfixSet; } if(commitLog?.message?.startsWith?.('docs')) { commitObject.message = commitObject?.message?.replace?.('docs', ''); set = documentationSet; } if(!commitObject?.message?.startsWith?.('(') && !commitObject?.message?.startsWith?.(':')) return; if(commitObject?.message?.startsWith?.('(')) { const componentClose = commitObject?.message?.indexOf?.(':') - 2; commitObject.component = commitObject?.message?.substr?.(1, componentClose); commitObject.message = commitObject?.message?.substr?.(componentClose + 3); } // eslint-disable-next-line curly if(commitObject?.message?.startsWith?.(':')) { commitObject.message = commitObject?.message?.substr?.(1); } commitObject.message = humanizeString?.(commitObject?.message); set?.push?.(commitObject); }); // Step 2: Compute if this is a pre-release, or a proper release const path = require('path'); const semver = require('semver'); const projectPackageJson = path.join(ctxt?.options?.currentWorkingDirectory, 'package.json'); const { version } = require(projectPackageJson); if(!version) { throw new Error(`package.json at ${projectPackageJson} doesn't contain a version field.`); } if(!semver.valid(version)) { throw new Error(`${projectPackageJson} contains a non-semantic-version format: ${version}`); } const parsedVersion = semver?.parse?.(version); const releaseType = parsedVersion?.prerelease?.length ? 'pre-release' : 'release'; // Step 3: Generate the release notes const gitRemote = await ctxt?.options?.git?.remote?.(['get-url', '--push', ctxt?.options?.currentReleaseUpstream]); const hostedGitInfo = require('hosted-git-info'); const repository = hostedGitInfo?.fromUrl?.(gitRemote); repository.project = repository?.project?.replace?.('.git\n', ''); let lastTag = ctxt?.options?.useTag ?? ''; if(!lastTag?.length) { lastTag = await ctxt?.options?.git?.tag?.(['--sort=-creatordate']); lastTag = lastTag?.split?.(`\n`)?.shift?.()?.trim?.(); } const releaseData = { 'REPO': repository, 'RELEASE_NAME': ctxt?.options?.releaseName, 'RELEASE_TYPE': releaseType, 'RELEASE_TAG': lastTag, 'NUM_FEATURES': featureSet?.length, 'NUM_FIXES': bugfixSet?.length, 'NUM_DOCS': documentationSet?.length, 'NUM_AUTHORS': ctxt?.options?.authorProfiles?.length, 'FEATURES': featureSet, 'FIXES': bugfixSet, 'DOCS': documentationSet, 'AUTHORS': ctxt?.options?.authorProfiles }; const ejs = require('ejs'); const releaseMessagePath = ctxt?.options?.releaseMessage ?? ''; releaseData['RELEASE_NOTES'] = await ejs?.renderFile?.(releaseMessagePath, releaseData, { 'async': true, 'cache': false, 'debug': false, 'rmWhitespace': false, 'strict': false }); ctxt.options.releaseData = releaseData; task.title = 'Generate release notes: Done'; } catch(err) { task.title = 'Generate release notes: Error'; ctxt.options.releaseData = null; ctxt.releaseError = err; } } async _createRelease(ctxt, task) { try { // Step 1: Get the repo info for the currentReleaseUpstream const gitRemote = await ctxt?.options?.git?.remote?.(['get-url', '--push', ctxt?.options?.currentReleaseUpstream]); const hostedGitInfo = require('hosted-git-info'); const repository = hostedGitInfo?.fromUrl?.(gitRemote); repository.project = repository?.project?.replace?.('.git\n', ''); // Step 2: Instantiate the relevant Git Host Wrapper const GitHostWrapper = require(`./../git_host_utilities/${repository?.type}`)?.GitHostWrapper; const gitHostWrapper = new GitHostWrapper(ctxt?.options?.[`${repository?.type}Token`]); await gitHostWrapper?.createRelease?.(ctxt?.options?.releaseData); task.title = 'Push release: Done'; } catch(err) { task.title = 'Push release: Error'; ctxt.releaseError = err; } } async _storeReleaseNotes(ctxt, task) { try { const mkdirp = require('mkdirp'); await mkdirp(ctxt?.options?.outputPath); for(let idx = 0; idx < ctxt?.options?.outputFormat?.length; idx++) { const thisOutputFormat = ctxt?.options?.outputFormat?.[idx]; switch (thisOutputFormat) { case 'json': await this?._storeJsonReleaseNotes?.(ctxt); break; case 'pdf': await this?._storePdfReleaseNotes?.(ctxt); break; default: break; } } task.title = 'Store release notes: Done'; } catch(err) { task.title = 'Store release notes: Error'; ctxt.releaseError = err; } } async _storeJsonReleaseNotes(ctxt) { const upstreamReleaseData = JSON?.parse?.(JSON?.stringify?.(ctxt?.options?.releaseData)); delete upstreamReleaseData['RELEASE_NOTES']; const path = require('path'); const filePath = path?.join?.(ctxt?.options?.outputPath, `${ctxt?.options?.currentReleaseUpstream}-release-notes-${upstreamReleaseData?.['RELEASE_NAME']?.toLowerCase?.()?.replace?.(/ /g, '-')}.json`); const fs = require('fs/promises'); // eslint-disable-next-line security/detect-non-literal-fs-filename await fs?.writeFile?.(filePath, JSON?.stringify?.(upstreamReleaseData, null, '\t')); } async _storePdfReleaseNotes(ctxt) { const { mdToPdf } = require('md-to-pdf'); const upstreamReleaseData = ctxt?.options?.releaseData; const pdf = await mdToPdf({ 'content': upstreamReleaseData?.['RELEASE_NOTES'] }); const path = require('path'); const filePath = path?.join?.(ctxt?.options?.outputPath, `${ctxt?.options?.currentReleaseUpstream}-release-notes-${upstreamReleaseData?.['RELEASE_NAME']?.toLowerCase?.()?.replace?.(/ /g, '-')}.pdf`); const fs = require('fs/promises'); // eslint-disable-next-line security/detect-non-literal-fs-filename await fs?.writeFile?.(filePath, pdf?.content); } // #endregion // #region Utility Methods async _sleep(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } // #endregion // #region Private Fields #execMode = null; // #endregion } // Add the command to the cli exports.commandCreator = function commandCreator(commanderProcess, configuration) { const Commander = require('commander'); const release = new Commander.Command('release'); // Get package.json into memory... we'll use it in multiple places here const path = require('path'); const projectPackageJson = path.join((configuration?.release?.currentWorkingDirectory?.trim?.() ?? process.cwd()), 'package.json'); let pkg = null; try { pkg = require(projectPackageJson); } catch(err) { // Do nothing pkg = null; } if(pkg) { // Get the dynamic template filler - use it for configuration substitution const fillTemplate = require('es6-dynamic-template'); if(configuration?.release?.currentWorkingDirectory) { configuration.release.currentWorkingDirectory = fillTemplate?.(configuration?.release?.currentWorkingDirectory, pkg); } if(configuration?.release?.commitMessage) { configuration.release.commitMessage = fillTemplate?.(configuration?.release?.commitMessage, pkg); } if(configuration?.release?.useTag) { configuration.release.useTag = fillTemplate?.(configuration?.release?.useTag, pkg); } if(configuration?.release?.tagName) { configuration.release.tagName = fillTemplate?.(configuration?.release?.tagName, pkg); } if(configuration?.release?.tagMessage) { configuration.release.tagMessage = fillTemplate?.(configuration?.release?.tagMessage, pkg); } if(configuration?.release?.releaseName) { configuration.release.releaseName = fillTemplate?.(configuration?.release?.releaseName, pkg); } if(configuration?.release?.releaseMessage) { configuration.release.releaseMessage = fillTemplate?.(configuration?.release?.releaseMessage, pkg); } if(configuration?.release?.outputPath) { configuration.release.outputPath = fillTemplate?.(configuration?.release?.outputPath, pkg); } } // Setup the command release?.alias?.('rel'); release ?.option?.('--current-working-directory <folder>', 'Path to the current working directory', configuration?.release?.currentWorkingDirectory?.trim?.() ?? process?.cwd?.()) ?.option?.('--commit', 'Commit code if branch is dirty', configuration?.release?.commit ?? false) ?.option?.('--commit-message', 'Commit message if branch is dirty. Ignored if --commit is not passed in', configuration?.release?.commitMessage ?? '') ?.option?.('--no-tag', 'Don\'t tag now. Use last tag when cutting this release', configuration?.release?.tag ?? false) ?.option?.('--use-tag <name>', 'Use the (existing) tag specified when cutting this release', configuration?.release?.useTag?.trim?.() ?? '') ?.option?.('--tag-name <name>', 'Tag Name to use for this release', configuration?.release?.tagName?.trim?.() ?? `V${(pkg ? pkg.version : '') ?? ''}`) ?.option?.('--tag-message <message>', 'Message to use when creating the tag.', configuration?.release?.tagMessage?.trim?.() ?? `The spaghetti recipe at the time of releasing V${(pkg ? pkg.version : '') ?? ''}`) ?.option?.('--no-release', 'Don\'t release now. Simply tag and exit', configuration?.release?.release ?? false) ?.option?.('--release-name <name>', 'Name to use for this release', configuration?.release?.releaseName?.trim?.() ?? `V${(pkg ? pkg.version : '') ?? ''} Release`) ?.option?.('--release-message <path to release notes EJS>', 'Path to EJS file containing the release message/notes, with/without a placeholder for auto-generated metrics', configuration?.release?.releaseMessage?.trim?.() ?? '') ?.option?.('--output-format <json|pdf|all>', 'Format(s) to output the generated release notes', configuration?.release?.outputFormat?.trim?.() ?? 'none') ?.option?.('--output-path <release notes path>', 'Path to store the generated release notes at', configuration?.release?.outputPath?.trim?.() ?? '.') ?.option?.('--upstream <remotes-list>', 'Comma separated list of git remote(s) to push the release to', configuration?.release?.upstream?.trim?.() ?? 'upstream') ?.option?.('--github-token <token>', 'Token to use for creating the release on GitHub', configuration?.release?.githubToken?.trim?.() ?? process.env.GITHUB_TOKEN ?? 'PROCESS.ENV.GITHUB_TOKEN') ?.option?.('--gitlab-token <token>', 'Token to use for creating the release on GitLab', configuration?.release?.gitlabToken?.trim?.() ?? process.env.GITLAB_TOKEN ?? 'PROCESS.ENV.GITLAB_TOKEN') ; const commandObj = new ReleaseCommandClass('cli'); release?.action?.(commandObj?.execute?.bind?.(commandObj, configuration?.release)); // Add it to the mix commanderProcess?.addCommand?.(release); return; }; // Export the API for usage by downstream programs exports.apiCreator = function apiCreator() { const commandObj = new ReleaseCommandClass('api'); return { 'name': 'release', 'method': commandObj?.execute?.bind?.(commandObj) }; };