2025-12-29 17:12:25 +00:00
# ! / u s r / b i n / e n v b u n
import { $ } from "bun"
2026-01-30 06:15:24 +00:00
import { createOpencode } from "@opencode-ai/sdk/v2"
2025-12-29 17:12:25 +00:00
import { parseArgs } from "util"
2026-02-02 01:17:15 +00:00
import { Script } from "@opencode-ai/script"
2025-12-29 17:12:25 +00:00
2026-01-30 05:43:36 +00:00
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" )
2025-12-29 17:12:25 +00:00
}
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 =
2026-01-02 21:02:52 +00:00
await $ ` gh api "/repos/anomalyco/opencode/compare/ ${ fromRef } ... ${ toRef } " --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}' ` . text ( )
2025-12-29 17:12:25 +00:00
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 ) ) {
2025-12-31 18:05:35 +00:00
if ( file . startsWith ( "packages/opencode/src/cli/cmd/" ) ) areas . add ( "tui" )
2025-12-29 17:12:25 +00:00
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 ,
} )
}
2025-12-30 03:15:29 +00:00
return filterRevertedCommits ( commits )
}
function filterRevertedCommits ( commits : Commit [ ] ) : Commit [ ] {
const revertPattern = /^Revert "(.+)"$/
const seen = new Map < string , Commit > ( )
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 ( ) ]
2025-12-29 17:12:25 +00:00
}
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"
}
2025-12-30 03:15:29 +00:00
async function summarizeCommit ( opencode : Awaited < ReturnType < typeof createOpencode > > , message : string ) : Promise < string > {
2025-12-29 17:12:25 +00:00
console . log ( "summarizing commit:" , message )
2025-12-30 03:15:29 +00:00
const session = await opencode . client . session . create ( )
2025-12-29 17:12:25 +00:00
const result = await opencode . client . session
2026-01-30 06:15:24 +00:00
. prompt (
{
sessionID : session.data ! . id ,
2025-12-29 17:12:25 +00:00
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 } ` ,
} ,
] ,
} ,
2026-01-30 06:15:24 +00:00
{
signal : AbortSignal.timeout ( 120 _000 ) ,
} ,
)
2025-12-29 17:12:25 +00:00
. 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 > > ) {
2025-12-30 03:15:29 +00:00
// 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 )
}
2025-12-29 17:12:25 +00:00
const grouped = new Map < string , string [ ] > ( )
2025-12-30 03:15:29 +00:00
for ( let i = 0 ; i < commits . length ; i ++ ) {
const commit = commits [ i ] !
2025-12-29 17:12:25 +00:00
const section = getSection ( commit . areas )
2026-02-02 01:17:15 +00:00
const attribution = commit . author && ! Script . team . includes ( commit . author ) ? ` (@ ${ commit . author } ) ` : ""
2025-12-30 03:15:29 +00:00
const entry = ` - ${ summaries [ i ] } ${ attribution } `
2025-12-29 17:12:25 +00:00
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 =
2026-01-02 21:02:52 +00:00
await $ ` gh api "/repos/anomalyco/opencode/compare/ ${ fromRef } ... ${ toRef } " --jq '.commits[] | {login: .author.login, message: .commit.message}' ` . text ( )
2026-01-14 20:34:57 +00:00
const contributors = new Map < string , Set < string > > ( )
2025-12-29 17:12:25 +00:00
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
2026-02-02 01:17:15 +00:00
if ( login && ! Script . team . includes ( login ) ) {
2026-01-14 20:34:57 +00:00
if ( ! contributors . has ( login ) ) contributors . set ( login , new Set ( ) )
contributors . get ( login ) ! . add ( title )
2025-12-29 17:12:25 +00:00
}
}
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 )
2026-01-30 06:15:24 +00:00
const opencode = await createOpencode ( { port : 0 } )
2025-12-29 17:12:25 +00:00
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 {
2026-01-30 06:15:24 +00:00
await opencode . server . close ( )
2025-12-29 17:12:25 +00:00
}
2026-01-30 06:15:24 +00:00
console . log ( "changelog generation complete" )
2025-12-29 17:12:25 +00:00
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" ) )
}