Skip to content

Commit 93fad99

Browse files
authored
smarter changelog (anomalyco#20138)
1 parent 057848d commit 93fad99

4 files changed

Lines changed: 330 additions & 252 deletions

File tree

.opencode/command/changelog.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
11
---
2-
model: opencode/kimi-k2.5
2+
model: opencode/gpt-5.4
33
---
44

55
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
66
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
77
Do not preserve, merge, or reuse text from the existing file.
88

9-
Any command arguments are passed directly to `bun script/changelog.ts`.
10-
Use `--from` / `-f` and `--to` / `-t` to preview a specific release range.
11-
129
The input already contains the exact commit range since the last non-draft release.
1310
The commits are already filtered to the release-relevant packages and grouped into
1411
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
1512
The input may also include a `## Community Contributors Input` section.
1613

1714
Before writing any entry you keep, inspect the real diff with
18-
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so the
19-
summary reflects the actual user-facing change and not just the commit message.
15+
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
16+
understand the actual code changes and not just the commit message (they may be misleading).
2017
Do not use `git log` or author metadata when deciding attribution.
2118

2219
Rules:
@@ -38,7 +35,12 @@ Rules:
3835
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
3936
- Do not derive the thank-you section from the main summary bullets
4037
- Do not include the heading `## Community Contributors Input` in the final file
38+
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
39+
40+
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
41+
42+
<changelog_input>
4143

42-
## Changelog Input
44+
!`bun script/raw-changelog.ts $ARGUMENTS`
4345

44-
!`bun script/changelog.ts $ARGUMENTS`
46+
</changelog_input>

script/changelog.ts

Lines changed: 58 additions & 243 deletions
Original file line numberDiff line numberDiff line change
@@ -1,261 +1,76 @@
11
#!/usr/bin/env bun
22

3-
import { $ } from "bun"
3+
import { rm } from "fs/promises"
4+
import path from "path"
45
import { parseArgs } from "util"
56

6-
type Release = {
7-
tag_name: string
8-
draft: boolean
9-
}
10-
11-
type Commit = {
12-
hash: string
13-
author: string | null
14-
message: string
15-
areas: Set<string>
16-
}
17-
18-
type User = Map<string, Set<string>>
19-
type Diff = {
20-
sha: string
21-
login: string | null
22-
message: string
23-
}
24-
25-
const repo = process.env.GH_REPO ?? "anomalyco/opencode"
26-
const bot = ["actions-user", "opencode", "opencode-agent[bot]"]
27-
const team = [
28-
...(await Bun.file(new URL("../.github/TEAM_MEMBERS", import.meta.url))
29-
.text()
30-
.then((x) => x.split(/\r?\n/).map((x) => x.trim()))
31-
.then((x) => x.filter((x) => x && !x.startsWith("#")))),
32-
...bot,
33-
]
34-
const order = ["Core", "TUI", "Desktop", "SDK", "Extensions"] as const
35-
const sections = {
36-
core: "Core",
37-
tui: "TUI",
38-
app: "Desktop",
39-
tauri: "Desktop",
40-
sdk: "SDK",
41-
plugin: "SDK",
42-
"extensions/zed": "Extensions",
43-
"extensions/vscode": "Extensions",
44-
github: "Extensions",
45-
} as const
46-
47-
function ref(input: string) {
48-
if (input === "HEAD") return input
49-
if (input.startsWith("v")) return input
50-
if (input.match(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/)) return `v${input}`
51-
return input
52-
}
53-
54-
async function latest() {
55-
const data = await $`gh api "/repos/${repo}/releases?per_page=100"`.json()
56-
const release = (data as Release[]).find((item) => !item.draft)
57-
if (!release) throw new Error("No releases found")
58-
return release.tag_name.replace(/^v/, "")
59-
}
60-
61-
async function diff(base: string, head: string) {
62-
const list: Diff[] = []
63-
for (let page = 1; ; page++) {
64-
const text =
65-
await $`gh api "/repos/${repo}/compare/${base}...${head}?per_page=100&page=${page}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text()
66-
const batch = text
67-
.split("\n")
68-
.filter(Boolean)
69-
.map((line) => JSON.parse(line) as Diff)
70-
if (batch.length === 0) break
71-
list.push(...batch)
72-
if (batch.length < 100) break
73-
}
74-
return list
75-
}
76-
77-
function section(areas: Set<string>) {
78-
const priority = ["core", "tui", "app", "tauri", "sdk", "plugin", "extensions/zed", "extensions/vscode", "github"]
79-
for (const area of priority) {
80-
if (areas.has(area)) return sections[area as keyof typeof sections]
81-
}
82-
return "Core"
83-
}
84-
85-
function reverted(commits: Commit[]) {
86-
const seen = new Map<string, Commit>()
87-
88-
for (const commit of commits) {
89-
const match = commit.message.match(/^Revert "(.+)"$/)
90-
if (match) {
91-
const msg = match[1]!
92-
if (seen.has(msg)) seen.delete(msg)
93-
else seen.set(commit.message, commit)
94-
continue
95-
}
96-
97-
const revert = `Revert "${commit.message}"`
98-
if (seen.has(revert)) {
99-
seen.delete(revert)
100-
continue
101-
}
102-
103-
seen.set(commit.message, commit)
104-
}
105-
106-
return [...seen.values()]
107-
}
108-
109-
async function commits(from: string, to: string) {
110-
const base = ref(from)
111-
const head = ref(to)
112-
113-
const data = new Map<string, { login: string | null; message: string }>()
114-
for (const item of await diff(base, head)) {
115-
data.set(item.sha, { login: item.login, message: item.message.split("\n")[0] ?? "" })
116-
}
117-
118-
const log =
119-
await $`git log ${base}..${head} --format=%H -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text()
120-
121-
const list: Commit[] = []
122-
for (const hash of log.split("\n").filter(Boolean)) {
123-
const item = data.get(hash)
124-
if (!item) continue
125-
if (item.message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
126-
127-
const diff = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text()
128-
const areas = new Set<string>()
129-
130-
for (const file of diff.split("\n").filter(Boolean)) {
131-
if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui")
132-
else if (file.startsWith("packages/opencode/")) areas.add("core")
133-
else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri")
134-
else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app")
135-
else if (file.startsWith("packages/sdk/") || file.startsWith("packages/plugin/")) areas.add("sdk")
136-
else if (file.startsWith("packages/extensions/")) areas.add("extensions/zed")
137-
else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode")
138-
}
139-
140-
if (areas.size === 0) continue
141-
142-
list.push({
143-
hash: hash.slice(0, 7),
144-
author: item.login,
145-
message: item.message,
146-
areas,
147-
})
148-
}
149-
150-
return reverted(list)
151-
}
152-
153-
async function contributors(from: string, to: string) {
154-
const base = ref(from)
155-
const head = ref(to)
156-
157-
const users: User = new Map()
158-
for (const item of await diff(base, head)) {
159-
const title = item.message.split("\n")[0] ?? ""
160-
if (!item.login || team.includes(item.login)) continue
161-
if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
162-
if (!users.has(item.login)) users.set(item.login, new Set())
163-
users.get(item.login)!.add(title)
164-
}
165-
166-
return users
167-
}
168-
169-
async function published(to: string) {
170-
if (to === "HEAD") return
171-
const body = await $`gh release view ${ref(to)} --repo ${repo} --json body --jq .body`.text().catch(() => "")
172-
if (!body) return
173-
174-
const lines = body.split(/\r?\n/)
175-
const start = lines.findIndex((line) => line.startsWith("**Thank you to "))
176-
if (start < 0) return
177-
return lines.slice(start).join("\n").trim()
178-
}
179-
180-
async function thanks(from: string, to: string, reuse: boolean) {
181-
const release = reuse ? await published(to) : undefined
182-
if (release) return release.split(/\r?\n/)
183-
184-
const users = await contributors(from, to)
185-
if (users.size === 0) return []
186-
187-
const lines = [`**Thank you to ${users.size} community contributor${users.size > 1 ? "s" : ""}:**`]
188-
for (const [name, commits] of users) {
189-
lines.push(`- @${name}:`)
190-
for (const commit of commits) lines.push(` - ${commit}`)
191-
}
192-
return lines
193-
}
194-
195-
function format(from: string, to: string, list: Commit[], thanks: string[]) {
196-
const grouped = new Map<string, string[]>()
197-
for (const title of order) grouped.set(title, [])
198-
199-
for (const commit of list) {
200-
const title = section(commit.areas)
201-
const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : ""
202-
grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`)
203-
}
204-
205-
const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""]
206-
207-
if (list.length === 0) {
208-
lines.push("No notable changes.")
209-
}
210-
211-
for (const title of order) {
212-
const entries = grouped.get(title)
213-
if (!entries || entries.length === 0) continue
214-
lines.push(`## ${title}`)
215-
lines.push(...entries)
216-
lines.push("")
217-
}
218-
219-
if (thanks.length > 0) {
220-
if (lines.at(-1) !== "") lines.push("")
221-
lines.push("## Community Contributors Input")
222-
lines.push("")
223-
lines.push(...thanks)
224-
}
225-
226-
if (lines.at(-1) === "") lines.pop()
227-
return lines.join("\n")
228-
}
229-
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(`
7+
const root = path.resolve(import.meta.dir, "..")
8+
const file = path.join(root, "UPCOMING_CHANGELOG.md")
9+
const { values, positionals } = parseArgs({
10+
args: Bun.argv.slice(2),
11+
options: {
12+
from: { type: "string", short: "f" },
13+
to: { type: "string", short: "t" },
14+
variant: { type: "string", default: "low" },
15+
quiet: { type: "boolean", default: false },
16+
print: { type: "boolean", default: false },
17+
help: { type: "boolean", short: "h", default: false },
18+
},
19+
allowPositionals: true,
20+
})
21+
const args = [...positionals]
22+
23+
if (values.from) args.push("--from", values.from)
24+
if (values.to) args.push("--to", values.to)
25+
26+
if (values.help) {
27+
console.log(`
24228
Usage: bun script/changelog.ts [options]
24329
30+
Generates UPCOMING_CHANGELOG.md by running the opencode changelog command.
31+
24432
Options:
24533
-f, --from <version> Starting version (default: latest non-draft GitHub release)
24634
-t, --to <ref> Ending ref (default: HEAD)
35+
--variant <name> Thinking variant for opencode run (default: low)
36+
--quiet Suppress opencode command output unless it fails
37+
--print Print the generated UPCOMING_CHANGELOG.md after success
24738
-h, --help Show this help message
24839
24940
Examples:
25041
bun script/changelog.ts
25142
bun script/changelog.ts --from 1.0.200
25243
bun script/changelog.ts -f 1.0.200 -t 1.0.205
25344
`)
254-
process.exit(0)
255-
}
45+
process.exit(0)
46+
}
25647

257-
const to = values.to!
258-
const from = values.from ?? (await latest())
259-
const list = await commits(from, to)
260-
console.log(format(from, to, list, await thanks(from, to, !values.from)))
48+
await rm(file, { force: true })
49+
50+
const quiet = values.quiet
51+
const cmd = ["opencode", "run"]
52+
cmd.push("--variant", values.variant)
53+
cmd.push("--command", "changelog", "--", ...args)
54+
55+
const proc = Bun.spawn(cmd, {
56+
cwd: root,
57+
stdin: "inherit",
58+
stdout: quiet ? "pipe" : "inherit",
59+
stderr: quiet ? "pipe" : "inherit",
60+
})
61+
62+
const [out, err] = quiet
63+
? await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
64+
: ["", ""]
65+
const code = await proc.exited
66+
if (code === 0) {
67+
if (values.print) process.stdout.write(await Bun.file(file).text())
68+
process.exit(0)
26169
}
70+
71+
if (quiet) {
72+
if (out) process.stdout.write(out)
73+
if (err) process.stderr.write(err)
74+
}
75+
76+
process.exit(code)

0 commit comments

Comments
 (0)