raw-changelog.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  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 ?? "Kilo-Org/kilocode"
  21. const bot = ["actions-user", "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 reverted(commits: Commit[]) {
  76. const seen = new Map<string, Commit>()
  77. for (const commit of commits) {
  78. const match = commit.message.match(/^Revert "(.+)"$/)
  79. if (match) {
  80. const msg = match[1]!
  81. if (seen.has(msg)) seen.delete(msg)
  82. else seen.set(commit.message, commit)
  83. continue
  84. }
  85. const revert = `Revert "${commit.message}"`
  86. if (seen.has(revert)) {
  87. seen.delete(revert)
  88. continue
  89. }
  90. seen.set(commit.message, commit)
  91. }
  92. return [...seen.values()]
  93. }
  94. async function commits(from: string, to: string) {
  95. const base = ref(from)
  96. const head = ref(to)
  97. const data = new Map<string, { login: string | null; message: string }>()
  98. for (const item of await diff(base, head)) {
  99. data.set(item.sha, { login: item.login, message: item.message.split("\n")[0] ?? "" })
  100. }
  101. const log =
  102. await $`git log ${base}..${head} --format=%H -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text()
  103. const list: Commit[] = []
  104. for (const hash of log.split("\n").filter(Boolean)) {
  105. const item = data.get(hash)
  106. if (!item) continue
  107. if (item.message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
  108. const diff = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text()
  109. const areas = new Set<string>()
  110. for (const file of diff.split("\n").filter(Boolean)) {
  111. if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui")
  112. else if (file.startsWith("packages/opencode/")) areas.add("core")
  113. else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri")
  114. else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app")
  115. else if (file.startsWith("packages/sdk/") || file.startsWith("packages/plugin/")) areas.add("sdk")
  116. else if (file.startsWith("packages/extensions/")) areas.add("extensions/zed")
  117. else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode")
  118. }
  119. if (areas.size === 0) continue
  120. list.push({
  121. hash: hash.slice(0, 7),
  122. author: item.login,
  123. message: item.message,
  124. areas,
  125. })
  126. }
  127. return reverted(list)
  128. }
  129. async function contributors(from: string, to: string) {
  130. const base = ref(from)
  131. const head = ref(to)
  132. const users: User = new Map()
  133. for (const item of await diff(base, head)) {
  134. const title = item.message.split("\n")[0] ?? ""
  135. if (!item.login || team.includes(item.login)) continue
  136. if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
  137. if (!users.has(item.login)) users.set(item.login, new Set())
  138. users.get(item.login)!.add(title)
  139. }
  140. return users
  141. }
  142. async function published(to: string) {
  143. if (to === "HEAD") return
  144. const body = await $`gh release view ${ref(to)} --repo ${repo} --json body --jq .body`.text().catch(() => "")
  145. if (!body) return
  146. const lines = body.split(/\r?\n/)
  147. const start = lines.findIndex((line) => line.startsWith("**Thank you to "))
  148. if (start < 0) return
  149. return lines.slice(start).join("\n").trim()
  150. }
  151. async function thanks(from: string, to: string, reuse: boolean) {
  152. const release = reuse ? await published(to) : undefined
  153. if (release) return release.split(/\r?\n/)
  154. const users = await contributors(from, to)
  155. if (users.size === 0) return []
  156. const lines = [`**Thank you to ${users.size} community contributor${users.size > 1 ? "s" : ""}:**`]
  157. for (const [name, commits] of users) {
  158. lines.push(`- @${name}:`)
  159. for (const commit of commits) lines.push(` - ${commit}`)
  160. }
  161. return lines
  162. }
  163. function format(from: string, to: string, list: Commit[], thanks: string[]) {
  164. const grouped = new Map<string, string[]>()
  165. for (const title of order) grouped.set(title, [])
  166. for (const commit of list) {
  167. const title = section(commit.areas)
  168. const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : ""
  169. grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`)
  170. }
  171. const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""]
  172. if (list.length === 0) {
  173. lines.push("No notable changes.")
  174. }
  175. for (const title of order) {
  176. const entries = grouped.get(title)
  177. if (!entries || entries.length === 0) continue
  178. lines.push(`## ${title}`)
  179. lines.push(...entries)
  180. lines.push("")
  181. }
  182. if (thanks.length > 0) {
  183. if (lines.at(-1) !== "") lines.push("")
  184. lines.push("## Community Contributors Input")
  185. lines.push("")
  186. lines.push(...thanks)
  187. }
  188. if (lines.at(-1) === "") lines.pop()
  189. return lines.join("\n")
  190. }
  191. if (import.meta.main) {
  192. const { values } = parseArgs({
  193. args: Bun.argv.slice(2),
  194. options: {
  195. from: { type: "string", short: "f" },
  196. to: { type: "string", short: "t", default: "HEAD" },
  197. help: { type: "boolean", short: "h", default: false },
  198. },
  199. })
  200. if (values.help) {
  201. console.log(`
  202. Usage: bun script/raw-changelog.ts [options]
  203. Options:
  204. -f, --from <version> Starting version (default: latest non-draft GitHub release)
  205. -t, --to <ref> Ending ref (default: HEAD)
  206. -h, --help Show this help message
  207. Examples:
  208. bun script/raw-changelog.ts
  209. bun script/raw-changelog.ts --from 1.0.200
  210. bun script/raw-changelog.ts -f 1.0.200 -t 1.0.205
  211. `)
  212. process.exit(0)
  213. }
  214. const to = values.to!
  215. const from = values.from ?? (await latest())
  216. const list = await commits(from, to)
  217. console.log(format(from, to, list, await thanks(from, to, !values.from)))
  218. }