dootask/bin/version.js
2026-01-13 14:48:05 +00:00

348 lines
12 KiB
JavaScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const fs = require('fs');
const path = require("path");
const exec = require('child_process').exec;
let ProxyAgent = null;
try {
ProxyAgent = require("undici").ProxyAgent;
} catch (error) {
ProxyAgent = null;
}
const packageFile = path.resolve(process.cwd(), "package.json");
const changeFile = path.resolve(process.cwd(), "CHANGELOG.md");
const verOffset = 6394; // 版本号偏移量
const codeOffset = 34; // 代码版本号偏移量
const envFilePath = path.resolve(process.cwd(), ".env");
const defaultAiSystemPrompt = "你是一位软件发布日志编辑专家。请产出 Markdown 更新日志,面向普通用户,以通俗友好的简体中文描述更新带来的直接好处,避免技术术语。所有章节标题必须以 `### ` 开头并保持英文 Title Case例如 `### Features`、`### Bug Fixes`、`### Performance`、`### Documentation` 等)。每个章节内的条目按用户价值和影响范围排序,将更重要、影响更广的更新放在前面。";
const defaultOpenAiEndpoint = "https://api.openai.com/v1/chat/completions";
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) {
return;
}
const content = fs.readFileSync(filePath, "utf8");
content.split(/\r?\n/).forEach(rawLine => {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
return;
}
const equalsIndex = line.indexOf("=");
if (equalsIndex === -1) {
return;
}
let key = line.slice(0, equalsIndex).trim();
if (key.startsWith("export ")) {
key = key.slice(7).trim();
}
let value = line.slice(equalsIndex + 1).trim();
if (!value) {
value = "";
}
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
} else {
const commentIndex = value.indexOf(" #");
if (commentIndex !== -1) {
value = value.slice(0, commentIndex).trim();
}
}
if (process.env[key] === undefined) {
process.env[key] = value;
}
});
}
loadEnvFile(envFilePath);
function resolveApiEndpoint(candidate) {
const source = (candidate || "").trim();
if (!source) {
return defaultOpenAiEndpoint;
}
if (/\/chat\/completions(\?|$)/.test(source)) {
return source;
}
const normalized = source.replace(/\/+$/, "");
if (/\/v\d+$/i.test(normalized)) {
return `${normalized}/chat/completions`;
}
return `${normalized}/v1/chat/completions`;
}
function loadSocksProxyAgent(proxyUrl) {
try {
const { SocksProxyAgent } = require('socks-proxy-agent');
return new SocksProxyAgent(proxyUrl);
} catch (error) {
if (error && error.code === 'MODULE_NOT_FOUND') {
console.warn("检测到 SOCKS 代理,但未安装 socks-proxy-agent请运行 `npm install --save-dev socks-proxy-agent` 后重试。");
} else {
console.warn(`无法初始化 SOCKS 代理: ${error?.message || error}`);
}
return null;
}
}
function createProxyDispatcher(proxyUrl) {
if (!proxyUrl) {
return null;
}
let parsedProtocol = '';
try {
parsedProtocol = new URL(proxyUrl).protocol.replace(':', '').toLowerCase();
} catch (error) {
console.warn(`代理地址无效 (${proxyUrl}): ${error.message}`);
return null;
}
if (parsedProtocol.startsWith('socks')) {
return loadSocksProxyAgent(proxyUrl);
}
if (!ProxyAgent) {
console.warn('未找到 undici.ProxyAgent无法启用 HTTP 代理。');
return null;
}
try {
return new ProxyAgent(proxyUrl);
} catch (error) {
console.warn(`无法初始化代理 (${proxyUrl}): ${error.message}`);
return null;
}
}
function buildDefaultUserPrompt(version, changelogSection) {
return [
"你是一位软件发布日志编辑专家。",
"下面是一段通过 git 提交记录自动生成的更新日志文本。",
"",
"请将其整理为一份「面向普通用户、简洁概览风格」的 changelog保持 Markdown 格式,包含以下结构:",
"",
`## [${version}]`,
"",
"### Features",
"",
"- ...",
"",
"### Bug Fixes",
"",
"- ...",
"",
"### Performance",
"",
"- ...",
"",
"**要求:**",
"1. 删除技术性或重复的细节,合并相似项。",
"2. 语句自然简洁,用简体中文描述。",
"3. 使用贴近日常的词汇,突出更新对普通用户的直接价值,避免开发或管理术语(如\"refactor\"、\"merge branch\"、\"commit lint\")。",
"4. 小节标题必须以 `### ` 开头并保持英文 Title Case例如 `### Features`、`### Bug Fixes`、`### Performance`、`### Documentation`、`### Security`、`### Miscellaneous` 等),不得翻译成中文。",
"5. 每个小节内的条目按用户价值和影响范围排序,将更重要、影响更广的更新放在前面。",
"6. 若某个小节没有内容,请省略整段小节(包括标题)。",
"7. 输出仅为 Markdown changelog 内容,不加其他解释。",
"",
"以下是原始日志:",
"```markdown",
changelogSection,
"```"
].join("\n");
}
function runExec(command) {
return new Promise((resolve, reject) => {
exec(command, { maxBuffer: 1024 * 1024 * 10 }, (err, stdout, stderr) => {
if (err) {
reject(err);
return;
}
resolve(stdout.toString());
});
});
}
function removeDuplicateLines(log) {
const logs = log.split(/(\n## \[.*?\])/);
return logs.map(str => {
const array = [];
const items = str.split("\n");
items.forEach(item => {
if (/^-/.test(item)) {
if (array.indexOf(item) === -1) {
array.push(item);
}
} else {
array.push(item);
}
});
return array.join("\n");
}).join('');
}
function findSectionBounds(content, version) {
const heading = `## [${version}]`;
const start = content.indexOf(heading);
if (start === -1) {
return null;
}
const nextHeadingIndex = content.indexOf("\n## [", start + heading.length);
const end = nextHeadingIndex === -1 ? content.length : nextHeadingIndex;
return { start, end };
}
function trimCliffOutput(rawOutput, version) {
const markerIndex = rawOutput.indexOf("## [");
if (markerIndex === -1) {
return "";
}
return rawOutput
.slice(markerIndex)
.replace("## [Unreleased]", `## [${version}]`)
.trim();
}
function buildAiHeaders(apiUrl, apiKey) {
const headers = { "Content-Type": "application/json" };
const customHeader = process.env.CHANGELOG_AI_AUTH_HEADER;
if (customHeader) {
const separatorIndex = customHeader.indexOf(":");
if (separatorIndex !== -1) {
const headerName = customHeader.slice(0, separatorIndex).trim();
const headerValue = customHeader.slice(separatorIndex + 1).trim();
if (headerName && headerValue) {
headers[headerName] = headerValue;
}
}
return headers;
}
if (apiUrl.includes("openai.azure.com")) {
headers["api-key"] = apiKey;
} else {
headers.Authorization = `Bearer ${apiKey}`;
}
return headers;
}
async function enhanceWithAI(version, changelogSection) {
const apiKey = (process.env.OPENAI_API_KEY || "").trim();
if (!apiKey) {
console.warn("未设置 OPENAI_API_KEY跳过 AI 发布日志整理。");
return changelogSection;
}
const proxyUrl = (process.env.OPENAI_PROXY_URL || "").trim();
const explicitApiUrl = process.env.CHANGELOG_AI_URL || process.env.OPENAI_API_URL;
const apiUrl = resolveApiEndpoint(explicitApiUrl);
const dispatcher = createProxyDispatcher(proxyUrl);
const model = process.env.CHANGELOG_AI_MODEL || process.env.OPENAI_API_MODEL || "gpt-4o-mini";
const systemPrompt = process.env.CHANGELOG_AI_SYSTEM_PROMPT || defaultAiSystemPrompt;
const userPrompt = process.env.CHANGELOG_AI_PROMPT || buildDefaultUserPrompt(version, changelogSection);
try {
const requestInit = {
method: "POST",
headers: buildAiHeaders(apiUrl, apiKey),
body: JSON.stringify({
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }
],
})
};
if (dispatcher) {
requestInit.dispatcher = dispatcher;
}
const response = await fetch(apiUrl, requestInit);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`AI request failed: ${errorText}`);
}
const data = await response.json();
const aiText = data?.choices?.[0]?.message?.content?.trim();
if (!aiText) {
throw new Error("AI response did not contain content.");
}
return aiText
.replace(/^\s*```markdown\s*/i, "")
.replace(/\s*```\s*$/i, "")
.trim();
} catch (error) {
console.warn("AI summarization failed, falling back to original section:", error.message);
return changelogSection;
}
}
async function generateLatestSection(version) {
const rawOutput = await runExec('docker run -t --rm -v "$(pwd)":/app/ orhunp/git-cliff:1.3.0 --unreleased');
const section = trimCliffOutput(rawOutput, version);
if (!section.trim() || section.trim() === `## [${version}]`) {
return "";
}
return section;
}
function insertChangelogSection(existing, section, version) {
const trimmedSection = section.trim();
if (!trimmedSection) {
return existing;
}
const bounds = findSectionBounds(existing, version);
if (bounds) {
return `${existing.slice(0, bounds.start)}${trimmedSection}\n\n${existing.slice(bounds.end).replace(/^(\n)+/, "")}`;
}
const insertIndex = existing.indexOf("\n## [");
if (insertIndex === -1) {
return `${existing.trimEnd()}\n\n${trimmedSection}\n`;
}
const head = existing.slice(0, insertIndex).trimEnd();
const tail = existing.slice(insertIndex).replace(/^(\n)+/, "");
return `${head}\n\n${trimmedSection}\n\n${tail}`;
}
async function main() {
try {
const verCountRaw = await runExec("git rev-list --count HEAD");
const codeCountRaw = await runExec("git tag --merged pro -l 'v*' | wc -l");
const verCount = verCountRaw.trim();
const codeCount = codeCountRaw.trim();
const num = verOffset + parseInt(verCount, 10);
if (Number.isNaN(num) || Math.floor(num % 100) < 0) {
throw new Error(`get version error ${verCount}`);
}
const version = `${Math.floor(num / 10000)}.${Math.floor((num % 10000) / 100)}.${Math.floor(num % 100)}`;
const codeVersion = codeOffset + parseInt(codeCount, 10);
let packageContent = fs.readFileSync(packageFile, "utf8");
packageContent = packageContent.replace(/"version":\s*"(.*?)"/, `"version": "${version}"`);
packageContent = packageContent.replace(/"codeVerson":(.*?)(,|$)/, `"codeVerson": ${codeVersion}$2`);
fs.writeFileSync(packageFile, packageContent, "utf8");
console.log("New version: " + version);
console.log("New code verson: " + codeVersion);
if (!fs.existsSync(changeFile)) {
throw new Error("Change file does not exist");
}
const latestSection = await generateLatestSection(version);
if (!latestSection) {
console.log("No new changelog entries detected.");
return;
}
const aiSection = await enhanceWithAI(version, latestSection);
const changelogContent = fs.readFileSync(changeFile, "utf8");
const mergedContent = insertChangelogSection(changelogContent, aiSection, version);
const dedupedContent = removeDuplicateLines(mergedContent);
fs.writeFileSync(changeFile, dedupedContent.trimEnd() + "\n", "utf8");
console.log("Log file updated: CHANGELOG.md");
} catch (error) {
console.error(error);
process.exitCode = 1;
}
}
main();