Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 274 additions & 0 deletions script/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
#!/usr/bin/env bun

import { $ } from "bun"
import { createOpencode } from "@opencode-ai/sdk"
import { parseArgs } from "util"

export const team = [
"actions-user",
"opencode",
"rekram1-node",
"thdxr",
"kommander",
"jayair",
"fwang",
"adamdotdevin",
"iamdavidhill",
"opencode-agent[bot]",
]

export async function getLatestRelease() {
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.tag_name.replace(/^v/, ""))
}

type Commit = {
hash: string
author: string | null
message: string
areas: Set<string>
}

export async function getCommits(from: string, to: string): Promise<Commit[]> {
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/sst/opencode/compare/${fromRef}...${toRef}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text()

const commitData = new Map<string, { login: string | null; message: string }>()
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<string>()

for (const file of files.split("\n").filter(Boolean)) {
if (file.startsWith("packages/opencode/src/cli/cmd/tui/")) 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 commits
}

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>): 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<ReturnType<typeof createOpencode>>,
sessionId: string,
message: string,
): Promise<string> {
console.log("summarizing commit:", message)
const result = await opencode.client.session
.prompt({
path: { id: sessionId },
body: {
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<ReturnType<typeof createOpencode>>) {
const session = await opencode.client.session.create()

const grouped = new Map<string, string[]>()

for (const commit of commits) {
const section = getSection(commit.areas)
const summary = await summarizeCommit(opencode, session.data!.id, commit.message)
const attribution = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : ""
const entry = `- ${summary}${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/sst/opencode/compare/${fromRef}...${toRef}" --jq '.commits[] | {login: .author.login, message: .commit.message}'`.text()
const contributors = new Map<string, string[]>()

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, [])
contributors.get(login)?.push(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: 5044 })
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 {
opencode.server.close()
}

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 <version> Starting version (default: latest GitHub release)
-t, --to <ref> 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"))
}
Loading