import { execSync } from "node:child_process"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; // --- Types --- interface ConventionalCommit { hash: string; type: string; scope: string | null; subject: string; breaking: boolean; author: string; date: string; } interface ChangelogEntry { type: string; label: string; commits: ConventionalCommit[]; } interface ChangelogResult { version: string | null; date: string; entries: ChangelogEntry[]; total_commits: number; breaking_changes: ConventionalCommit[]; markdown: string; } // --- Constants --- const COMMIT_TYPE_LABELS: Record = { feat: "Features", fix: "Bug Fixes", docs: "Documentation", style: "Styles", refactor: "Code Refactoring", perf: "Performance Improvements", test: "Tests", ci: "CI/CD", build: "Build System", chore: "Miscellaneous", revert: "Reverts", }; const TYPE_ORDER = [ "feat", "fix", "perf", "refactor", "docs", "style", "test", "ci", "build", "chore", "revert", ]; // --- Parser --- const CONVENTIONAL_PATTERN = /^(\w+)(?:\(([^)]*)\))?(!)?:\s+(.+)$/; export function parseCommitLine(line: string): ConventionalCommit | null { // Format: hash|author|date|subject const parts = line.split("|"); if (parts.length < 4) return null; const [hash, author, date, ...subjectParts] = parts; const fullSubject = subjectParts.join("|").trim(); const match = CONVENTIONAL_PATTERN.exec(fullSubject); if (!match) { return { hash: hash.trim(), type: "other", scope: null, subject: fullSubject, breaking: false, author: author.trim(), date: date.trim(), }; } const [, type, scope, bang, subject] = match; return { hash: hash.trim(), type: type.toLowerCase(), scope: scope || null, subject: subject.trim(), breaking: bang === "!", author: author.trim(), date: date.trim(), }; } export function getGitLog( cwd?: string, fromRef?: string, toRef?: string, ): string { const base = cwd || process.cwd(); const range = fromRef ? `${fromRef}..${toRef || "HEAD"}` : ""; const cmd = `git log ${range} --format="%H|%an|%ad|%s" --date=short`; try { return execSync(cmd, { cwd: base, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, }).trim(); } catch { return ""; } } export function getLatestTag(cwd?: string): string | null { const base = cwd || process.cwd(); try { return execSync("git describe --tags --abbrev=0", { cwd: base, encoding: "utf-8", }).trim(); } catch { return null; } } export function getRepoUrl(cwd?: string): string | null { const base = cwd || process.cwd(); try { const url = execSync("git remote get-url origin", { cwd: base, encoding: "utf-8", }).trim(); // Convert SSH to HTTPS if (url.startsWith("git@github.com:")) { return `https://github.com/${url.replace("git@github.com:", "").replace(".git", "")}`; } return url.replace(".git", ""); } catch { return null; } } // --- Changelog generation --- export function generateChangelog( cwd?: string, fromRef?: string, toRef?: string, version?: string, ): ChangelogResult { const logOutput = getGitLog(cwd, fromRef, toRef); if (!logOutput) { return { version: version || null, date: new Date().toISOString().split("T")[0], entries: [], total_commits: 0, breaking_changes: [], markdown: "# Changelog\n\nNo commits found.\n", }; } const lines = logOutput.split("\n"); const commits: ConventionalCommit[] = []; for (const line of lines) { const commit = parseCommitLine(line); if (commit) commits.push(commit); } // Group by type const grouped = new Map(); for (const commit of commits) { const existing = grouped.get(commit.type) || []; existing.push(commit); grouped.set(commit.type, existing); } // Sort groups by TYPE_ORDER const entries: ChangelogEntry[] = []; for (const type of TYPE_ORDER) { const typeCommits = grouped.get(type); if (typeCommits && typeCommits.length > 0) { entries.push({ type, label: COMMIT_TYPE_LABELS[type] || type, commits: typeCommits, }); } } // Add any remaining types not in TYPE_ORDER for (const [type, typeCommits] of grouped) { if (!TYPE_ORDER.includes(type) && typeCommits.length > 0) { entries.push({ type, label: COMMIT_TYPE_LABELS[type] || type, commits: typeCommits, }); } } const breakingChanges = commits.filter((c) => c.breaking); const resolvedVersion = version || getLatestTag(cwd) || null; const date = new Date().toISOString().split("T")[0]; // Generate markdown const repoUrl = getRepoUrl(cwd); const markdown = formatMarkdown( entries, breakingChanges, resolvedVersion, date, repoUrl, ); return { version: resolvedVersion, date, entries, total_commits: commits.length, breaking_changes: breakingChanges, markdown, }; } function formatMarkdown( entries: ChangelogEntry[], breakingChanges: ConventionalCommit[], version: string | null, date: string, repoUrl: string | null, ): string { const lines: string[] = ["# Changelog", ""]; const header = version ? `## [${version}] - ${date}` : `## ${date}`; lines.push(header); lines.push(""); // Breaking changes first if (breakingChanges.length > 0) { lines.push("### ⚠ BREAKING CHANGES"); lines.push(""); for (const commit of breakingChanges) { const scope = commit.scope ? `**${commit.scope}:** ` : ""; const hash = repoUrl ? `[${commit.hash.slice(0, 7)}](${repoUrl}/commit/${commit.hash})` : commit.hash.slice(0, 7); lines.push(`- ${scope}${commit.subject} (${hash})`); } lines.push(""); } // Grouped entries for (const entry of entries) { if (entry.type === "other" && entry.commits.length === 0) continue; lines.push(`### ${entry.label}`); lines.push(""); for (const commit of entry.commits) { const scope = commit.scope ? `**${commit.scope}:** ` : ""; const hash = repoUrl ? `[${commit.hash.slice(0, 7)}](${repoUrl}/commit/${commit.hash})` : commit.hash.slice(0, 7); lines.push(`- ${scope}${commit.subject} (${hash})`); } lines.push(""); } return lines.join("\n"); } // --- Tool: generate_changelog --- // (registered in default export below) // --- Extension registration --- export default function (pi: ExtensionAPI) { // Tool: generate_changelog pi.registerTool({ name: "generate_changelog", label: "Generate Changelog", description: "Generate a markdown changelog from git history using conventional commits. Groups changes by type (feat, fix, chore, etc.) and highlights breaking changes.", parameters: Type.Object({ from_ref: Type.Optional( Type.String({ description: "Git ref to start from (tag, commit, branch). Defaults to latest tag.", }), ), to_ref: Type.Optional( Type.String({ description: "Git ref to end at. Defaults to HEAD.", }), ), version: Type.Optional( Type.String({ description: "Version label for the changelog header. Auto-detected from latest tag if omitted.", }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const fromRef = params.from_ref || getLatestTag(ctx.cwd) || undefined; const result = generateChangelog( ctx.cwd, fromRef, params.to_ref, params.version, ); return { content: [{ type: "text", text: result.markdown }], details: result, }; }, }); // Command: /changelog pi.registerCommand("changelog", { description: "Generate and display a changelog from git conventional commits", handler: async (_args, ctx) => { ctx.ui.notify("Generating changelog from git history...", "info"); const fromRef = getLatestTag(ctx.cwd) || undefined; const result = generateChangelog(ctx.cwd, fromRef); ctx.ui.notify(result.markdown, "info"); ctx.ui.notify( `Changelog generated: ${result.total_commits} commits, ${result.entries.length} categories`, "info", ); }, }); }