raw-changelog.ts 8.1 KB

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