Skip to content

Instantly share code, notes, and snippets.

@pmutua
Created January 14, 2025 09:29
Show Gist options
  • Save pmutua/cc1e7ec654951a51a2a8a276213a1c36 to your computer and use it in GitHub Desktop.
Save pmutua/cc1e7ec654951a51a2a8a276213a1c36 to your computer and use it in GitHub Desktop.
Change log generator
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