| #!/usr/bin/env bun |
|
|
| import { $ } from "bun" |
| import fs from "fs/promises" |
|
|
| const model = "opencode/deepseek-v4-flash" |
|
|
| 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/cli") |
| 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/cli`.", |
| "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 checkout() { |
| console.log("Fetching latest v2 branch...") |
| await $`git fetch origin v2` |
|
|
| console.log("Checking out beta branch...") |
| await $`git checkout -B beta origin/v2` |
|
|
| console.log("Installing v2 dependencies...") |
| await $`bun install --frozen-lockfile` |
| } |
|
|
| async function main() { |
| console.log("Fetching open PRs with beta label...") |
|
|
| const stdout = |
| await $`gh pr list --state open --draft=false --base v2 --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`) |
|
|
| await checkout() |
|
|
| 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 v2 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/v2..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/v2..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) |
| }) |
|
|