#!/usr/bin/env bun import { $ } from "bun" import { createOpencode } from "@opencode-ai/sdk/v2" import { parseArgs } from "util" export const team = [ "actions-user", "opencode", "rekram1-node", "thdxr", "kommander", "jayair", "fwang", "adamdotdevin", "iamdavidhill", "opencode-agent[bot]", "R44VC0RP", ] type Release = { tag_name: string draft: boolean prerelease: boolean } export async function getLatestRelease(skip?: string) { const data = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=100").then((res) => { if (!res.ok) throw new Error(res.statusText) return res.json() }) const releases = data as Release[] const target = skip?.replace(/^v/, "") for (const release of releases) { if (release.draft) continue const tag = release.tag_name.replace(/^v/, "") if (target && tag === target) continue return tag } throw new Error("No releases found") } type Commit = { hash: string author: string | null message: string areas: Set } export async function getCommits(from: string, to: string): Promise { const fromRef = from.startsWith("v") ? from : `v${from}` const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}` // Get commit data with GitHub usernames from the API const compare = await $`gh api "/repos/anomalyco/opencode/compare/${fromRef}...${toRef}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text() const commitData = new Map() for (const line of compare.split("\n").filter(Boolean)) { const data = JSON.parse(line) as { sha: string; login: string | null; message: string } commitData.set(data.sha, { login: data.login, message: data.message.split("\n")[0] ?? "" }) } // Get commits that touch the relevant packages const log = await $`git log ${fromRef}..${toRef} --oneline --format="%H" -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text() const hashes = log.split("\n").filter(Boolean) const commits: Commit[] = [] for (const hash of hashes) { const data = commitData.get(hash) if (!data) continue const message = data.message if (message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue const files = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text() const areas = new Set() for (const file of files.split("\n").filter(Boolean)) { if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui") else if (file.startsWith("packages/opencode/")) areas.add("core") else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri") else if (file.startsWith("packages/desktop/")) areas.add("app") else if (file.startsWith("packages/app/")) areas.add("app") else if (file.startsWith("packages/sdk/")) areas.add("sdk") else if (file.startsWith("packages/plugin/")) areas.add("plugin") else if (file.startsWith("packages/extensions/")) areas.add("extensions/zed") else if (file.startsWith("sdks/vscode/")) areas.add("extensions/vscode") else if (file.startsWith("github/")) areas.add("github") } if (areas.size === 0) continue commits.push({ hash: hash.slice(0, 7), author: data.login, message, areas, }) } return filterRevertedCommits(commits) } function filterRevertedCommits(commits: Commit[]): Commit[] { const revertPattern = /^Revert "(.+)"$/ const seen = new Map() for (const commit of commits) { const match = commit.message.match(revertPattern) if (match) { // It's a revert - remove the original if we've seen it const original = match[1]! if (seen.has(original)) seen.delete(original) else seen.set(commit.message, commit) // Keep revert if original not in range } else { // Regular commit - remove if its revert exists, otherwise add const revertMsg = `Revert "${commit.message}"` if (seen.has(revertMsg)) seen.delete(revertMsg) else seen.set(commit.message, commit) } } return [...seen.values()] } const sections = { core: "Core", tui: "TUI", app: "Desktop", tauri: "Desktop", sdk: "SDK", plugin: "SDK", "extensions/zed": "Extensions", "extensions/vscode": "Extensions", github: "Extensions", } as const function getSection(areas: Set): string { // Priority order for multi-area commits const priority = ["core", "tui", "app", "tauri", "sdk", "plugin", "extensions/zed", "extensions/vscode", "github"] for (const area of priority) { if (areas.has(area)) return sections[area as keyof typeof sections] } return "Core" } async function summarizeCommit(opencode: Awaited>, message: string): Promise { console.log("summarizing commit:", message) const session = await opencode.client.session.create() const result = await opencode.client.session .prompt( { sessionID: session.data!.id, model: { providerID: "opencode", modelID: "claude-sonnet-4-5" }, tools: { "*": false, }, parts: [ { type: "text", 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:". Commit: ${message}`, }, ], }, { signal: AbortSignal.timeout(120_000), }, ) .then((x) => x.data?.parts?.find((y) => y.type === "text")?.text ?? message) return result.trim() } export async function generateChangelog(commits: Commit[], opencode: Awaited>) { // Summarize commits in parallel with max 10 concurrent requests const BATCH_SIZE = 10 const summaries: string[] = [] for (let i = 0; i < commits.length; i += BATCH_SIZE) { const batch = commits.slice(i, i + BATCH_SIZE) const results = await Promise.all(batch.map((c) => summarizeCommit(opencode, c.message))) summaries.push(...results) } const grouped = new Map() for (let i = 0; i < commits.length; i++) { const commit = commits[i]! const section = getSection(commit.areas) const attribution = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : "" const entry = `- ${summaries[i]}${attribution}` if (!grouped.has(section)) grouped.set(section, []) grouped.get(section)!.push(entry) } const sectionOrder = ["Core", "TUI", "Desktop", "SDK", "Extensions"] const lines: string[] = [] for (const section of sectionOrder) { const entries = grouped.get(section) if (!entries || entries.length === 0) continue lines.push(`## ${section}`) lines.push(...entries) } return lines } export async function getContributors(from: string, to: string) { const fromRef = from.startsWith("v") ? from : `v${from}` const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}` const compare = await $`gh api "/repos/anomalyco/opencode/compare/${fromRef}...${toRef}" --jq '.commits[] | {login: .author.login, message: .commit.message}'`.text() const contributors = new Map>() for (const line of compare.split("\n").filter(Boolean)) { const { login, message } = JSON.parse(line) as { login: string | null; message: string } const title = message.split("\n")[0] ?? "" if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue if (login && !team.includes(login)) { if (!contributors.has(login)) contributors.set(login, new Set()) contributors.get(login)!.add(title) } } return contributors } export async function buildNotes(from: string, to: string) { const commits = await getCommits(from, to) if (commits.length === 0) { return [] } console.log("generating changelog since " + from) const opencode = await createOpencode({ port: 0 }) const notes: string[] = [] try { const lines = await generateChangelog(commits, opencode) notes.push(...lines) console.log("---- Generated Changelog ----") console.log(notes.join("\n")) console.log("-----------------------------") } catch (error) { if (error instanceof Error && error.name === "TimeoutError") { console.log("Changelog generation timed out, using raw commits") for (const commit of commits) { const attribution = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : "" notes.push(`- ${commit.message}${attribution}`) } } else { throw error } } finally { await opencode.server.close() } console.log("changelog generation complete") const contributors = await getContributors(from, to) if (contributors.size > 0) { notes.push("") notes.push(`**Thank you to ${contributors.size} community contributor${contributors.size > 1 ? "s" : ""}:**`) for (const [username, userCommits] of contributors) { notes.push(`- @${username}:`) for (const c of userCommits) { notes.push(` - ${c}`) } } } return notes } // CLI entrypoint if (import.meta.main) { const { values } = parseArgs({ args: Bun.argv.slice(2), options: { from: { type: "string", short: "f" }, to: { type: "string", short: "t", default: "HEAD" }, help: { type: "boolean", short: "h", default: false }, }, }) if (values.help) { console.log(` Usage: bun script/changelog.ts [options] Options: -f, --from Starting version (default: latest GitHub release) -t, --to Ending ref (default: HEAD) -h, --help Show this help message Examples: bun script/changelog.ts # Latest release to HEAD bun script/changelog.ts --from 1.0.200 # v1.0.200 to HEAD bun script/changelog.ts -f 1.0.200 -t 1.0.205 `) process.exit(0) } const to = values.to! const from = values.from ?? (await getLatestRelease()) console.log(`Generating changelog: v${from} -> ${to}\n`) const notes = await buildNotes(from, to) console.log("\n=== Final Notes ===") console.log(notes.join("\n")) }