UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

243 lines (207 loc) 7.26 kB
import { exec } from "node:child_process"; import { promisify } from "node:util"; import { $inject, $state, z } from "alepha"; import { $command } from "alepha/command"; import { $logger } from "alepha/logger"; import { changelogOptions } from "../../atoms/changelogOptions.ts"; import { GitMessageParser } from "../../services/GitMessageParser.ts"; export { type ChangelogOptions, changelogOptions, DEFAULT_IGNORE, } from "../../atoms/changelogOptions.ts"; export { GitMessageParser } from "../../services/GitMessageParser.ts"; const execAsync = promisify(exec); // ============================================================================= // GIT PROVIDER // ============================================================================= /** * Git provider for executing git commands. * Can be substituted in tests with a mock implementation. */ export class GitProvider { async exec(cmd: string, cwd: string): Promise<string> { const { stdout } = await execAsync(`git ${cmd}`, { cwd }); return stdout; } } // ============================================================================= // TYPES // ============================================================================= export interface Commit { hash: string; type: string; scope: string | null; description: string; breaking: boolean; } interface ChangelogEntry { features: Commit[]; fixes: Commit[]; } // ============================================================================= // CHANGELOG COMMAND // ============================================================================= /** * Changelog command for generating release notes from git commits. * * Usage: * - `alepha gen changelog` - Show unreleased changes since latest tag to HEAD * - `alepha gen changelog --from=1.0.0` - Show changes from version to HEAD * - `alepha gen changelog --from=1.0.0 --to=1.1.0` - Show changes between two refs * - `alepha gen changelog | tee -a CHANGELOG.md` - Append to file */ export class ChangelogCommand { protected readonly log = $logger(); protected readonly git = $inject(GitProvider); protected readonly parser = $inject(GitMessageParser); protected readonly config = $state(changelogOptions); // --------------------------------------------------------------------------- // FORMATTING // --------------------------------------------------------------------------- /** * Format a single commit line. * Example: `- **cli**: add new command (\`abc1234\`)` * Breaking changes are flagged: `- **cli**: add new command [BREAKING] (\`abc1234\`)` */ protected formatCommit(commit: Commit): string { const breaking = commit.breaking ? " [BREAKING]" : ""; return `- **${commit.scope}**: ${commit.description}${breaking} (\`${commit.hash}\`)`; } /** * Format the changelog entry with sections. */ protected formatEntry(entry: ChangelogEntry): string { const sections: string[] = []; if (entry.features.length > 0) { sections.push("### Features\n"); for (const commit of entry.features) { sections.push(this.formatCommit(commit)); } sections.push(""); } if (entry.fixes.length > 0) { sections.push("### Bug Fixes\n"); for (const commit of entry.fixes) { sections.push(this.formatCommit(commit)); } sections.push(""); } return sections.join("\n"); } // --------------------------------------------------------------------------- // PARSING // --------------------------------------------------------------------------- /** * Parse git log output into a changelog entry. */ protected parseCommits(commitsOutput: string): ChangelogEntry { const entry: ChangelogEntry = { features: [], fixes: [], }; for (const line of commitsOutput.trim().split("\n")) { if (!line.trim()) continue; const commit = this.parser.parseCommit(line, this.config); if (!commit) { this.log.trace("Skipping commit", { line }); continue; } this.log.trace("Parsed commit", { commit }); // Categorize commit (breaking flag is preserved on the commit itself) if (commit.type === "feat") { entry.features.push(commit); } else if (commit.type === "fix") { entry.fixes.push(commit); } } return entry; } /** * Check if entry has any public commits. */ protected hasChanges(entry: ChangelogEntry): boolean { return entry.features.length > 0 || entry.fixes.length > 0; } /** * Get the latest version tag. */ protected async getLatestTag( git: (cmd: string) => Promise<string>, ): Promise<string | null> { const tagsOutput = await git("tag --sort=-version:refname"); const tags = tagsOutput .trim() .split("\n") .filter((tag) => tag.match(/^\d+\.\d+\.\d+$/)); return tags[0] || null; } // --------------------------------------------------------------------------- // COMMAND // --------------------------------------------------------------------------- public readonly command = $command({ name: "changelog", description: "Generate changelog from conventional commits (outputs to stdout)", flags: z.object({ /** * Show changes from this ref (tag, commit, branch). * Defaults to the latest version tag. * Example: --from=1.0.0 */ from: z .string() .meta({ aliases: ["f"] }) .describe("Starting ref (default: latest tag)") .optional(), /** * Show changes up to this ref (tag, commit, branch). * Defaults to HEAD. * Example: --to=main */ to: z .string() .meta({ aliases: ["t"] }) .describe("Ending ref (default: HEAD)") .optional(), }), handler: async ({ flags, root }) => { const git = (cmd: string) => this.git.exec(cmd, root); // Determine the starting point let fromRef: string; if (flags.from) { // User specified a ref fromRef = flags.from; this.log.debug("Using specified from ref", { from: fromRef }); } else { // Use latest tag const latestTag = await this.getLatestTag(git); if (!latestTag) { process.stdout.write("No version tags found in repository\n"); return; } fromRef = latestTag; this.log.debug("Using latest tag", { from: fromRef }); } // Determine the ending point const toRef = flags.to || "HEAD"; this.log.debug("Using to ref", { to: toRef }); // Get commits in range const commitsOutput = await git(`log ${fromRef}..${toRef} --oneline`); if (!commitsOutput.trim()) { process.stdout.write(`No changes in range ${fromRef}..${toRef}\n`); return; } // Parse and format const entry = this.parseCommits(commitsOutput); if (!this.hasChanges(entry)) { process.stdout.write( `No public changes in range ${fromRef}..${toRef}\n`, ); return; } // Output the formatted changelog (no header - caller adds it if needed) process.stdout.write(this.formatEntry(entry)); }, }); }