Created
January 14, 2025 09:29
-
-
Save pmutua/cc1e7ec654951a51a2a8a276213a1c36 to your computer and use it in GitHub Desktop.
Change log generator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const child = require('child_process'); | |
const fs = require('fs'); | |
const path = require('path'); | |
// Configuration | |
const CHANGELONG_PATH = path.resolve(__dirname, '../CHANGELOG.md'); | |
const PACKAGE_JSON_PATH = path.resolve(__dirname, '../package.json'); | |
const LOCK_FILE_PATH = path.resolve(__dirname, '../.changelog.lock'); | |
const REPO_URL_COMMAND = 'git config --get remote.origin.url'; | |
const COMMIT_URL_TEMPLATE = '%s/commit/%s'; // e.g., https://github.com/user/repo/commit/sha | |
/** | |
* Executes a shell command synchronously and returns the output. | |
* @param {string} cmd - The command to execute. | |
* @returns {string} - The output of the command. | |
* @throws {Error} - Throws an error if the command execution fails. | |
*/ | |
const executeCommand = (cmd) => { | |
try { | |
return child.execSync(cmd).toString('utf-8').trim(); | |
} catch (error) { | |
throw new Error(`Command failed: ${cmd}\n${error.message}`); | |
} | |
}; | |
/** | |
* Reads a JSON file and parses it into an object. | |
* @param {string} filePath - The path to the JSON file. | |
* @returns {Object} - The parsed JSON object. | |
* @throws {Error} - Throws an error if the file cannot be read or parsed. | |
*/ | |
const readJSON = (filePath) => { | |
try { | |
return JSON.parse(fs.readFileSync(filePath, 'utf-8')); | |
} catch (error) { | |
throw new Error( | |
`Failed to read or parse JSON file at ${filePath}: ${error.message}`, | |
); | |
} | |
}; | |
/** | |
* Writes data to a JSON file. | |
* @param {string} filePath - The path to the file. | |
* @param {Object} data - The data to write to the file. | |
* @throws {Error} - Throws an error if the file cannot be written. | |
*/ | |
const writeJSON = (filePath, data) => { | |
try { | |
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); | |
} catch (error) { | |
throw new Error( | |
`Failed to write JSON file at ${filePath}: ${error.message}`, | |
); | |
} | |
}; | |
/** | |
* Appends new changelog data to the existing CHANGE_LOG.md file. | |
* @param {string} newChangelog - The new changelog entry to append. | |
* @throws {Error} - Throws an error if the changelog file cannot be updated. | |
*/ | |
const appendChangelog = (newChangelog) => { | |
try { | |
const existingChangelog = fs.existsSync(CHANGELONG_PATH) | |
? fs.readFileSync(CHANGELONG_PATH, 'utf-8') | |
: ''; | |
fs.writeFileSync( | |
CHANGELONG_PATH, | |
`${newChangelog}\n\n${existingChangelog}`, | |
'utf-8', | |
); | |
} catch (error) { | |
throw new Error(`Failed to update CHANGELOG.md: ${error.message}`); | |
} | |
}; | |
/** | |
* Generates a changelog based on git commits, updates the version in package.json, and appends the changelog to CHANGE_LOG.md. | |
* The process includes handling different commit types (feat, fix, chore, docs, etc.) and determines if the version needs to be bumped. | |
* @throws {Error} - Throws an error if any part of the process fails. | |
*/ | |
const generateChangelog = () => { | |
// Lock Mechanism to prevent concurrent changelog generation | |
if (fs.existsSync(LOCK_FILE_PATH)) { | |
throw new Error('Another changelog generation process is running.'); | |
} | |
fs.writeFileSync(LOCK_FILE_PATH, 'lock'); | |
try { | |
// Ensure necessary files exist | |
if (!fs.existsSync(PACKAGE_JSON_PATH)) { | |
throw new Error('package.json not found.'); | |
} | |
// Backup Files | |
const backupFile = (filePath) => { | |
if (fs.existsSync(filePath)) { | |
fs.copyFileSync(filePath, `${filePath}.bak`); | |
} | |
}; | |
backupFile(PACKAGE_JSON_PATH); | |
backupFile(CHANGELONG_PATH); | |
// Get Repository URL | |
const repoUrl = executeCommand(REPO_URL_COMMAND); | |
if (!repoUrl) { | |
throw new Error('Failed to retrieve repository URL.'); | |
} | |
const commitUrlBase = repoUrl.endsWith('.git') | |
? repoUrl.slice(0, -4) | |
: repoUrl; | |
const commitUrlPattern = COMMIT_URL_TEMPLATE.replace('%s', commitUrlBase); | |
// Parse Commits | |
const gitLog = executeCommand(`git log --format=%B%H----DELIMITER----`); | |
const commitsArray = gitLog | |
.split('----DELIMITER----\n') | |
.map((commit) => { | |
const [message, sha] = commit.split('\n'); | |
return { sha: sha.trim(), message: message.trim() }; | |
}) | |
.filter((commit) => Boolean(commit.sha) && Boolean(commit.message)); | |
// Validate and Categorize Commits | |
const validCommitRegex = | |
/^(feat|fix|chore|docs|style|refactor|perf|test|build|ci|security|release|BREAKING CHANGE)(\([a-zA-Z0-9._\s-]+\))?:/; | |
const categorizedCommits = { | |
features: [], | |
fixes: [], | |
chores: [], | |
docs: [], | |
styles: [], | |
refactors: [], | |
perfs: [], | |
tests: [], | |
builds: [], | |
cis: [], | |
securitys: [], | |
}; | |
let isBreakingChange = false; | |
commitsArray.forEach((commit) => { | |
if (!validCommitRegex.test(commit.message)) { | |
console.warn(`Skipping invalid commit format: "${commit.message}"`); | |
return; | |
} | |
// Categorize the commit based on its type | |
Object.keys(categorizedCommits).forEach((category) => { | |
if (commit.message.startsWith(`${category.slice(0, -1)}: `)) { | |
categorizedCommits[category].push( | |
`* ${commit.message.replace(`${category.slice(0, -1)}: `, '')} ([${commit.sha.substring(0, 6)}](${commitUrlPattern.replace('%s', commit.sha)}))`, | |
); | |
} | |
}); | |
if (commit.message.includes('BREAKING CHANGE')) { | |
isBreakingChange = true; | |
} | |
}); | |
// If no relevant commits found, exit early | |
if ( | |
Object.values(categorizedCommits).every( | |
(commitList) => commitList.length === 0, | |
) && | |
!isBreakingChange | |
) { | |
console.log('No relevant commits found for changelog.'); | |
return; | |
} | |
// Read Current Version | |
const packageJson = readJSON(PACKAGE_JSON_PATH); | |
const currentVersion = packageJson.version; | |
if (!/^\d+\.\d+\.\d+$/.test(currentVersion)) { | |
throw new Error( | |
`Current version "${currentVersion}" does not follow SemVer format.`, | |
); | |
} | |
let [major, minor, patch] = currentVersion.split('.').map(Number); | |
// Determine Version Bump | |
if (isBreakingChange) { | |
major += 1; | |
minor = 0; | |
patch = 0; | |
} else if (categorizedCommits.features.length > 0) { | |
minor += 1; | |
patch = 0; | |
} else if (categorizedCommits.fixes.length > 0) { | |
patch += 1; | |
} | |
const newVersion = `${major}.${minor}.${patch}`; | |
packageJson.version = newVersion; | |
// Update package.json | |
writeJSON(PACKAGE_JSON_PATH, packageJson); | |
// Create New Changelog Entry | |
let newChangelog = `# Version ${newVersion} (${new Date().toISOString().split('T')[0]})\n\n`; | |
// Append commit categories to changelog | |
Object.keys(categorizedCommits).forEach((category) => { | |
if (categorizedCommits[category].length) { | |
newChangelog += `## ${category.charAt(0).toUpperCase() + category.slice(1)}\n${categorizedCommits[category].join('\n')}\n\n`; | |
} | |
}); | |
if (isBreakingChange) { | |
newChangelog += `## Breaking Changes\n* See [commit details](${commitUrlPattern.replace('%s', commitsArray.find((c) => c.message.includes('BREAKING CHANGE')).sha)})\n\n`; | |
} | |
// Append to CHANGE_LOG.md | |
appendChangelog(newChangelog); | |
console.log(`Changelog and version updated to ${newVersion}.`); | |
} catch (error) { | |
console.error('Error generating changelog:', error.message); | |
} finally { | |
// Clean up the lock file to allow future changelog generation | |
fs.unlinkSync(LOCK_FILE_PATH); | |
} | |
}; | |
// Run the script to generate the changelog | |
generateChangelog(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment