|
1 | 1 | #!/usr/bin/env bun |
2 | 2 |
|
3 | | -import { $ } from "bun" |
| 3 | +import { rm } from "fs/promises" |
| 4 | +import path from "path" |
4 | 5 | import { parseArgs } from "util" |
5 | 6 |
|
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(` |
242 | 28 | Usage: bun script/changelog.ts [options] |
243 | 29 |
|
| 30 | +Generates UPCOMING_CHANGELOG.md by running the opencode changelog command. |
| 31 | +
|
244 | 32 | Options: |
245 | 33 | -f, --from <version> Starting version (default: latest non-draft GitHub release) |
246 | 34 | -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 |
247 | 38 | -h, --help Show this help message |
248 | 39 |
|
249 | 40 | Examples: |
250 | 41 | bun script/changelog.ts |
251 | 42 | bun script/changelog.ts --from 1.0.200 |
252 | 43 | bun script/changelog.ts -f 1.0.200 -t 1.0.205 |
253 | 44 | `) |
254 | | - process.exit(0) |
255 | | - } |
| 45 | + process.exit(0) |
| 46 | +} |
256 | 47 |
|
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) |
261 | 69 | } |
| 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