@ruan-cat/release-toolkit
Version:
基于 changelogen 增强 changesets 工作流的发布工具包,提供语义化提交解析和 GitHub Release 同步功能。
379 lines (320 loc) • 10.8 kB
text/typescript
import type { ChangelogFunctions } from "@changesets/types";
import { consola } from "consola";
import { commitTypes, type CommitType } from "@ruan-cat/commitlint-config";
import { getGitDiff, parseCommits, loadChangelogConfig, type GitCommit, type ChangelogConfig } from "changelogen";
import changelogConfig from "../configs/changelogen.config.ts";
/**
* 创建 type -> emoji 映射
*/
function createTypeEmojiMap(): Map<string, { emoji: string; description: string }> {
const map = new Map<string, { emoji: string; description: string }>();
commitTypes.forEach(({ type, emoji, description }) => {
map.set(type, { emoji, description });
});
return map;
}
/**
* 创建 emoji -> type 映射
*/
function createEmojiTypeMap(): Map<string, string> {
const map = new Map<string, string>();
commitTypes.forEach(({ type, emoji }) => {
map.set(emoji, type);
});
return map;
}
/**
* 从 git commit 历史中获取提交信息并解析
*/
async function getCommitsFromGitHistory(from?: string, to?: string): Promise<GitCommit[]> {
try {
// 加载 changelogen 配置
const config = await loadChangelogConfig(process.cwd(), {
...changelogConfig,
from: from || "",
to: to || "HEAD",
});
consola.debug("Loaded changelogen config:", config);
// 使用 changelogen 获取 git 提交差异
const rawCommits = await getGitDiff(config.from, config.to);
consola.debug(`Found ${rawCommits.length} raw commits from git history`);
// 使用 changelogen 解析提交信息
const parsedCommits = parseCommits(rawCommits, config);
consola.debug(`Parsed ${parsedCommits.length} semantic commits`);
return parsedCommits;
} catch (error) {
consola.error("Error getting commits from git history:", error);
return [];
}
}
/**
* 将 changelogen 的 GitCommit 转换为变更日志行
*/
function formatCommitToChangelogLine(commit: GitCommit, repoUrl?: string): string {
let line = "- ";
// 添加 emoji (从类型映射中获取)
const typeEmojiMap = createTypeEmojiMap();
const typeInfo = typeEmojiMap.get(commit.type);
if (typeInfo?.emoji) {
line += `${typeInfo.emoji} `;
}
// 添加类型标签
if (commit.type && commit.type !== "other") {
line += `**${commit.type}**`;
// 添加作用域
if (commit.scope) {
line += `(${commit.scope})`;
}
line += ": ";
}
// 添加 BREAKING CHANGE 标记
if (commit.isBreaking) {
line += "**BREAKING**: ";
}
// 添加描述
line += commit.description;
// 添加提交链接
if (repoUrl) {
const commitUrl = `${repoUrl}/commit/${commit.shortHash}`;
line += ` ([${commit.shortHash}](${commitUrl}))`;
}
return line;
}
/**
* 生成增强的变更日志行 - 集成 changelogen 功能
*/
const getReleaseLine: ChangelogFunctions["getReleaseLine"] = async (changeset, type, changelogOpts) => {
try {
const repoUrl = `https://github.com/${changelogOpts?.repo || "ruan-cat/monorepo"}`;
// 方案1: 如果有关联的提交,直接使用提交哈希
if (changeset.commit) {
consola.debug(`Processing changeset ${changeset.id} with commit ${changeset.commit}`);
// 尝试从 git 历史中获取该特定提交的详细信息
const commits = await getCommitsFromGitHistory(changeset.commit, changeset.commit);
if (commits.length > 0) {
const commit = commits[0];
const line = formatCommitToChangelogLine(commit, repoUrl);
consola.debug(`Generated changelog line from git commit for ${changeset.id}:`, line);
return line;
}
// 如果无法获取 git 提交信息,回退到基于 changeset 内容的解析
consola.warn(`Could not find git commit ${changeset.commit}, falling back to changeset parsing`);
}
// 方案2: 基于 changeset 内容解析 (回退方案)
consola.debug(`Processing changeset ${changeset.id} without commit, using changeset content`);
// 尝试从 changeset 摘要中提取语义化提交信息
const firstLine = changeset.summary.split("\n")[0];
const typeEmojiMap = createTypeEmojiMap();
const emojiTypeMap = createEmojiTypeMap();
// 简化的语义化解析
let line = "- ";
let emoji = "";
let commitType = "";
let scope = "";
let description = firstLine;
let isBreaking = false;
// 检查是否是 BREAKING CHANGE
isBreaking = firstLine.includes("!:") || firstLine.toLowerCase().includes("breaking");
// 尝试匹配 emoji + conventional 格式
const emojiConventionalMatch = firstLine.match(
/^([\u{1f000}-\u{1f9ff}|\u{2600}-\u{27bf}|\u{2700}-\u{27BF}|\u{1F600}-\u{1F64F}|\u{1F300}-\u{1F5FF}|\u{1F680}-\u{1F6FF}|\u{1F1E0}-\u{1F1FF}|\u{2600}-\u{26FF}|\u{2700}-\u{27BF}])\s+(\w+)(\([^)]+\))?(!)?\s*:\s*(.+)$/u,
);
if (emojiConventionalMatch) {
[, emoji, commitType, scope, , description] = emojiConventionalMatch;
scope = scope ? scope.slice(1, -1) : "";
} else {
// 尝试匹配纯 conventional 格式
const conventionalMatch = firstLine.match(/^(\w+)(\([^)]+\))?(!)?\s*:\s*(.+)$/);
if (conventionalMatch) {
[, commitType, scope, , description] = conventionalMatch;
scope = scope ? scope.slice(1, -1) : "";
const typeInfo = typeEmojiMap.get(commitType);
emoji = typeInfo?.emoji || "";
}
}
// 构建变更日志行
if (emoji) {
line += `${emoji} `;
}
if (commitType && commitType !== "other") {
line += `**${commitType}**`;
if (scope) {
line += `(${scope})`;
}
line += ": ";
}
if (isBreaking) {
line += "**BREAKING**: ";
}
line += description;
// 如果有提交哈希,添加链接
if (changeset.commit) {
const commitUrl = `${repoUrl}/commit/${changeset.commit}`;
line += ` ([${changeset.commit.substring(0, 7)}](${commitUrl}))`;
}
consola.debug(`Generated changelog line for ${changeset.id}:`, line);
return line;
} catch (error) {
consola.error(`Error processing changeset ${changeset.id}:`, error);
return `- ${changeset.summary}`;
}
};
/**
* 生成变更日志依赖行
*/
const getDependencyReleaseLine: ChangelogFunctions["getDependencyReleaseLine"] = async (
changesets,
dependenciesUpdated,
changelogOpts,
) => {
if (dependenciesUpdated.length === 0) return "";
const updatedDependencies = dependenciesUpdated.map((dependency) => {
const type = dependency.type === "patch" ? "Patch" : dependency.type === "minor" ? "Minor" : "Major";
return ` - ${dependency.name}@${dependency.newVersion} (${type})`;
});
return `- Updated dependencies:\n${updatedDependencies.join("\n")}`;
};
/**
* 从 git commit 历史生成完整的变更日志内容
* 这个功能可以独立于 changesets 使用
*/
export async function generateChangelogFromGitHistory(
from?: string,
to?: string,
options?: {
repo?: string;
includeAuthors?: boolean;
groupByType?: boolean;
},
): Promise<string> {
try {
consola.info("Generating changelog from git commit history...");
const commits = await getCommitsFromGitHistory(from, to);
if (commits.length === 0) {
consola.warn("No commits found in the specified range");
return "";
}
const repoUrl = options?.repo ? `https://github.com/${options.repo}` : undefined;
let changelog = "";
if (options?.groupByType) {
// 按类型分组生成变更日志
const commitsByType = new Map<string, GitCommit[]>();
commits.forEach((commit) => {
const type = commit.type || "other";
if (!commitsByType.has(type)) {
commitsByType.set(type, []);
}
commitsByType.get(type)!.push(commit);
});
// 按重要性排序类型
const typeOrder = [
"feat",
"fix",
"perf",
"revert",
"docs",
"style",
"refactor",
"test",
"build",
"ci",
"chore",
"other",
];
const sortedTypes = Array.from(commitsByType.keys()).sort((a, b) => {
const indexA = typeOrder.indexOf(a);
const indexB = typeOrder.indexOf(b);
return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB);
});
// 为每个类型生成变更日志节
for (const type of sortedTypes) {
const typeCommits = commitsByType.get(type)!;
if (typeCommits.length === 0) continue;
// 获取类型显示名称
const typeEmojiMap = createTypeEmojiMap();
const typeInfo = typeEmojiMap.get(type);
const typeTitle = typeInfo ? `${typeInfo.emoji} ${typeInfo.description}` : type.toUpperCase();
changelog += `\n### ${typeTitle}\n\n`;
typeCommits.forEach((commit) => {
changelog += formatCommitToChangelogLine(commit, repoUrl) + "\n";
});
}
} else {
// 按时间顺序生成变更日志
commits.forEach((commit) => {
changelog += formatCommitToChangelogLine(commit, repoUrl) + "\n";
});
}
// 添加贡献者信息
if (options?.includeAuthors) {
const authors = new Set<string>();
commits.forEach((commit) => {
commit.authors.forEach((author) => {
authors.add(author.name);
});
});
if (authors.size > 0) {
changelog += `\n### Contributors\n\n`;
Array.from(authors)
.sort()
.forEach((author) => {
changelog += `- ${author}\n`;
});
}
}
consola.success(`Generated changelog with ${commits.length} commits`);
return changelog;
} catch (error) {
consola.error("Error generating changelog from git history:", error);
return "";
}
}
/**
* 混合模式:结合 changesets 和 git commit 历史生成变更日志
* 当 changesets 不足时,自动补充 git commit 信息
*/
export async function generateHybridChangelog(
changesets: any[],
options?: {
repo?: string;
from?: string;
to?: string;
fallbackToGit?: boolean;
},
): Promise<string> {
try {
let changelog = "";
// 首先处理 changesets
if (changesets && changesets.length > 0) {
consola.info(`Processing ${changesets.length} changesets...`);
for (const changeset of changesets) {
// 这里可以调用 getReleaseLine 函数来处理每个 changeset
// 但由于我们在插件上下文外,需要模拟调用
const line = await getReleaseLine(changeset, "patch", { repo: options?.repo });
changelog += line + "\n";
}
}
// 如果启用回退到 git 且 changesets 不足,补充 git commit 信息
if (options?.fallbackToGit && (!changesets || changesets.length === 0)) {
consola.info("No changesets found, falling back to git commit history...");
const gitChangelog = await generateChangelogFromGitHistory(options.from, options.to, {
repo: options.repo,
groupByType: true,
includeAuthors: true,
});
changelog += gitChangelog;
}
return changelog;
} catch (error) {
consola.error("Error generating hybrid changelog:", error);
return "";
}
}
/**
* 导出 changesets changelog 函数
*/
export const changelogFunctions: ChangelogFunctions = {
getReleaseLine,
getDependencyReleaseLine,
};
export default changelogFunctions;