const fs = require('fs').promises
const fsSync = require('fs')
const path = require('path')
const { execSync } = require('child_process')
const getCommitsSinceLastBump = (repoDir) => {
try {
const bumps = execSync(
'git log --oneline --grep="bump version" --grep="chore: bump" --grep="chore(release):" --format="%H" -2',
{ cwd: repoDir, encoding: 'utf-8' }
).trim().split('\n').filter(Boolean)
if (bumps.length >= 1) {
const afterBump = execSync(
`git log --oneline --no-merges ${bumps[0]}..HEAD`,
{ cwd: repoDir, encoding: 'utf-8' }
).trim()
if (afterBump) return afterBump
}
if (bumps.length >= 2) {
const log = execSync(
`git log --oneline --no-merges ${bumps[1]}..${bumps[0]}`,
{ cwd: repoDir, encoding: 'utf-8' }
).trim()
if (log) return log
}
} catch (e) {}
try {
return execSync('git log --oneline --no-merges -20', { cwd: repoDir, encoding: 'utf-8' }).trim()
} catch (e) {
return ''
}
}
const getLastPublishedVersion = async (packageName, repoDir) => {
const nodeModulesPkgPath = path.resolve(repoDir, 'node_modules', packageName, 'package.json')
try {
const content = await fs.readFile(nodeModulesPkgPath, 'utf8')
return JSON.parse(content).version
} catch (e) {
return null
}
}
const getCommitForVersion = (repoDir, version) => {
try {
const commit = execSync(
`git log --all --oneline --grep="v${version}" --grep="${version}" --format="%H" -1`,
{ cwd: repoDir, encoding: 'utf-8' }
).trim()
if (commit) return commit
} catch (e) {}
try {
const commit = execSync(
`git log --all --oneline -S '"version": "${version}"' --format="%H" -- package.json | tail -1`,
{ cwd: repoDir, encoding: 'utf-8' }
).trim()
if (commit) return commit
} catch (e) {}
return null
}
/**
* Collect git logs from multiple repos.
*/
const collectLogs = async (options) => {
const { cwd, repos } = options
if (!repos || repos.length === 0) {
const log = getCommitsSinceLastBump(cwd)
return log ? [log] : []
}
const logs = []
for (const repoCfg of repos) {
const repoDir = path.resolve(cwd, repoCfg.dir)
try {
if (repoCfg.npmName) {
const bumpLog = getCommitsSinceLastBump(repoDir)
if (bumpLog) {
logs.push(`## ${repoCfg.dir}\n${bumpLog}`)
continue
}
const lastPublishedVersion = await getLastPublishedVersion(repoCfg.npmName, cwd)
let sinceCommit = null
if (lastPublishedVersion) {
sinceCommit = getCommitForVersion(repoDir, lastPublishedVersion)
}
let logCmd
if (sinceCommit) {
logCmd = `git log --oneline --no-merges ${sinceCommit}..HEAD`
} else {
let lastTag = ''
try {
lastTag = execSync('git describe --tags --abbrev=0 2>/dev/null', { cwd: repoDir, encoding: 'utf-8' }).trim()
} catch (e) {}
logCmd = lastTag ? `git log --oneline --no-merges ${lastTag}..HEAD` : 'git log --oneline --no-merges -20'
}
const log = execSync(logCmd, { cwd: repoDir, encoding: 'utf-8' }).trim()
if (log) logs.push(`## ${repoCfg.dir}\n${log}`)
} else {
const bumpLog = getCommitsSinceLastBump(repoDir)
if (bumpLog) {
logs.push(`## ${repoCfg.dir}\n${bumpLog}`)
continue
}
let lastTag = ''
try {
lastTag = execSync('git describe --tags --abbrev=0 2>/dev/null', { cwd: repoDir, encoding: 'utf-8' }).trim()
} catch (e) {}
const logCmd = lastTag ? `git log --oneline --no-merges ${lastTag}..HEAD` : 'git log --oneline --no-merges -20'
const log = execSync(logCmd, { cwd: repoDir, encoding: 'utf-8' }).trim()
if (log) logs.push(`## ${repoCfg.dir}\n${log}`)
}
} catch (e) {
console.error(`Failed to get git log for ${repoCfg.dir}:`, e.message)
}
}
return logs
}
/**
* Archive previous year entries from change-log.md into change-log.YYYY.md
* Only runs when the current year differs from the year of existing entries.
*/
const archivePreviousYear = async (changelogPath, cwd) => {
const currentYear = new Date().getFullYear()
let changelog
try {
changelog = await fs.readFile(changelogPath, 'utf-8')
} catch (e) {
return
}
// Find all version entries with their years (format: ### vYYYY.M.P)
const entryRegex = /### v(\d{4})\.\d+\.\d+/g
const years = new Set()
let match
while ((match = entryRegex.exec(changelog)) !== null) {
years.add(parseInt(match[1]))
}
// Find years that are not the current year and need archiving
for (const year of years) {
if (year >= currentYear) continue
const archivePath = path.resolve(cwd, `change-log.${year}.md`)
try {
await fs.access(archivePath)
// Archive already exists, skip
continue
} catch (e) {}
// Extract entries for this year
const yearEntries = []
const allEntriesRegex = /### v(\d{4}\.\d+\.\d+)\n([\s\S]*?)(?=\n### v|\n\[\/\/\]|$)/g
let entryMatch
while ((entryMatch = allEntriesRegex.exec(changelog)) !== null) {
const entryYear = parseInt(entryMatch[1].split('.')[0])
if (entryYear === year) {
yearEntries.push(`### v${entryMatch[1]}\n${entryMatch[2].trimEnd()}`)
}
}
if (yearEntries.length === 0) continue
// Write archive file
const archiveContent = yearEntries.join('\n\n') + '\n'
await fs.writeFile(archivePath, archiveContent)
console.log(`Archived ${yearEntries.length} entries from ${year} to change-log.${year}.md`)
// Remove archived entries from main changelog
for (const entry of yearEntries) {
const escapedHeader = entry.split('\n')[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const removeRegex = new RegExp(`\\n?${escapedHeader}\\n[\\s\\S]*?(?=\\n### v|\\n\\[\/\/\\]|$)`)
changelog = changelog.replace(removeRegex, '')
}
await fs.writeFile(changelogPath, changelog)
// Git add the archive
try {
execSync(`git add change-log.${year}.md`, { cwd, stdio: 'inherit' })
} catch (e) {}
}
}
/**
* Auto-create change-log.md if it doesn't exist.
*/
const ensureChangelog = async (changelogPath, projectName) => {
try {
await fs.access(changelogPath)
} catch (e) {
const content = `[//]: #@corifeus-header
# ${projectName}
[//]: #@corifeus-header:end
`
await fs.writeFile(changelogPath, content)
console.log(`Created change-log.md for ${projectName}`)
}
}
/**
* Generate changelog entry using Claude AI and update change-log.md
*
* @param {Object} options
* @param {string} options.cwd - project root
* @param {string} options.projectName - e.g. "P3X Network MCP" or "P3X Redis UI"
* @param {Array<{dir: string, npmName?: string}>} [options.repos] - workspace repos
* @param {boolean} [options.skipCommit] - skip git commit (useful when called from grunt)
*/
const generateChangelog = async (options) => {
const { cwd, projectName, repos, skipCommit } = options
const pkg = JSON.parse(await fs.readFile(path.resolve(cwd, 'package.json'), 'utf-8'))
const version = pkg.version
const today = new Date().toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' })
const changelogPath = path.resolve(cwd, 'change-log.md')
// Auto-create change-log.md if missing
await ensureChangelog(changelogPath, projectName || pkg.description || pkg.name)
// Archive previous year entries
await archivePreviousYear(changelogPath, cwd)
const logs = await collectLogs({ cwd, repos })
const allLogs = logs.join('\n\n')
if (!allLogs) {
console.log('No new commits for changelog')
return
}
const existingChangelog = await fs.readFile(changelogPath, 'utf-8')
let previousEntry = ''
const prevMatch = existingChangelog.match(/### v[\d.]+\nReleased on [^\n]+\n([\s\S]*?)(?=\n### v|\n$)/)
if (prevMatch) {
previousEntry = prevMatch[0]
}
let changelogEntry = ''
try {
const repoNote = repos && repos.length > 1
? `- The commits come from MULTIPLE repos (${repos.map(r => r.dir).join(', ')}) — include significant changes from ALL repos, not just the parent`
: ''
const prompt = `Generate a changelog entry for version v${version} of ${projectName}.
Based on these recent git commits, write changelog bullet points.
${allLogs}
IMPORTANT: The following is the PREVIOUS version's changelog entry. Do NOT repeat any of these items. Only include changes that are NEW in this version:
${previousEntry}
Rules:
- Use this EXACT format:
### v${version}
Released on ${today}
* CATEGORY: Description.
- Valid categories: FEATURE, BUGFIX, PERF, REFACTOR, DOCS, CHORE
- Each bullet starts with "* CATEGORY: " (with the asterisk)
- Only include user-facing or significant changes
- SKIP these types of commits entirely:
- Version bumps, submodule updates, typo fixes, changelog updates
- Anything related to the secure/ folder
- Internal CI/CD, build pipeline, or release automation changes
- Co-Authored-By lines or merge commits
- Keep descriptions concise (one line each)
- Do NOT repeat anything from the previous changelog entry shown above
- Do NOT use markdown code fences
- Output ONLY the changelog entry starting with ### — absolutely NO extra text
${repoNote}
- If there are many feature commits, list each feature separately`
const tmpPrompt = path.resolve(cwd, '.changelog-prompt.tmp')
await fs.writeFile(tmpPrompt, prompt)
changelogEntry = execSync(
`cat ${JSON.stringify(tmpPrompt)} | claude -p - --no-session-persistence`,
{ encoding: 'utf-8', timeout: 60000 }
).trim()
try { await fs.unlink(tmpPrompt) } catch (e) {}
const headerIndex = changelogEntry.indexOf('### v')
if (headerIndex > 0) {
changelogEntry = changelogEntry.substring(headerIndex)
}
const lines = changelogEntry.split('\n')
const lastBulletIndex = lines.reduce((last, line, i) => line.startsWith('* ') ? i : last, -1)
if (lastBulletIndex >= 0) {
changelogEntry = lines.slice(0, lastBulletIndex + 1).join('\n')
}
console.log('Claude generated changelog:\n' + changelogEntry)
} catch (e) {
console.error('Claude changelog generation failed, using fallback:', e.message)
changelogEntry = `### v${version}\nReleased on ${today}\n* CHORE: Release v${version}.`
}
let changelog = await fs.readFile(changelogPath, 'utf-8')
const headerEnd = '[//]: #@corifeus-header:end'
const headerEndIndex = changelog.indexOf(headerEnd)
if (headerEndIndex !== -1) {
const insertPos = headerEndIndex + headerEnd.length
changelog = changelog.slice(0, insertPos) + '\n\n' + changelogEntry + '\n' + changelog.slice(insertPos).replace(/^\n+/, '\n')
await fs.writeFile(changelogPath, changelog)
console.log(`Changelog updated for v${version}`)
} else {
console.error('Could not find header end marker in change-log.md')
}
if (!skipCommit) {
execSync(`git add change-log.md change-log.*.md 2>/dev/null; git commit -m "chore: update changelog for v${version}" || true`, {
cwd,
stdio: 'inherit',
})
}
return changelogEntry
}
/**
* Get changelog entry for a specific version
*/
const getChangelogEntry = (changelogPath, version) => {
try {
const changelog = fsSync.readFileSync(changelogPath, 'utf-8')
const versionEscaped = version.replace(/\./g, '\\.')
const regex = new RegExp(`### v${versionEscaped}\\n([\\s\\S]*?)(?=\\n### v|\\n\\[//\\]|$)`)
const match = changelog.match(regex)
if (match) {
return `### v${version}\n${match[1].trim()}`
}
} catch (e) {}
return ''
}
module.exports = {
generateChangelog,
getChangelogEntry,
collectLogs,
getCommitsSinceLastBump,
archivePreviousYear,
ensureChangelog,
}