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.

Revisions

  1. pmutua created this gist Jan 14, 2025.
    233 changes: 233 additions & 0 deletions changelog.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,233 @@
    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();