feat(release): include commit body in changelog output

This commit is contained in:
Quorafind 2025-11-29 21:12:46 +08:00
parent 694898644c
commit b1e3c22082

View file

@ -1,10 +1,10 @@
#!/usr/bin/env node
import { execSync } from 'child_process';
import { readFileSync, writeFileSync } from 'fs';
import semver from 'semver';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from "child_process";
import { readFileSync, writeFileSync } from "fs";
import semver from "semver";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -14,20 +14,22 @@ const __dirname = path.dirname(__filename);
*/
export function generateMergedChangelog(targetVersion, options = {}) {
const { dryRun = false } = options;
// 获取上一个正式版本标签
function getLastStableTag() {
try {
const allTags = execSync('git tag -l', { encoding: 'utf8' })
const allTags = execSync("git tag -l", { encoding: "utf8" })
.trim()
.split('\n')
.split("\n")
.filter(Boolean);
const stableTags = [];
for (const tag of allTags) {
try {
execSync(`git merge-base --is-ancestor ${tag} HEAD`, { encoding: 'utf8' });
const versionString = tag.replace(/^v/, '');
execSync(`git merge-base --is-ancestor ${tag} HEAD`, {
encoding: "utf8",
});
const versionString = tag.replace(/^v/, "");
const version = semver.valid(versionString);
if (version && !semver.prerelease(version)) {
stableTags.push({ tag, version });
@ -36,162 +38,207 @@ export function generateMergedChangelog(targetVersion, options = {}) {
// 标签不在当前分支历史中,跳过
}
}
if (stableTags.length === 0) {
return 'HEAD~30';
return "HEAD~30";
}
const sortedTags = stableTags.sort((a, b) => {
return semver.rcompare(a.version, b.version);
});
return sortedTags[0].tag;
} catch (error) {
return 'HEAD~30';
return "HEAD~30";
}
}
const lastStableTag = getLastStableTag();
console.log(`📦 Generating changelog from ${lastStableTag} to ${targetVersion}`);
console.log(
`📦 Generating changelog from ${lastStableTag} to ${targetVersion}`,
);
// 获取所有提交
const rawCommits = execSync(
`git log ${lastStableTag}..HEAD --pretty=format:"%H|||%s|||%b|||%an|||%ae|||%ad" --no-merges`,
{ encoding: 'utf8' }
{ encoding: "utf8" },
).trim();
const commits = rawCommits ? rawCommits.split('\n').filter(Boolean) : [];
const commits = rawCommits ? rawCommits.split("\n").filter(Boolean) : [];
// 按类型分组提交
const groupedCommits = {
'Features': [],
'Bug Fixes': [],
'Performance': [],
'Refactors': [],
'Documentation': [],
'Styles': [],
'Tests': [],
'Reverts': [],
'Breaking Changes': []
Features: [],
"Bug Fixes": [],
Performance: [],
Refactors: [],
Documentation: [],
Styles: [],
Tests: [],
Reverts: [],
"Breaking Changes": [],
};
const typeMap = {
'feat': 'Features',
'fix': 'Bug Fixes',
'perf': 'Performance',
'refactor': 'Refactors',
'docs': 'Documentation',
'style': 'Styles',
'test': 'Tests',
'revert': 'Reverts'
feat: "Features",
fix: "Bug Fixes",
perf: "Performance",
refactor: "Refactors",
docs: "Documentation",
style: "Styles",
test: "Tests",
revert: "Reverts",
};
// 解析提交
commits.forEach(commit => {
const parts = commit.split('|||');
commits.forEach((commit) => {
const parts = commit.split("|||");
if (parts.length < 2) return;
const [hash, subject, body] = parts;
if (!subject) return;
// 解析 conventional commit 格式
const match = subject.match(/^(\w+)(?:\(([^)]+)\))?: (.+)$/);
if (!match) return;
const [, type, scope, description] = match;
// 过滤掉 beta release commits
if (type === 'chore' && description && (
description.includes('beta') ||
description.includes('-beta.') ||
description.match(/v\d+\.\d+\.\d+-beta/))) {
if (
type === "chore" &&
description &&
(description.includes("beta") ||
description.includes("-beta.") ||
description.match(/v\d+\.\d+\.\d+-beta/))
) {
return;
}
// 检查是否有 BREAKING CHANGE
if (body && body.includes('BREAKING CHANGE')) {
groupedCommits['Breaking Changes'].push({
if (body && body.includes("BREAKING CHANGE")) {
groupedCommits["Breaking Changes"].push({
hash: hash.substring(0, 7),
scope,
description,
body
body,
});
}
const section = typeMap[type];
if (section) {
groupedCommits[section].push({
hash: hash.substring(0, 7),
scope,
description
description,
body: body?.trim() || "",
});
}
});
// 生成 changelog 内容
const date = new Date().toISOString().split('T')[0];
const date = new Date().toISOString().split("T")[0];
const compareUrl = `https://github.com/Quorafind/Obsidian-Task-Genius/compare/${lastStableTag}...${targetVersion}`;
let newChangelog = `## [${targetVersion}](${compareUrl}) (${date})\n\n`;
// 按顺序输出各个分组Breaking Changes 优先)
const sectionOrder = ['Breaking Changes', 'Features', 'Bug Fixes', 'Performance', 'Refactors', 'Documentation', 'Tests', 'Styles', 'Reverts'];
sectionOrder.forEach(section => {
const sectionOrder = [
"Breaking Changes",
"Features",
"Bug Fixes",
"Performance",
"Refactors",
"Documentation",
"Tests",
"Styles",
"Reverts",
];
sectionOrder.forEach((section) => {
const sectionCommits = groupedCommits[section];
if (sectionCommits && sectionCommits.length > 0) {
newChangelog += `### ${section}\n\n`;
sectionCommits.forEach(commit => {
sectionCommits.forEach((commit) => {
const commitUrl = `https://github.com/Quorafind/Obsidian-Task-Genius/commit/${commit.hash}`;
const scopePrefix = commit.scope ? `**${commit.scope}:** ` : '';
const scopePrefix = commit.scope ? `**${commit.scope}:** ` : "";
newChangelog += `* ${scopePrefix}${commit.description} ([${commit.hash}](${commitUrl}))\n`;
if (commit.body && section === 'Breaking Changes') {
// 添加 breaking change 详细说明
const breakingDetail = commit.body.split('BREAKING CHANGE:')[1]?.trim();
if (breakingDetail) {
newChangelog += ` ${breakingDetail}\n`;
// Add commit body as sub-items if present
if (commit.body) {
if (section === "Breaking Changes") {
// Handle BREAKING CHANGE specially
const breakingDetail = commit.body
.split("BREAKING CHANGE:")[1]
?.trim();
if (breakingDetail) {
newChangelog += ` ${breakingDetail}\n`;
}
} else {
// Parse body lines and add as sub-list items
const bodyLines = commit.body
.split("\n")
.map((line) => line.trim())
.filter(
(line) =>
line && !line.startsWith("BREAKING CHANGE"),
);
if (bodyLines.length > 0) {
bodyLines.forEach((line) => {
// Remove leading "- " if present to avoid double bullets
const cleanLine = line.replace(/^[-*]\s*/, "");
if (cleanLine) {
newChangelog += ` - ${cleanLine}\n`;
}
});
}
}
}
});
newChangelog += '\n';
newChangelog += "\n";
}
});
// 读取现有的 changelog
const changelogPath = path.join(__dirname, '..', 'CHANGELOG.md');
let existingChangelog = '';
const changelogPath = path.join(__dirname, "..", "CHANGELOG.md");
let existingChangelog = "";
try {
existingChangelog = readFileSync(changelogPath, 'utf8');
existingChangelog = readFileSync(changelogPath, "utf8");
} catch (e) {
// 文件不存在,创建新的
existingChangelog = '# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n';
existingChangelog =
"# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n";
}
// 确保不重复添加相同版本
if (existingChangelog.includes(`## [${targetVersion}]`)) {
console.log(`⚠️ Version ${targetVersion} already exists in CHANGELOG.md`);
console.log(
`⚠️ Version ${targetVersion} already exists in CHANGELOG.md`,
);
return existingChangelog;
}
// 插入新的 changelog 到文件开头(在 header 之后)
const header = '# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n';
const restContent = existingChangelog.replace(header, '').trim();
const updatedChangelog = header + '\n' + newChangelog + restContent;
const header =
"# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n";
const restContent = existingChangelog.replace(header, "").trim();
const updatedChangelog = header + "\n" + newChangelog + restContent;
if (!dryRun) {
writeFileSync(changelogPath, updatedChangelog);
console.log(`✅ CHANGELOG.md updated with version ${targetVersion}`);
} else {
console.log('📋 Preview (dry-run mode):');
console.log("📋 Preview (dry-run mode):");
console.log(newChangelog);
}
return updatedChangelog;
}
// 如果直接运行此脚本
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const version = process.argv[2] || '9.8.0';
const dryRun = process.argv.includes('--dry-run');
const version = process.argv[2] || "9.8.0";
const dryRun = process.argv.includes("--dry-run");
generateMergedChangelog(version, { dryRun });
}
}