#!/usr/bin/env node /*! * @license * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-disable @typescript-eslint/naming-convention */ import { argv, exit } from 'node:process'; import { parseArgs } from 'node:util'; import { spawnSync } from 'node:child_process'; import * as path from 'path'; import { logger } from './logger'; import * as fs from 'fs'; import * as ejs from 'ejs'; interface Commit { hash: string; author: string; author_email: string; date: string; subject: string; } interface DiffOptions { /** * Commit range, e.g. "master..develop" */ range: string; /** * Working directory */ dir: string; /** * Max number of commits */ max?: number; /** * Number of commits to skip from the top */ skip?: number; /** * Exclude commits by the author */ exclude?: string; } /** * Get the remote URL for the cloned git repository * * @param workingDir Repository directory * @returns URL pointing to the git remote */ function getRemote(workingDir: string): string { // Use spawnSync with array arguments for safer command execution (prevents shell injection) const result = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd: workingDir, encoding: 'utf-8', shell: false }); if (result.error) { throw new Error(`Failed to get git remote: ${result.error.message}`); } if (result.status !== 0) { throw new Error(`git config command failed with exit code ${result.status}: ${result.stderr}`); } return result.stdout.trim(); } /** * Get the list of commits based on the configuration options * * @param options Logging options * @returns Collection of Commit objects */ function getCommits(options: DiffOptions): Array { let authorFilter = (options.exclude || '') .split(',') .map((str) => str.trim().replace(/\\/g, '')) .join('|'); if (!authorFilter) { authorFilter = `bot|Alfresco Build User`; } // Build git command arguments array for safe execution (prevents shell injection) const args = [ 'log', options.range, '--no-merges', '--first-parent', // this format is needed to allow parsing all characters in the commit message and safely convert to JSON '--format={ ^@^hash^@^: ^@^%h^@^, ^@^author^@^: ^@^%an^@^, ^@^author_email^@^: ^@^%ae^@^, ^@^date^@^: ^@^%ad^@^, ^@^subject^@^: ^@^%s^@^ }' ]; if (options.max !== undefined) { args.push(`--max-count=${options.max}`); } if (options.skip !== undefined) { args.push(`--skip=${options.skip}`); } // Use spawnSync with array arguments for safer command execution const result = spawnSync('git', args, { cwd: options.dir, encoding: 'utf-8', shell: false, maxBuffer: 10 * 1024 * 1024 // 10MB to handle large git logs }); if (result.error) { throw new Error(`Failed to get git commits: ${result.error.message}`); } if (result.status !== 0) { throw new Error(`git log command failed with exit code ${result.status}: ${result.stderr}`); } let log = result.stdout; // https://stackoverflow.com/a/13928240/14644447 log = JSON.stringify(log.trim()).slice(1, -1).replace(/\^@\^/gm, '"'); if (log.endsWith(',')) { log = log.substring(0, log.length - 1); } return log .split('\\n') .map((str: string) => { try { return JSON.parse(str) as Commit; } catch (error) { logger.error(`Unparsable commit message: ${str}, dropping it... Please apply manual fix.`); return null; } }) .filter((commit) => commit !== null) .filter((commit: Commit) => commitAuthorAllowed(commit, authorFilter)); } /** * Check if commit author is allowed * * @param commit git commit * @param authorFilter filter * @returns `true` if author is allowed, otherwise `false` */ function commitAuthorAllowed(commit: Commit, authorFilter: string): boolean { const filterRegex = RegExp(authorFilter); return !(filterRegex.test(commit.author) || filterRegex.test(commit.author_email)); } /** * Changelog command * * @param _args (unused) * @param workingDir working directory * @returns void */ export default function main(_args: string[], workingDir: string) { if (argv.includes('-h') || argv.includes('--help')) { console.log(` Usage: changelog [options] Generate changelog report for two branches of git repository Options: -v, --version Output the version number -r, --range Commit range, e.g. origin/master..develop (default: "origin/master..develop") -d, --dir Working directory (default: working directory) -m, --max Limit the number of commits to output -o, --output Output directory, will use console output if not defined --skip Skip number commits before starting to show the commit output -f, --format Output format (md, html) (default: "md") -e, --exclude Exclude authors from the output, comma-delimited list -h, --help Display help for command `); exit(0); } if (argv.includes('-v') || argv.includes('--version')) { console.log('0.0.1'); exit(0); } const { values } = parseArgs({ args: argv.slice(2), options: { range: { type: 'string', short: 'r', default: 'origin/master..develop' }, dir: { type: 'string', short: 'd' }, max: { type: 'string', short: 'm' }, output: { type: 'string', short: 'o' }, skip: { type: 'string' }, format: { type: 'string', short: 'f', default: 'md' }, exclude: { type: 'string', short: 'e' } }, allowPositionals: true }); const dir = path.resolve((values.dir as string) || workingDir); const range = values.range as string; const skip = values.skip ? parseInt(values.skip as string, 10) : undefined; const max = values.max ? parseInt(values.max as string, 10) : undefined; const format = values.format as string; const output = values.output as string | undefined; const exclude = values.exclude as string | undefined; const remote = getRemote(dir); let repo_url = remote; if (repo_url.endsWith('.git')) { repo_url = repo_url.substring(0, repo_url.length - 4); } const commits = getCommits({ dir, range, skip, max, exclude }); const packagePath = path.resolve(dir, 'package.json'); if (!fs.existsSync(packagePath)) { console.error('The package.json file was not found'); exit(1); } const templatePath = path.resolve(__dirname, `../templates/changelog-${format}.ejs`); if (!fs.existsSync(templatePath)) { console.error(`Cannot find the report template: ${templatePath}`); exit(1); } return new Promise((resolve, reject) => { const packageJson = JSON.parse(fs.readFileSync(packagePath).toString()); ejs.renderFile( templatePath, { remote, repo_url, commits, projVersion: packageJson.version, projName: packageJson.name }, {}, (err: any, text: string) => { if (err) { console.error(err); reject(err); } else { if (output) { const outputDir = path.resolve(output); const outputFile = path.join(outputDir, `changelog-${packageJson.version}.${format}`); console.log('Writing changelog to', outputFile); fs.writeFileSync(outputFile, text); } else { console.log(text); } resolve(0); } } ); }); }