|
| 1 | +#!/usr/bin/env bun |
| 2 | +/** |
| 3 | + * Release Notes Generator |
| 4 | + * |
| 5 | + * Generates release notes from git commit history using conventional commits. |
| 6 | + * Analyzes commits between two references (tags, commits, or branches). |
| 7 | + * |
| 8 | + * Usage: |
| 9 | + * bun run scripts/generate-release-notes.ts [options] |
| 10 | + * |
| 11 | + * Options: |
| 12 | + * --since <ref> Start reference (tag, commit, branch). Default: auto-detect last tag |
| 13 | + * --until <ref> End reference. Default: HEAD |
| 14 | + * --version <ver> Version string for the release (e.g., "0.14.0") |
| 15 | + * --format <fmt> Output format: markdown (default), json |
| 16 | + * --output <file> Write to file instead of stdout |
| 17 | + */ |
| 18 | + |
| 19 | +import * as childProcess from "child_process"; |
| 20 | +import * as fs from "fs"; |
| 21 | + |
| 22 | +// Conventional commit types and their display names |
| 23 | +const COMMIT_TYPES: Record<string, { title: string; emoji: string; priority: number }> = { |
| 24 | + feat: { title: "New Features", emoji: "🚀", priority: 1 }, |
| 25 | + fix: { title: "Bug Fixes", emoji: "🐛", priority: 2 }, |
| 26 | + perf: { title: "Performance Improvements", emoji: "⚡", priority: 3 }, |
| 27 | + refactor: { title: "Refactoring", emoji: "♻️", priority: 4 }, |
| 28 | + docs: { title: "Documentation", emoji: "📚", priority: 5 }, |
| 29 | + chore: { title: "Maintenance", emoji: "🔧", priority: 6 }, |
| 30 | + test: { title: "Testing", emoji: "🧪", priority: 7 }, |
| 31 | + ci: { title: "CI/CD", emoji: "🔄", priority: 8 }, |
| 32 | + build: { title: "Build System", emoji: "📦", priority: 9 }, |
| 33 | + style: { title: "Code Style", emoji: "💅", priority: 10 }, |
| 34 | +}; |
| 35 | + |
| 36 | +// Scopes to highlight (CLI, monitoring, etc.) |
| 37 | +const KNOWN_SCOPES = ["cli", "monitoring", "reporter", "grafana", "mcp", "prepare-db", "checkup", "deps", "ci", "formula", "pgai", "dashboards"]; |
| 38 | + |
| 39 | +interface ParsedCommit { |
| 40 | + hash: string; |
| 41 | + shortHash: string; |
| 42 | + type: string; |
| 43 | + scope: string | null; |
| 44 | + subject: string; |
| 45 | + body: string; |
| 46 | + breaking: boolean; |
| 47 | + date: string; |
| 48 | + author: string; |
| 49 | +} |
| 50 | + |
| 51 | +interface ReleaseNotes { |
| 52 | + version: string; |
| 53 | + date: string; |
| 54 | + sinceRef: string; |
| 55 | + untilRef: string; |
| 56 | + commits: ParsedCommit[]; |
| 57 | + categories: Record<string, ParsedCommit[]>; |
| 58 | + breaking: ParsedCommit[]; |
| 59 | + stats: { |
| 60 | + total: number; |
| 61 | + features: number; |
| 62 | + fixes: number; |
| 63 | + contributors: string[]; |
| 64 | + }; |
| 65 | +} |
| 66 | + |
| 67 | +function execSync(command: string): string { |
| 68 | + try { |
| 69 | + const result = childProcess.execSync(command, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }); |
| 70 | + return result.trim(); |
| 71 | + } catch (err) { |
| 72 | + return ""; |
| 73 | + } |
| 74 | +} |
| 75 | + |
| 76 | +function parseArgs(): { since: string; until: string; version: string; format: string; output: string | null } { |
| 77 | + const args = process.argv.slice(2); |
| 78 | + const result = { since: "", until: "HEAD", version: "", format: "markdown", output: null as string | null }; |
| 79 | + |
| 80 | + for (let i = 0; i < args.length; i++) { |
| 81 | + const arg = args[i]; |
| 82 | + const next = args[i + 1]; |
| 83 | + switch (arg) { |
| 84 | + case "--since": |
| 85 | + result.since = next || ""; |
| 86 | + i++; |
| 87 | + break; |
| 88 | + case "--until": |
| 89 | + result.until = next || "HEAD"; |
| 90 | + i++; |
| 91 | + break; |
| 92 | + case "--version": |
| 93 | + result.version = next || ""; |
| 94 | + i++; |
| 95 | + break; |
| 96 | + case "--format": |
| 97 | + result.format = next || "markdown"; |
| 98 | + i++; |
| 99 | + break; |
| 100 | + case "--output": |
| 101 | + result.output = next || null; |
| 102 | + i++; |
| 103 | + break; |
| 104 | + case "--help": |
| 105 | + console.log(` |
| 106 | +Release Notes Generator |
| 107 | +
|
| 108 | +Usage: bun run scripts/generate-release-notes.ts [options] |
| 109 | +
|
| 110 | +Options: |
| 111 | + --since <ref> Start reference (tag, commit, or branch) |
| 112 | + Default: auto-detect last release tag |
| 113 | + --until <ref> End reference (tag, commit, or branch) |
| 114 | + Default: HEAD |
| 115 | + --version <ver> Version string for the release header |
| 116 | + Default: derived from --until or current date |
| 117 | + --format <fmt> Output format: markdown (default) or json |
| 118 | + --output <file> Write to file instead of stdout |
| 119 | +
|
| 120 | +Examples: |
| 121 | + # Generate notes for upcoming 0.14.0 release |
| 122 | + bun run scripts/generate-release-notes.ts --version 0.14.0 |
| 123 | +
|
| 124 | + # Generate notes between two commits |
| 125 | + bun run scripts/generate-release-notes.ts --since abc123 --until def456 |
| 126 | +
|
| 127 | + # Output as JSON |
| 128 | + bun run scripts/generate-release-notes.ts --format json |
| 129 | +`); |
| 130 | + process.exit(0); |
| 131 | + } |
| 132 | + } |
| 133 | + return result; |
| 134 | +} |
| 135 | + |
| 136 | +function detectLastTag(): string { |
| 137 | + // Try to find the last version tag |
| 138 | + const tags = execSync("git tag --sort=-version:refname 2>/dev/null").split("\n").filter(Boolean); |
| 139 | + |
| 140 | + // Look for semantic version tags |
| 141 | + for (const tag of tags) { |
| 142 | + if (/^v?\d+\.\d+/.test(tag)) { |
| 143 | + return tag; |
| 144 | + } |
| 145 | + } |
| 146 | + |
| 147 | + // Fallback: find a meaningful starting point from commit messages |
| 148 | + const versionCommit = execSync("git log --oneline --grep='prepare-for-0.14\\|0.13\\|release' --format='%H' | head -1"); |
| 149 | + if (versionCommit) { |
| 150 | + return versionCommit; |
| 151 | + } |
| 152 | + |
| 153 | + // Last resort: 100 commits back |
| 154 | + return "HEAD~100"; |
| 155 | +} |
| 156 | + |
| 157 | +function getCommitsBetween(since: string, until: string): string[] { |
| 158 | + // Get commit hashes between the two refs |
| 159 | + const range = since ? `${since}..${until}` : until; |
| 160 | + const output = execSync(`git log ${range} --format='%H' --no-merges 2>/dev/null`); |
| 161 | + return output.split("\n").filter(Boolean); |
| 162 | +} |
| 163 | + |
| 164 | +function parseCommit(hash: string): ParsedCommit | null { |
| 165 | + const format = "%H%n%h%n%s%n%b%n%ad%n%an%n---END---"; |
| 166 | + const output = execSync(`git log -1 --format='${format}' --date=short ${hash}`); |
| 167 | + |
| 168 | + if (!output) return null; |
| 169 | + |
| 170 | + const parts = output.split("\n---END---")[0]?.split("\n") || []; |
| 171 | + const [fullHash, shortHash, subject, ...rest] = parts; |
| 172 | + const author = rest.pop() || ""; |
| 173 | + const date = rest.pop() || ""; |
| 174 | + const body = rest.join("\n").trim(); |
| 175 | + |
| 176 | + if (!subject) return null; |
| 177 | + |
| 178 | + // Parse conventional commit format: type(scope): subject |
| 179 | + // Also handle: type: subject, type!: subject (breaking) |
| 180 | + const match = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/); |
| 181 | + |
| 182 | + let type = "other"; |
| 183 | + let scope: string | null = null; |
| 184 | + let breaking = false; |
| 185 | + let cleanSubject = subject; |
| 186 | + |
| 187 | + if (match) { |
| 188 | + type = match[1]?.toLowerCase() || "other"; |
| 189 | + scope = match[2] || null; |
| 190 | + breaking = !!match[3] || body.includes("BREAKING CHANGE"); |
| 191 | + cleanSubject = match[4] || subject; |
| 192 | + } |
| 193 | + |
| 194 | + // Normalize type aliases |
| 195 | + if (type === "feature") type = "feat"; |
| 196 | + if (type === "bugfix") type = "fix"; |
| 197 | + |
| 198 | + return { |
| 199 | + hash: fullHash || hash, |
| 200 | + shortHash: shortHash || hash.slice(0, 7), |
| 201 | + type, |
| 202 | + scope, |
| 203 | + subject: cleanSubject, |
| 204 | + body, |
| 205 | + breaking, |
| 206 | + date: date || new Date().toISOString().split("T")[0] || "", |
| 207 | + author, |
| 208 | + }; |
| 209 | +} |
| 210 | + |
| 211 | +function categorizeCommits(commits: ParsedCommit[]): Record<string, ParsedCommit[]> { |
| 212 | + const categories: Record<string, ParsedCommit[]> = {}; |
| 213 | + |
| 214 | + for (const commit of commits) { |
| 215 | + const type = COMMIT_TYPES[commit.type] ? commit.type : "other"; |
| 216 | + if (!categories[type]) { |
| 217 | + categories[type] = []; |
| 218 | + } |
| 219 | + categories[type].push(commit); |
| 220 | + } |
| 221 | + |
| 222 | + return categories; |
| 223 | +} |
| 224 | + |
| 225 | +function generateMarkdown(notes: ReleaseNotes): string { |
| 226 | + const lines: string[] = []; |
| 227 | + |
| 228 | + // Header |
| 229 | + const dateStr = new Date().toISOString().split("T")[0]; |
| 230 | + lines.push(`# Release ${notes.version || "Notes"}`); |
| 231 | + lines.push(""); |
| 232 | + lines.push(`**Release Date:** ${dateStr}`); |
| 233 | + lines.push(""); |
| 234 | + |
| 235 | + // Stats summary |
| 236 | + lines.push("## Summary"); |
| 237 | + lines.push(""); |
| 238 | + lines.push(`This release includes **${notes.stats.total}** changes:`); |
| 239 | + lines.push(`- ${notes.stats.features} new features`); |
| 240 | + lines.push(`- ${notes.stats.fixes} bug fixes`); |
| 241 | + if (notes.stats.contributors.length > 0) { |
| 242 | + lines.push(`- ${notes.stats.contributors.length} contributors`); |
| 243 | + } |
| 244 | + lines.push(""); |
| 245 | + |
| 246 | + // Breaking changes (if any) |
| 247 | + if (notes.breaking.length > 0) { |
| 248 | + lines.push("## Breaking Changes"); |
| 249 | + lines.push(""); |
| 250 | + for (const commit of notes.breaking) { |
| 251 | + const scopeStr = commit.scope ? `**${commit.scope}:** ` : ""; |
| 252 | + lines.push(`- ${scopeStr}${commit.subject}`); |
| 253 | + } |
| 254 | + lines.push(""); |
| 255 | + } |
| 256 | + |
| 257 | + // Categories sorted by priority |
| 258 | + const sortedTypes = Object.keys(notes.categories).sort((a, b) => { |
| 259 | + const pa = COMMIT_TYPES[a]?.priority ?? 99; |
| 260 | + const pb = COMMIT_TYPES[b]?.priority ?? 99; |
| 261 | + return pa - pb; |
| 262 | + }); |
| 263 | + |
| 264 | + for (const type of sortedTypes) { |
| 265 | + const commits = notes.categories[type]; |
| 266 | + if (!commits || commits.length === 0) continue; |
| 267 | + |
| 268 | + const typeInfo = COMMIT_TYPES[type] || { title: "Other Changes", emoji: "📝", priority: 99 }; |
| 269 | + lines.push(`## ${typeInfo.emoji} ${typeInfo.title}`); |
| 270 | + lines.push(""); |
| 271 | + |
| 272 | + // Group by scope within each type |
| 273 | + const byScope: Record<string, ParsedCommit[]> = {}; |
| 274 | + for (const commit of commits) { |
| 275 | + const scope = commit.scope || "_general"; |
| 276 | + if (!byScope[scope]) byScope[scope] = []; |
| 277 | + byScope[scope].push(commit); |
| 278 | + } |
| 279 | + |
| 280 | + // Sort scopes: known scopes first, then alphabetically |
| 281 | + const scopes = Object.keys(byScope).sort((a, b) => { |
| 282 | + if (a === "_general") return 1; |
| 283 | + if (b === "_general") return -1; |
| 284 | + const aKnown = KNOWN_SCOPES.includes(a); |
| 285 | + const bKnown = KNOWN_SCOPES.includes(b); |
| 286 | + if (aKnown && !bKnown) return -1; |
| 287 | + if (!aKnown && bKnown) return 1; |
| 288 | + return a.localeCompare(b); |
| 289 | + }); |
| 290 | + |
| 291 | + for (const scope of scopes) { |
| 292 | + const scopeCommits = byScope[scope] || []; |
| 293 | + if (scope !== "_general" && scopeCommits.length > 0) { |
| 294 | + lines.push(`### ${scope}`); |
| 295 | + lines.push(""); |
| 296 | + } |
| 297 | + for (const commit of scopeCommits) { |
| 298 | + lines.push(`- ${commit.subject} (\`${commit.shortHash}\`)`); |
| 299 | + } |
| 300 | + lines.push(""); |
| 301 | + } |
| 302 | + } |
| 303 | + |
| 304 | + // Contributors |
| 305 | + if (notes.stats.contributors.length > 0) { |
| 306 | + lines.push("## Contributors"); |
| 307 | + lines.push(""); |
| 308 | + lines.push("Thank you to all contributors:"); |
| 309 | + lines.push(""); |
| 310 | + for (const contributor of notes.stats.contributors.sort()) { |
| 311 | + lines.push(`- ${contributor}`); |
| 312 | + } |
| 313 | + lines.push(""); |
| 314 | + } |
| 315 | + |
| 316 | + return lines.join("\n"); |
| 317 | +} |
| 318 | + |
| 319 | +function generateJson(notes: ReleaseNotes): string { |
| 320 | + return JSON.stringify(notes, null, 2); |
| 321 | +} |
| 322 | + |
| 323 | +async function main() { |
| 324 | + const args = parseArgs(); |
| 325 | + |
| 326 | + // Determine the range |
| 327 | + const since = args.since || detectLastTag(); |
| 328 | + const until = args.until; |
| 329 | + |
| 330 | + const log = (msg: string) => process.stderr.write(msg + "\n"); |
| 331 | + log(`Analyzing commits from ${since} to ${until}...`); |
| 332 | + |
| 333 | + // Get and parse commits |
| 334 | + const hashes = getCommitsBetween(since, until); |
| 335 | + log(`Found ${hashes.length} commits to analyze`); |
| 336 | + |
| 337 | + const commits: ParsedCommit[] = []; |
| 338 | + for (const hash of hashes) { |
| 339 | + const parsed = parseCommit(hash); |
| 340 | + if (parsed) { |
| 341 | + commits.push(parsed); |
| 342 | + } |
| 343 | + } |
| 344 | + |
| 345 | + // Build release notes structure |
| 346 | + const categories = categorizeCommits(commits); |
| 347 | + const breaking = commits.filter((c) => c.breaking); |
| 348 | + const contributors = [...new Set(commits.map((c) => c.author))]; |
| 349 | + |
| 350 | + const notes: ReleaseNotes = { |
| 351 | + version: args.version || "", |
| 352 | + date: new Date().toISOString().split("T")[0] || "", |
| 353 | + sinceRef: since, |
| 354 | + untilRef: until, |
| 355 | + commits, |
| 356 | + categories, |
| 357 | + breaking, |
| 358 | + stats: { |
| 359 | + total: commits.length, |
| 360 | + features: categories["feat"]?.length || 0, |
| 361 | + fixes: categories["fix"]?.length || 0, |
| 362 | + contributors, |
| 363 | + }, |
| 364 | + }; |
| 365 | + |
| 366 | + // Generate output |
| 367 | + let output: string; |
| 368 | + if (args.format === "json") { |
| 369 | + output = generateJson(notes); |
| 370 | + } else { |
| 371 | + output = generateMarkdown(notes); |
| 372 | + } |
| 373 | + |
| 374 | + // Write output |
| 375 | + if (args.output) { |
| 376 | + fs.writeFileSync(args.output, output, "utf8"); |
| 377 | + log(`Release notes written to: ${args.output}`); |
| 378 | + } else { |
| 379 | + console.log(output); |
| 380 | + } |
| 381 | +} |
| 382 | + |
| 383 | +main().catch((err) => { |
| 384 | + console.error("Error generating release notes:", err); |
| 385 | + process.exit(1); |
| 386 | +}); |
0 commit comments