import type { GitCommit } from 'changelogen' import { execSync } from 'child_process' import fse from 'fs-extra' import minimist from 'minimist' import open from 'open' import semver from 'semver' import { getLatestCommits } from './getLatestCommits.js' import { getRecommendedBump } from './getRecommendedBump.js' type Args = { fromVersion?: string toVersion?: string bump?: 'major' | 'minor' | 'patch' | 'prerelease' dryRun?: boolean openReleaseUrl?: boolean writeChangelog?: boolean } type ChangelogResult = { /** * URL to open releases/new with the changelog pre-filled */ releaseUrl: string /** * The changelog content, does not include contributors */ changelog: string /** * The release notes, includes contributors. This is the content used for the releaseUrl */ releaseNotes: string } export const updateChangelog = async (args: Args = {}): Promise => { const { toVersion = 'HEAD', dryRun, bump, openReleaseUrl, writeChangelog } = args const fromVersion = args.fromVersion || execSync('git describe --match "v*" --tags --abbrev=0').toString().trim() const tag = fromVersion.match(/-(\w+)\.\d+$/)?.[1] || 'latest' const recommendedBump = tag !== 'latest' ? 'prerelease' : await getRecommendedBump(fromVersion, toVersion) if (bump && bump !== recommendedBump) { console.log(`WARNING: Recommended bump is '${recommendedBump}', but you specified '${bump}'`) } const calculatedBump = bump || recommendedBump const proposedReleaseVersion = 'v' + semver.inc(fromVersion, calculatedBump, undefined, tag) console.log({ tag, recommendedBump, fromVersion, toVersion, proposedVersion: proposedReleaseVersion, }) const conventionalCommits = await getLatestCommits(fromVersion, toVersion) type SectionKey = 'breaking' | 'feat' | 'fix' | 'perf' const sections = conventionalCommits.reduce( (sections, c) => { if (c.isBreaking) { sections.breaking.push(c) } if (['feat', 'fix', 'perf'].includes(c.type)) { sections[c.type].push(c) } return sections }, { feat: [], fix: [], perf: [], breaking: [] } as Record, ) // Sort commits by scope, unscoped first Object.values(sections).forEach((section) => { section.sort((a, b) => (a.scope || '').localeCompare(b.scope || '')) }) const stringifiedSections = Object.fromEntries( Object.entries(sections).map(([key, commits]) => [ key, commits.map((commit) => formatCommitForChangelog(commit, key === 'breaking')), ]), ) // Fetch commits for fromVersion to toVersion const contributors = await createContributorSection(conventionalCommits) const yyyyMMdd = new Date().toISOString().split('T')[0] // Might need to swap out HEAD for the new proposed version let changelog = `## [${proposedReleaseVersion}](https://github.com/payloadcms/payload/compare/${fromVersion}...${proposedReleaseVersion}) (${yyyyMMdd})\n\n\n` // Add section headers if (stringifiedSections.feat.length) { changelog += `### 🚀 Features\n\n${stringifiedSections.feat.join('\n')}\n\n` } if (stringifiedSections.perf.length) { changelog += `### ⚡ Performance\n\n${stringifiedSections.perf.join('\n')}\n\n` } if (stringifiedSections.fix.length) { changelog += `### 🐛 Bug Fixes\n\n${stringifiedSections.fix.join('\n')}\n\n` } if (stringifiedSections.breaking.length) { changelog += `### ⚠️ BREAKING CHANGES\n\n${stringifiedSections.breaking.join('\n')}\n\n` } if (writeChangelog) { const changelogPath = 'CHANGELOG.md' const existingChangelog = await fse.readFile(changelogPath, 'utf8') const newChangelog = changelog + '\n\n' + existingChangelog await fse.writeFile(changelogPath, newChangelog) console.log(`Changelog updated at ${changelogPath}`) } // Add contributors after writing to file const releaseNotes = changelog + contributors let releaseUrl = `https://github.com/payloadcms/payload/releases/new?tag=${proposedReleaseVersion}&title=${proposedReleaseVersion}&body=${encodeURIComponent(releaseNotes)}` if (tag !== 'latest') { releaseUrl += `&prerelease=1` } if (!openReleaseUrl) { await open(releaseUrl) } return { releaseUrl, changelog, releaseNotes, } } // Helper functions async function createContributorSection(commits: GitCommit[]): Promise { const contributors = await getContributors(commits) if (!contributors.length) { return '' } let contributorsSection = `### 🤝 Contributors\n\n` for (const contributor of contributors) { contributorsSection += `- ${contributor.name} (@${contributor.username})\n` } return contributorsSection } async function getContributors(commits: GitCommit[]): Promise { const contributors: Contributor[] = [] const emails = new Set() for (const commit of commits) { if (emails.has(commit.author.email) || commit.author.name === 'dependabot[bot]') { continue } const res = await fetch( `https://api.github.com/repos/payloadcms/payload/commits/${commit.shortHash}`, { headers: { Accept: 'application/vnd.github.v3+json', Authorization: `token ${process.env.GITHUB_TOKEN}`, }, }, ) if (!res.ok) { console.error(await res.text()) console.log(`Failed to fetch commit: ${res.status} ${res.statusText}`) continue } const { author } = (await res.json()) as { author: { login: string; email: string } } // TODO: Handle co-authors if (!contributors.some((c) => c.username === author.login)) { contributors.push({ name: commit.author.name, username: author.login }) } emails.add(author.email) } return contributors } type Contributor = { name: string; username: string } function formatCommitForChangelog(commit: GitCommit, includeBreakingNotes = false): string { const { scope, references, description, isBreaking } = commit let formatted = `* ${scope ? `**${scope}:** ` : ''}${description}` references.forEach((ref) => { if (ref.type === 'pull-request') { // /issues will redirect to /pulls if the issue is a PR formatted += ` ([${ref.value}](https://github.com/payloadcms/payload/issues/${ref.value.replace('#', '')}))` } if (ref.type === 'hash') { const shortHash = ref.value.slice(0, 7) formatted += ` ([${shortHash}](https://github.com/payloadcms/payload/commit/${shortHash}))` } }) if (isBreaking && includeBreakingNotes) { // Parse breaking change notes from commit body const [rawNotes, _] = commit.body.split('\n\n') let notes = ` ` + rawNotes .split('\n') .map((l) => ` ${l}`) // Indent notes .join('\n') .trim() // Remove random trailing quotes that sometimes appear if (notes.endsWith('"')) { notes = notes.slice(0, -1) } formatted += `\n\n${notes}\n\n` } return formatted } // module import workaround for ejs if (import.meta.url === `file://${process.argv[1]}`) { // This module is being run directly const { fromVersion, toVersion, bump, openReleaseUrl, writeChangelog } = minimist( process.argv.slice(2), ) updateChangelog({ bump, fromVersion, toVersion, dryRun: false, openReleaseUrl, writeChangelog }) .then(() => { console.log('Done') }) .catch((err) => { console.error(err) }) }