UNPKG

nlm

Version:

Lifecycle manager for node projects

499 lines (428 loc) â€ĸ 12.1 kB
/* * Copyright (c) 2015, Groupon, Inc. * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * * Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * Neither the name of GROUPON nor the names of its contributors may be * used to endorse or promote products derived from this software without * specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ 'use strict'; /** * @typedef PR * @property {number} pullId * @property {{name: string, href: string}} author * @property {string} href * @property {string} title * @property {string[]} shas * @property {Commit[]} commits */ /** * @typedef Commit * @extends PR * @property {string} type * @property {string} header * @property {string} subject * @property {string} sha * @property {{title: string, text:string}[]} notes * @property {{prefix: string, issue: number, href: string}[]} references */ /** * @typedef RepoInfo * @property {string} htmlBase * @property {string} username * @property {string} repository */ /** * @typedef NlmOptions * @property {{skip?: boolean, set: {[type: string]: string}}} emoji * @property {{verbose?: boolean, omit?: string[]}} changelog */ const debug = require('debug')('nlm'); const replaceAll = require('string.prototype.replaceall'); const Github = require('../github/client'); const parseRepository = require('../github/parse-repository'); const DEP_REGEXP = /(`)?([@\w\/-_.]+[@\s]v?\d+[0-9x.]+)(`)?/gim; const emojiMaps = new Map([ [ 'default', { // angular types: breaking: 'đŸ’Ĩ', feat: '🚀', perf: '⚡', refactor: 'đŸ“Ļī¸', fix: '🐛', revert: 'â†Šī¸', style: '💅', docs: '📝', // internal types: deps: 'đŸ”ŧ', internal: '🏡', }, ], ]); /** * @param {string} type * @param {{nlmOptions?: NlmOptions}} options * @return {string} */ function getTypeCategory(type, options) { const { nlmOptions = {} } = options; const { emoji: emojiOpts = {} } = nlmOptions; let descr; const headlineLevel = '####'; switch (type) { case 'breaking': descr = 'Breaking Changes'; break; case 'feat': descr = 'New Features'; break; case 'perf': descr = 'Performance Improvements'; break; case 'refactor': descr = 'Code Refactoring'; break; case 'fix': descr = 'Bug Fixes'; break; case 'deps': descr = 'Dependencies'; break; case 'revert': descr = 'Reverts'; break; case 'docs': descr = 'Documentation'; break; case 'style': descr = 'Polish'; break; default: descr = 'Internal'; } const emojiSet = { ...emojiMaps.get('default'), ...(emojiOpts.set || {}) }; const emoji = !emojiOpts.skip ? emojiSet[type] || emojiSet['internal'] : ''; return (emoji ? [headlineLevel, emoji, descr] : [headlineLevel, descr]).join( ' ' ); } /** * @param {*[]} data * @return {*[]} */ function sortByType(data) { if (!data.length) { return []; } const sorted = []; [ 'breaking', 'feat', 'perf', 'refactor', 'fix', 'deps', 'revert', 'style', 'docs', ].forEach(type => { data = data.reduce((acc, entry) => { if (entry[0] === type) { sorted.push(entry); } else { acc.push(entry); } return acc; }, []); }); return sorted.concat(data); } /** * @param {string} title * @return {string} */ function getType(title) { const match = title.match(/^(\w+)[:(]/); let type = match && match[1]; if (findDependency(title) && type === 'chore') { type = 'deps'; } return type || 'internal'; } /** * @param {String} str * @return {RegExpMatchArray|null} */ function findDependency(str) { return str.match(DEP_REGEXP); } /** * * @param {Object} github * @param {Commit[]} commits * @param {PR} pr * @return {Promise<void>} */ function addPullRequestCommits(github, commits, pr) { pr.commits = null; pr.shas = null; return Promise.all([ github.pull.get(pr.pullId), github.pull.commits(pr.pullId), ]) .then(([info, prCommits]) => { pr.author = { name: info.user.login, href: info.user.html_url, }; pr.href = info.html_url; pr.title = info.title || info.header; pr.shas = prCommits.map(c => c.sha); pr.commits = commits.filter(commit => pr.shas.includes(commit.sha)); }) .catch(err => { if (err.statusCode !== 404) throw err; // If the PR doesn't exist, handle it gracefully. }); } /** * @param {*[]} arrs * @return {*[]} */ function flatten(arrs) { return [].concat.apply([], arrs); } /** * @param {PR[]} prs * @param {Commit[]} commits * @return {*[]|Commit[]} */ function removePRCommits(prs, commits) { const prShas = flatten(prs.map(pr => pr.shas)); return commits.filter( commit => commit.type !== 'pr' && !prShas.includes(commit.sha) ); } /** * @param {Commit} commit * @return {*[]|{commit: Commit, text: string}[]} */ function extractBreakingChanges(commit) { if (!(commit.notes && commit.notes.length)) { return []; } return commit.notes .filter(n => n.title.startsWith('BREAKING CHANGE')) .map(note => { return { text: note.text, commit, }; }); } /** * @param {PR[]} prs */ function removeInvalidPRs(prs) { // Warning: We're doing something evil here and mutate the input array. const filtered = prs.filter(pr => { return pr.shas && pr.shas.length === pr.commits.length; }); prs.length = filtered.length; Object.assign(prs, filtered); } /** * @param {{prefix: string, issue: number, href: string}[]} refs * @return {string} */ function formatReferences(refs) { if (!refs || refs.length === 0) { return ''; } const references = refs.map(ref => { return `[${ref.prefix}${ref.issue}](${ref.href})`; }); return ` - see: ${references.join(', ')}`; } /** * @param {Commit} commit * @param {RepoInfo} repoInfo * @return {string} */ function getCommitLink(commit, repoInfo) { const abbr = commit.sha.substr(0, 7); const href = [ repoInfo.htmlBase, repoInfo.username, repoInfo.repository, 'commit', commit.sha, ].join('/'); return `[\`${abbr}\`](${href})`; } /** * @param {PR[]} prs * @param {Commit[]} commits * @return {*[]|(string)[][]} */ function getBreakingChanges(prs, commits) { const breaking = flatten(commits.map(extractBreakingChanges)); if (!breaking.length) { return []; } let breakingChanges; if (prs.length) { breakingChanges = prs .reduce((acc, pr) => { const associatedCommits = breaking.filter(change => { return pr.shas.includes(change.commit.sha); }); if (associatedCommits.length) { const titleLine = `[#${pr.pullId}](${pr.href}) ${pr.title} ([@${pr.author.name}](${pr.author.href}))`; acc.push( [titleLine, '', ...associatedCommits.map(({ text }) => text)].join( '\n' ) ); } return acc; }, []) .join('\n\n'); } else { breakingChanges = breaking.map(({ text }) => text).join('\n'); } return [['breaking', breakingChanges]]; } /** * @param {Commit} commit * @param {RepoInfo} repoInfo * @return {string} */ function formatCommit(commit, repoInfo) { let subject; if (commit.type) { subject = `${commit.type}: ${commit.subject}`; } else { subject = commit.header; } if (findDependency(subject)) { subject = replaceAll(subject, DEP_REGEXP, '`$2`'); } return `${getCommitLink(commit, repoInfo)} ${subject}${formatReferences( commit.references )}`; } /** * @param {PR} pr * @param {{nlmOptions?: NlmOptions}} options * @param {RepoInfo} repoInfo * @return {string} */ function formatPR(pr, options, repoInfo) { const { nlmOptions = {} } = options; const changes = nlmOptions.changelog && nlmOptions.changelog.verbose ? pr.commits.map(commit => { return ` - ${formatCommit(commit, repoInfo)}`; }) : []; const titleLine = `[#${pr.pullId}](${pr.href}) ${pr.title} ([@${pr.author.name}](${pr.author.href}))`; return [titleLine].concat(changes).join('\n'); } /** * @param {PR[]} rawPRs * @param {Commit[]} rawCommits * @param {{nlmOptions?: NlmOptions}} options * @param {RepoInfo} repoInfo * @return {*[]|[string, string][]} */ function format(rawPRs, rawCommits, options, repoInfo) { const orphansCommits = rawCommits.map(commit => [ getType(`${commit.type}: ${commit.subject}`), formatCommit(commit, repoInfo), ]); return rawPRs .map(pr => [getType(pr.title), formatPR(pr, options, repoInfo)]) .concat(orphansCommits) .map(([type, line]) => [type, `* ${line}`]); } /** * @param {[string, string][]|*[]} data * @param {{nlmOptions?: NlmOptions}} options * @return {string} */ function mergeChangelog(data, options) { const { nlmOptions = {} } = options; const changelog = new Map(); const sorted = sortByType(data); for (const [type, entry] of sorted) { const category = getTypeCategory(type, options); // filter out types if (nlmOptions.changelog && nlmOptions.changelog.omit) { if (nlmOptions.changelog.omit.includes(type)) continue; } if (!changelog.has(category)) { changelog.set(category, [entry]); } else { changelog.set(category, changelog.get(category).concat(entry)); } } return [...changelog] .map(([headline, entries]) => `${headline}\n\n${entries.join('\n')}`) .join('\n\n'); } /** * @param {*} cwd * @param {{repository: string|{url: string}}} pkg * @param {{commits: Commit[], nlmOptions?: NlmOptions, changelog?: string}} options * @return {Promise<string>} */ async function generateChangeLog(cwd, pkg, options) { debug.extend('step')('generate changelog'); const { commits } = options; const repoInfo = parseRepository(pkg.repository); const github = Github.forRepository(pkg.repository); /** @type {PR[]} */ const prs = commits.filter(c => c.type === 'pr' && c.pullId); // step1: fetch PR commits data from GH & match with commits for (const pr of prs) { await addPullRequestCommits(github, commits, pr); } // step2: remove PRs without commits removeInvalidPRs(prs); // step3: remove commits of type `pr` const cleanedCommits = removePRCommits(prs, commits); // step4: generate PRs / commits changelog entries const data = format(prs, cleanedCommits, options, repoInfo); // step5: scan commits for breaking changes const breakingChanges = getBreakingChanges(prs, commits); // step6: build changelog options.changelog = mergeChangelog(breakingChanges.concat(data), options); return options.changelog; } generateChangeLog.emojiMaps = emojiMaps; module.exports = generateChangeLog;