feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - System prompt injection for routing - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
360
script/beta.ts
Executable file
360
script/beta.ts
Executable file
@@ -0,0 +1,360 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
|
||||
const model = "opencode/gpt-5.3-codex"
|
||||
|
||||
interface PR {
|
||||
number: number
|
||||
title: string
|
||||
author: { login: string }
|
||||
labels: Array<{ name: string }>
|
||||
}
|
||||
|
||||
interface FailedPR {
|
||||
number: number
|
||||
title: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
async function commentOnPR(prNumber: number, reason: string) {
|
||||
const body = `⚠️ **Blocking Beta Release**
|
||||
|
||||
This PR cannot be merged into the beta branch due to: **${reason}**
|
||||
|
||||
Please resolve this issue to include this PR in the next beta release.`
|
||||
|
||||
try {
|
||||
await $`gh pr comment ${prNumber} --body ${body}`
|
||||
console.log(` Posted comment on PR #${prNumber}`)
|
||||
} catch (err) {
|
||||
console.log(` Failed to post comment on PR #${prNumber}: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function conflicts() {
|
||||
const out = await $`git diff --name-only --diff-filter=U`.text().catch(() => "")
|
||||
return out
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
try {
|
||||
await $`git merge --abort`
|
||||
} catch {}
|
||||
try {
|
||||
await $`git checkout -- .`
|
||||
} catch {}
|
||||
try {
|
||||
await $`git clean -fd`
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function lines(prs: PR[]) {
|
||||
return prs.map((x) => `- #${x.number}: ${x.title}`).join("\n") || "(none)"
|
||||
}
|
||||
|
||||
function group(title: string) {
|
||||
if (process.env.GITHUB_ACTIONS !== "true") {
|
||||
console.log(title)
|
||||
return { [Symbol.dispose]() {} }
|
||||
}
|
||||
console.log(`::group::${title}`)
|
||||
return {
|
||||
[Symbol.dispose]() {
|
||||
console.log("::endgroup::")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function typecheck() {
|
||||
console.log(" Running typecheck...")
|
||||
|
||||
try {
|
||||
await $`bun typecheck`
|
||||
return true
|
||||
} catch (err) {
|
||||
console.log(`Typecheck failed: ${err}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function build() {
|
||||
console.log(" Running final build smoke check...")
|
||||
|
||||
try {
|
||||
await $`./script/build.ts --single`.cwd("packages/opencode")
|
||||
return true
|
||||
} catch (err) {
|
||||
console.log(`Build failed: ${err}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function validate() {
|
||||
if (!(await typecheck())) return false
|
||||
if (!(await build())) return false
|
||||
return true
|
||||
}
|
||||
|
||||
async function commitSmokeChanges() {
|
||||
const out = await $`git status --porcelain`.text()
|
||||
if (!out.trim()) {
|
||||
console.log("Smoke check passed")
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
await $`git add -A`
|
||||
await $`git commit -m "Fix beta integration"`
|
||||
} catch (err) {
|
||||
console.log(`Failed to commit smoke fixes: ${err}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(await validate())) return false
|
||||
|
||||
const left = await $`git status --porcelain`.text()
|
||||
if (!left.trim()) {
|
||||
console.log("Smoke check passed")
|
||||
return true
|
||||
}
|
||||
|
||||
console.log(`Smoke check left uncommitted changes:\n${left}`)
|
||||
return false
|
||||
}
|
||||
|
||||
async function install() {
|
||||
console.log(" Regenerating bun.lock...")
|
||||
|
||||
try {
|
||||
await fs.rm("bun.lock", { force: true })
|
||||
await $`bun install`
|
||||
await $`git add bun.lock`
|
||||
return true
|
||||
} catch (err) {
|
||||
console.log(`Install failed: ${err}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: number) {
|
||||
console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`)
|
||||
|
||||
const done = lines(prs.filter((x) => applied.includes(x.number)))
|
||||
const next = lines(prs.slice(idx + 1))
|
||||
|
||||
const prompt = [
|
||||
`Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`,
|
||||
`PR #${pr.number}: ${pr.title}`,
|
||||
`Start with these conflicted files: ${files.join(", ")}.`,
|
||||
`Merged PRs on HEAD:\n${done}`,
|
||||
`Pending PRs after this one (context only):\n${next}`,
|
||||
"IMPORTANT: The conflict resolution must be consistent with already-merged PRs.",
|
||||
"Pending PRs are context only; do not introduce their changes unless they are already present on HEAD.",
|
||||
"Prefer already-merged PRs over the base branch when resolving stacked conflicts.",
|
||||
"If bun.lock is conflicted, do not hand-merge it. Delete bun.lock and run bun install after the code conflicts are resolved.",
|
||||
"If a PR already deleted a file/directory, do not re-add it, instead apply changes in the new semantic location.",
|
||||
"If a PR already changed an import, keep that change.",
|
||||
"After resolving the conflicts, run `bun typecheck` at the repo root.",
|
||||
"If typecheck fails, you may also update any files reported by typecheck.",
|
||||
"Keep any non-conflict edits narrowly scoped to restoring a valid merged state for the current PR batch.",
|
||||
"Fix any merge-caused typecheck errors before finishing.",
|
||||
"Keep the merge in progress, do not abort the merge, and do not create a commit.",
|
||||
"When done, leave the working tree with no unmerged files and a passing typecheck.",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
await $`opencode run -m ${model} ${prompt}`
|
||||
} catch (err) {
|
||||
console.log(` opencode failed: ${err}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const left = await conflicts()
|
||||
if (left.length > 0) {
|
||||
console.log(` Conflicts remain: ${left.join(", ")}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (files.includes("bun.lock") && !(await install())) return false
|
||||
|
||||
if (!(await typecheck())) return false
|
||||
|
||||
console.log(" Conflicts resolved with opencode")
|
||||
return true
|
||||
}
|
||||
|
||||
async function smoke(prs: PR[], applied: number[]) {
|
||||
console.log("\nRunning final smoke check...")
|
||||
|
||||
if (await validate()) return commitSmokeChanges()
|
||||
|
||||
console.log("\nTrying to fix final smoke check with opencode...")
|
||||
|
||||
const done = lines(prs.filter((x) => applied.includes(x.number)))
|
||||
const prompt = [
|
||||
"The beta merge batch is complete, but the deterministic final smoke check failed.",
|
||||
`Merged PRs on HEAD:\n${done}`,
|
||||
"Run `bun typecheck` at the repo root.",
|
||||
"Run `./script/build.ts --single` in `packages/opencode`.",
|
||||
"Fix any merge-caused issues until both commands pass.",
|
||||
"Do not create a commit.",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
await $`opencode run -m ${model} ${prompt}`
|
||||
} catch (err) {
|
||||
console.log(`Smoke fix failed: ${err}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(await validate())) return false
|
||||
return commitSmokeChanges()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Fetching open PRs with beta label...")
|
||||
|
||||
const stdout =
|
||||
await $`gh pr list --state open --draft=false --label beta --json number,title,author,labels --limit 100`.text()
|
||||
const prs: PR[] = JSON.parse(stdout).sort((a: PR, b: PR) => a.number - b.number)
|
||||
|
||||
console.log(`Found ${prs.length} open PRs with beta label`)
|
||||
|
||||
if (prs.length === 0) {
|
||||
console.log("No team PRs to merge")
|
||||
return
|
||||
}
|
||||
|
||||
console.log("Fetching latest dev branch...")
|
||||
await $`git fetch origin dev`
|
||||
|
||||
console.log("Checking out beta branch...")
|
||||
await $`git checkout -B beta origin/dev`
|
||||
|
||||
const applied: number[] = []
|
||||
const failed: FailedPR[] = []
|
||||
|
||||
for (const [idx, pr] of prs.entries()) {
|
||||
console.log()
|
||||
using _ = group(`Processing PR ${idx + 1}/${prs.length} #${pr.number}: ${pr.title}`)
|
||||
console.log(" Fetching PR head...")
|
||||
try {
|
||||
await $`git fetch origin pull/${pr.number}/head:pr/${pr.number}`
|
||||
} catch (err) {
|
||||
console.log(` Failed to fetch: ${err}`)
|
||||
failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" })
|
||||
await commentOnPR(pr.number, "Fetch failed")
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(" Merging...")
|
||||
try {
|
||||
await $`git merge --no-commit --no-ff pr/${pr.number}`
|
||||
} catch {
|
||||
const files = await conflicts()
|
||||
if (files.length > 0) {
|
||||
console.log(" Failed to merge (conflicts)")
|
||||
if (!(await fix(pr, files, prs, applied, idx))) {
|
||||
await cleanup()
|
||||
failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" })
|
||||
await commentOnPR(pr.number, "Merge conflicts with dev branch")
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
console.log(" Failed to merge")
|
||||
await cleanup()
|
||||
failed.push({ number: pr.number, title: pr.title, reason: "Merge failed" })
|
||||
await commentOnPR(pr.number, "Merge failed")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await $`git rev-parse -q --verify MERGE_HEAD`.text()
|
||||
} catch {
|
||||
console.log(" No changes, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await $`git add -A`
|
||||
} catch {
|
||||
console.log(" Failed to stage changes")
|
||||
failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" })
|
||||
await commentOnPR(pr.number, "Failed to stage changes")
|
||||
continue
|
||||
}
|
||||
|
||||
const commitMsg = `Apply PR #${pr.number}: ${pr.title}`
|
||||
try {
|
||||
await $`git commit -m ${commitMsg}`
|
||||
} catch (err) {
|
||||
console.log(` Failed to commit: ${err}`)
|
||||
failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" })
|
||||
await commentOnPR(pr.number, "Failed to commit changes")
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(" Applied successfully")
|
||||
applied.push(pr.number)
|
||||
}
|
||||
|
||||
console.log("\n--- Summary ---")
|
||||
console.log(`Applied: ${applied.length} PRs`)
|
||||
applied.forEach((num) => console.log(` - PR #${num}`))
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.log(`Failed: ${failed.length} PRs`)
|
||||
failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`))
|
||||
throw new Error(`${failed.length} PR(s) failed to merge`)
|
||||
}
|
||||
|
||||
console.log("\nChecking if beta branch has changes...")
|
||||
await $`git fetch origin beta`
|
||||
|
||||
const localTree = (await $`git rev-parse beta^{tree}`.text()).trim()
|
||||
const remoteTrees = (await $`git log origin/dev..origin/beta --format=%T`.text()).split("\n")
|
||||
|
||||
const matchIdx = remoteTrees.indexOf(localTree)
|
||||
if (matchIdx !== -1) {
|
||||
if (matchIdx !== 0) {
|
||||
console.log(`Beta branch contains this sync, but additional commits exist after it. Leaving beta branch as is.`)
|
||||
} else {
|
||||
console.log("Beta branch has identical contents, no push needed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await smoke(prs, applied))) throw new Error("Final smoke check failed")
|
||||
|
||||
await $`git fetch origin beta`
|
||||
|
||||
const validatedTree = (await $`git rev-parse beta^{tree}`.text()).trim()
|
||||
const remoteTreesAfterSmoke = (await $`git log origin/dev..origin/beta --format=%T`.text()).split("\n")
|
||||
const matchIdxAfterSmoke = remoteTreesAfterSmoke.indexOf(validatedTree)
|
||||
if (matchIdxAfterSmoke !== -1) {
|
||||
if (matchIdxAfterSmoke !== 0) {
|
||||
console.log(
|
||||
`Beta branch contains this validated sync, but additional commits exist after it. Leaving beta branch as is.`,
|
||||
)
|
||||
} else {
|
||||
console.log("Validated beta branch now matches remote contents, no push needed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
console.log("Force pushing validated beta branch...")
|
||||
await $`git push origin beta --force --no-verify`
|
||||
|
||||
console.log("Successfully synced beta branch")
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
76
script/changelog.ts
Executable file
76
script/changelog.ts
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { parseArgs } from "util"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "..")
|
||||
const file = path.join(root, "UPCOMING_CHANGELOG.md")
|
||||
const { values, positionals } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
from: { type: "string", short: "f" },
|
||||
to: { type: "string", short: "t" },
|
||||
variant: { type: "string", default: "low" },
|
||||
quiet: { type: "boolean", default: false },
|
||||
print: { type: "boolean", default: false },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
const args = [...positionals]
|
||||
|
||||
if (values.from) args.push("--from", values.from)
|
||||
if (values.to) args.push("--to", values.to)
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
Usage: bun script/changelog.ts [options]
|
||||
|
||||
Generates UPCOMING_CHANGELOG.md by running the opencode changelog command.
|
||||
|
||||
Options:
|
||||
-f, --from <version> Starting version (default: latest non-draft GitHub release)
|
||||
-t, --to <ref> Ending ref (default: HEAD)
|
||||
--variant <name> Thinking variant for opencode run (default: low)
|
||||
--quiet Suppress opencode command output unless it fails
|
||||
--print Print the generated UPCOMING_CHANGELOG.md after success
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
bun script/changelog.ts
|
||||
bun script/changelog.ts --from 1.0.200
|
||||
bun script/changelog.ts -f 1.0.200 -t 1.0.205
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await rm(file, { force: true })
|
||||
|
||||
const quiet = values.quiet
|
||||
const cmd = ["opencode", "run"]
|
||||
cmd.push("--variant", values.variant)
|
||||
cmd.push("--command", "changelog", "--", ...args)
|
||||
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd: root,
|
||||
stdin: "inherit",
|
||||
stdout: quiet ? "pipe" : "inherit",
|
||||
stderr: quiet ? "pipe" : "inherit",
|
||||
})
|
||||
|
||||
const [out, err] = quiet
|
||||
? await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
|
||||
: ["", ""]
|
||||
const code = await proc.exited
|
||||
if (code === 0) {
|
||||
if (values.print) process.stdout.write(await Bun.file(file).text())
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (quiet) {
|
||||
if (out) process.stdout.write(out)
|
||||
if (err) process.stderr.write(err)
|
||||
}
|
||||
|
||||
process.exit(code)
|
||||
79
script/duplicate-pr.ts
Executable file
79
script/duplicate-pr.ts
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "bun"
|
||||
import { createOpencode } from "@opencode-ai/sdk"
|
||||
import { parseArgs } from "util"
|
||||
|
||||
async function main() {
|
||||
const { values, positionals } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
file: { type: "string", short: "f" },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
Usage: bun script/duplicate-pr.ts [options] <message>
|
||||
|
||||
Options:
|
||||
-f, --file <path> File to attach to the prompt
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details"
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const message = positionals.join(" ")
|
||||
if (!message) {
|
||||
console.error("Error: message is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const opencode = await createOpencode({ port: 0 })
|
||||
|
||||
try {
|
||||
const parts: Array<{ type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }> =
|
||||
[]
|
||||
|
||||
if (values.file) {
|
||||
const resolved = path.resolve(process.cwd(), values.file)
|
||||
const file = Bun.file(resolved)
|
||||
if (!(await file.exists())) {
|
||||
console.error(`Error: file not found: ${values.file}`)
|
||||
process.exit(1)
|
||||
}
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: pathToFileURL(resolved).href,
|
||||
filename: path.basename(resolved),
|
||||
mime: "text/plain",
|
||||
})
|
||||
}
|
||||
|
||||
parts.push({ type: "text", text: message })
|
||||
|
||||
const session = await opencode.client.session.create()
|
||||
const result = await opencode.client.session
|
||||
.prompt({
|
||||
path: { id: session.data!.id },
|
||||
body: {
|
||||
agent: "duplicate-pr",
|
||||
parts,
|
||||
},
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
})
|
||||
.then((x) => x.data?.parts?.find((y) => y.type === "text")?.text ?? "")
|
||||
|
||||
console.log(result.trim())
|
||||
} finally {
|
||||
opencode.server.close()
|
||||
}
|
||||
}
|
||||
|
||||
void main()
|
||||
5
script/format.ts
Executable file
5
script/format.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
|
||||
await $`bun run prettier --ignore-unknown --write .`
|
||||
9
script/generate.ts
Executable file
9
script/generate.ts
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
|
||||
await $`bun ./packages/sdk/js/script/build.ts`
|
||||
|
||||
await $`bun dev generate > ../sdk/openapi.json`.cwd("packages/opencode")
|
||||
|
||||
await $`./script/format.ts`
|
||||
96
script/github/close-issues.ts
Executable file
96
script/github/close-issues.ts
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
const repo = "anomalyco/opencode"
|
||||
const days = 60
|
||||
const msg = `To stay organized issues are automatically closed after ${days} days of no activity. If the issue is still relevant please open a new one.`
|
||||
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (!token) {
|
||||
console.error("GITHUB_TOKEN environment variable is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||
|
||||
type Issue = {
|
||||
number: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
async function close(num: number) {
|
||||
const base = `https://api.github.com/repos/${repo}/issues/${num}`
|
||||
|
||||
const comment = await fetch(`${base}/comments`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ body: msg }),
|
||||
})
|
||||
if (!comment.ok) throw new Error(`Failed to comment #${num}: ${comment.status} ${comment.statusText}`)
|
||||
|
||||
const patch = await fetch(base, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ state: "closed", state_reason: "not_planned" }),
|
||||
})
|
||||
if (!patch.ok) throw new Error(`Failed to close #${num}: ${patch.status} ${patch.statusText}`)
|
||||
|
||||
console.log(`Closed https://github.com/${repo}/issues/${num}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let page = 1
|
||||
let closed = 0
|
||||
|
||||
while (true) {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&page=${page}`,
|
||||
{ headers },
|
||||
)
|
||||
if (!res.ok) throw new Error(res.statusText)
|
||||
|
||||
const all = (await res.json()) as Issue[]
|
||||
if (all.length === 0) break
|
||||
console.log(`Fetched page ${page} ${all.length} issues`)
|
||||
|
||||
const stale: number[] = []
|
||||
for (const i of all) {
|
||||
const updated = new Date(i.updated_at)
|
||||
if (updated < cutoff) {
|
||||
stale.push(i.number)
|
||||
} else {
|
||||
console.log(`\nFound fresh issue #${i.number}, stopping`)
|
||||
if (stale.length > 0) {
|
||||
for (const num of stale) {
|
||||
await close(num)
|
||||
closed++
|
||||
}
|
||||
}
|
||||
console.log(`Closed ${closed} issues total`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (stale.length > 0) {
|
||||
for (const num of stale) {
|
||||
await close(num)
|
||||
closed++
|
||||
}
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
console.log(`Closed ${closed} issues total`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
393
script/github/close-prs.ts
Executable file
393
script/github/close-prs.ts
Executable file
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { parseArgs } from "util"
|
||||
|
||||
const defaultRepo = "anomalyco/opencode"
|
||||
const defaultAgeMonths = 1
|
||||
const defaultThreshold = 2
|
||||
const defaultSleepMs = 20_000
|
||||
const defaultPrintLimit = 50
|
||||
const positiveReactions = new Set(["THUMBS_UP", "HEART", "HOORAY", "ROCKET"])
|
||||
const cleanupLabel = "automated-pr-cleanup"
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
execute: { type: "boolean", default: false },
|
||||
"dry-run": { type: "boolean", default: false },
|
||||
repo: { type: "string", default: defaultRepo },
|
||||
threshold: { type: "string", default: String(defaultThreshold) },
|
||||
"age-months": { type: "string", default: String(defaultAgeMonths) },
|
||||
"max-close": { type: "string" },
|
||||
"sleep-ms": { type: "string", default: String(defaultSleepMs) },
|
||||
"print-limit": { type: "string", default: String(defaultPrintLimit) },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
})
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
Usage: bun script/github/close-prs.ts [options]
|
||||
|
||||
Dry-run is the default. The script only comments and closes PRs when --execute is passed.
|
||||
|
||||
Criteria:
|
||||
- PRs created within the last month are untouched
|
||||
- PRs older than one month are closed when they have fewer than 2 positive reactions
|
||||
- Positive reactions are THUMBS_UP, HEART, HOORAY, and ROCKET reactions on the PR
|
||||
|
||||
Options:
|
||||
--execute Comment and close matching PRs
|
||||
--dry-run Explicitly run without changing anything
|
||||
--repo <owner/repo> Repository to clean up (default: ${defaultRepo})
|
||||
--threshold <n> Positive reaction threshold (default: ${defaultThreshold})
|
||||
--age-months <n> Age cutoff in months (default: ${defaultAgeMonths})
|
||||
--max-close <n> Maximum matching PRs to process
|
||||
--sleep-ms <n> Delay between closing PRs (default: ${defaultSleepMs})
|
||||
--print-limit <n> Number of matching PRs to print in dry-run (default: ${defaultPrintLimit})
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
bun script/github/close-prs.ts
|
||||
bun script/github/close-prs.ts --threshold 2 --print-limit 100
|
||||
bun script/github/close-prs.ts --execute --threshold 2 --max-close 25
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (values.execute && values["dry-run"]) {
|
||||
console.error("Use either --execute or --dry-run, not both")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const token = await requireToken()
|
||||
const repo = requireRepo(values.repo)
|
||||
const threshold = requirePositiveInteger("threshold", values.threshold)
|
||||
const ageMonths = requirePositiveInteger("age-months", values["age-months"])
|
||||
const maxClose =
|
||||
values["max-close"] === undefined ? undefined : requirePositiveInteger("max-close", values["max-close"])
|
||||
const sleepMs = requireNonNegativeInteger("sleep-ms", values["sleep-ms"])
|
||||
const printLimit = requireNonNegativeInteger("print-limit", values["print-limit"])
|
||||
const cutoff = subtractMonths(new Date(), ageMonths)
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
type PullRequest = {
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
createdAt: string
|
||||
reactionGroups: Array<{
|
||||
content: string
|
||||
users: {
|
||||
totalCount: number
|
||||
}
|
||||
}>
|
||||
labels: {
|
||||
nodes: Array<{
|
||||
name: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
type GraphqlResponse = {
|
||||
data?: {
|
||||
rateLimit: {
|
||||
cost: number
|
||||
remaining: number
|
||||
resetAt: string
|
||||
}
|
||||
repository: {
|
||||
pullRequests: {
|
||||
pageInfo: {
|
||||
hasNextPage: boolean
|
||||
endCursor: string | null
|
||||
}
|
||||
nodes: PullRequest[]
|
||||
}
|
||||
}
|
||||
}
|
||||
errors?: Array<{
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
|
||||
type CleanupCandidate = PullRequest & {
|
||||
positiveReactions: number
|
||||
}
|
||||
|
||||
const message = `Automated PR Cleanup
|
||||
|
||||
Thank you for contributing to opencode.
|
||||
|
||||
Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.
|
||||
|
||||
This PR was closed because it matched the following cleanup criteria:
|
||||
|
||||
- The PR was created more than ${ageMonths === 1 ? "1 month" : `${ageMonths} months`} ago
|
||||
- The PR had fewer than ${threshold} positive reactions
|
||||
- Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR
|
||||
|
||||
PRs created within the last ${ageMonths === 1 ? "month are" : `${ageMonths} months are`} not affected by this cleanup.
|
||||
|
||||
If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.
|
||||
|
||||
Thanks again for taking the time to contribute.`
|
||||
|
||||
async function main() {
|
||||
console.log(`${values.execute ? "EXECUTE" : "DRY RUN"}: PR cleanup for ${repo.owner}/${repo.name}`)
|
||||
console.log(`Cutoff: ${cutoff.toISOString()}`)
|
||||
console.log(`Threshold: fewer than ${threshold} positive reactions`)
|
||||
|
||||
const prs = await fetchOpenPullRequests()
|
||||
const recentCount = prs.filter((pr) => new Date(pr.createdAt) >= cutoff).length
|
||||
const matching = prs
|
||||
.map((pr) => ({ ...pr, positiveReactions: positiveReactionCount(pr) }))
|
||||
.filter((pr) => new Date(pr.createdAt) < cutoff && pr.positiveReactions < threshold)
|
||||
const candidates = matching.filter((pr) => !hasPriorCleanup(pr))
|
||||
const selected = maxClose === undefined ? candidates : candidates.slice(0, maxClose)
|
||||
|
||||
console.log(`Fetched ${prs.length} open PRs`)
|
||||
console.log(`Matching cleanup criteria: ${matching.length}`)
|
||||
console.log(`Skipped previously cleaned PRs: ${matching.length - candidates.length}`)
|
||||
console.log(`Recent PRs untouched: ${recentCount}`)
|
||||
console.log(
|
||||
`Older PRs with at least ${threshold} positive reactions untouched: ${prs.length - matching.length - recentCount}`,
|
||||
)
|
||||
|
||||
if (selected.length === 0) return
|
||||
|
||||
if (!values.execute) {
|
||||
console.log(`\nDry-run only. Re-run with --execute to comment and close matching PRs.`)
|
||||
console.log(`Showing ${Math.min(printLimit, selected.length)} of ${selected.length} matching PRs:\n`)
|
||||
for (const pr of selected.slice(0, printLimit)) {
|
||||
console.log(`#${pr.number} ${pr.createdAt} positive=${pr.positiveReactions} ${pr.url}`)
|
||||
}
|
||||
if (selected.length > printLimit) console.log(`... ${selected.length - printLimit} more not shown`)
|
||||
return
|
||||
}
|
||||
|
||||
await ensureCleanupLabel()
|
||||
|
||||
console.log(`\nCommenting and closing ${selected.length} PRs...`)
|
||||
for (const pr of selected) {
|
||||
await closePullRequest(pr)
|
||||
if (sleepMs > 0) await sleep(sleepMs)
|
||||
}
|
||||
console.log(`Closed ${selected.length} PRs`)
|
||||
}
|
||||
|
||||
async function fetchOpenPullRequests() {
|
||||
const prs: PullRequest[] = []
|
||||
let endCursor: string | null = null
|
||||
|
||||
while (true) {
|
||||
const page = await graphql({
|
||||
query: `query($owner: String!, $name: String!, $endCursor: String) {
|
||||
rateLimit {
|
||||
cost
|
||||
remaining
|
||||
resetAt
|
||||
}
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequests(first: 100, states: OPEN, orderBy: { field: CREATED_AT, direction: ASC }, after: $endCursor) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
number
|
||||
title
|
||||
url
|
||||
createdAt
|
||||
reactionGroups {
|
||||
content
|
||||
users {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
labels(first: 100) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
variables: {
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
endCursor,
|
||||
},
|
||||
})
|
||||
|
||||
prs.push(...page.repository.pullRequests.nodes)
|
||||
console.log(
|
||||
`Fetched ${prs.length} PRs, GraphQL rate limit remaining ${page.rateLimit.remaining} (cost ${page.rateLimit.cost})`,
|
||||
)
|
||||
|
||||
if (page.rateLimit.remaining < 100) {
|
||||
const delay = Math.max(0, new Date(page.rateLimit.resetAt).getTime() - Date.now()) + 1_000
|
||||
console.warn(`GraphQL rate limit low; sleeping ${Math.ceil(delay / 1000)}s until reset`)
|
||||
await sleep(delay)
|
||||
}
|
||||
|
||||
if (!page.repository.pullRequests.pageInfo.hasNextPage) return prs
|
||||
endCursor = page.repository.pullRequests.pageInfo.endCursor
|
||||
}
|
||||
}
|
||||
|
||||
async function graphql(input: { query: string; variables: Record<string, string | null> }) {
|
||||
const response = await githubRequest("/graphql", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
const body = (await response.json()) as GraphqlResponse
|
||||
if (body.errors?.length)
|
||||
throw new Error(`GitHub GraphQL error: ${body.errors.map((error) => error.message).join(", ")}`)
|
||||
if (!body.data) throw new Error("GitHub GraphQL response did not include data")
|
||||
return body.data
|
||||
}
|
||||
|
||||
async function closePullRequest(pr: CleanupCandidate) {
|
||||
await githubRequest(`/repos/${repo.owner}/${repo.name}/issues/${pr.number}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: message }),
|
||||
})
|
||||
await githubRequest(`/repos/${repo.owner}/${repo.name}/pulls/${pr.number}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ state: "closed" }),
|
||||
})
|
||||
await githubRequest(`/repos/${repo.owner}/${repo.name}/issues/${pr.number}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ labels: [cleanupLabel] }),
|
||||
})
|
||||
console.log(`Closed #${pr.number} positive=${pr.positiveReactions} ${pr.url}`)
|
||||
}
|
||||
|
||||
async function ensureCleanupLabel() {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${repo.owner}/${repo.name}/labels/${encodeURIComponent(cleanupLabel)}`,
|
||||
{
|
||||
headers,
|
||||
},
|
||||
)
|
||||
if (response.ok) return
|
||||
if (response.status !== 404)
|
||||
throw new Error(`Failed to check cleanup label: ${response.status} ${response.statusText}`)
|
||||
|
||||
await githubRequest(`/repos/${repo.owner}/${repo.name}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: cleanupLabel,
|
||||
color: "ededed",
|
||||
description: "PR was closed by automated cleanup",
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async function githubRequest(path: string, init: RequestInit, attempt = 0): Promise<Response> {
|
||||
const response = await fetch(path.startsWith("https://") ? path : `https://api.github.com${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...headers,
|
||||
...init.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) return response
|
||||
|
||||
const body = await response.text()
|
||||
const retryAfter = response.headers.get("retry-after")
|
||||
const reset = response.headers.get("x-ratelimit-reset")
|
||||
const retryMs = retryAfter
|
||||
? Number(retryAfter) * 1000
|
||||
: response.headers.get("x-ratelimit-remaining") === "0" && reset
|
||||
? Math.max(0, Number(reset) * 1000 - Date.now()) + 1_000
|
||||
: body.toLowerCase().includes("secondary rate limit")
|
||||
? 300_000
|
||||
: response.status >= 500
|
||||
? Math.min(300_000, 10_000 * 2 ** attempt)
|
||||
: 0
|
||||
|
||||
if ((response.status === 403 || response.status === 429 || response.status >= 500) && retryMs > 0 && attempt < 10) {
|
||||
console.warn(`GitHub request failed; sleeping ${Math.ceil(retryMs / 1000)}s before retry ${attempt + 1}`)
|
||||
await sleep(retryMs)
|
||||
return githubRequest(path, init, attempt + 1)
|
||||
}
|
||||
|
||||
throw new Error(`GitHub request failed: ${response.status} ${response.statusText}\n${body}`)
|
||||
}
|
||||
|
||||
function positiveReactionCount(pr: PullRequest) {
|
||||
return pr.reactionGroups
|
||||
.filter((group) => positiveReactions.has(group.content))
|
||||
.reduce((total, group) => total + group.users.totalCount, 0)
|
||||
}
|
||||
|
||||
function hasPriorCleanup(pr: PullRequest) {
|
||||
return pr.labels.nodes.some((label) => label.name === cleanupLabel)
|
||||
}
|
||||
|
||||
function requireRepo(value: string | undefined) {
|
||||
if (!value) throw new Error("repo is required")
|
||||
const [owner, name] = value.split("/")
|
||||
if (!owner || !name) throw new Error(`Invalid repo ${value}; expected owner/name`)
|
||||
return { owner, name }
|
||||
}
|
||||
|
||||
async function requireToken() {
|
||||
const envToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
|
||||
if (envToken) return envToken
|
||||
|
||||
const proc = Bun.spawn(["gh", "auth", "token"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
if (exitCode === 0 && stdout.trim()) return stdout.trim()
|
||||
|
||||
throw new Error(
|
||||
`GitHub authentication is required. Set GITHUB_TOKEN/GH_TOKEN or run gh auth login.\n${stderr.trim()}`,
|
||||
)
|
||||
}
|
||||
|
||||
function requirePositiveInteger(name: string, value: string | undefined) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
function requireNonNegativeInteger(name: string, value: string | undefined) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${name} must be a non-negative integer`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
function subtractMonths(date: Date, months: number) {
|
||||
const result = new Date(date)
|
||||
const day = result.getUTCDate()
|
||||
result.setUTCDate(1)
|
||||
result.setUTCMonth(result.getUTCMonth() - months)
|
||||
result.setUTCDate(
|
||||
Math.min(day, new Date(Date.UTC(result.getUTCFullYear(), result.getUTCMonth() + 1, 0)).getUTCDate()),
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error("Error:", error)
|
||||
process.exit(1)
|
||||
})
|
||||
19
script/hooks
Executable file
19
script/hooks
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ ! -d ".git" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p .git/hooks
|
||||
|
||||
cat > .git/hooks/pre-push << 'EOF'
|
||||
#!/bin/sh
|
||||
# Ensure dependencies are installed before typecheck
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
bun install >/dev/null 2>&1 || true
|
||||
fi
|
||||
bun run typecheck
|
||||
EOF
|
||||
|
||||
chmod +x .git/hooks/pre-push
|
||||
echo "✅ Pre-push hook installed"
|
||||
70
script/publish.ts
Executable file
70
script/publish.ts
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
console.log("=== publishing ===\n")
|
||||
|
||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
process.chdir(dir)
|
||||
const tag = `v${Script.version}`
|
||||
|
||||
const pkgjsons = await Array.fromAsync(
|
||||
new Bun.Glob("**/package.json").scan({
|
||||
absolute: true,
|
||||
}),
|
||||
).then((arr) => arr.filter((x) => !x.includes("node_modules") && !x.includes("dist")))
|
||||
|
||||
async function prepareReleaseFiles() {
|
||||
for (const file of pkgjsons) {
|
||||
let pkg = await Bun.file(file).text()
|
||||
pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`)
|
||||
console.log("updated:", file)
|
||||
await Bun.file(file).write(pkg)
|
||||
}
|
||||
|
||||
await $`bun install`
|
||||
await $`./packages/sdk/js/script/build.ts`
|
||||
}
|
||||
|
||||
if (Script.release && !Script.preview) {
|
||||
await $`git fetch origin --tags`
|
||||
await $`git switch --detach`
|
||||
}
|
||||
|
||||
await prepareReleaseFiles()
|
||||
|
||||
console.log("\n=== cli ===\n")
|
||||
await $`bun ./packages/opencode/script/publish.ts`
|
||||
|
||||
console.log("\n=== preview cli ===\n")
|
||||
await $`bun ./packages/cli/script/publish.ts`
|
||||
|
||||
console.log("\n=== sdk ===\n")
|
||||
await $`bun ./packages/sdk/js/script/publish.ts`
|
||||
|
||||
console.log("\n=== plugin ===\n")
|
||||
await $`bun ./packages/plugin/script/publish.ts`
|
||||
|
||||
if (Script.release) {
|
||||
await $`bun ./packages/desktop/scripts/finalize-latest-json.ts`
|
||||
await $`bun ./packages/desktop/scripts/finalize-latest-yml.ts`
|
||||
}
|
||||
|
||||
if (Script.release && !Script.preview) {
|
||||
await $`git commit -am "release: ${tag}"`
|
||||
await $`git tag -d ${tag}`.nothrow()
|
||||
await $`git tag ${tag}`
|
||||
await $`git push origin refs/tags/${tag} --force-with-lease --no-verify`
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000))
|
||||
await $`git fetch origin`
|
||||
await $`git checkout -B dev origin/dev`
|
||||
await prepareReleaseFiles()
|
||||
await $`git commit -am "sync release versions for ${tag}"`
|
||||
await $`git push origin HEAD:dev --no-verify`
|
||||
}
|
||||
|
||||
if (Script.release) {
|
||||
await $`gh release edit ${tag} --draft=false --repo ${process.env.GH_REPO}`
|
||||
}
|
||||
283
script/raw-changelog.ts
Normal file
283
script/raw-changelog.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { parseArgs } from "util"
|
||||
|
||||
type Release = {
|
||||
tag_name: string
|
||||
draft: boolean
|
||||
}
|
||||
|
||||
type Commit = {
|
||||
hash: string
|
||||
author: string | null
|
||||
message: string
|
||||
areas: Set<string>
|
||||
}
|
||||
|
||||
type User = Map<string, Set<string>>
|
||||
type Diff = {
|
||||
sha: string
|
||||
login: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
const repo = process.env.GH_REPO ?? "anomalyco/opencode"
|
||||
const bot = ["actions-user", "github-actions[bot]", "opencode", "opencode-agent[bot]"]
|
||||
const team = [
|
||||
...(await Bun.file(new URL("../.github/TEAM_MEMBERS", import.meta.url))
|
||||
.text()
|
||||
.then((x) => x.split(/\r?\n/).map((x) => x.trim()))
|
||||
.then((x) => x.filter((x) => x && !x.startsWith("#")))),
|
||||
...bot,
|
||||
]
|
||||
const order = ["Core", "TUI", "Desktop", "SDK", "Extensions"] as const
|
||||
const sections = {
|
||||
core: "Core",
|
||||
tui: "TUI",
|
||||
app: "Desktop",
|
||||
tauri: "Desktop",
|
||||
sdk: "SDK",
|
||||
plugin: "SDK",
|
||||
"extensions/vscode": "Extensions",
|
||||
github: "Extensions",
|
||||
} as const
|
||||
|
||||
function ref(input: string) {
|
||||
if (input === "HEAD") return input
|
||||
if (input.startsWith("v")) return input
|
||||
if (input.match(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/)) return `v${input}`
|
||||
return input
|
||||
}
|
||||
|
||||
async function latest() {
|
||||
const data = await $`gh api "/repos/${repo}/releases?per_page=100"`.json()
|
||||
const release = (data as Release[]).find((item) => !item.draft)
|
||||
if (!release) throw new Error("No releases found")
|
||||
return release.tag_name.replace(/^v/, "")
|
||||
}
|
||||
|
||||
async function diff(base: string, head: string) {
|
||||
const list: Diff[] = []
|
||||
for (let page = 1; ; page++) {
|
||||
const text =
|
||||
await $`gh api "/repos/${repo}/compare/${base}...${head}?per_page=100&page=${page}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text()
|
||||
const batch = text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as Diff)
|
||||
if (batch.length === 0) break
|
||||
list.push(...batch)
|
||||
if (batch.length < 100) break
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
function section(areas: Set<string>) {
|
||||
const priority = ["core", "tui", "app", "tauri", "sdk", "plugin", "extensions/vscode", "github"]
|
||||
for (const area of priority) {
|
||||
if (areas.has(area)) return sections[area as keyof typeof sections]
|
||||
}
|
||||
return "Core"
|
||||
}
|
||||
|
||||
function type(message: string) {
|
||||
if (message.match(/fix/i)) return "Bugfixes"
|
||||
return "Improvements"
|
||||
}
|
||||
|
||||
function reverted(commits: Commit[]) {
|
||||
const seen = new Map<string, Commit>()
|
||||
|
||||
for (const commit of commits) {
|
||||
const match = commit.message.match(/^Revert "(.+)"$/)
|
||||
if (match) {
|
||||
const msg = match[1]!
|
||||
if (seen.has(msg)) seen.delete(msg)
|
||||
else seen.set(commit.message, commit)
|
||||
continue
|
||||
}
|
||||
|
||||
const revert = `Revert "${commit.message}"`
|
||||
if (seen.has(revert)) {
|
||||
seen.delete(revert)
|
||||
continue
|
||||
}
|
||||
|
||||
seen.set(commit.message, commit)
|
||||
}
|
||||
|
||||
return [...seen.values()]
|
||||
}
|
||||
|
||||
async function commits(from: string, to: string) {
|
||||
const base = ref(from)
|
||||
const head = ref(to)
|
||||
|
||||
const data = new Map<string, { login: string | null; message: string }>()
|
||||
for (const item of await diff(base, head)) {
|
||||
data.set(item.sha, { login: item.login, message: item.message.split("\n")[0] ?? "" })
|
||||
}
|
||||
|
||||
const log =
|
||||
await $`git log ${base}..${head} --format=%H -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text()
|
||||
|
||||
const list: Commit[] = []
|
||||
for (const hash of log.split("\n").filter(Boolean)) {
|
||||
const item = data.get(hash)
|
||||
if (!item) continue
|
||||
if (item.message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
||||
|
||||
const diff = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text()
|
||||
const areas = new Set<string>()
|
||||
|
||||
for (const file of diff.split("\n").filter(Boolean)) {
|
||||
if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui")
|
||||
else if (file.startsWith("packages/opencode/")) areas.add("core")
|
||||
else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri")
|
||||
else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app")
|
||||
else if (file.startsWith("packages/sdk/") || file.startsWith("packages/plugin/")) areas.add("sdk")
|
||||
else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode")
|
||||
}
|
||||
|
||||
if (areas.size === 0) continue
|
||||
|
||||
list.push({
|
||||
hash: hash.slice(0, 7),
|
||||
author: item.login,
|
||||
message: item.message,
|
||||
areas,
|
||||
})
|
||||
}
|
||||
|
||||
return reverted(list)
|
||||
}
|
||||
|
||||
async function contributors(from: string, to: string) {
|
||||
const base = ref(from)
|
||||
const head = ref(to)
|
||||
|
||||
const users: User = new Map()
|
||||
for (const item of await diff(base, head)) {
|
||||
const title = item.message.split("\n")[0] ?? ""
|
||||
if (!item.login || team.includes(item.login)) continue
|
||||
if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
||||
if (!users.has(item.login)) users.set(item.login, new Set())
|
||||
users.get(item.login)!.add(title)
|
||||
}
|
||||
|
||||
return users
|
||||
}
|
||||
|
||||
async function published(to: string) {
|
||||
if (to === "HEAD") return
|
||||
const body = await $`gh release view ${ref(to)} --repo ${repo} --json body --jq .body`.text().catch(() => "")
|
||||
if (!body) return
|
||||
|
||||
const lines = body.split(/\r?\n/)
|
||||
const start = lines.findIndex((line) => line.startsWith("**Thank you to "))
|
||||
if (start < 0) return
|
||||
return lines.slice(start).join("\n").trim()
|
||||
}
|
||||
|
||||
async function thanks(from: string, to: string, reuse: boolean) {
|
||||
const release = reuse ? await published(to) : undefined
|
||||
if (release) return release.split(/\r?\n/)
|
||||
|
||||
const users = await contributors(from, to)
|
||||
if (users.size === 0) return []
|
||||
|
||||
const lines = [`**Thank you to ${users.size} community contributor${users.size > 1 ? "s" : ""}:**`]
|
||||
for (const [name, commits] of users) {
|
||||
lines.push(`- @${name}:`)
|
||||
for (const commit of commits) lines.push(` - ${commit}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function format(from: string, to: string, list: Commit[], thanks: string[]) {
|
||||
const grouped = new Map<string, Map<string, string[]>>()
|
||||
for (const title of order) {
|
||||
grouped.set(
|
||||
title,
|
||||
new Map([
|
||||
["Improvements", []],
|
||||
["Bugfixes", []],
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
for (const commit of list) {
|
||||
const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : ""
|
||||
grouped.get(section(commit.areas))!.get(type(commit.message))!.push(`- \`${commit.hash}\` ${commit.message}${attr}`)
|
||||
}
|
||||
|
||||
const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""]
|
||||
|
||||
if (list.length === 0) {
|
||||
lines.push("No notable changes.")
|
||||
}
|
||||
|
||||
for (const title of order) {
|
||||
const groups = grouped.get(title)
|
||||
if (!groups || [...groups.values()].every((entries) => entries.length === 0)) continue
|
||||
lines.push(`## ${title}`)
|
||||
const improvements = groups.get("Improvements")!
|
||||
const bugfixes = groups.get("Bugfixes")!
|
||||
if (bugfixes.length === 0) {
|
||||
lines.push(...improvements)
|
||||
lines.push("")
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [subtitle, entries] of groups) {
|
||||
if (entries.length === 0) continue
|
||||
lines.push(`### ${subtitle}`)
|
||||
lines.push(...entries)
|
||||
lines.push("")
|
||||
}
|
||||
}
|
||||
|
||||
if (thanks.length > 0) {
|
||||
if (lines.at(-1) !== "") lines.push("")
|
||||
lines.push("## Community Contributors Input")
|
||||
lines.push("")
|
||||
lines.push(...thanks)
|
||||
}
|
||||
|
||||
if (lines.at(-1) === "") lines.pop()
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
from: { type: "string", short: "f" },
|
||||
to: { type: "string", short: "t", default: "HEAD" },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
})
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
Usage: bun script/raw-changelog.ts [options]
|
||||
|
||||
Options:
|
||||
-f, --from <version> Starting version (default: latest non-draft GitHub release)
|
||||
-t, --to <ref> Ending ref (default: HEAD)
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
bun script/raw-changelog.ts
|
||||
bun script/raw-changelog.ts --from 1.0.200
|
||||
bun script/raw-changelog.ts -f 1.0.200 -t 1.0.205
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const to = values.to!
|
||||
const from = values.from ?? (await latest())
|
||||
const list = await commits(from, to)
|
||||
console.log(format(from, to, list, await thanks(from, to, !values.from)))
|
||||
}
|
||||
5
script/release
Executable file
5
script/release
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
BUMP_TYPE=${1:-patch}
|
||||
|
||||
gh workflow run publish.yml -f bump="$BUMP_TYPE"
|
||||
70
script/sign-windows.ps1
Normal file
70
script/sign-windows.ps1
Normal file
@@ -0,0 +1,70 @@
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]] $Path
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not $Path -or $Path.Count -eq 0) {
|
||||
throw "At least one path is required"
|
||||
}
|
||||
|
||||
if ($env:GITHUB_ACTIONS -ne "true") {
|
||||
Write-Host "Skipping Windows signing because this is not running on GitHub Actions"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$vars = @{
|
||||
endpoint = $env:AZURE_TRUSTED_SIGNING_ENDPOINT
|
||||
account = $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME
|
||||
profile = $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE
|
||||
}
|
||||
|
||||
if ($vars.Values | Where-Object { -not $_ }) {
|
||||
Write-Host "Skipping Windows signing because Azure Artifact Signing is not configured"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$moduleVersion = "0.5.8"
|
||||
$module = Get-Module -ListAvailable -Name TrustedSigning | Where-Object { $_.Version -eq [version] $moduleVersion }
|
||||
|
||||
if (-not $module) {
|
||||
try {
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-Host "NuGet package provider install skipped: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
Install-Module -Name TrustedSigning -RequiredVersion $moduleVersion -Force -Repository PSGallery -Scope CurrentUser
|
||||
}
|
||||
|
||||
Import-Module TrustedSigning -RequiredVersion $moduleVersion -Force
|
||||
|
||||
$files = @($Path | ForEach-Object { Resolve-Path $_ -ErrorAction SilentlyContinue } | Select-Object -ExpandProperty Path -Unique)
|
||||
|
||||
if (-not $files -or $files.Count -eq 0) {
|
||||
throw "No files matched the requested paths"
|
||||
}
|
||||
|
||||
$params = @{
|
||||
Endpoint = $vars.endpoint
|
||||
CodeSigningAccountName = $vars.account
|
||||
CertificateProfileName = $vars.profile
|
||||
Files = ($files -join ",")
|
||||
FileDigest = "SHA256"
|
||||
TimestampDigest = "SHA256"
|
||||
TimestampRfc3161 = "http://timestamp.acs.microsoft.com"
|
||||
ExcludeEnvironmentCredential = $true
|
||||
ExcludeWorkloadIdentityCredential = $true
|
||||
ExcludeManagedIdentityCredential = $true
|
||||
ExcludeSharedTokenCacheCredential = $true
|
||||
ExcludeVisualStudioCredential = $true
|
||||
ExcludeVisualStudioCodeCredential = $true
|
||||
ExcludeAzureCliCredential = $false
|
||||
ExcludeAzurePowerShellCredential = $true
|
||||
ExcludeAzureDeveloperCliCredential = $true
|
||||
ExcludeInteractiveBrowserCredential = $true
|
||||
}
|
||||
|
||||
Invoke-TrustedSigning @params
|
||||
225
script/stats.ts
Executable file
225
script/stats.ts
Executable file
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
async function sendToPostHog(event: string, properties: Record<string, any>) {
|
||||
const key = process.env["POSTHOG_KEY"]
|
||||
|
||||
if (!key) {
|
||||
console.warn("POSTHOG_API_KEY not set, skipping PostHog event")
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch("https://us.i.posthog.com/i/v0/e/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
distinct_id: "download",
|
||||
api_key: key,
|
||||
event,
|
||||
properties: {
|
||||
...properties,
|
||||
},
|
||||
}),
|
||||
}).catch(() => null)
|
||||
|
||||
if (response && !response.ok) {
|
||||
console.warn(`PostHog API error: ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
interface Asset {
|
||||
name: string
|
||||
download_count: number
|
||||
}
|
||||
|
||||
interface Release {
|
||||
tag_name: string
|
||||
name: string
|
||||
assets: Asset[]
|
||||
}
|
||||
|
||||
interface NpmDownloadsRange {
|
||||
start: string
|
||||
end: string
|
||||
package: string
|
||||
downloads: Array<{
|
||||
downloads: number
|
||||
day: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function fetchNpmDownloads(packageName: string): Promise<number> {
|
||||
try {
|
||||
// Use a range from 2020 to current year + 5 years to ensure it works forever
|
||||
const currentYear = new Date().getFullYear()
|
||||
const endYear = currentYear + 5
|
||||
const response = await fetch(`https://api.npmjs.org/downloads/range/2020-01-01:${endYear}-12-31/${packageName}`)
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch npm downloads for ${packageName}: ${response.status}`)
|
||||
return 0
|
||||
}
|
||||
const data: NpmDownloadsRange = await response.json()
|
||||
return data.downloads.reduce((total, day) => total + day.downloads, 0)
|
||||
} catch (error) {
|
||||
console.warn(`Error fetching npm downloads for ${packageName}:`, error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchReleases(): Promise<Release[]> {
|
||||
const releases: Release[] = []
|
||||
let page = 1
|
||||
const per = 100
|
||||
|
||||
while (true) {
|
||||
const url = `https://api.github.com/repos/anomalyco/opencode/releases?page=${page}&per_page=${per}`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const batch: Release[] = await response.json()
|
||||
if (batch.length === 0) break
|
||||
|
||||
releases.push(...batch)
|
||||
console.log(`Fetched page ${page} with ${batch.length} releases`)
|
||||
|
||||
if (batch.length < per) break
|
||||
page++
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
return releases
|
||||
}
|
||||
|
||||
function calculate(releases: Release[]) {
|
||||
let total = 0
|
||||
const stats = []
|
||||
|
||||
for (const release of releases) {
|
||||
let downloads = 0
|
||||
const assets = []
|
||||
|
||||
for (const asset of release.assets) {
|
||||
downloads += asset.download_count
|
||||
assets.push({
|
||||
name: asset.name,
|
||||
downloads: asset.download_count,
|
||||
})
|
||||
}
|
||||
|
||||
total += downloads
|
||||
stats.push({
|
||||
tag: release.tag_name,
|
||||
name: release.name,
|
||||
downloads,
|
||||
assets,
|
||||
})
|
||||
}
|
||||
|
||||
return { total, stats }
|
||||
}
|
||||
|
||||
async function save(githubTotal: number, npmDownloads: number) {
|
||||
const file = "STATS.md"
|
||||
const date = new Date().toISOString().split("T")[0]
|
||||
const total = githubTotal + npmDownloads
|
||||
|
||||
let previousGithub = 0
|
||||
let previousNpm = 0
|
||||
let previousTotal = 0
|
||||
let content = ""
|
||||
|
||||
try {
|
||||
content = await Bun.file(file).text()
|
||||
const lines = content.trim().split("\n")
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim()
|
||||
if (line.startsWith("|") && !line.includes("Date") && !line.includes("---")) {
|
||||
const match = line.match(
|
||||
/\|\s*[\d-]+\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|/,
|
||||
)
|
||||
if (match) {
|
||||
previousGithub = parseInt(match[1].replace(/,/g, ""))
|
||||
previousNpm = parseInt(match[2].replace(/,/g, ""))
|
||||
previousTotal = parseInt(match[3].replace(/,/g, ""))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
content =
|
||||
"# Download Stats\n\n| Date | GitHub Downloads | npm Downloads | Total |\n|------|------------------|---------------|-------|\n"
|
||||
}
|
||||
|
||||
const githubChange = githubTotal - previousGithub
|
||||
const npmChange = npmDownloads - previousNpm
|
||||
const totalChange = total - previousTotal
|
||||
|
||||
const githubChangeStr =
|
||||
githubChange > 0
|
||||
? ` (+${githubChange.toLocaleString()})`
|
||||
: githubChange < 0
|
||||
? ` (${githubChange.toLocaleString()})`
|
||||
: " (+0)"
|
||||
const npmChangeStr =
|
||||
npmChange > 0 ? ` (+${npmChange.toLocaleString()})` : npmChange < 0 ? ` (${npmChange.toLocaleString()})` : " (+0)"
|
||||
const totalChangeStr =
|
||||
totalChange > 0
|
||||
? ` (+${totalChange.toLocaleString()})`
|
||||
: totalChange < 0
|
||||
? ` (${totalChange.toLocaleString()})`
|
||||
: " (+0)"
|
||||
const line = `| ${date} | ${githubTotal.toLocaleString()}${githubChangeStr} | ${npmDownloads.toLocaleString()}${npmChangeStr} | ${total.toLocaleString()}${totalChangeStr} |\n`
|
||||
|
||||
if (!content.includes("# Download Stats")) {
|
||||
content =
|
||||
"# Download Stats\n\n| Date | GitHub Downloads | npm Downloads | Total |\n|------|------------------|---------------|-------|\n"
|
||||
}
|
||||
|
||||
await Bun.write(file, content + line)
|
||||
await Bun.spawn(["bunx", "prettier", "--write", file]).exited
|
||||
|
||||
console.log(
|
||||
`\nAppended stats to ${file}: GitHub ${githubTotal.toLocaleString()}${githubChangeStr}, npm ${npmDownloads.toLocaleString()}${npmChangeStr}, Total ${total.toLocaleString()}${totalChangeStr}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log("Fetching GitHub releases for anomalyco/opencode...\n")
|
||||
|
||||
const releases = await fetchReleases()
|
||||
console.log(`\nFetched ${releases.length} releases total\n`)
|
||||
|
||||
const { total: githubTotal } = calculate(releases)
|
||||
|
||||
console.log("Fetching npm all-time downloads for opencode-ai...\n")
|
||||
const npmDownloads = await fetchNpmDownloads("opencode-ai")
|
||||
console.log(`Fetched npm all-time downloads: ${npmDownloads.toLocaleString()}\n`)
|
||||
|
||||
await save(githubTotal, npmDownloads)
|
||||
|
||||
await sendToPostHog("download", {
|
||||
count: githubTotal,
|
||||
source: "github",
|
||||
})
|
||||
|
||||
await sendToPostHog("download", {
|
||||
count: npmDownloads,
|
||||
source: "npm",
|
||||
})
|
||||
|
||||
const totalDownloads = githubTotal + npmDownloads
|
||||
|
||||
console.log("=".repeat(60))
|
||||
console.log(`TOTAL DOWNLOADS: ${totalDownloads.toLocaleString()}`)
|
||||
console.log(` GitHub: ${githubTotal.toLocaleString()}`)
|
||||
console.log(` npm: ${npmDownloads.toLocaleString()}`)
|
||||
console.log("=".repeat(60))
|
||||
|
||||
console.log("-".repeat(60))
|
||||
console.log(`GitHub Total: ${githubTotal.toLocaleString()} downloads across ${releases.length} releases`)
|
||||
console.log(`npm Total: ${npmDownloads.toLocaleString()} downloads`)
|
||||
console.log(`Combined Total: ${totalDownloads.toLocaleString()} downloads`)
|
||||
192
script/upgrade-opentui.ts
Normal file
192
script/upgrade-opentui.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import path from "node:path"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const usage = "Usage: bun run script/upgrade-opentui.ts [--snapshot] <version>"
|
||||
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
console.log(usage)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const snapshotArg = args.find((arg) => arg.startsWith("--snapshot="))
|
||||
const snapshot = args.includes("--snapshot") || snapshotArg !== undefined
|
||||
const unknown = args.find((arg) => arg.startsWith("-") && arg !== "--snapshot" && !arg.startsWith("--snapshot="))
|
||||
if (unknown) {
|
||||
console.error(`Unknown option: ${unknown}`)
|
||||
console.error(usage)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const positional = args.filter((arg) => arg !== "--snapshot" && !arg.startsWith("--snapshot="))
|
||||
const raw = snapshotArg?.slice("--snapshot=".length) || positional[0]
|
||||
if (!raw || positional.length > (snapshotArg ? 0 : 1)) {
|
||||
console.error(usage)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (snapshotArg === "--snapshot=") {
|
||||
console.error("Missing snapshot version")
|
||||
console.error(usage)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ver = raw.replace(/^v/, "")
|
||||
const root = path.resolve(import.meta.dir, "..")
|
||||
const lockfile = path.join(root, "bun.lock")
|
||||
const skip = new Set([".git", ".opencode", ".turbo", "dist", "node_modules"])
|
||||
const keys = ["@opentui/core", "@opentui/keymap", "@opentui/solid"] as const
|
||||
|
||||
const files = (await Array.fromAsync(new Bun.Glob("**/package.json").scan({ cwd: root }))).filter(
|
||||
(file) => !file.split("/").some((part) => skip.has(part)),
|
||||
)
|
||||
|
||||
const setVersion = (cur: string, kind: "dep" | "peer") => {
|
||||
if (cur === "catalog:" || cur.startsWith("workspace:")) return cur
|
||||
if (snapshot) return ver
|
||||
if (kind === "peer") return `>=${ver}`
|
||||
if (cur.startsWith(">=")) return `>=${ver}`
|
||||
if (cur.startsWith("^")) return `^${ver}`
|
||||
if (cur.startsWith("~")) return `~${ver}`
|
||||
return ver
|
||||
}
|
||||
|
||||
const editDeps = (obj: unknown, kind: "dep" | "peer") => {
|
||||
if (!obj || typeof obj !== "object") return false
|
||||
const map = obj as Record<string, unknown>
|
||||
return keys
|
||||
.map((key) => {
|
||||
const cur = map[key]
|
||||
if (typeof cur !== "string") return false
|
||||
const next = setVersion(cur, kind)
|
||||
if (next === cur) return false
|
||||
map[key] = next
|
||||
return true
|
||||
})
|
||||
.some(Boolean)
|
||||
}
|
||||
|
||||
const editCatalog = (obj: unknown) => {
|
||||
if (!obj || typeof obj !== "object") return false
|
||||
const map = obj as Record<string, unknown>
|
||||
return keys
|
||||
.map((key) => {
|
||||
const cur = map[key]
|
||||
if (typeof cur !== "string" || cur === ver) return false
|
||||
map[key] = ver
|
||||
return true
|
||||
})
|
||||
.some(Boolean)
|
||||
}
|
||||
|
||||
const editOverrides = (obj: unknown) => {
|
||||
if (!obj || typeof obj !== "object") return false
|
||||
const map = obj as Record<string, unknown>
|
||||
return keys
|
||||
.map((key) => {
|
||||
const cur = map[key]
|
||||
if (typeof cur !== "string") return false
|
||||
const next = snapshot ? ver : "catalog:"
|
||||
if (next === cur) return false
|
||||
map[key] = next
|
||||
return true
|
||||
})
|
||||
.some(Boolean)
|
||||
}
|
||||
|
||||
const out = (
|
||||
await Promise.all(
|
||||
files.map(async (rel) => {
|
||||
const file = path.join(root, rel)
|
||||
const txt = await Bun.file(file).text()
|
||||
const json = JSON.parse(txt)
|
||||
const hit = [
|
||||
editCatalog(json.workspaces?.catalog),
|
||||
editOverrides(json.overrides),
|
||||
editDeps(json.dependencies, "dep"),
|
||||
editDeps(json.devDependencies, "dep"),
|
||||
editDeps(json.peerDependencies, "peer"),
|
||||
].some(Boolean)
|
||||
if (!hit) return null
|
||||
await Bun.write(file, `${JSON.stringify(json, null, 2)}\n`)
|
||||
return rel
|
||||
}),
|
||||
)
|
||||
).filter((item): item is string => item !== null)
|
||||
|
||||
if (out.length === 0) {
|
||||
console.log(`No opentui manifest updates needed for ${ver}`)
|
||||
}
|
||||
|
||||
if (out.length > 0) {
|
||||
console.log(`Updated opentui${snapshot ? " snapshot" : ""} to ${ver} in:`)
|
||||
for (const file of out) {
|
||||
console.log(`- ${file}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Running bun install to update bun.lock...")
|
||||
const install = Bun.spawn([process.execPath, "install"], {
|
||||
cwd: root,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
const installCode = await install.exited
|
||||
if (installCode !== 0) process.exit(installCode)
|
||||
|
||||
const fixed = await fixKnownLockfileIssues()
|
||||
if (fixed.length > 0) {
|
||||
console.log("Removed stale opentui-spinner peer lockfile entries:")
|
||||
for (const item of fixed) {
|
||||
console.log(`- ${item}`)
|
||||
}
|
||||
}
|
||||
|
||||
const stale = await findStaleLockfileEntries()
|
||||
if (stale.length > 0) {
|
||||
console.error(`bun.lock still contains stale opentui versions after upgrading to ${ver}:`)
|
||||
for (const item of stale) {
|
||||
console.error(`- ${item.entry}: ${item.pkg}@${item.version}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log("bun.lock opentui versions are consistent")
|
||||
|
||||
async function fixKnownLockfileIssues() {
|
||||
const txt = await Bun.file(lockfile).text()
|
||||
const stale = findStaleLockfileEntriesInText(txt)
|
||||
if (stale.length === 0) return []
|
||||
if (stale.some((item) => !item.entry.startsWith("opentui-spinner/@opentui/"))) return []
|
||||
|
||||
const removed = txt
|
||||
.split("\n")
|
||||
.map((line) => line.match(/^ "(opentui-spinner\/@opentui\/[^\"]+)": /)?.[1])
|
||||
.filter((item): item is string => item !== undefined)
|
||||
|
||||
if (removed.length === 0) return []
|
||||
|
||||
await Bun.write(
|
||||
lockfile,
|
||||
txt
|
||||
.split("\n")
|
||||
.filter((line) => !line.match(/^ "opentui-spinner\/@opentui\//))
|
||||
.join("\n"),
|
||||
)
|
||||
return removed
|
||||
}
|
||||
|
||||
async function findStaleLockfileEntries() {
|
||||
return findStaleLockfileEntriesInText(await Bun.file(lockfile).text())
|
||||
}
|
||||
|
||||
function findStaleLockfileEntriesInText(txt: string) {
|
||||
return Array.from(txt.matchAll(/^ "([^"]+)": \["(@opentui\/(?:core(?:-[^@"]+)?|keymap|solid))@([^"]+)"/gm))
|
||||
.map((match) => ({
|
||||
entry: match[1]!,
|
||||
pkg: match[2]!,
|
||||
version: match[3]!,
|
||||
}))
|
||||
.filter((item) => item.version !== ver)
|
||||
}
|
||||
36
script/version.ts
Executable file
36
script/version.ts
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
|
||||
const output = [`version=${Script.version}`]
|
||||
const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim()
|
||||
|
||||
if (!Script.preview) {
|
||||
await $`bun script/changelog.ts --to ${sha}`.cwd(process.cwd())
|
||||
const file = `${process.cwd()}/UPCOMING_CHANGELOG.md`
|
||||
const body = await Bun.file(file)
|
||||
.text()
|
||||
.catch(() => "No notable changes")
|
||||
const dir = process.env.RUNNER_TEMP ?? "/tmp"
|
||||
const notesFile = `${dir}/opencode-release-notes.txt`
|
||||
await Bun.write(notesFile, body)
|
||||
await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --notes-file ${notesFile}`
|
||||
const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json()
|
||||
output.push(`release=${release.databaseId}`)
|
||||
output.push(`tag=${release.tagName}`)
|
||||
} else if (Script.channel === "beta") {
|
||||
await $`gh release create v${Script.version} -d --title "v${Script.version}" --repo ${process.env.GH_REPO}`
|
||||
const release =
|
||||
await $`gh release view v${Script.version} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json()
|
||||
output.push(`release=${release.databaseId}`)
|
||||
output.push(`tag=${release.tagName}`)
|
||||
}
|
||||
|
||||
output.push(`repo=${process.env.GH_REPO}`)
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
await Bun.write(process.env.GITHUB_OUTPUT, output.join("\n"))
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
Reference in New Issue
Block a user