UNPKG

remark-common-changelog

Version:

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

488 lines (391 loc) 15.4 kB
import { is } from 'unist-util-is' import { u } from 'unist-builder' import semver from 'semver' import Githost from 'find-githost' import closest from 'read-closest-package' import path from 'path' import { execFileSync } from 'child_process' import Changelog from './lib/changelog.js' import getCommits from './lib/git-log-between.js' import getChanges from './lib/get-changes.js' const plugin = 'remark-common-changelog' const REJECT_NAMES = new Set(['history', 'releases', 'changelog']) const GROUP_TYPES = new Set(['Changed', 'Added', 'Deprecated', 'Removed', 'Fixed', 'Security']) const UNCATEGORIZED = 'Uncategorized' export default function attacher (opts) { opts = opts || {} const fix = !!opts.fix const submodules = !!opts.submodules const add = opts.add const parse = (str) => this.parse(str).children return async function transform (root, file) { if (file.basename && file.basename !== 'CHANGELOG.md') { if (REJECT_NAMES.has(file.stem.toLowerCase())) { warn('Filename must be CHANGELOG.md', root, 'filename') } return } if (!is(root, 'root') || !root.children) { throw new Error('Expected a root node') } const cwd = path.resolve(opts.cwd || file.cwd) const pkg = lazyPkg(cwd, opts.pkg) const githubUrl = repo(cwd, opts, pkg) // NOTE: tags cannot be used in lint mode because CI like GitHub Actions // commonly uses shallow git checkouts without tags. const tags = gitTags(cwd) const changelog = Changelog(parse, root.children) const versions = new Set() if (fix) { changelog.buildHeading() } else if (!changelog.hasValidHeading()) { warn('Changelog must start with a top-level "Changelog" heading', changelog.heading || root, 'title') } if (fix) { if (add) { addRelease(add, true) } changelog.children.sort(cmpRelease) } else if (!isSorted(changelog.children, cmpRelease)) { warn('Releases must be sorted latest-first', root, 'latest-release-first') // Sort anyway (doesn't affect original tree) so that we // can correctly compute diff urls and commit ranges below. changelog.children.sort(cmpRelease) } if (fix) { // Only needed in fix mode and in lint mode we may not have tags. changelog.children.forEach(relateVersions) } await Promise.all(changelog.children.map(lintRelease)) // Lint or rebuild headings, with links and definitions for (let i = 0; i < changelog.children.length; i++) { const { version, date, linkType, heading } = changelog.children[i] if (!version) continue const identifier = version.toLowerCase() const oldUrl = (changelog.definitions.get(identifier) || {}).url const isFirstRelease = i === changelog.children.length - 1 if (fix) { const label = identifier const referenceType = 'shortcut' const url = oldUrl || defaultReleaseUrl(githubUrl, tags, version) heading.children = [u('linkReference', { identifier, label, referenceType }, [ u('text', version) ])] heading.children.push(u('text', ` - ${date || 'YYYY-MM-DD'}`)) changelog.definitions.set(identifier, u('definition', { identifier, label, url, title: null })) } else if (!isFirstRelease) { if (!linkType) { warn('Release version must have a link', heading, 'release-version-link') } else if (linkType !== 'linkReference') { warn('Use link reference in release heading', heading, 'release-version-link-reference') } } } if (fix) { changelog.definitions = sortMap(changelog.definitions, cmpVersion) } else if (!isMapSorted(changelog.definitions, cmpVersion)) { warn('Definitions must be sorted latest-first', root, 'latest-definition-first') } if (fix) { // Reconstruct tree root.children = changelog.tree() return root } // Add previousVersion property to releases, used to find commits between releases function relateVersions (release, i, arr) { release.previousVersion = arr[i + 1] ? arr[i + 1].version : null if (release.version) { // For when not all tags have releases, find a tag between this release and the previous // TODO: use a binary search const ti = tags.findIndex(el => el.version === release.version) const previousTag = ti >= 0 ? tags[ti + 1] && tags[ti + 1].version : tags[0] && tags[0].version const gt = (v) => release.previousVersion ? semver.gt(v, release.previousVersion) : true // Take it if previous release < previous tag < version if (previousTag && gt(previousTag) && semver.gt(release.version, previousTag)) { release.previousVersion = previousTag } } } function addRelease (add, asReleaseType) { if (Array.isArray(add)) { add.forEach(x => addRelease(x, asReleaseType)) return } else if (typeof add === 'object' && add !== null) { // NOTE: experimental and undocumented const range = { gte: null, lte: null } for (const k of ['gte', 'lte']) { if (add[k]) { range[k] = typeof add[k] === 'string' ? semver.parse(add[k]) : null if (!range[k]) { warn('The `' + k + '` option must be a semver-valid version', root, 'add-new-release') return } } } const versions = tags.map(t => t.version) const matches = versions.filter(rangeFilter(range)) addRelease(matches, false) return } else if (typeof add !== 'string' || add === '') { warn('Target must be a non-empty string', root, 'add-new-release') return } let target = semver.valid(add) const specificVersion = !!target if (!target && asReleaseType) { // Determine current version if possible. If none yet, use 0.0.0. let from = opts.version || pkg().version || nearestTaggedVersion(cwd) || '0.0.0' // Take version of last release if greater than current version const lastRelease = changelog.children[0] && changelog.children[0].version if (lastRelease && semver.gt(lastRelease, from)) { from = lastRelease } if (semver.valid(from) !== from) { throw new Error(`Current version is not semver-valid: ${from}`) } target = semver.inc(from, add) } if (!target) { warn(`Target (${add}) must be a version or release type ([pre]major, [pre]minor, [pre]patch or prerelease)`, root, 'add-new-release') return } else if (changelog.children.some(release => release.version === target)) { warn(`Target version ${target} already exists`, root, 'add-new-release') return } // Take date from tag if it exists and was annotated const date = specificVersion ? tagDate(cwd, 'v' + target) : null const Ctor = opts.Date || Date // Will be sorted and populated by other code changelog.createRelease(target, releaseDate(date || new Ctor())) } async function lintRelease (release) { const { heading } = release if (!is(heading, { depth: 2 })) { warn('Release must start with second-level heading', heading, 'release-heading-depth') return } else if (!release.parseable) { warn('Release heading must have the format "<version> - <date>"', heading, 'release-heading') return } if (release.version) { if (versions.has(release.version)) { warn('Release version must be unique', heading, 'unique-release') } versions.add(release.version) } if (!release.version) { warn('Release must have a version', heading, 'release-version') } else if (semver.valid(release.version) !== release.version) { warn('Release version must be semver-valid', heading, 'release-version') } if (!release.date) { warn('Release must have date', heading, 'release-date') } else if (!/^\d{4}-\d{2}-\d{2}$/.test(release.date)) { warn('Release date must have format YYYY-MM-DD', heading, 'release-date') } if (release.isEmpty()) { await lintEmptyRelease(release) } const hasUncategorizedChanges = release.children.some(function (group) { return group.type() === UNCATEGORIZED && !group.isEmpty() }) release.children.forEach(function (group) { lintGroup(group, hasUncategorizedChanges) }) } async function lintEmptyRelease (release) { const { heading, version, previousVersion } = release if (fix && version && previousVersion) { const populate = opts.commits !== false let commits = [] if (populate) { const gt = forgivingTag(previousVersion, tags) const xopts = { cwd, gt, limit: 100, submodules } const lt = tags.find(el => el.version === version) if (lt) { // Take commits up until but excluding the tag xopts.lt = lt.tag } else { // If not tagged, assume version is new xopts.lte = 'HEAD' } try { commits = await getCommits(xopts) } catch (err) { const hint = `> ${xopts.gt} ` + (xopts.lt ? `< ${xopts.lt}` : `<= ${xopts.lte}`) const msg = `Failed to get commits for release (${version}) (${hint}): ${err.message}` warn(msg, heading, 'no-empty-release') return } } const grouped = getChanges(commits) // Add other types as a hint to categorize const insertEmpty = populate ? grouped[UNCATEGORIZED].length > 0 : true const notice = [] for (const type in grouped) { const changes = grouped[type] if (!changes.length && (!insertEmpty || type === UNCATEGORIZED)) { continue } const group = release.createGroup(type) if (changes.length) { group.createList(changes) for (const change of changes) { if (change.notice) { notice.push(change.notice) } } } } if (populate && notice.length > 0) { release.createNotice(notice.map(sentence).join(' ')) } if (!release.isEmpty()) return } else if (fix && (version === '0.0.1' || version === '0.1.0' || version === '1.0.0')) { release.createNotice(':seedling: Initial release.') return } warn(`Release (${version || 'n/a'}) is empty`, heading, 'no-empty-release') } function lintGroup (group, hasUncategorizedChanges) { if (!group.hasValidHeading()) { warn('Group must start with a third-level, text-only heading', group.heading, 'group-heading') return } const type = group.type() const types = Array.from(GROUP_TYPES).join(', ') if (!type) { warn(`Group heading must be one of ${types}`, group.heading, 'group-heading-type') } else if ((type === UNCATEGORIZED || !hasUncategorizedChanges) && group.isEmpty()) { warn(`Remove or fill empty group ${type}`, group.heading, 'no-empty-group') } else if (!GROUP_TYPES.has(type)) { if (type === UNCATEGORIZED) { warn('Categorize the changes', group.heading, 'no-uncategorized-changes') } else { warn(`Group heading must be one of ${types}`, group.heading, 'group-heading-type') } } } function warn (msg, node, rule) { file.message(msg, node, `${plugin}:${rule}`) } function lazyPkg (cwd, pkg) { return function () { pkg = pkg || closest.sync({ cwd }) || {} return pkg } } } } function cmpRelease (a, b) { // Retain original sort order of invalid releases if (!a.version || !b.version) return a.index - b.index return cmpVersion(a.version, b.version) } function cmpVersion (a, b) { if (a === b) return 0 let av = semver.valid(a) let bv = semver.valid(b) // Make -rc9 vs -rc10 sortable by converting to (proper) -rc.9 vs -rc.10 if (av) av = av.replace(/-rc(\d+)$/, (m, p1) => '-rc.' + p1) if (bv) bv = bv.replace(/-rc(\d+)$/, (m, p1) => '-rc.' + p1) return av && bv ? semver.compare(bv, av) : av ? -1 : bv ? 1 : a.localeCompare(b) } // TODO: use a binary search function rangeFilter (range) { return function filter (v) { return ( (range.gte == null || semver.gte(v, range.gte)) && (range.lte == null || semver.lte(v, range.lte)) ) } } function defaultReleaseUrl (githubUrl, tags, version) { return `${githubUrl}/releases/tag/${forgivingTag(`v${version}`, tags)}` } // If a (historical) tag without "v" prefix exists, use that. function forgivingTag (tag, tags) { if (tag[0] !== 'v') tag = 'v' + tag const match = tags.find(el => el.normalTag === tag) if (match) return match.tag return tag } function gitTags (cwd) { const output = execFileSync('git', ['tag'], { cwd, maxBuffer: 1024 * 1024 * 16, encoding: 'utf8' }) const tags = output.split(/\r?\n/).map(tag => { const version = tag && semver.valid(tag) return version ? { tag, normalTag: 'v' + version, version } : null }) return tags.filter(Boolean).sort((a, b) => cmpVersion(a.version, b.version)) } // TODO: use isomorphic-git if possible and if faster function nearestTaggedVersion (cwd) { let output try { // Should only consider current branch. I.e. if a semver-later tag exists // but points to a commit on a different branch, ignore it. output = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { cwd, encoding: 'utf8', // Swallow stderr stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { // If no tags exist return null } return (output && semver.valid(output)) || null } function tagDate (cwd, tag) { try { const iso = execFileSync('git', ['log', '-1', '--format=%aI', tag], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() return iso ? new Date(iso) : null } catch { return null } } function repo (cwd, options, pkg) { if (options.repository) { return Githost.fromUrl(options.repository, { committish: false }).homepage() } const host = ( Githost.fromPkg(pkg(), { committish: false, optional: true }) || Githost.fromGit(cwd, { committish: false }) ) return host.homepage() } function isSorted (array, comparator) { for (let i = 0; i < array.length - 1; i++) { if (comparator(array[i], array[i + 1]) > 0) { return false } } return true } function isMapSorted (map, comparator) { return isSorted(Array.from(map.keys()), comparator) } function sortMap (map, comparator) { const entries = Array.from(map.entries()) entries.sort((a, b) => comparator(a[0], b[0])) return new Map(entries) } function releaseDate (date) { const yyyy = date.getFullYear() const mm = twoDigits(date.getMonth() + 1) const dd = twoDigits(date.getDate()) return `${yyyy}-${mm}-${dd}` } function twoDigits (n) { return n < 10 ? `0${n}` : n } function sentence (str) { str = str.trim() return str.endsWith('.') ? str : str + '.' }