UNPKG

remark-common-changelog

Version:

Lint or fix a changelog written in markdown, following Common Changelog.

213 lines (174 loc) 5.83 kB
const CATEGORIES = new Map([ ['change', 'Changed'], ['addition', 'Added'], ['removal', 'Removed'], ['fix', 'Fixed'], ['uncategorized', 'Uncategorized'], // Means to exclude commit ['none', null] ]) const TRAILER_CATEGORY = 1 const TRAILER_NOTICE = 2 const TRAILER_CO_AUTHOR = 3 const TRAILER_REF = 4 const TRAILER_EXCLUDE = 5 export default function getChanges (commits) { const grouped = { Changed: [], Added: [], Removed: [], Fixed: [], Uncategorized: [] } for (const commit of commits) { if (commit.isMergeCommit) { continue } let title = commit.title const shortRef = commit.oid.slice(0, 7) const author = isBot(commit.author) ? merger(commit) : commit.author const metadata = getMetadata(commit.description || '', author) // Add references to GH PRs and commits, and include links if we know the // github repository, else let remark-github handle that. For submodules, // remark-github lacks the necessary context and would link to the wrong // repo. Plus, for commit references, we can shorten the link text a bit, // e.g. [abcdef0] rather than [owner/repo@abcdef0]. if (commit.ghrepo) { title = title.replace(/#\d+/g, (ref) => { return issueLink(commit.ghrepo, ref.slice(1)) }) } title = prefixTitle(title, commit.submodule) if (metadata.references.length > 0) { const refs = metadata.references.map(ref => { if (commit.ghrepo && ref.startsWith('#')) { return issueLink(commit.ghrepo, ref.slice(1)) } else { return ref } }) title += ` (${refs.join(', ')})` } if (commit.ghrepo) { if (commit.pr) title += ` (${issueLink(commit.ghrepo, commit.pr)})` else if (shortRef) title += ` (${commitLink(commit.ghrepo, shortRef)})` } else if (!commit.submodule) { if (commit.pr) title += ` (#${commit.pr})` else if (shortRef) title += ` (${shortRef})` } if (metadata.authors.length > 0) { title += ` (${metadata.authors.join(', ')})` } const category = metadata.category ? CATEGORIES.get(metadata.category) : 'Uncategorized' if (category) { grouped[category].push({ title, description: metadata.description, notice: metadata.notice }) } } return grouped } function issueLink (repo, issue) { return `[#${issue}](https://github.com/${repo}/issues/${issue})` } function commitLink (repo, commit) { return `[\`${commit}\`](https://github.com/${repo}/commit/${commit})` } function prefixTitle (title, subsystem) { if (/^breaking:/i.test(title)) { title = title.slice(9).trim() subsystem = subsystem ? `${subsystem} (breaking)` : 'Breaking' } return subsystem ? `**${subsystem}:** ${title}` : title } function getMetadata (description, author) { const metadata = { description: null, category: null, notice: null, references: [], authors: null } const lines = description.split(/\r?\n/) const authors = new Set() // Rebuild a clean description description = '' // Parse git trailers (https://git-scm.com/docs/git-interpret-trailers) for (let i = 0; i < lines.length; i++) { const line = lines[i] const pos = line.indexOf(':') const key = pos > 0 ? getTrailerKey(line.slice(0, pos)) : null if (!key) { description += line + '\n' continue } let value = line.slice(pos + 1).trim() // Take multiline value while (i + 1 < lines.length && lines[i + 1].startsWith(' ')) { if (value.length !== 0) value += ' ' value += lines[++i].trim() } switch (key) { case TRAILER_CATEGORY: { if (value.endsWith('.')) value = value.slice(0, value.length - 1) if (CATEGORIES.has(value)) metadata.category = value break } case TRAILER_NOTICE: { if (value) metadata.notice = value break } case TRAILER_CO_AUTHOR: { // Loosely parse "name <email>" because we only want the name anyway const sep = value.indexOf('<') if (sep > 0) { const author = value.slice(0, sep).trim() if (author.length !== 0 && !isBot({ name: author })) { authors.add(author) } } break } case TRAILER_REF: { if (value.endsWith('.')) value = value.slice(0, value.length - 1) const refs = value.split(/\s*,\s*/) metadata.references.push(...refs.filter(isNumericReference)) break } } } // Exclude bot authors unless that's the only author if (author && author.name && (authors.size === 0 || !isBot(author)) && !authors.has(author.name)) { metadata.authors = [author.name, ...authors] } else { metadata.authors = Array.from(authors) } metadata.description = description.trim() return metadata } function merger (commit) { return commit.mergeCommit ? commit.mergeCommit.author : commit.committer } function isBot (author) { return author.name === 'Greenkeeper' || author.name === 'greenkeeper[bot]' || (author.email && author.email.endsWith('@greenkeeper.io')) || author.name === 'dependabot[bot]' || author.name === 'github-actions' } function getTrailerKey (key) { if (/^Category$/i.test(key)) return TRAILER_CATEGORY if (/^Notice$/i.test(key)) return TRAILER_NOTICE if (/^Co-Authored-By$/i.test(key)) return TRAILER_CO_AUTHOR if (/^(Ref|Refs|Fixes|Closes|CVE-ID)$/i.test(key)) return TRAILER_REF // Parsed only to exclude them from the changelog if (/^(Reviewed-By|Signed-Off-By|Acked-By)$/i.test(key)) return TRAILER_EXCLUDE } function isNumericReference (ref) { // E.g. #123, GH-123, JIRA-123, CVE-2024-123. return /^(#\d+|[a-z]{2,4}-\d+|CVE-\d+-\d+)$/i.test(ref) }