From d5a1a04521c5fb02a1d0e6929293982aa5c45fff Mon Sep 17 00:00:00 2001 From: chufan Date: Sun, 14 Jul 2024 13:41:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(changelog):=20=E6=96=B0=E5=A2=9E=E8=84=9A?= =?UTF-8?q?=E6=89=8B=E6=9E=B6=E6=94=AF=E6=8C=81=E8=87=AA=E5=8A=A8=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=8F=98=E6=9B=B4=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/changelog/README.md | 145 +++++++++++++++++++ packages/changelog/build.config.ts | 14 ++ packages/changelog/cli.mjs | 3 + packages/changelog/package.json | 69 +++++++++ packages/changelog/src/changelog.ts | 100 +++++++++++++ packages/changelog/src/core/config.ts | 57 ++++++++ packages/changelog/src/core/generate.ts | 22 +++ packages/changelog/src/core/git.ts | 76 ++++++++++ packages/changelog/src/core/github.ts | 142 ++++++++++++++++++ packages/changelog/src/core/markdown.ts | 183 ++++++++++++++++++++++++ packages/changelog/src/core/parse.ts | 8 ++ packages/changelog/src/core/types.ts | 86 +++++++++++ packages/changelog/src/index.ts | 7 + packages/changelog/tsconfig.json | 10 ++ 14 files changed, 922 insertions(+) create mode 100644 packages/changelog/README.md create mode 100644 packages/changelog/build.config.ts create mode 100755 packages/changelog/cli.mjs create mode 100644 packages/changelog/package.json create mode 100644 packages/changelog/src/changelog.ts create mode 100644 packages/changelog/src/core/config.ts create mode 100644 packages/changelog/src/core/generate.ts create mode 100644 packages/changelog/src/core/git.ts create mode 100644 packages/changelog/src/core/github.ts create mode 100644 packages/changelog/src/core/markdown.ts create mode 100644 packages/changelog/src/core/parse.ts create mode 100644 packages/changelog/src/core/types.ts create mode 100644 packages/changelog/src/index.ts create mode 100644 packages/changelog/tsconfig.json diff --git a/packages/changelog/README.md b/packages/changelog/README.md new file mode 100644 index 0000000..7dfea36 --- /dev/null +++ b/packages/changelog/README.md @@ -0,0 +1,145 @@ +# @142vip/changelog + +根据git提交记录,自动生成changelog文档 + +[![NPM version](https://img.shields.io/npm/v/@142vip/changelog?color=a1b858&label=version)](https://www.npmjs.com/package/@142vip/changelog) + +Generate changelog for GitHub releases from [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), +powered by [changelogen](https://github.com/unjs/changelogen). + +[👉 使用示例](https://github.com/unocss/unocss/releases/tag/v0.39.0) + +## 新功能 + +- Support exclamation mark as breaking change, e.g. `chore!: drop node v10` +- Grouped scope in changelog +- Create the release note, or update the existing one +- List contributors + +## 使用 + +### 生成CHANGELOG.md文档 + +```bash +# output参数可以配置,支持做本地文档更新 +npx changelog --output CHANGELOG.md +``` + +### 配合Github Actions使用 + +```yml +# .github/workflows/release.yml + +name: Release + +permissions: + contents: write + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + # 安装node版本,大于16 + - uses: actions/setup-node@v3 + with: + node-version: 16.x + + # Github发布版本,并更新Release信息 + - run: npx changelog + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} +``` + +向 GitHub 推送以“v”开头的标签时,`github actions`会被触发。 + +在142vip所有的开源仓库中,都可以通过`@142vip/changelog`模块来实现发布,例如: + +```yaml +# CD持续交付 +# - 部署到Github Pages +# - 部署到Vercel托管平台 +# - 发布新的Github Release +# 参考:https://v2.vuepress.vuejs.org/zh/guide/deployment.html#github-pages +# + +name: CD + +on: + push: + branches: + - next + workflow_dispatch: + +jobs: + # 版本发布 + release: + name: 创建Github发布 + runs-on: ubuntu-latest + # 主库next且执行release更新时执行 + if: github.repository == '142vip/core-x' && startsWith(github.event.head_commit.message, 'chore(release):') + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + token: ${{ secrets.TOKEN }} + persist-credentials: false + # “最近更新时间” 等 git 日志相关信息,需要拉取全部提交记录 + fetch-depth: 0 + + # 安装node版本,大于16 + - uses: actions/setup-node@v3 + with: + node-version: 16.x + + # Github发布版本,并更新Release信息 + - run: npx changelog + env: + GITHUB_TOKEN: ${{secrets.TOKEN}} + + # 提取版本号 + - name: Get New Version Number + id: releaseVersion + run: | + echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + + # 更新资源 区分压缩包上传 + - name: Upload Resource Assets + uses: actions/upload-release-asset@latest + env: + GITHUB_TOKEN: ${{ secrets.TOKEN }} + with: + upload_url: ${{ steps.createRelease.outputs.upload_url }} + asset_path: ./142vip-oauth.zip + asset_name: 142vip-oauth.zip + asset_content_type: application/zip +``` + +## 配置 + +You can put a configuration file in the project root, named +as `changelogithub.config.{json,ts,js,mjs,cjs}`, `.changelogithubrc` or use the `changelogithub` field +in `package.json`. + +## 本地预览y + +```bash +# 只本地生成创建版本的URL +npx changelogithub --dry +``` + +## 感谢 + +- changelogen: +- changelogithub: + +## 证书 diff --git a/packages/changelog/build.config.ts b/packages/changelog/build.config.ts new file mode 100644 index 0000000..2c79aa4 --- /dev/null +++ b/packages/changelog/build.config.ts @@ -0,0 +1,14 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + entries: [ + 'src/index', + 'src/changelog', + ], + declaration: true, + clean: true, + rollup: { + emitCJS: true, + inlineDependencies: true, + }, +}) diff --git a/packages/changelog/cli.mjs b/packages/changelog/cli.mjs new file mode 100755 index 0000000..9eb25a2 --- /dev/null +++ b/packages/changelog/cli.mjs @@ -0,0 +1,3 @@ +#!/usr/bin/env node +// eslint-disable-next-line antfu/no-import-dist +import './dist/changelog.mjs' diff --git a/packages/changelog/package.json b/packages/changelog/package.json new file mode 100644 index 0000000..d1616c1 --- /dev/null +++ b/packages/changelog/package.json @@ -0,0 +1,69 @@ +{ + "name": "@142vip/changelog", + "version": "0.0.1", + "private": false, + "type": "module", + "description": "公众号搜:储凡", + "author": "mmdapl ", + "license": "MIT", + "keywords": [ + "公众号搜:储凡", + "142vip", + "@142vip/changelog", + "github", + "release", + "releases", + "conventional", + "changelog" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + }, + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "bin": "./cli.mjs", + "files": [ + "*.mjs", + "dist" + ], + "engines": { + "node": ">=16.0.0" + }, + "scripts": { + "dev": "unbuild --stub", + "build": "unbuild", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@antfu/utils": "^0.7.10", + "c12": "^1.11.1", + "cac": "^6.7.14", + "changelogen": "0.5.5", + "convert-gitmoji": "^0.1.5", + "dayjs": "^1.11.11", + "execa": "^8.0.1", + "kolorist": "^1.8.0", + "ofetch": "^1.3.4", + "semver": "^7.6.2" + }, + "devDependencies": { + "@antfu/eslint-config": "^2.22.0", + "@types/debug": "^4.1.12", + "@types/fs-extra": "^11.0.4", + "@types/minimist": "^1.2.5", + "@types/semver": "^7.5.8", + "bumpp": "^9.4.1", + "eslint": "^9.7.0", + "esno": "^4.7.0", + "fs-extra": "^11.2.0", + "typescript": "^5.5.3", + "unbuild": "^2.0.0", + "vitest": "^2.0.2" + } +} diff --git a/packages/changelog/src/changelog.ts b/packages/changelog/src/changelog.ts new file mode 100644 index 0000000..051527c --- /dev/null +++ b/packages/changelog/src/changelog.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +/* eslint-disable no-console */ +import process from 'node:process' +import { blue, bold, cyan, dim, red, yellow } from 'kolorist' +import cac from 'cac' +import { version } from '../package.json' +import { generate, isRepoShallow, sendRelease, updateChangelog } from './index' + +const cli = cac('changelog') + +// 参数 +cli + .version(version) + .option('-t, --tokens ', 'GitHub Token') + .option('--from ', 'From tag') + .option('--to ', 'To tag') + .option('--github ', 'GitHub Repository, e.g. @142vip/core-x') + .option('--name ', 'Name of the release') + .option('--prerelease', 'Mark release as prerelease') + .option('--output ', 'Output to file instead of sending to GitHub') + .option('--dry', 'Dry run') + .help() + +// 命令 +cli + .command('') + .action(async (args) => { + args.token = args.token || process.env.GITHUB_TOKEN + + let webUrl = '' + + try { + console.log() + console.log(dim(`${bold('@142vip/changelog')} `) + dim(`v${version}`)) + + const { config, markdown, commits } = await generate(args) + webUrl = `https://${config.baseUrl}/${config.repo}/releases/new?title=${encodeURIComponent(String(config.name || config.to))}&body=${encodeURIComponent(String(markdown))}&tag=${encodeURIComponent(String(config.to))}&prerelease=${config.prerelease}` + + console.log(cyan(config.from) + dim(' -> ') + blue(config.to) + dim(` (${commits.length} commits)`)) + console.log(dim('--------------')) + console.log() + console.log(markdown.replace(/ /g, '')) + console.log() + console.log(dim('--------------')) + + function printWebUrl() { + console.log() + console.error(yellow('使用以下链接手动发布新的版本:')) + console.error(yellow(webUrl)) + console.log() + } + + if (config.dry) { + console.log(yellow('试运行。已跳过版本发布。')) + printWebUrl() + return + } + + // 更新changelog文档 + if (typeof config.output === 'string') { + await updateChangelog(config.output, markdown, config.to) + return + } + + // 带token上传 + if (!config.tokens) { + console.error(red('未找到 GitHub 令牌,请通过 GITHUB_TOKEN 环境变量指定。已跳过版本发布。')) + printWebUrl() + return + } + + if (!commits.length && await isRepoShallow()) { + console.error(yellow('存储库似乎克隆得很浅,这使得更改日志无法生成。您可能希望在 CI 配置中指定 \'fetch-depth: 0\'。')) + printWebUrl() + return + } + + // 调用api 直接发布 + await sendRelease(config, markdown) + } + catch (e: any) { + console.error(red(String(e))) + if (e?.stack) + console.error(dim(e.stack?.split('\n').slice(1).join('\n'))) + + // 手动执行,创建release + if (webUrl) { + console.log() + console.error(red('无法创建发布。使用以下链接手动创建它:')) + console.error(yellow(webUrl)) + console.log() + } + } + finally { + process.exitCode = 1 + } + }) + +cli.parse() diff --git a/packages/changelog/src/core/config.ts b/packages/changelog/src/core/config.ts new file mode 100644 index 0000000..5ee146e --- /dev/null +++ b/packages/changelog/src/core/config.ts @@ -0,0 +1,57 @@ +import process from 'node:process' +import { + getCurrentGitBranch, + getFirstGitCommit, + getGitHubRepo, + getLastMatchingTag, + isPrerelease, +} from './git' +import type { ChangelogOptions, ResolvedChangelogOptions } from './types' + +// const defaultOutput = 'CHANGELOG.md' +const defaultConfig: ChangelogOptions = { + scopeMap: {}, + types: { + feat: { title: '✨ Features', semver: 'minor' }, + perf: { title: '🔥 Performance', semver: 'patch' }, + fix: { title: '🐛 Bug Fixes', semver: 'patch' }, + refactor: { title: '💅 Refactors', semver: 'patch' }, + docs: { title: '📖 Documentation', semver: 'patch' }, + build: { title: '📦 Build', semver: 'patch' }, + types: { title: '🌊 Types', semver: 'patch' }, + }, + titles: { + breakingChanges: '🚨 Breaking Changes', + }, + tokens: { + github: process.env.GITHUB_TOKEN || process.env.TOKEN, + }, + contributors: true, + capitalize: true, + group: true, + emoji: true, + // output: defaultOutput, +} + +export async function resolveConfig(options: ChangelogOptions) { + const { loadConfig } = await import('c12') + const config = await loadConfig({ + name: '@142vip/changelog', + defaults: defaultConfig, + overrides: options, + packageJson: '@142vip/changelog', + }).then(r => r.config || defaultConfig) + + config.baseUrl = config.baseUrl ?? 'github.com' + config.baseUrlApi = config.baseUrlApi ?? 'api.github.com' + config.to = config.to || await getCurrentGitBranch() + config.from = config.from || await getLastMatchingTag(config.to) || await getFirstGitCommit() + // @ts-expect-error backward compatibility + config.repo = config.repo || config.github || await getGitHubRepo(config.baseUrl) + config.prerelease = config.prerelease ?? isPrerelease(config.to) + + if (typeof config.repo !== 'string') + throw new Error(`Invalid GitHub repository, expected a string but got ${JSON.stringify(config.repo)}`) + + return config as ResolvedChangelogOptions +} diff --git a/packages/changelog/src/core/generate.ts b/packages/changelog/src/core/generate.ts new file mode 100644 index 0000000..4412e1c --- /dev/null +++ b/packages/changelog/src/core/generate.ts @@ -0,0 +1,22 @@ +import { getGitDiff } from 'changelogen' +import type { ChangelogOptions } from './types' +import { generateMarkdown } from './markdown' +import { resolveConfig } from './config' +import { parseCommits } from './parse' +import { resolveAuthors } from './github' + +export async function generate(options: ChangelogOptions) { + const config = await resolveConfig(options) + + const rawCommits = await getGitDiff(config.from, config.to) + const commits = parseCommits(rawCommits, config) + + // 添加贡献者 + if (config.contributors) { + await resolveAuthors(commits, config) + } + // 生成文档 + const markdown = await generateMarkdown(commits, config) + + return { config, markdown, commits } +} diff --git a/packages/changelog/src/core/git.ts b/packages/changelog/src/core/git.ts new file mode 100644 index 0000000..79e569a --- /dev/null +++ b/packages/changelog/src/core/git.ts @@ -0,0 +1,76 @@ +import semver from 'semver' + +export async function getGitHubRepo(baseUrl: string) { + const url = await execCommand('git', ['config', '--get', 'remote.origin.url']) + const escapedBaseUrl = baseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const regex = new RegExp(`${escapedBaseUrl}[\/:]([\\w\\d._-]+?)\\/([\\w\\d._-]+?)(\\.git)?$`, 'i') + const match = regex.exec(url) + if (!match) + throw new Error(`Can not parse GitHub repo from url ${url}`) + return `${match[1]}/${match[2]}` +} + +export async function getCurrentGitBranch() { + return await execCommand('git', ['tag', '--points-at', 'HEAD']) || await execCommand('git', ['rev-parse', '--abbrev-ref', 'HEAD']) +} + +export async function isRepoShallow() { + return (await execCommand('git', ['rev-parse', '--is-shallow-repository'])).trim() === 'true' +} + +export async function getGitTags() { + return (await execCommand('git', ['--no-pager', 'tag', '-l', '--sort=creatordate']).then(r => r.split('\n'))) + .reverse() +} + +function getTagWithoutPrefix(tag: string) { + return tag.replace(/^v/, '') +} + +export async function getLastMatchingTag(inputTag: string) { + const inputTagWithoutPrefix = getTagWithoutPrefix(inputTag) + const isVersion = semver.valid(inputTagWithoutPrefix) !== null + const isPrerelease = semver.prerelease(inputTag) !== null + const tags = await getGitTags() + + let tag: string | undefined + // Doing a stable release, find the last stable release to compare with + if (!isPrerelease && isVersion) { + tag = tags.find((tag) => { + const tagWithoutPrefix = getTagWithoutPrefix(tag) + + return tagWithoutPrefix !== inputTagWithoutPrefix + && semver.valid(tagWithoutPrefix) !== null + && semver.prerelease(tagWithoutPrefix) === null + }) + } + + // Fallback to the last tag, that are not the input tag + tag ||= tags.find(tag => tag !== inputTag) + + return tag +} + +export async function isRefGitTag(to: string) { + const { execa } = await import('execa') + try { + await execa('git', ['show-ref', '--verify', `refs/tags/${to}`], { reject: true }) + } + catch { + return false + } +} + +export async function getFirstGitCommit() { + return await execCommand('git', ['rev-list', '--max-parents=0', 'HEAD']) +} + +export function isPrerelease(version: string) { + return !/^[^.]*(?:\.[\d.]*|\d)$/.test(version) +} + +async function execCommand(cmd: string, args: string[]) { + const { execa } = await import('execa') + const res = await execa(cmd, args) + return res.stdout.trim() +} diff --git a/packages/changelog/src/core/github.ts b/packages/changelog/src/core/github.ts new file mode 100644 index 0000000..d8f3245 --- /dev/null +++ b/packages/changelog/src/core/github.ts @@ -0,0 +1,142 @@ +/* eslint-disable no-console */ +import { $fetch } from 'ofetch' +import { cyan, green } from 'kolorist' +import { notNullish } from '@antfu/utils' +import type { AuthorInfo, ChangelogOptions, Commit } from './types' + +export async function sendRelease( + options: ChangelogOptions, + content: string, +) { + const headers = getHeaders(options) + let url = `https://${options.baseUrlApi}/repos/${options.repo}/releases` + let method = 'POST' + + try { + const exists = await $fetch(`https://${options.baseUrlApi}/repos/${options.repo}/releases/tags/${options.to}`, { + headers, + }) + if (exists.url) { + url = exists.url + method = 'PATCH' + } + } + catch (e) { + console.log(3, e) + } + + const body = { + body: content, + draft: options.draft || false, + name: options.name || options.to, + prerelease: options.prerelease, + tag_name: options.to, + } + console.log(cyan(method === 'POST' + ? 'Creating release notes...' + : 'Updating release notes...'), + ) + const res = await $fetch(url, { + method, + body: JSON.stringify(body), + headers, + }) + console.log(green(`Released on ${res.html_url}`)) +} + +function getHeaders(options: ChangelogOptions) { + return { + accept: 'application/vnd.github.v3+json', + authorization: `token ${options.token}`, + } +} + +export async function resolveAuthorInfo(options: ChangelogOptions, info: AuthorInfo) { + if (info.login) + return info + + // token not provided, skip github resolving + if (!options.token) + return info + + try { + const data = await $fetch(`https://${options.baseUrlApi}/search/users?q=${encodeURIComponent(info.email)}`, { + headers: getHeaders(options), + }) + info.login = data.items[0].login + } + catch { + } + + if (info.login) + return info + + if (info.commits.length) { + try { + const data = await $fetch(`https://${options.baseUrlApi}/repos/${options.repo}/commits/${info.commits[0]}`, { + headers: getHeaders(options), + }) + info.login = data.author.login + } + catch { + } + } + + return info +} + +export async function resolveAuthors(commits: Commit[], options: ChangelogOptions) { + const map = new Map() + commits.forEach((commit) => { + commit.resolvedAuthors = commit.authors.map((a, idx) => { + if (!a.email || !a.name) + return null + if (!map.has(a.email)) { + map.set(a.email, { + commits: [], + name: a.name, + email: a.email, + }) + } + const info = map.get(a.email)! + + // record commits only for the first author + if (idx === 0) + info.commits.push(commit.shortHash) + + return info + }).filter(notNullish) + }) + const authors = Array.from(map.values()) + const resolved = await Promise.all(authors.map(info => resolveAuthorInfo(options, info))) + + const loginSet = new Set() + const nameSet = new Set() + return resolved + .sort((a, b) => (a.login || a.name).localeCompare(b.login || b.name)) + .filter((i) => { + if (i.login && loginSet.has(i.login)) + return false + if (i.login) { + loginSet.add(i.login) + } + else { + if (nameSet.has(i.name)) + return false + nameSet.add(i.name) + } + return true + }) +} + +export async function hasTagOnGitHub(tag: string, options: ChangelogOptions) { + try { + await $fetch(`https://${options.baseUrlApi}/repos/${options.repo}/git/ref/tags/${tag}`, { + headers: getHeaders(options), + }) + return true + } + catch { + return false + } +} diff --git a/packages/changelog/src/core/markdown.ts b/packages/changelog/src/core/markdown.ts new file mode 100644 index 0000000..73377ec --- /dev/null +++ b/packages/changelog/src/core/markdown.ts @@ -0,0 +1,183 @@ +import { existsSync, promises as fsp } from 'node:fs' +import { partition } from '@antfu/utils' +import type { Reference } from 'changelogen' +import { convert } from 'convert-gitmoji' +import dayjs from 'dayjs' +import type { Commit, ResolvedChangelogOptions } from './types' + +const emojisRE = /([\u2700-\u27BF\uE000-\uF8FF\u2011-\u26FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|\uD83E[\uDD10-\uDDFF])/g + +function formatReferences(references: Reference[], baseUrl: string, github: string, type: 'issues' | 'hash'): string { + const refs = references + .filter((i) => { + if (type === 'issues') + return i.type === 'issue' || i.type === 'pull-request' + return i.type === 'hash' + }) + .map((ref) => { + if (!github) + return ref.value + if (ref.type === 'pull-request' || ref.type === 'issue') + return `https://${baseUrl}/${github}/issues/${ref.value.slice(1)}` + return `[(${ref.value.slice(0, 5)})](https://${baseUrl}/${github}/commit/${ref.value})` + }) + + const referencesString = join(refs).trim() + + if (type === 'issues') + return referencesString && `in ${referencesString}` + return referencesString +} + +function formatLine(commit: Commit, options: ResolvedChangelogOptions) { + const prRefs = formatReferences(commit.references, options.baseUrl, options.repo as string, 'issues') + const hashRefs = formatReferences(commit.references, options.baseUrl, options.repo as string, 'hash') + + let authors = join([...new Set(commit.resolvedAuthors?.map(i => i.login ? `@${i.login}` : `**${i.name}**`))])?.trim() + if (authors) + authors = `by ${authors}` + + let refs = [authors, prRefs, hashRefs].filter(i => i?.trim()).join(' ') + + if (refs) + refs = ` -  ${refs}` + + const description = options.capitalize ? capitalize(commit.description) : commit.description + + return [description, refs].filter(i => i?.trim()).join(' ') +} + +// 标题 +function formatTitle(name: string, options: ResolvedChangelogOptions) { + if (!options.emoji) + name = name.replace(emojisRE, '') + + return `### ${name.trim()}` +} + +function formatSection(commits: Commit[], sectionName: string, options: ResolvedChangelogOptions) { + if (!commits.length) + return [] + + const lines: string[] = [ + '', + formatTitle(sectionName, options), + '', + ] + + const scopes = groupBy(commits, 'scope') + let useScopeGroup = options.group + + // group scopes only when one of the scope have multiple commits + if (!Object.entries(scopes).some(([k, v]) => k && v.length > 1)) + useScopeGroup = false + + Object.keys(scopes).sort().forEach((scope) => { + let padding = '' + let prefix = '' + const scopeText = `**${options.scopeMap[scope] || scope}**` + if (scope && (useScopeGroup === true || (useScopeGroup === 'multiple' && scopes[scope].length > 1))) { + lines.push(`- ${scopeText}:`) + padding = ' ' + } + else if (scope) { + prefix = `${scopeText}: ` + } + + lines.push(...scopes[scope] + .reverse() + .map(commit => `${padding}- ${prefix}${formatLine(commit, options)}`), + ) + }) + + return lines +} + +export async function generateMarkdown(commits: Commit[], options: ResolvedChangelogOptions) { + const lines: string[] = [] + + const [breaking, changes] = partition(commits, c => c.isBreaking) + + const group = groupBy(changes, 'type') + + // 破坏性改动 + lines.push( + ...formatSection(breaking, options.titles.breakingChanges!, options), + ) + + for (const type of Object.keys(options.types)) { + const items = group[type] || [] + lines.push( + ...formatSection(items, options.types[type].title, options), + ) + } + + if (!lines.length) { + lines.push('\n**无重要变更**') + } + else { + const url = `https://${options.baseUrl}/${options.repo}/compare/${options.from}...${options.to}` + // 添加版本 + lines.push(`\n**Release New Version v${options.to} [👉 see more](${url})**`) + } + + return convert(lines.join('\n').trim(), true) +} + +/** + * 更新changelog + * @param outputPath + * @param markdown + * @param to + */ +export async function updateChangelog(outputPath: string, markdown: string, to: string) { + let changelogMD: string + if (existsSync(outputPath)) { + console.info(`Updating ${outputPath}`) + changelogMD = await fsp.readFile(outputPath, 'utf8') + } + else { + console.info(`Creating ${outputPath}`) + changelogMD = '# Changelog\n\nAll notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.\n' + } + + // 添加版本头部 + const newMd = `## ${to} (${dayjs().format('YYYY-MM-DD')})\n\n${markdown}` + + const lastEntry = changelogMD.match(/^##\s+(?:\S.*)?$/m) + + if (lastEntry) { + changelogMD = `${changelogMD.slice(0, lastEntry.index)}${newMd}\n\n${changelogMD.slice(lastEntry.index)}` + } + else { + changelogMD += `\n${newMd}` + } + + await fsp.writeFile(outputPath, changelogMD, 'utf-8') +} + +function groupBy(items: T[], key: string, groups: Record = {}) { + for (const item of items) { + const v = (item as any)[key] as string + groups[v] = groups[v] || [] + groups[v].push(item) + } + return groups +} + +function capitalize(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1) +} + +function join(array?: string[], glue = ', ', finalGlue = ' and '): string { + if (!array || array.length === 0) + return '' + + if (array.length === 1) + return array[0] + + if (array.length === 2) + return array.join(finalGlue) + + return `${array.slice(0, -1).join(glue)}${finalGlue}${array.slice(-1)}` +} diff --git a/packages/changelog/src/core/parse.ts b/packages/changelog/src/core/parse.ts new file mode 100644 index 0000000..47ae7fd --- /dev/null +++ b/packages/changelog/src/core/parse.ts @@ -0,0 +1,8 @@ +import { notNullish } from '@antfu/utils' +import { parseGitCommit } from 'changelogen' +import type { GitCommit, RawGitCommit } from 'changelogen' +import type { ChangelogEnOptions } from './types' + +export function parseCommits(commits: RawGitCommit[], config: ChangelogEnOptions): GitCommit[] { + return commits.map(commit => parseGitCommit(commit, config)).filter(notNullish) +} diff --git a/packages/changelog/src/core/types.ts b/packages/changelog/src/core/types.ts new file mode 100644 index 0000000..51f3680 --- /dev/null +++ b/packages/changelog/src/core/types.ts @@ -0,0 +1,86 @@ +import type { ChangelogConfig, GitCommit } from 'changelogen' + +export type ChangelogEnOptions = ChangelogConfig + +export interface GitHubRepo { + owner: string + repo: string +} + +export interface GitHubAuth { + token: string + url: string +} + +export interface Commit extends GitCommit { + resolvedAuthors?: AuthorInfo[] +} + +export interface ChangelogOptions extends Partial { + /** + * Dry run. Skip releasing to GitHub. + */ + dry?: boolean + /** + * Whether to include contributors in release notes. + * + * @default true + */ + contributors?: boolean + /** + * Name of the release + */ + name?: string + /** + * Mark the release as a draft + */ + draft?: boolean + /** + * Mark the release as prerelease + */ + prerelease?: boolean + /** + * GitHub Token + */ + token?: string + /** + * Custom titles + */ + titles?: { + breakingChanges?: string + } + /** + * Capitalize commit messages + * @default true + */ + capitalize?: boolean + /** + * Nest commit messages under their scopes + * @default true + */ + group?: boolean | 'multiple' + /** + * Use emojis in section titles + * @default true + */ + emoji?: boolean + /** + * Github base url + * @default github.com + */ + baseUrl?: string + /** + * Github base API url + * @default api.github.com + */ + baseUrlApi?: string +} + +export type ResolvedChangelogOptions = Required + +export interface AuthorInfo { + commits: string[] + login?: string + email: string + name: string +} diff --git a/packages/changelog/src/index.ts b/packages/changelog/src/index.ts new file mode 100644 index 0000000..efd1010 --- /dev/null +++ b/packages/changelog/src/index.ts @@ -0,0 +1,7 @@ +export * from './core/types' +export * from './core/github' +export * from './core/git' +export * from './core/markdown' +export * from './core/generate' +export * from './core/config' +export * from './core/parse' diff --git a/packages/changelog/tsconfig.json b/packages/changelog/tsconfig.json new file mode 100644 index 0000000..85c75c4 --- /dev/null +++ b/packages/changelog/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.package.json", + "exclude": [ + "node_modules", + "dist" + ], + "include": [ + "src/**/*" + ] +}