From 2eb9d57daa5cae7e13acebd83b6489fe80d1dc9d Mon Sep 17 00:00:00 2001 From: Ma Zhiyu Date: Thu, 27 Aug 2026 02:00:06 +0000 Subject: [PATCH 1/2] Add devcontainer with Bun 1.3 --- .devcontainer/devcontainer.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..592b9cfb --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,6 @@ +{ + "image": "oven/bun:1.3", + "features": { + "ghcr.io/devcontainers/features/git:1": {} + } +} \ No newline at end of file From fe3aca1cff0cf5720e3dc28e1823b1ef496b4b2b Mon Sep 17 00:00:00 2001 From: Ma Zhiyu Date: Sat, 5 Sep 2026 22:08:17 +0000 Subject: [PATCH 2/2] Refactor beta.ts main() to reduce complexity, Add unit tests for extracted control flow. --- script/beta.test.ts | 119 ++++++++++++++++++++++ script/beta.ts | 241 +++++++++++++++++++++++++------------------- 2 files changed, 259 insertions(+), 101 deletions(-) create mode 100644 script/beta.test.ts diff --git a/script/beta.test.ts b/script/beta.test.ts new file mode 100644 index 00000000..98aa2cee --- /dev/null +++ b/script/beta.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test" +import { fail, lines, report, run, syncState, type Outcome, type PR } from "./beta" + +function pr(number: number, title = `PR ${number}`): PR { + return { number, title, author: { login: "someone" }, labels: [{ name: "beta" }] } +} + +describe("fail", () => { + test("defaults the PR comment to the summary reason", () => { + expect(fail("Fetch failed")).toEqual({ + status: "failed", + reason: "Fetch failed", + comment: "Fetch failed", + }) + }) + + test("keeps a distinct comment when one is supplied", () => { + expect(fail("Merge conflicts", "Merge conflicts with dev branch")).toEqual({ + status: "failed", + reason: "Merge conflicts", + comment: "Merge conflicts with dev branch", + }) + }) +}) + +describe("syncState", () => { + test("reports unsynced when the local tree is absent from beta", () => { + expect(syncState("aaa", ["bbb", "ccc"])).toBe("unsynced") + }) + + test("reports current when the local tree is the tip of beta", () => { + expect(syncState("aaa", ["aaa", "bbb"])).toBe("current") + }) + + test("reports superseded when commits exist after the matching tree", () => { + expect(syncState("bbb", ["aaa", "bbb"])).toBe("superseded") + }) +}) + +describe("run", () => { + test("collects every applied PR and comments on none of them", async () => { + const comments: number[] = [] + const result = await run([pr(1), pr(2)], { + apply: async () => ({ status: "applied" }) as Outcome, + comment: async (n) => void comments.push(n), + }) + + expect(result.applied).toEqual([1, 2]) + expect(result.failed).toEqual([]) + expect(comments).toEqual([]) + }) + + test("records a failure and comments with the comment text, not the reason", async () => { + const comments: Array<[number, string]> = [] + const result = await run([pr(7, "Add widget")], { + apply: async () => fail("Merge conflicts", "Merge conflicts with dev branch"), + comment: async (n, reason) => void comments.push([n, reason]), + }) + + expect(result.applied).toEqual([]) + expect(result.failed).toEqual([{ number: 7, title: "Add widget", reason: "Merge conflicts" }]) + expect(comments).toEqual([[7, "Merge conflicts with dev branch"]]) + }) + + test("treats a skipped PR as neither applied nor failed", async () => { + const comments: number[] = [] + const result = await run([pr(3)], { + apply: async () => ({ status: "skipped" }) as Outcome, + comment: async (n) => void comments.push(n), + }) + + expect(result.applied).toEqual([]) + expect(result.failed).toEqual([]) + expect(comments).toEqual([]) + }) + + test("keeps processing later PRs after an earlier one fails", async () => { + const result = await run([pr(1), pr(2), pr(3)], { + apply: async (target) => + target.number === 2 ? fail("Merge failed") : ({ status: "applied" } as Outcome), + comment: async () => {}, + }) + + expect(result.applied).toEqual([1, 3]) + expect(result.failed.map((x) => x.number)).toEqual([2]) + }) + + test("passes the accumulated applied list and index into each apply call", async () => { + const seen: Array<{ idx: number; applied: number[] }> = [] + await run([pr(10), pr(20)], { + apply: async (_target, _all, applied, idx) => { + seen.push({ idx, applied: [...applied] }) + return { status: "applied" } as Outcome + }, + comment: async () => {}, + }) + + expect(seen).toEqual([ + { idx: 0, applied: [] }, + { idx: 1, applied: [10] }, + ]) + }) +}) + +describe("report", () => { + test("summarizes without throwing so the caller controls exit behavior", () => { + expect(report([1], [{ number: 2, title: "Broken", reason: "Merge failed" }])).toBeUndefined() + }) +}) + +describe("lines", () => { + test("formats PRs one per line", () => { + expect(lines([pr(1, "First"), pr(2, "Second")])).toBe("- #1: First\n- #2: Second") + }) + + test("falls back to a placeholder when there are no PRs", () => { + expect(lines([])).toBe("(none)") + }) +}) \ No newline at end of file diff --git a/script/beta.ts b/script/beta.ts index d738c36f..de548016 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -5,19 +5,28 @@ import fs from "fs/promises" const model = "opencode/gpt-5.3-codex" -interface PR { +export interface PR { number: number title: string author: { login: string } labels: Array<{ name: string }> } -interface FailedPR { +export interface FailedPR { number: number title: string reason: string } +export type Outcome = + | { status: "applied" } + | { status: "skipped" } + | { status: "failed"; reason: string; comment: string } + +export function fail(reason: string, comment: string = reason): Outcome { + return { status: "failed", reason, comment } +} + async function commentOnPR(prNumber: number, reason: string) { const body = `⚠️ **Blocking Beta Release** @@ -53,7 +62,7 @@ async function cleanup() { } catch {} } -function lines(prs: PR[]) { +export function lines(prs: PR[]) { return prs.map((x) => `- #${x.number}: ${x.title}`).join("\n") || "(none)" } @@ -216,7 +225,7 @@ async function smoke(prs: PR[], applied: number[]) { return commitSmokeChanges() } -async function main() { +async function fetchPRs() { console.log("Fetching open PRs with beta label...") const stdout = @@ -224,137 +233,167 @@ async function main() { 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`) + return prs +} - if (prs.length === 0) { - console.log("No team PRs to merge") - return - } - +async function reset() { console.log("Fetching latest dev branch...") await $`git fetch origin dev` console.log("Checking out beta branch...") await $`git checkout -B beta origin/dev` +} + +async function merge(pr: PR, prs: PR[], applied: number[], idx: number): Promise { + console.log(" Merging...") + + try { + await $`git merge --no-commit --no-ff pr/${pr.number}` + return null + } catch {} + + const files = await conflicts() + if (files.length === 0) { + console.log(" Failed to merge") + await cleanup() + return fail("Merge failed") + } + + console.log(" Failed to merge (conflicts)") + if (await fix(pr, files, prs, applied, idx)) return null + + await cleanup() + return fail("Merge conflicts", "Merge conflicts with dev branch") +} + +async function commit(pr: PR): Promise { + try { + await $`git rev-parse -q --verify MERGE_HEAD`.text() + } catch { + console.log(" No changes, skipping") + return { status: "skipped" } + } + + try { + await $`git add -A` + } catch { + console.log(" Failed to stage changes") + return fail("Staging failed", "Failed to stage changes") + } + + const commitMsg = `Apply PR #${pr.number}: ${pr.title}` + try { + await $`git commit -m ${commitMsg}` + } catch (err) { + console.log(` Failed to commit: ${err}`) + return fail("Commit failed", "Failed to commit changes") + } + + console.log(" Applied successfully") + return { status: "applied" } +} + +async function apply(pr: PR, prs: PR[], applied: number[], idx: number): Promise { + 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}`) + return fail("Fetch failed") + } + const failure = await merge(pr, prs, applied, idx) + if (failure) return failure + + return commit(pr) +} + +export interface RunDeps { + apply: (pr: PR, prs: PR[], applied: number[], idx: number) => Promise + comment: (prNumber: number, reason: string) => Promise +} + +export async function run(prs: PR[], deps: RunDeps = { apply, comment: commentOnPR }) { 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 outcome = await deps.apply(pr, prs, applied, idx) + if (outcome.status === "applied") applied.push(pr.number) + if (outcome.status === "failed") { + failed.push({ number: pr.number, title: pr.title, reason: outcome.reason }) + await deps.comment(pr.number, outcome.comment) } - - 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) } + return { applied, failed } +} + +export function report(applied: number[], failed: FailedPR[]) { 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`) - } + if (failed.length === 0) return + console.log(`Failed: ${failed.length} PRs`) + failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) +} - console.log("\nChecking if beta branch has changes...") +export type SyncState = "unsynced" | "current" | "superseded" + +export function syncState(tree: string, remote: string[]): SyncState { + const idx = remote.indexOf(tree) + if (idx === -1) return "unsynced" + return idx === 0 ? "current" : "superseded" +} + +async function synced(label: string) { 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 tree = (await $`git rev-parse beta^{tree}`.text()).trim() + const remote = (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 - } + const state = syncState(tree, remote) + if (state === "unsynced") return false - if (!(await smoke(prs, applied))) throw new Error("Final smoke check failed") + if (state === "current") console.log(`Beta branch has identical${label} contents, no push needed`) + else console.log(`Beta branch contains this${label} sync, but additional commits exist after it. Leaving beta as is.`) - await $`git fetch origin beta` + return true +} - 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") - } +async function main() { + const prs = await fetchPRs() + if (prs.length === 0) { + console.log("No team PRs to merge") return } + await reset() + + const { applied, failed } = await run(prs) + report(applied, failed) + if (failed.length > 0) throw new Error(`${failed.length} PR(s) failed to merge`) + + console.log("\nChecking if beta branch has changes...") + if (await synced("")) return + + if (!(await smoke(prs, applied))) throw new Error("Final smoke check failed") + if (await synced(" validated")) 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) -}) +if (process.argv[1]?.endsWith("beta.ts")) { + main().catch((err) => { + console.error("Error:", err) + process.exit(1) + }) +} \ No newline at end of file