Files
CipherTalk/scripts/generate-release-body.js
T

311 lines
12 KiB
JavaScript
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 rootDir = path.resolve(__dirname, '..')
const releaseDir = path.join(rootDir, 'release')
const contextPath = path.join(releaseDir, 'release-context.json')
const outputPath = path.join(releaseDir, 'release-body.md')
function parseEnvText(content) {
const result = {}
for (const line of String(content || '').split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIndex = trimmed.indexOf('=')
if (eqIndex <= 0) continue
const key = trimmed.slice(0, eqIndex).trim()
let value = trimmed.slice(eqIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
result[key] = value
}
return result
}
function loadLocalSecretEnv() {
const candidates = [
path.join(rootDir, '.release.local.env'),
path.join(rootDir, '.env.local')
]
const merged = {}
for (const filePath of candidates) {
if (!fs.existsSync(filePath)) continue
try {
const parsed = parseEnvText(fs.readFileSync(filePath, 'utf8'))
Object.assign(merged, parsed)
console.log(`[ReleaseBody] Loaded local env file: ${path.basename(filePath)}`)
} catch (e) {
console.warn(`[ReleaseBody] Failed to read local env file: ${filePath}`, String(e))
}
}
return merged
}
const localSecrets = loadLocalSecretEnv()
const aiApiKey = process.env.AI_API_KEY || localSecrets.AI_API_KEY || ''
const aiApiUrl = process.env.AI_API_URL || localSecrets.AI_API_URL || 'https://api.openai.com/v1/chat/completions'
const aiModel = process.env.AI_MODEL || localSecrets.AI_MODEL || 'gpt-5.4'
const PRODUCT_NAME = 'CipherTalk'
const PRIMARY_AUTHOR_LOGINS = new Set(['ILoveBingLu'])
const PRIMARY_AUTHOR_NAMES = new Set(['ILoveBingLu', 'BingLu', 'ILoveBinglu'])
function isPrimaryAuthor(person) {
if (!person) return false
const login = String(person.authorLogin || '').trim()
const name = String(person.authorName || '').trim()
return PRIMARY_AUTHOR_LOGINS.has(login) || PRIMARY_AUTHOR_NAMES.has(name)
}
function classifyCommit(subject) {
const normalized = String(subject || '').toLowerCase()
if (normalized.startsWith('feat')) return '新增'
if (normalized.startsWith('fix')) return '修复'
return '调整'
}
function buildThanks(context) {
const lines = []
for (const pr of context.pullRequests || []) {
if (!isPrimaryAuthor({ authorLogin: pr.authorLogin, authorName: pr.authorName })) {
lines.push(`- 感谢 @${pr.authorLogin} 提交 PR #${pr.number}${pr.title}`)
}
}
const prNumbers = new Set((context.pullRequests || []).map((pr) => pr.number))
for (const commit of context.commits || []) {
const hasPrRef = /#(\d+)/.test(commit.subject || '')
if (hasPrRef) continue
if (!isPrimaryAuthor(commit)) {
lines.push(`- 感谢 ${commit.authorName} 提交改动《${commit.subject}`)
}
}
return Array.from(new Set(lines))
}
function buildReferences(context) {
const lines = []
for (const pr of context.pullRequests || []) {
lines.push(`- PR #${pr.number}: [${pr.title}](${pr.url})`)
}
for (const commit of context.commits || []) {
lines.push(`- Commit [${commit.shortSha}](${commit.url}): ${commit.subject}`)
}
return lines
}
function inferReleaseTone(context) {
const subjects = (context.commits || []).map((commit) => String(commit.subject || '').toLowerCase())
if (subjects.some((subject) => subject.startsWith('feat'))) return '新功能开始成型'
if (subjects.some((subject) => subject.startsWith('fix'))) return '这次重点在修整体验'
if (subjects.some((subject) => subject.includes('release') || subject.includes('workflow') || subject.includes('ci'))) {
return '发布链路做了一轮收口'
}
if ((context.commits || []).length >= 5) return '这一版主要在做内部打磨'
return '这次更新以稳定和整理为主'
}
function buildReleaseTitle(context) {
return `${PRODUCT_NAME} v${context.version} · ${inferReleaseTone(context)}`
}
function buildFallbackBody(context) {
const groups = {
新增: [],
修复: [],
调整: []
}
for (const commit of context.commits || []) {
groups[classifyCommit(commit.subject)].push(`- ${commit.subject}${commit.shortSha}`)
}
const thanks = buildThanks(context)
const references = buildReferences(context)
const blockedVersions = context.forceUpdate?.blockedVersions || []
const hasUpgradeReminder = Boolean(context.forceUpdate?.minimumSupportedVersion || blockedVersions.length > 0)
const totalCommits = (context.commits || []).length
const totalPrs = (context.pullRequests || []).length
const touchedAreas = Object.entries(groups)
.filter(([, items]) => items.length > 0)
.map(([name]) => name)
const summary = touchedAreas.length
? `这次共整理了 ${totalCommits} 条提交${totalPrs ? `${totalPrs} 个 PR` : ''},重点落在 ${touchedAreas.join(' / ')}`
: `这次共整理了 ${totalCommits} 条提交${totalPrs ? `${totalPrs} 个 PR` : ''},整体以维护性调整为主。`
return [
`## ${buildReleaseTitle(context)}`,
'',
`> ${summary}`,
'',
'### 这次更新',
`- ${inferReleaseTone(context)}`,
`- ${summary}`,
'',
'### 变更明细',
'',
'#### 新增',
...(groups.新增.length ? groups.新增 : ['- 本次没有单独拎出来的新功能提交']),
'',
'#### 修复',
...(groups.修复.length ? groups.修复 : ['- 本次没有明确归类为缺陷修复的提交']),
'',
'#### 调整',
...(groups.调整.length ? groups.调整 : ['- 本次主要是零散维护项']),
'',
...(hasUpgradeReminder ? [
'### 升级提醒',
...(context.forceUpdate.minimumSupportedVersion ? [`- 最低安全版本:${context.forceUpdate.minimumSupportedVersion}`] : []),
...(blockedVersions.length ? [`- 封禁版本:${blockedVersions.join(', ')}`] : []),
''
] : []),
'### 感谢贡献者',
...(thanks.length ? thanks : ['- 本版本无新增外部贡献']),
'',
'### 相关提交与 PR',
...(references.length ? references : ['- 无']),
''
].join('\n')
}
function isValidAiBody(body) {
if (!body) return false
return body.includes(`## ${PRODUCT_NAME}`) && body.includes('### 感谢贡献者') && body.includes('### 相关提交与 PR')
}
function logAiConfig() {
console.log('[ReleaseBody] AI config:')
console.log(` apiUrl=${aiApiUrl}`)
console.log(` model=${aiModel}`)
console.log(` apiKeyConfigured=${Boolean(aiApiKey)}`)
console.log(` usingDefaultApiUrl=${!process.env.AI_API_URL}`)
console.log(` usingDefaultModel=${!process.env.AI_MODEL}`)
}
async function generateAiBody(context) {
if (!aiApiKey) {
throw new Error('AI_API_KEY 未配置')
}
logAiConfig()
const systemPrompt = [
'你是一个发布说明撰写助手。',
'只能基于输入中的 commits 和 pull requests 生成,不得编造任何功能或修复。',
'输出必须是中文 Markdown,风格要自然,像真实产品版本说明,不要写成死板模板。',
'标题必须包含软件名,不能只写版本号。',
'第一行使用格式:## CipherTalk vX.Y.Z · 一句简短版本名',
'第二段使用一行引用块(>)写一句导语,概括这次更新的重心。',
'正文优先使用以下结构:',
'### 这次更新',
'### 变更明细',
'#### 新增',
'#### 修复',
'#### 调整',
'### 感谢贡献者',
'### 相关提交与 PR',
'如果上方有些内容没有,即可用一些涩话来填充,不要显得很死板或机械。',
'如果存在最低安全版本或封禁版本,增加 ### 升级提醒 章节。',
'分类建议:可参考提交标题前缀 feat/fix 做粗分类到 新增/修复;其余放到 调整(如果标题无法判断,就放到 调整)。',
'如果某个分类为空,不要反复写“无/未检测到”这种机械表达,可以改成更自然但仍然克制的表述。',
'如果这次主要是 chore、ci、release、workflow、refactor,也要把这些工程改动翻译成用户能理解的影响,比如“发布链路更稳”“版本分发更顺”“维护成本更低”,但不能编造功能。',
'引用规则:',
'有 PR 时优先引用 PR 标题;没有 PR 时才引用 commit 标题。',
'列表尽量短:最多每类列出 5 条最关键的标题;其余可在导语或“这次更新”里用一句话说明总量。',
'感谢规则:只有非主作者的 PR/commit 才出现在感谢段;主作者按代码中的逻辑是 ILoveBingLu(及其大小写/拼写变体)相关。',
'不要写猜测:如果输入里没有足够信息,就明确说这次以内部整理、稳定性、发布链路或工程维护为主。',
'不要输出代码块,不要输出 JSON,不要套娃标题。'
].join('\n')
const userPrompt = [
`请根据以下发布上下文,为 ${PRODUCT_NAME} ${context.tag} 生成一份更有辨识度的发布说明。`,
'附加要求:',
`- 标题必须带 ${PRODUCT_NAME} 和版本号,并给这次版本起一个简短名字。`,
'- 不要每次都复用同一套句式。',
'- 如果提交主要是发布流程、CI、脚本、环境变量之类的工程项,也要写出它们对发版和分发稳定性的意义。',
'- 保留“感谢贡献者”和“相关提交与 PR”章节。',
'- 你是一个类似伤感者的文案大师,写出来的东西要有温度和辨识度,不要死板无趣。',
'',
JSON.stringify(context, null, 2)
].join('\n')
const startedAt = Date.now()
console.log(`[ReleaseBody] AI request start for ${context.tag}`)
const response = await fetch(aiApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${aiApiKey}`
},
body: JSON.stringify({
model: aiModel,
temperature: 0.7,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
]
})
})
const durationMs = Date.now() - startedAt
console.log(`[ReleaseBody] AI response received status=${response.status} durationMs=${durationMs}`)
if (!response.ok) {
const raw = await response.text()
console.error(`[ReleaseBody] AI response error body=${raw}`)
throw new Error(`AI 请求失败: ${response.status}`)
}
const data = await response.json()
const content = data?.choices?.[0]?.message?.content
console.log(`[ReleaseBody] AI content length=${typeof content === 'string' ? content.length : 0}`)
if (typeof content !== 'string' || !content.trim()) {
throw new Error('AI 返回内容为空')
}
const body = content.trim()
if (!isValidAiBody(body)) {
console.error('[ReleaseBody] AI output preview:')
console.error(body.slice(0, 1000))
throw new Error('AI 返回内容不符合格式要求')
}
console.log('[ReleaseBody] AI output validated successfully')
return body
}
async function main() {
if (!fs.existsSync(contextPath)) {
throw new Error(`未找到 release context: ${contextPath}`)
}
const context = JSON.parse(fs.readFileSync(contextPath, 'utf8'))
let body
try {
body = await generateAiBody(context)
console.log('✅ 已生成 AI Release Body')
} catch (error) {
console.warn('⚠️ AI 生成失败,回退到模板正文:', String(error))
body = buildFallbackBody(context)
console.log(`[ReleaseBody] Fallback body length=${body.length}`)
}
fs.writeFileSync(outputPath, `${body.trim()}\n`, 'utf8')
console.log(`✅ release-body.md 已生成: ${outputPath}`)
console.log(`[ReleaseBody] Final body length=${body.trim().length}`)
}
main().catch((error) => {
console.error('❌ 生成 release-body.md 失败:', error)
process.exit(1)
})