|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * ✨ @ UnschooledGamer (baked With AI, Modified by @ UnschooledGamer) ~ 2025. |
| 4 | + * |
| 5 | + * GitHub Release Notes Generator |
| 6 | + * |
| 7 | + * Features: |
| 8 | + * - Auto categorizes commits by type |
| 9 | + * - Optional compact "plain" output to save space |
| 10 | + * - Option to include only important tags (feat, fix, refactor, perf) |
| 11 | + * - Option to use only merge commits |
| 12 | + * |
| 13 | + * Usage: |
| 14 | + * GITHUB_TOKEN=<token> node generate-release-notes.js <owner> <repo> <current_tag> [options] |
| 15 | + * |
| 16 | + * Options: |
| 17 | + * --plain Output minimal Markdown (no emojis, compact) |
| 18 | + * --important-only Include only features, fixes, refactors, and perf |
| 19 | + * --merge-only Include only merge commits |
| 20 | + * --help Show usage |
| 21 | + * --format [md/json] Output Format |
| 22 | + * --fromTag v1.11.0 The From/Previous Tag |
| 23 | + * --quiet Suppress output to stdout |
| 24 | + */ |
| 25 | + |
| 26 | +const args = process.argv.slice(2); |
| 27 | + |
| 28 | +function getArgValue(flag) { |
| 29 | + const idx = args.indexOf(flag); |
| 30 | + return idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith("-") |
| 31 | + ? args[idx + 1] |
| 32 | + : null; |
| 33 | +} |
| 34 | +if (args.includes("--help") || args.length < 3) { |
| 35 | + console.log(` |
| 36 | +Usage: GITHUB_TOKEN=<token> node generate-release-notes.js <owner> <repo> <tag> [options] |
| 37 | +✨ @ UnschooledGamer (baked With AI, Modified by @ UnschooledGamer) ~ 2025 |
| 38 | +
|
| 39 | +Options: |
| 40 | + --plain Compact, no emojis (saves space) |
| 41 | + --important-only Only include Features, Fixes, Refactors, Perf |
| 42 | + --merge-only Include only merge commits |
| 43 | + --help Show this help message |
| 44 | + --format [md/json] Output Format |
| 45 | + --from-tag v1.11.0 The From/Previous Tag |
| 46 | + --quiet Suppress output to stdout |
| 47 | + --stdout-only Output to stdout only |
| 48 | + --changelog-only Output changelog only |
| 49 | +`); |
| 50 | + process.exit(0); |
| 51 | +} |
| 52 | + |
| 53 | +const [owner, repo, currentTag, previousTagArg] = args; |
| 54 | +const token = process.env.GITHUB_TOKEN; |
| 55 | +if (!token) { |
| 56 | + console.error("❌ Missing GITHUB_TOKEN environment variable."); |
| 57 | + process.exit(1); |
| 58 | +} |
| 59 | + |
| 60 | +const flags = { |
| 61 | + plain: args.includes("--plain"), |
| 62 | + importantOnly: args.includes("--important-only"), |
| 63 | + mergeOnly: args.includes("--merge-only"), |
| 64 | + quiet: args.includes("--quiet") || args.includes("--stdout-only"), |
| 65 | + format: getArgValue("--format") || "md", |
| 66 | + fromTag: getArgValue("--from-tag"), |
| 67 | + changelogOnly: args.includes("--changelog-only"), |
| 68 | +}; |
| 69 | + |
| 70 | +function log(...msg) { |
| 71 | + if (!flags.quiet) console.error(...msg); |
| 72 | +} |
| 73 | + |
| 74 | +const headers = { |
| 75 | + Authorization: `token ${token}`, |
| 76 | + Accept: "application/vnd.github+json", |
| 77 | + "User-Agent": "release-notes-script", |
| 78 | +}; |
| 79 | + |
| 80 | +async function getPreviousTag() { |
| 81 | + const res = await fetch( |
| 82 | + `https://api.github.com/repos/${owner}/${repo}/tags`, |
| 83 | + { headers }, |
| 84 | + ); |
| 85 | + const tags = await res.json(); |
| 86 | + if (!Array.isArray(tags) || tags.length < 2) return null; |
| 87 | + return tags[1].name; |
| 88 | +} |
| 89 | + |
| 90 | +async function getCommits(previousTag, currentTag) { |
| 91 | + const url = `https://api.github.com/repos/${owner}/${repo}/compare/${previousTag}...${currentTag}`; |
| 92 | + const res = await fetch(url, { headers }); |
| 93 | + if (!res.ok) throw new Error(`Failed to fetch commits: ${res.status}`); |
| 94 | + const data = await res.json(); |
| 95 | + return data.commits || []; |
| 96 | +} |
| 97 | + |
| 98 | +function categorizeCommits(commits, { mergeOnly, importantOnly }) { |
| 99 | + const sections = { |
| 100 | + feat: [], |
| 101 | + fix: [], |
| 102 | + perf: [], |
| 103 | + refactor: [], |
| 104 | + docs: [], |
| 105 | + chore: [], |
| 106 | + test: [], |
| 107 | + add: [], |
| 108 | + revert: [], |
| 109 | + update: [], |
| 110 | + other: [], |
| 111 | + }; |
| 112 | + |
| 113 | + for (const c of commits) { |
| 114 | + const msg = c.commit.message.split("\n")[0]; |
| 115 | + const isMerge = |
| 116 | + msg.startsWith("Merge pull request") || msg.startsWith("Merge branch"); |
| 117 | + |
| 118 | + if (mergeOnly && !isMerge) continue; |
| 119 | + |
| 120 | + const type = |
| 121 | + Object.keys(sections).find( |
| 122 | + (k) => |
| 123 | + msg.toLowerCase().startsWith(`${k}:`) || |
| 124 | + msg.toLowerCase().startsWith(`${k} `), |
| 125 | + ) || "other"; |
| 126 | + |
| 127 | + if ( |
| 128 | + importantOnly && |
| 129 | + !["feat", "fix", "refactor", "perf", "add", "revert", "update"].includes( |
| 130 | + type, |
| 131 | + ) |
| 132 | + ) |
| 133 | + continue; |
| 134 | + |
| 135 | + const author = c.author?.login |
| 136 | + ? `[${c.author.login}](https://github.com/${c.author.login})` |
| 137 | + : "unknown"; |
| 138 | + |
| 139 | + const entry = `- ${msg} (${c.sha.slice(0, 7)}) by ${author}`; |
| 140 | + sections[type].push(entry); |
| 141 | + } |
| 142 | + |
| 143 | + return sections; |
| 144 | +} |
| 145 | + |
| 146 | +const emojis = { |
| 147 | + feat: flags.plain ? "" : "✨ ", |
| 148 | + fix: flags.plain ? "" : "🐞 ", |
| 149 | + perf: flags.plain ? "" : "⚡ ", |
| 150 | + refactor: flags.plain ? "" : "🔧 ", |
| 151 | + docs: flags.plain ? "" : "📝 ", |
| 152 | + chore: flags.plain ? "" : "🧹 ", |
| 153 | + test: flags.plain ? "" : "🧪 ", |
| 154 | + other: flags.plain ? "" : "📦 ", |
| 155 | + revert: flags.plain ? "" : "⏪ ", |
| 156 | + add: flags.plain ? "" : "➕ ", |
| 157 | + update: flags.plain ? "" : "🔄 ", |
| 158 | +}; |
| 159 | + |
| 160 | +function formatMarkdown(tag, prevTag, sections, { plain }) { |
| 161 | + const lines = [ |
| 162 | + flags.changelogOnly |
| 163 | + ? "" |
| 164 | + : `Changes since [${prevTag}](https://github.com/${owner}/${repo}/releases/tag/${prevTag})`, |
| 165 | + "", |
| 166 | + ]; |
| 167 | + |
| 168 | + for (const [type, list] of Object.entries(sections)) { |
| 169 | + if (list.length === 0) continue; |
| 170 | + const header = plain |
| 171 | + ? `## ${type}` |
| 172 | + : `## ${emojis[type]}${type[0].toUpperCase() + type.slice(1)}`; |
| 173 | + lines.push(header, "", list.join("\n"), ""); |
| 174 | + } |
| 175 | + |
| 176 | + // Compact single-line mode for super small output |
| 177 | + // if (plain) { |
| 178 | + // const compact = Object.entries(sections) |
| 179 | + // .filter(([_, list]) => list.length) |
| 180 | + // .map(([type, list]) => `${type.toUpperCase()}: ${list.length} commits`) |
| 181 | + // .join(" | "); |
| 182 | + // lines.push(`\n_Summary: ${compact}_`); |
| 183 | + // } |
| 184 | + |
| 185 | + return lines.join("\n"); |
| 186 | +} |
| 187 | + |
| 188 | +function formatJSON(tag, prevTag, sections, plain = true) { |
| 189 | + const lines = [ |
| 190 | + "", |
| 191 | + flags.changelogOnly |
| 192 | + ? "" |
| 193 | + : `Changes since [${prevTag}](https://github.com/${owner}/${repo}/releases/tag/${prevTag})`, |
| 194 | + "", |
| 195 | + ]; |
| 196 | + |
| 197 | + // todo: split into function |
| 198 | + for (const [type, list] of Object.entries(sections)) { |
| 199 | + if (list.length === 0) continue; |
| 200 | + const header = plain |
| 201 | + ? `## ${type}` |
| 202 | + : `## ${emojis[type]}${type[0].toUpperCase() + type.slice(1)}`; |
| 203 | + lines.push(header, "", list.join("\n"), ""); |
| 204 | + } |
| 205 | + return JSON.stringify( |
| 206 | + { |
| 207 | + release: tag, |
| 208 | + previous: prevTag, |
| 209 | + sections: Object.fromEntries( |
| 210 | + Object.entries(sections).filter(([_, v]) => v.length), |
| 211 | + ), |
| 212 | + notes: lines.join("\n"), |
| 213 | + }, |
| 214 | + null, |
| 215 | + 2, |
| 216 | + ); |
| 217 | +} |
| 218 | + |
| 219 | +async function main() { |
| 220 | + log(`🔍 Generating release notes for ${owner}/${repo} @ ${currentTag}...`); |
| 221 | + |
| 222 | + const prevTag = flags.fromTag || (await getPreviousTag()); |
| 223 | + if (!prevTag) { |
| 224 | + console.error("No previous tag found. Use --from-tag to specify one."); |
| 225 | + process.exit(1); |
| 226 | + } |
| 227 | + |
| 228 | + const commits = await getCommits(prevTag, currentTag); |
| 229 | + if (!commits.length) { |
| 230 | + console.error("No commits found."); |
| 231 | + process.exit(1); |
| 232 | + } |
| 233 | + const categorized = categorizeCommits(commits, flags); |
| 234 | + let output; |
| 235 | + |
| 236 | + if (flags.format === "json") { |
| 237 | + output = formatJSON(currentTag, prevTag, categorized); |
| 238 | + } else { |
| 239 | + output = formatMarkdown(currentTag, prevTag, categorized, flags); |
| 240 | + } |
| 241 | + |
| 242 | + process.stdout.write(output + "\n"); |
| 243 | +} |
| 244 | + |
| 245 | +main().catch((err) => console.error(err)); |
0 commit comments