207 lines
6.2 KiB
TypeScript
Executable File
207 lines
6.2 KiB
TypeScript
Executable File
import type { GitCommit, RawGitCommit } from 'changelogen'
|
|
|
|
import chalk from 'chalk'
|
|
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
|
|
}
|
|
|
|
export const updateChangelog = async (args: Args = {}) => {
|
|
const { toVersion = 'HEAD', dryRun, bump, openReleaseUrl, writeChangelog } = args
|
|
|
|
const fromVersion =
|
|
args.fromVersion || execSync('git describe --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 = semver.inc(fromVersion, calculatedBump, undefined, tag)
|
|
|
|
console.log({
|
|
tag,
|
|
recommendedBump,
|
|
fromVersion,
|
|
toVersion,
|
|
proposedVersion: proposedReleaseVersion,
|
|
})
|
|
|
|
const conventionalCommits = await getLatestCommits(fromVersion, toVersion)
|
|
|
|
const sections: Record<'breaking' | 'feat' | 'fix', string[]> = {
|
|
feat: [],
|
|
fix: [],
|
|
breaking: [],
|
|
}
|
|
|
|
// Group commits by type
|
|
conventionalCommits.forEach((c) => {
|
|
if (c.isBreaking) {
|
|
sections.breaking.push(formatCommitForChangelog(c, true))
|
|
}
|
|
|
|
if (c.type === 'feat' || c.type === 'fix') {
|
|
sections[c.type].push(formatCommitForChangelog(c))
|
|
}
|
|
})
|
|
|
|
// 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 output = `## [${proposedReleaseVersion}](https://github.com/payloadcms/payload/compare/${fromVersion}...${proposedReleaseVersion}) (${yyyyMMdd})\n\n\n`
|
|
if (sections.feat.length) {
|
|
output += `### Features\n\n${sections.feat.join('\n')}\n\n`
|
|
}
|
|
if (sections.fix.length) {
|
|
output += `### Bug Fixes\n\n${sections.fix.join('\n')}\n\n`
|
|
}
|
|
if (sections.breaking.length) {
|
|
output += `### BREAKING CHANGES\n\n${sections.breaking.join('\n')}\n\n`
|
|
}
|
|
|
|
console.log(chalk.green('\nChangelog Preview:\n'))
|
|
console.log(chalk.gray(output))
|
|
|
|
if (writeChangelog) {
|
|
const changelogPath = 'CHANGELOG.md'
|
|
const changelog = await fse.readFile(changelogPath, 'utf8')
|
|
const newChangelog = output + '\n\n' + changelog
|
|
await fse.writeFile(changelogPath, newChangelog)
|
|
console.log(`Changelog updated at ${changelogPath}`)
|
|
}
|
|
|
|
// Add contributors after writing to file
|
|
output += contributors
|
|
|
|
let url = `https://github.com/payloadcms/payload/releases/new?tag=${proposedReleaseVersion}&title=${proposedReleaseVersion}&body=${encodeURIComponent(output)}`
|
|
if (tag !== 'latest') {
|
|
url += `&prerelease=1`
|
|
}
|
|
console.log(`Release URL: ${chalk.dim(url)}`)
|
|
if (!openReleaseUrl) {
|
|
await open(url)
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
async function createContributorSection(commits: GitCommit[]): Promise<string> {
|
|
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<Contributor[]> {
|
|
const contributors: Contributor[] = []
|
|
const emails = new Set<string>()
|
|
|
|
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')
|
|
const notes = rawNotes
|
|
.split('\n')
|
|
.filter((l) => !l.toUpperCase().startsWith('BREAKING'))
|
|
.map((l) => `> ${l}`)
|
|
.join('\n')
|
|
.trim()
|
|
formatted += `\n\n${notes}`
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|