|
| 1 | +#!/usr/bin/env bun |
| 2 | + |
| 3 | +import { parseArgs } from "node:util"; |
| 4 | +import { createOpencode } from "@opencode-ai/sdk"; |
| 5 | +import { $ } from "bun"; |
| 6 | + |
| 7 | +export const team = ["donnes", "actions-user"]; |
| 8 | + |
| 9 | +export async function getLatestRelease() { |
| 10 | + return fetch("https://api.github.com/repos/donnes/syncode/releases/latest") |
| 11 | + .then((res) => { |
| 12 | + if (!res.ok) throw new Error(res.statusText); |
| 13 | + return res.json(); |
| 14 | + }) |
| 15 | + .then((data: { tag_name: string }) => data.tag_name.replace(/^v/, "")); |
| 16 | +} |
| 17 | + |
| 18 | +type Commit = { |
| 19 | + hash: string; |
| 20 | + author: string | null; |
| 21 | + message: string; |
| 22 | +}; |
| 23 | + |
| 24 | +export async function getCommits(from: string, to: string): Promise<Commit[]> { |
| 25 | + const fromRef = from.startsWith("v") ? from : `v${from}`; |
| 26 | + const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}`; |
| 27 | + |
| 28 | + // Get commit data with GitHub usernames from the API |
| 29 | + const compare = |
| 30 | + await $`gh api "/repos/donnes/syncode/compare/${fromRef}...${toRef}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text(); |
| 31 | + |
| 32 | + const commitData = new Map< |
| 33 | + string, |
| 34 | + { login: string | null; message: string } |
| 35 | + >(); |
| 36 | + for (const line of compare.split("\n").filter(Boolean)) { |
| 37 | + const data = JSON.parse(line) as { |
| 38 | + sha: string; |
| 39 | + login: string | null; |
| 40 | + message: string; |
| 41 | + }; |
| 42 | + commitData.set(data.sha, { |
| 43 | + login: data.login, |
| 44 | + message: data.message.split("\n")[0] ?? "", |
| 45 | + }); |
| 46 | + } |
| 47 | + |
| 48 | + // Get commits from the range |
| 49 | + const log = |
| 50 | + await $`git log ${fromRef}..${toRef} --oneline --format="%H"`.text(); |
| 51 | + const hashes = log.split("\n").filter(Boolean); |
| 52 | + |
| 53 | + const commits: Commit[] = []; |
| 54 | + for (const hash of hashes) { |
| 55 | + const data = commitData.get(hash); |
| 56 | + if (!data) continue; |
| 57 | + |
| 58 | + const message = data.message; |
| 59 | + if (message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue; |
| 60 | + |
| 61 | + commits.push({ |
| 62 | + hash: hash.slice(0, 7), |
| 63 | + author: data.login, |
| 64 | + message, |
| 65 | + }); |
| 66 | + } |
| 67 | + |
| 68 | + return filterRevertedCommits(commits); |
| 69 | +} |
| 70 | + |
| 71 | +function filterRevertedCommits(commits: Commit[]): Commit[] { |
| 72 | + const revertPattern = /^Revert "(.+)"$/; |
| 73 | + const seen = new Map<string, Commit>(); |
| 74 | + |
| 75 | + for (const commit of commits) { |
| 76 | + const match = commit.message.match(revertPattern); |
| 77 | + if (match) { |
| 78 | + const original = match[1]!; |
| 79 | + if (seen.has(original)) seen.delete(original); |
| 80 | + else seen.set(commit.message, commit); |
| 81 | + } else { |
| 82 | + const revertMsg = `Revert "${commit.message}"`; |
| 83 | + if (seen.has(revertMsg)) seen.delete(revertMsg); |
| 84 | + else seen.set(commit.message, commit); |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + return [...seen.values()]; |
| 89 | +} |
| 90 | + |
| 91 | +async function summarizeCommit( |
| 92 | + opencode: Awaited<ReturnType<typeof createOpencode>>, |
| 93 | + message: string, |
| 94 | +): Promise<string> { |
| 95 | + console.log("summarizing commit:", message); |
| 96 | + const session = await opencode.client.session.create(); |
| 97 | + const result = await opencode.client.session |
| 98 | + .prompt({ |
| 99 | + path: { id: session.data!.id }, |
| 100 | + body: { |
| 101 | + model: { providerID: "opencode", modelID: "claude-sonnet-4-5" }, |
| 102 | + tools: { |
| 103 | + "*": false, |
| 104 | + }, |
| 105 | + parts: [ |
| 106 | + { |
| 107 | + type: "text", |
| 108 | + text: `Summarize this commit message for a changelog entry. Return ONLY a single line summary starting with a capital letter. Be concise but specific. If the commit message is already well-written, just clean it up (capitalize, fix typos, proper grammar). Do not include any prefixes like "fix:" or "feat:". |
| 109 | +
|
| 110 | +Commit: ${message}`, |
| 111 | + }, |
| 112 | + ], |
| 113 | + }, |
| 114 | + signal: AbortSignal.timeout(120_000), |
| 115 | + }) |
| 116 | + .then( |
| 117 | + (x) => x.data?.parts?.find((y) => y.type === "text")?.text ?? message, |
| 118 | + ); |
| 119 | + return result.trim(); |
| 120 | +} |
| 121 | + |
| 122 | +export async function generateChangelog( |
| 123 | + commits: Commit[], |
| 124 | + opencode: Awaited<ReturnType<typeof createOpencode>>, |
| 125 | +) { |
| 126 | + // Summarize commits in parallel with max 10 concurrent requests |
| 127 | + const BATCH_SIZE = 10; |
| 128 | + const summaries: string[] = []; |
| 129 | + for (let i = 0; i < commits.length; i += BATCH_SIZE) { |
| 130 | + const batch = commits.slice(i, i + BATCH_SIZE); |
| 131 | + const results = await Promise.all( |
| 132 | + batch.map((c) => summarizeCommit(opencode, c.message)), |
| 133 | + ); |
| 134 | + summaries.push(...results); |
| 135 | + } |
| 136 | + |
| 137 | + const lines: string[] = []; |
| 138 | + lines.push("## Changes"); |
| 139 | + |
| 140 | + for (let i = 0; i < commits.length; i++) { |
| 141 | + const commit = commits[i]!; |
| 142 | + const attribution = |
| 143 | + commit.author && !team.includes(commit.author) |
| 144 | + ? ` (@${commit.author})` |
| 145 | + : ""; |
| 146 | + lines.push(`- ${summaries[i]}${attribution}`); |
| 147 | + } |
| 148 | + |
| 149 | + return lines; |
| 150 | +} |
| 151 | + |
| 152 | +export async function getContributors(from: string, to: string) { |
| 153 | + const fromRef = from.startsWith("v") ? from : `v${from}`; |
| 154 | + const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}`; |
| 155 | + const compare = |
| 156 | + await $`gh api "/repos/donnes/syncode/compare/${fromRef}...${toRef}" --jq '.commits[] | {login: .author.login, message: .commit.message}'`.text(); |
| 157 | + const contributors = new Map<string, Set<string>>(); |
| 158 | + |
| 159 | + for (const line of compare.split("\n").filter(Boolean)) { |
| 160 | + const { login, message } = JSON.parse(line) as { |
| 161 | + login: string | null; |
| 162 | + message: string; |
| 163 | + }; |
| 164 | + const title = message.split("\n")[0] ?? ""; |
| 165 | + if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue; |
| 166 | + |
| 167 | + if (login && !team.includes(login)) { |
| 168 | + if (!contributors.has(login)) contributors.set(login, new Set()); |
| 169 | + contributors.get(login)!.add(title); |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + return contributors; |
| 174 | +} |
| 175 | + |
| 176 | +export async function buildNotes(from: string, to: string) { |
| 177 | + const commits = await getCommits(from, to); |
| 178 | + |
| 179 | + if (commits.length === 0) { |
| 180 | + return []; |
| 181 | + } |
| 182 | + |
| 183 | + console.log("generating changelog since " + from); |
| 184 | + |
| 185 | + const opencode = await createOpencode({ port: 5044 }); |
| 186 | + const notes: string[] = []; |
| 187 | + |
| 188 | + try { |
| 189 | + const lines = await generateChangelog(commits, opencode); |
| 190 | + notes.push(...lines); |
| 191 | + console.log("---- Generated Changelog ----"); |
| 192 | + console.log(notes.join("\n")); |
| 193 | + console.log("-----------------------------"); |
| 194 | + } catch (error) { |
| 195 | + if (error instanceof Error && error.name === "TimeoutError") { |
| 196 | + console.log("Changelog generation timed out, using raw commits"); |
| 197 | + for (const commit of commits) { |
| 198 | + const attribution = |
| 199 | + commit.author && !team.includes(commit.author) |
| 200 | + ? ` (@${commit.author})` |
| 201 | + : ""; |
| 202 | + notes.push(`- ${commit.message}${attribution}`); |
| 203 | + } |
| 204 | + } else { |
| 205 | + throw error; |
| 206 | + } |
| 207 | + } finally { |
| 208 | + opencode.server.close(); |
| 209 | + } |
| 210 | + |
| 211 | + const contributors = await getContributors(from, to); |
| 212 | + |
| 213 | + if (contributors.size > 0) { |
| 214 | + notes.push(""); |
| 215 | + notes.push( |
| 216 | + `**Thank you to ${contributors.size} community contributor${contributors.size > 1 ? "s" : ""}:**`, |
| 217 | + ); |
| 218 | + for (const [username, userCommits] of contributors) { |
| 219 | + notes.push(`- @${username}:`); |
| 220 | + for (const c of userCommits) { |
| 221 | + notes.push(` - ${c}`); |
| 222 | + } |
| 223 | + } |
| 224 | + } |
| 225 | + |
| 226 | + return notes; |
| 227 | +} |
| 228 | + |
| 229 | +// CLI entrypoint |
| 230 | +if (import.meta.main) { |
| 231 | + const { values } = parseArgs({ |
| 232 | + args: Bun.argv.slice(2), |
| 233 | + options: { |
| 234 | + from: { type: "string", short: "f" }, |
| 235 | + to: { type: "string", short: "t", default: "HEAD" }, |
| 236 | + help: { type: "boolean", short: "h", default: false }, |
| 237 | + }, |
| 238 | + }); |
| 239 | + |
| 240 | + if (values.help) { |
| 241 | + console.log(` |
| 242 | +Usage: bun scripts/changelog.ts [options] |
| 243 | +
|
| 244 | +Options: |
| 245 | + -f, --from <version> Starting version (default: latest GitHub release) |
| 246 | + -t, --to <ref> Ending ref (default: HEAD) |
| 247 | + -h, --help Show this help message |
| 248 | +
|
| 249 | +Examples: |
| 250 | + bun scripts/changelog.ts # Latest release to HEAD |
| 251 | + bun scripts/changelog.ts --from 1.0.0 # v1.0.0 to HEAD |
| 252 | + bun scripts/changelog.ts -f 1.0.0 -t 1.1.0 |
| 253 | +`); |
| 254 | + process.exit(0); |
| 255 | + } |
| 256 | + |
| 257 | + const to = values.to!; |
| 258 | + const from = values.from ?? (await getLatestRelease()); |
| 259 | + |
| 260 | + console.log(`Generating changelog: v${from} -> ${to}\n`); |
| 261 | + |
| 262 | + const notes = await buildNotes(from, to); |
| 263 | + console.log("\n=== Final Notes ==="); |
| 264 | + console.log(notes.join("\n")); |
| 265 | +} |
0 commit comments