mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-18 10:18:52 +08:00
311 lines
12 KiB
JavaScript
311 lines
12 KiB
JavaScript
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)
|
||
})
|