Files

286 lines
8.2 KiB
TypeScript

#!/usr/bin/env bun
import path from "path"
import semver from "semver"
import { parseArgs } from "util"
type Bump = "major" | "minor" | "patch"
export type Release = {
tag_name: string
name?: string | null
body?: string | null
draft?: boolean
prerelease?: boolean
}
type Opts = {
from: string
to: string
root: string
}
type Group = Map<string, Map<string, string[]>>
const repo = "anomalyco/opencode"
const pkgs = ["@kilocode/cli", "kilo-code"]
const bump: Bump = "patch"
const drop = ["Desktop", "SDK"]
const usage = `
Usage: bun script/upstream/opencode-changesets.ts --from <version> --to <version>
Creates one changeset for upstream opencode releases in the semver range (from, to].
Options:
--from <version> Starting opencode version, exclusive
--to <version> Ending opencode version, inclusive
-h, --help Show this help message
Example:
bun script/upstream/opencode-changesets.ts --from v1.16.0 --to v1.17.7
`
function clean(input: string) {
const raw = input.trim().replace(/^v/, "")
const version = semver.valid(raw)
if (!version) throw new Error(`Invalid semver version: ${input}`)
return version
}
function tag(input: string) {
return `v${clean(input)}`
}
function slug(from: string, to: string) {
const base = tag(from)
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase()
const head = tag(to)
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase()
return `opencode-${base}-to-${head}.md`
}
function header(input: string[], bump: Bump) {
return input.map((item) => `"${item}": ${bump}`).join("\n")
}
function body(release: Release) {
const text = release.body?.replace(/\r\n?/g, "\n").trim()
if (text) return text
const name = release.name?.trim()
if (name && name !== release.tag_name) return name
return `Integrate upstream opencode ${release.tag_name}.`
}
function filter(input: string, sections: string[]) {
const dropped = new Set(sections.map((item) => item.trim().toLowerCase()).filter(Boolean))
const lines = input.replace(/\r\n?/g, "\n").split("\n")
const out: string[] = []
let skip = false
let thanks = false
for (const line of lines) {
const match = line.match(/^##\s+(.+?)\s*$/)
if (match) {
skip = dropped.has(match[1].trim().toLowerCase())
thanks = false
}
if (line.match(/^\*\*Thank you to \d+ community contributors?:\*\*\s*$/)) {
thanks = true
continue
}
if (thanks) {
if (!line.startsWith("-") && !line.startsWith(" -") && line.trim() !== "") thanks = false
if (thanks) continue
}
if (!skip) out.push(line)
}
return out
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim()
}
function add(groups: Group, section: string, category: string, lines: string[]) {
const text = lines.join("\n").trim()
if (!text) return
if (!groups.has(section)) groups.set(section, new Map())
const group = groups.get(section)!
if (!group.has(category)) group.set(category, [])
group.get(category)!.push(text)
}
function collect(releases: Release[]) {
const groups: Group = new Map()
for (const release of releases) {
const text = filter(body(release), drop)
let section = "Core"
let category = ""
const block: string[] = []
const flush = () => {
add(groups, section, category, block.splice(0))
}
for (const line of text.split("\n")) {
const heading = line.match(/^##\s+(.+?)\s*$/)
if (heading) {
flush()
section = heading[1].trim()
category = ""
if (!groups.has(section)) groups.set(section, new Map())
continue
}
const sub = line.match(/^###\s+(.+?)\s*$/)
if (sub) {
flush()
category = sub[1].trim()
if (!groups.has(section)) groups.set(section, new Map())
if (!groups.get(section)!.has(category)) groups.get(section)!.set(category, [])
continue
}
if (!line.trim() && block.length === 0) continue
if (line.match(/^[-*]\s+/) && block.length > 0) flush()
block.push(line)
}
flush()
}
return groups
}
function render(groups: Group) {
const lines: string[] = []
for (const [section, cats] of groups) {
for (const [category, items] of cats) {
if (items.length === 0) continue
const prefix = [section, category].filter(Boolean).join(" ")
for (const item of items) {
const text = item.replace(/^\s*[-*]\s+/, "").trimEnd()
const body = text.split("\n")
const [first = "", ...rest] = body
lines.push(`- ${prefix}: ${first.trim()}`)
lines.push(...rest.map((line) => (line.trim() ? ` ${line}` : "")))
}
}
}
return lines.join("\n")
}
function isRelease(input: unknown): input is Release {
return Boolean(input && typeof input === "object" && "tag_name" in input && typeof input.tag_name === "string")
}
export function select(releases: Release[], from: string, to: string) {
const base = clean(from)
const head = clean(to)
if (semver.gt(base, head) || base === head) throw new Error(`Expected from version to be lower than to version`)
const seen = new Set<string>()
const published = releases
.filter((release) => !release.draft)
.filter((release) => !release.prerelease)
.map((release) => ({ release, version: semver.valid(release.tag_name.replace(/^v/, "")) }))
.filter((item): item is { release: Release; version: string } => Boolean(item.version))
if (!published.some((item) => item.version === base)) {
throw new Error(`Starting opencode release does not exist or is not published: ${tag(base)}`)
}
if (!published.some((item) => item.version === head)) {
throw new Error(`Target opencode release does not exist or is not published: ${tag(head)}`)
}
return published
.filter((item) => {
if (seen.has(item.version)) return false
seen.add(item.version)
return semver.gt(item.version, base) && semver.lte(item.version, head)
})
.sort((a, b) => semver.compare(a.version, b.version))
.map((item) => ({ ...item.release, tag_name: tag(item.version) }))
}
export function changeset(releases: Release[], from: string, to: string) {
const text = render(collect(releases)) || "No upstream release notes were published."
return `---\n${header(pkgs, bump)}\n---\n\nChanges from opencode ${tag(from)} to ${tag(to)} upstream:\n\n${text}\n`
}
async function fetch_all() {
const list: Release[] = []
const auth = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
for (let page = 1; ; page++) {
const res = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`, {
headers: {
Accept: "application/vnd.github+json",
...(auth ? { Authorization: `Bearer ${auth}` } : {}),
},
})
if (!res.ok) throw new Error(`GitHub releases request failed for ${repo}: ${res.status} ${await res.text()}`)
const json: unknown = await res.json()
if (!Array.isArray(json) || !json.every(isRelease)) throw new Error(`GitHub returned invalid release data`)
const batch = json
list.push(...batch)
if (batch.length < 100) return list
}
}
function parse_opts() {
const parsed = parseArgs({
args: Bun.argv.slice(2),
options: {
from: { type: "string" },
to: { type: "string" },
help: { type: "boolean", short: "h", default: false },
},
})
if (parsed.values.help) {
process.stdout.write(usage)
process.exit(0)
}
const from = parsed.values.from
const to = parsed.values.to
if (!from || !to) throw new Error("Expected from and to opencode versions")
return { from, to, root: path.resolve(import.meta.dir, "../..") } satisfies Opts
}
export async function run(opts: Opts) {
const releases = select(await fetch_all(), opts.from, opts.to)
if (releases.length === 0) throw new Error(`No opencode releases found in range (${opts.from}, ${opts.to}]`)
const dir = path.join(opts.root, ".changeset")
const file = path.join(dir, slug(opts.from, opts.to))
await Bun.write(file, changeset(releases, opts.from, opts.to))
process.stdout.write(`Wrote ${path.relative(opts.root, file)}\n`)
}
if (import.meta.main) {
await (async () => run(parse_opts()))().catch((err) => {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
})
}