raw-changelog.ts 8.3 KB

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