UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

1,161 lines (1,081 loc) • 44.7 kB
#!/usr/bin/env node /** * Landing stages for one KXM branch. Each stage prints one JSON line * {stage, ok, code?, detail?} and the process exits 0, 1 (refused), or 2 (usage). * gh and git are argv arrays. GIT_* is stripped from every child. * Writes only inside the repo and .kxm/logs/. */ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { delimiter, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const STAGES = Object.freeze([ "verify", "docs", "push", "pr", "rebase", "unblock", "merge", "release", "milestone", ]); const VERIFY_FRESH_MS = 30 * 60 * 1000; const REBASE_ROUNDS = 5; const POLL_MS = positiveInt(process.env.KXM_LAND_POLL_MS, 30_000); const MERGE_STATE_WAIT_MS = positiveInt(process.env.KXM_LAND_MERGE_STATE_WAIT_MS, 120_000); const MERGE_STATE_POLL_MS = positiveInt(process.env.KXM_LAND_MERGE_STATE_POLL_MS, 1_000); const MERGE_STATE_POLL_CAP_MS = 30_000; const MERGE_READY = new Set(["CLEAN", "HAS_HOOKS", "ABSENT", ""]); const MERGE_REBASE = new Set(["BEHIND", "DIRTY"]); const VERIFY_FAIL_LINE = /✖|not ok\b|error TS\d*|error MD\d*|out of date|npm ERR!|ELIFECYCLE|AssertionError|# fail\b/i; const MERGE_WAIT_MS = 20 * 60 * 1000; const RELEASE_WAIT_MS = 20 * 60 * 1000; const RELEASE_PROGRESS_MS = 2 * 60 * 1000; const PUBLISH_WAIT_MS = 10 * 60 * 1000; const DOCS_COMMIT = "docs(roadmap): regenerate after verify"; const GENERATOR = "plans/kxm-roadmap/update-dashboard.mjs"; const STATE_FILE = "plans/kxm-roadmap/state.json"; const PHASE_SNAPSHOT = ".kxm/logs/land-phases-before.json"; const RELEASE_CONTEXT = ".kxm/logs/land-release-context.json"; const LANDED_HEADING = "### Landed in this tree (unreleased)"; const UNRELEASED_HEADING = "## Unreleased"; const root = process.cwd(); let dryRun = false; let bodyFile; let requestedTitle; let prNumber; let forceLease = false; let phasesSnapshotted = false; function positiveInt(value, fallback) { const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; } function childEnv() { const env = {}; for (const [name, value] of Object.entries(process.env)) { if (value === undefined) continue; if (name.toUpperCase().startsWith("GIT_")) continue; env[name] = value; } return env; } function pathValue(env) { const key = Object.keys(env).find((name) => name.toUpperCase() === "PATH"); return key ? env[key] : ""; } function comSpec(env) { const key = Object.keys(env).find((name) => name.toUpperCase() === "COMSPEC"); return (key && env[key].trim()) || "cmd.exe"; } /** CreateProcess does not run `npm.cmd`. Prefer a real `.exe`; otherwise launch the `.cmd`/`.bat` through cmd. */ function windowsScriptOnPath(command, env) { if (process.platform !== "win32") return undefined; if (/\.(?:exe|com)$/i.test(command) || /[\\/]/.test(command)) return undefined; if (/\.(?:cmd|bat)$/i.test(command)) return command; for (const dir of pathValue(env).split(delimiter)) { if (!dir) continue; if (existsSync(join(dir, `${command}.exe`))) return undefined; const cmd = join(dir, `${command}.cmd`); if (existsSync(cmd)) return cmd; const bat = join(dir, `${command}.bat`); if (existsSync(bat)) return bat; } return undefined; } function quoteCmdArg(value) { const text = String(value); if (/[\0\r\n"%!]/.test(text)) { throw new Error(`cannot pass ${JSON.stringify(text)} to a Windows command script`); } return `"${text}"`; } function run(command, args, timeout = 120_000) { const env = childEnv(); const script = windowsScriptOnPath(command, env); const windowsCommandScript = Boolean(script); const launchCommand = windowsCommandScript ? comSpec(env) : command; const launchArgs = windowsCommandScript ? ["/d", "/s", "/v:off", "/c", `"${[script, ...args].map(quoteCmdArg).join(" ")}"`] : args; const result = spawnSync(launchCommand, launchArgs, { cwd: root, encoding: "utf8", env, timeout, windowsHide: true, windowsVerbatimArguments: windowsCommandScript, }); return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: `${result.stderr ?? ""}${result.error ? result.error.message : ""}`, }; } function redact(text) { return String(text ?? "") .replace(/ghp_[A-Za-z0-9]+/g, "[redacted]") .replace(/github_pat_[A-Za-z0-9_]+/g, "[redacted]") .replace(/Bearer\s+\S+/gi, "Bearer [redacted]"); } function safeDetail(text) { return redact(text) .replace(/\s+/g, " ") .trim() .slice(0, 500); } function emit(payload) { process.stdout.write(`${JSON.stringify(payload)}\n`); } function pass(stage, extra = {}) { emit({ stage, ok: true, ...extra }); return 0; } function refuse(stage, code, detail, extra = {}) { const payload = { stage, ok: false, code, ...extra }; if (detail) payload.detail = safeDetail(detail); emit(payload); return 1; } function usage(detail) { emit({ stage: "usage", ok: false, code: "usage", detail }); return 2; } function plan(stage, commands, detail) { const payload = { stage, ok: true, dryRun: true, plan: commands }; if (detail) payload.detail = detail; emit(payload); return 0; } function sleep(ms) { if (ms <= 0) return; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } function gitText(args) { const result = run("git", args); return result.status === 0 ? result.stdout.trim() : ""; } function branchName() { const name = gitText(["rev-parse", "--abbrev-ref", "HEAD"]); return name && name !== "HEAD" ? name : ""; } function treeHash() { return gitText(["rev-parse", "HEAD^{tree}"]); } function porcelain() { const result = run("git", ["status", "--porcelain"]); if (result.status !== 0) return { ok: false, detail: result.stderr, paths: [] }; const paths = []; for (const line of result.stdout.split("\n")) { if (line.length < 4) continue; const rest = line.slice(3); const renamed = rest.split(" -> "); let path = renamed[renamed.length - 1] ?? rest; if (path.startsWith('"') && path.endsWith('"')) { path = path.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\"); } paths.push(path); } return { ok: true, paths }; } function gh(args, timeout = 120_000) { return run("gh", args, timeout); } function ghJson(args) { const result = gh(args); if (result.status !== 0) return { ok: false, result }; try { return { ok: true, value: JSON.parse(result.stdout) }; } catch { return { ok: false, result }; } } function logsDir() { const dir = join(root, ".kxm", "logs"); mkdirSync(dir, { recursive: true }); return dir; } function readJson(path) { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return undefined; } } function writeLog(name, value) { const path = join(logsDir(), name); writeFileSync(path, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600 }); return path; } function writeTextLog(name, text) { const path = join(logsDir(), name); const body = text.endsWith("\n") ? text : `${text}\n`; writeFileSync(path, body, { encoding: "utf8", mode: 0o600 }); return path; } function verifyFailureReport(stdout, stderr) { const lines = `${stdout}\n${stderr}`.split(/\r?\n/); const failing = []; let step = ""; for (const line of lines) { const text = line.trim(); if (!text) continue; if (/^>\s+\S/.test(text) && !/^npm ERR!/i.test(text)) step = text.slice(0, 180); if (VERIFY_FAIL_LINE.test(text)) failing.push(text.slice(0, 180)); } const picked = (failing.length > 0 ? failing : lines.map((line) => line.trim()).filter(Boolean).slice(-4)).slice(-8); return { step, excerpt: redact(picked.join(" | ")).replace(/\s+/g, " ").trim().slice(0, 700), }; } function parseArgs(argv) { const options = { json: false, dryRun: false }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--json") { options.json = true; continue; } if (arg === "--dry-run") { options.dryRun = true; continue; } if (arg === "--help" || arg === "-h") { options.help = true; continue; } if (arg === "--pr" || arg === "--body-file" || arg === "--stage" || arg === "--title") { const value = argv[index + 1]; if (value === undefined || value.startsWith("--")) return { error: `${arg} requires a value` }; index += 1; if (arg === "--pr") options.pr = value; if (arg === "--body-file") options.bodyFile = value; if (arg === "--stage") options.stage = value; if (arg === "--title") options.title = value; continue; } return { error: `unknown argument ${arg}` }; } if (options.pr !== undefined && !/^[0-9]+$/.test(options.pr)) return { error: "--pr expects a pull request number" }; if (options.stage !== undefined && !STAGES.includes(options.stage)) return { error: `unknown stage ${options.stage}` }; return { options }; } function ensurePr() { if (prNumber) { const viewed = ghJson(["pr", "view", prNumber, "--json", "number,title,id,body"]); if (!viewed.ok) return { ok: false, detail: viewed.result.stderr || viewed.result.stdout }; return { ok: true, pr: viewed.value }; } const branch = branchName(); if (!branch) return { ok: false, detail: "cannot resolve the current branch" }; const listed = ghJson(["pr", "list", "--head", branch, "--state", "open", "--json", "number,title"]); if (!listed.ok) return { ok: false, detail: listed.result.stderr || listed.result.stdout }; const rows = Array.isArray(listed.value) ? listed.value : []; if (rows.length === 0) return { ok: true, pr: undefined, branch }; const first = rows[0]; prNumber = String(first.number); const viewed = ghJson(["pr", "view", prNumber, "--json", "number,title,id,body"]); if (!viewed.ok) return { ok: true, pr: { number: first.number, title: first.title }, branch }; return { ok: true, pr: viewed.value, branch }; } function verifyStage() { if (dryRun) { return plan("verify", [ "git status --porcelain", "git rev-parse HEAD^{tree}", "npm run verify", ], "npm run verify is the first stage and is not replaced"); } const status = porcelain(); if (!status.ok) return refuse("verify", "land_verify_failed", status.detail); if (status.paths.length > 0) { return refuse("verify", "land_dirty_tree", `working tree is not clean: ${status.paths.join(", ")}`); } const tree = treeHash(); if (!tree) return refuse("verify", "land_verify_failed", "git rev-parse HEAD^{tree} failed"); const receiptName = `land-verify-${tree}.json`; const receiptPath = join(root, ".kxm", "logs", receiptName); if (existsSync(receiptPath)) { const age = Date.now() - statSync(receiptPath).mtimeMs; const prior = readJson(receiptPath); if (age >= 0 && age < VERIFY_FRESH_MS && prior && prior.ok === true && prior.tree === tree) { return pass("verify", { detail: "verify: skipped (receipt fresh)", tree }); } } const verified = run("npm", ["run", "verify"], 3_600_000); if (verified.status !== 0) { const logName = `land-verify-${tree}.log`; const logRel = `.kxm/logs/${logName}`; const report = verifyFailureReport(verified.stdout, verified.stderr); const excerpt = report.excerpt || "(no output)"; writeTextLog(logName, redact(`exit ${verified.status}\n--- stdout ---\n${verified.stdout}\n--- stderr ---\n${verified.stderr}\n`)); const receipt = { stage: "verify", ok: false, tree, exitCode: verified.status, excerpt, log: logRel, finishedAt: new Date().toISOString(), }; if (report.step) receipt.step = report.step; writeLog(receiptName, receipt); const step = report.step ? `; step ${report.step}` : ""; return refuse( "verify", "land_verify_failed", `exit ${verified.status}; log ${logRel}${step}; ${excerpt}`, { exitCode: verified.status, log: logRel, excerpt, ...(report.step ? { step: report.step } : {}), }, ); } writeLog(receiptName, { stage: "verify", ok: true, tree, finishedAt: new Date().toISOString() }); return pass("verify", { detail: "verify: passed", tree }); } function docsAllowed(path) { if (path === STATE_FILE) return false; return path.startsWith("docs/roadmap/") || path.startsWith("plans/kxm-roadmap/") || path.startsWith("docs/architecture/"); } function snapshotPhases() { if (phasesSnapshotted) return; phasesSnapshotted = true; const statePath = join(root, STATE_FILE); if (!existsSync(statePath)) return; const raw = readFileSync(statePath, "utf8"); mkdirSync(join(root, ".kxm", "logs"), { recursive: true }); writeFileSync(join(root, PHASE_SNAPSHOT), raw.endsWith("\n") ? raw : `${raw}\n`, { encoding: "utf8", mode: 0o600 }); } function docsStage() { if (dryRun) { return plan("docs", [ `node ${GENERATOR}`, `git commit -m ${DOCS_COMMIT}`, ], "skipped when the generator is absent; never edits state.json"); } snapshotPhases(); const generator = join(root, GENERATOR); if (!existsSync(generator)) { return pass("docs", { detail: "docs: skipped (generator absent)" }); } const generated = run(process.execPath, [generator], 600_000); if (generated.status !== 0) { return refuse("docs", "land_docs_failed", generated.stderr || generated.stdout); } const status = porcelain(); if (!status.ok) return refuse("docs", "land_docs_failed", status.detail); if (status.paths.length === 0) return pass("docs", { detail: "docs: no generated changes" }); const blocked = status.paths.filter((path) => !docsAllowed(path)); if (blocked.length > 0) { return refuse("docs", "land_docs_failed", `unexpected path ${blocked.join(", ")}`); } const added = run("git", ["add", "--", ...status.paths]); if (added.status !== 0) return refuse("docs", "land_docs_failed", added.stderr); const committed = run("git", ["commit", "-m", DOCS_COMMIT]); if (committed.status !== 0) return refuse("docs", "land_docs_failed", committed.stderr || committed.stdout); return pass("docs", { detail: DOCS_COMMIT }); } function pushStage() { const branch = branchName(); const args = ["push"]; if (forceLease) args.push("--force-with-lease"); args.push("-u", "origin", branch || "<branch>"); if (dryRun) return plan("push", [`git ${args.join(" ")}`]); if (!branch) return refuse("push", "land_push_rejected", "cannot resolve the current branch"); const pushed = run("git", ["push", ...(forceLease ? ["--force-with-lease"] : []), "-u", "origin", branch]); if (pushed.status !== 0) return refuse("push", "land_push_rejected", pushed.stderr || pushed.stdout); return pass("push", { detail: forceLease ? "pushed with lease" : "pushed" }); } function pullRequestTitle() { const explicit = String(requestedTitle ?? "").trim(); if (explicit) return { ok: true, text: explicit }; const logged = run("git", ["log", "--reverse", "--format=%s", "origin/main..HEAD"]); if (logged.status !== 0) return { ok: false, detail: "no commit subject on origin/main..HEAD" }; const subject = logged.stdout.split("\n").map((line) => line.trim()).find(Boolean) ?? ""; if (!subject) return { ok: false, detail: "no commit subject on origin/main..HEAD" }; return { ok: true, text: subject }; } function prStage() { const titled = pullRequestTitle(); if (dryRun) { if (!titled.ok) return refuse("pr", "land_pr_title_missing", titled.detail); return plan("pr", [ "gh pr list --head <branch> --state open --json number,title", `gh pr create --title ${titled.text} --body-file <path>`, ], titled.text); } const found = ensurePr(); if (!found.ok) return refuse("pr", "land_pr_body_missing", found.detail); if (found.pr?.number) { prNumber = String(found.pr.number); return pass("pr", { detail: `reused #${prNumber}` }); } if (!titled.ok) return refuse("pr", "land_pr_title_missing", titled.detail); if (!bodyFile) return refuse("pr", "land_pr_body_missing", "creating a pull request requires --body-file"); if (!existsSync(bodyFile.startsWith("/") ? bodyFile : join(root, bodyFile))) { return refuse("pr", "land_pr_body_missing", `body file not found: ${bodyFile}`); } const branch = found.branch || branchName(); const created = gh(["pr", "create", "--head", branch, "--title", titled.text, "--body-file", bodyFile]); if (created.status !== 0) return refuse("pr", "land_pr_body_missing", created.stderr || created.stdout); const again = ensurePr(); if (!again.ok || !again.pr?.number) return refuse("pr", "land_pr_body_missing", "pull request was not created"); prNumber = String(again.pr.number); return pass("pr", { detail: `created #${prNumber}` }); } function bulletKey(bullet) { return bullet.replace(/\s+/g, " ").trim(); } function parseGroups(body) { const groups = []; let heading = ""; let bullets = []; let current = null; const flushBullet = () => { if (!current) return; bullets.push(current.join("\n").replace(/\n+$/g, "")); current = null; }; const flushGroup = () => { flushBullet(); if (heading || bullets.length > 0) groups.push({ heading, bullets }); heading = ""; bullets = []; }; for (const line of body.split("\n")) { if (/^### /.test(line)) { flushGroup(); heading = line.trim(); continue; } if (/^- /.test(line)) { flushBullet(); current = [line]; continue; } if (current && (/^[ \t]/.test(line))) { current.push(line); continue; } flushBullet(); } flushGroup(); return groups; } function combineBullets(ours, theirs, newestFirst) { const seen = new Set(); const ordered = []; for (const bullet of [...ours, ...theirs]) { const key = bulletKey(bullet); if (!key || seen.has(key)) continue; seen.add(key); ordered.push(bullet.trim()); } if (!newestFirst) return ordered; return ordered .map((bullet, index) => ({ bullet, index, date: (bullet.match(/\d{4}-\d{2}-\d{2}/) ?? [""])[0] })) .sort((left, right) => { if (left.date === right.date) return left.index - right.index; if (!left.date) return 1; if (!right.date) return -1; return left.date < right.date ? 1 : -1; }) .map((item) => item.bullet); } function renderGroups(groups) { const lines = [""]; for (const group of groups) { if (group.bullets.length === 0 && !group.heading) continue; if (group.heading) lines.push(group.heading, ""); for (const bullet of group.bullets) { lines.push(bullet, ""); } } return `${lines.join("\n").replace(/\n+$/g, "\n")}`; } function sliceHeading(text, heading, endPattern) { const lines = String(text ?? "").split("\n"); const start = lines.findIndex((line) => line.trim() === heading); if (start < 0) return undefined; let end = lines.length; for (let index = start + 1; index < lines.length; index += 1) { if (endPattern.test(lines[index] ?? "")) { end = index; break; } } return { before: lines.slice(0, start).join("\n"), body: lines.slice(start + 1, end).join("\n"), after: lines.slice(end).join("\n"), }; } function unionSection(ours, theirs, heading, newestFirst, endPattern) { const own = sliceHeading(ours, heading, endPattern); const other = sliceHeading(theirs, heading, endPattern); if (!own && !other) return ours; const base = own ?? { before: String(ours ?? "").replace(/\s*$/g, ""), body: "", after: "" }; const theirsBody = other?.body ?? ""; const ownGroups = parseGroups(base.body); const theirGroups = parseGroups(theirsBody); const seen = new Set(); const merged = []; for (const group of ownGroups) { seen.add(group.heading); const match = theirGroups.find((item) => item.heading === group.heading); merged.push({ heading: group.heading, bullets: combineBullets(group.bullets, match?.bullets ?? [], newestFirst), }); } for (const group of theirGroups) { if (seen.has(group.heading)) continue; merged.push({ heading: group.heading, bullets: combineBullets([], group.bullets, newestFirst) }); } const before = base.before.length > 0 ? `${base.before}\n` : ""; const after = base.after.length > 0 ? `\n${base.after}` : ""; const rendered = renderGroups(merged); return `${before}${heading}\n${rendered}${after}`; } /** Unreleased bullets per heading, ours first, duplicates dropped. */ export function unionChangelogUnreleased(ours, theirs) { return unionSection(ours, theirs, UNRELEASED_HEADING, false, /^## /); } /** "Landed in this tree (unreleased)" bullets, newest ISO date first. */ export function unionLandedTracker(ours, theirs) { return unionSection(ours, theirs, LANDED_HEADING, true, /^#{2,3} /); } function showFile(rev, path) { const result = run("git", ["show", `${rev}:${path}`]); return result.status === 0 ? result.stdout : undefined; } function unmergedPaths() { const result = run("git", ["diff", "--name-only", "--diff-filter=U"]); if (result.status !== 0) return []; return result.stdout.split("\n").map((line) => line.trim()).filter(Boolean); } function abortRebase() { if (run("git", ["rev-parse", "--verify", "--quiet", "REBASE_HEAD"]).status === 0) { run("git", ["rebase", "--abort"]); } } function resolveConflicts() { const unresolved = []; let dist = false; let guard = 0; while (guard < 20) { guard += 1; const paths = unmergedPaths(); if (paths.length === 0) { const continued = run("git", ["-c", "core.editor=true", "-c", "sequence.editor=true", "rebase", "--continue"]); if (continued.status === 0) return { ok: true, paths: [] }; if (unmergedPaths().length === 0) return { ok: false, paths: unresolved, detail: continued.stderr }; } const pending = unmergedPaths(); dist = false; const manuals = []; for (const path of pending) { if (path === "plugins/kxm/dist" || path.startsWith("plugins/kxm/dist/")) { dist = true; continue; } if (path === "CHANGELOG.md" || path === "plans/implementation-plan.md") continue; manuals.push(path); } if (manuals.length > 0) return { ok: false, paths: manuals }; if (dist) { const checked = run("git", ["checkout", "origin/main", "--", "plugins/kxm/dist"]); if (checked.status !== 0) return { ok: false, paths: ["plugins/kxm/dist"], detail: checked.stderr }; const built = run("npm", ["run", "build"], 3_600_000); if (built.status !== 0) return { ok: false, paths: ["plugins/kxm/dist"], detail: built.stderr || built.stdout }; const staged = run("git", ["add", "--", "plugins/kxm/dist"]); if (staged.status !== 0) return { ok: false, paths: ["plugins/kxm/dist"], detail: staged.stderr }; } for (const path of pending) { if (path !== "CHANGELOG.md" && path !== "plans/implementation-plan.md") continue; const oursRev = run("git", ["rev-parse", "--verify", "--quiet", "REBASE_HEAD"]).status === 0 ? "REBASE_HEAD" : "HEAD"; const ours = showFile(oursRev, path); const theirs = showFile("origin/main", path); if (ours === undefined || theirs === undefined) return { ok: false, paths: [path] }; const merged = path === "CHANGELOG.md" ? unionChangelogUnreleased(ours, theirs) : unionLandedTracker(ours, theirs); writeFileSync(join(root, path), merged.endsWith("\n") ? merged : `${merged}\n`); const staged = run("git", ["add", "--", path]); if (staged.status !== 0) return { ok: false, paths: [path], detail: staged.stderr }; } const continued = run("git", ["-c", "core.editor=true", "-c", "sequence.editor=true", "rebase", "--continue"]); if (continued.status === 0) return { ok: true, paths: [] }; if (unmergedPaths().length === 0) return { ok: false, paths: pending, detail: continued.stderr }; } return { ok: false, paths: unmergedPaths() }; } function mergeState() { const found = ensurePr(); if (!found.ok) return { ok: false, detail: found.detail }; if (!found.pr?.number) return { ok: true, status: "ABSENT" }; prNumber = String(found.pr.number); const viewed = ghJson(["pr", "view", prNumber, "--json", "mergeStateStatus,title,number"]); if (!viewed.ok) return { ok: false, detail: viewed.result.stderr || viewed.result.stdout }; return { ok: true, status: String(viewed.value.mergeStateStatus ?? "").trim().toUpperCase(), title: viewed.value.title }; } function classifyMergeStatus(status) { if (MERGE_READY.has(status)) return "ready"; if (MERGE_REBASE.has(status)) return "rebase"; return "block"; } /** UNKNOWN is not mergeable. Poll with bounded backoff until GitHub names a state. */ function awaitKnownMergeState(stage, failureCode) { const deadline = Date.now() + MERGE_STATE_WAIT_MS; let delay = Math.min(Math.max(MERGE_STATE_POLL_MS, 1), MERGE_STATE_POLL_CAP_MS); let state = mergeState(); while (state.ok && state.status === "UNKNOWN") { if (Date.now() >= deadline) { return { ok: false, code: "land_merge_state_unknown", detail: "mergeStateStatus stayed UNKNOWN" }; } emit({ stage, ok: true, detail: "mergeStateStatus UNKNOWN; polling" }); const remaining = deadline - Date.now(); if (remaining <= 0) { return { ok: false, code: "land_merge_state_unknown", detail: "mergeStateStatus stayed UNKNOWN" }; } sleep(Math.min(delay, remaining)); delay = Math.min(delay * 2, MERGE_STATE_POLL_CAP_MS); state = mergeState(); } if (!state.ok) return { ok: false, code: failureCode, detail: state.detail || "merge state unavailable" }; return { ok: true, state }; } function readMergeStatus(stage, failureCode) { const resolved = awaitKnownMergeState(stage, failureCode); if (!resolved.ok) return resolved; const status = resolved.state.status; return { ok: true, status, action: classifyMergeStatus(status) }; } function rebaseStage() { if (dryRun) { return plan("rebase", [ "gh pr view --json mergeStateStatus", "git fetch origin", "git rebase origin/main", "git merge-tree --write-tree origin/main HEAD", "git push --force-with-lease -u origin <branch>", ], `at most ${REBASE_ROUNDS} rounds; UNKNOWN is polled and never passed`); } const branch = branchName(); if (branch === "main") return refuse("rebase", "land_conflict_manual", "refusing to rebase branch main"); const manual = []; for (let round = 1; round <= REBASE_ROUNDS; round += 1) { const read = readMergeStatus("rebase", "land_conflict_manual"); if (!read.ok) return refuse("rebase", read.code, read.detail); if (read.action === "block") return refuse("rebase", "land_merge_not_ready", `mergeStateStatus ${read.status}`); if (read.action === "ready") return pass("rebase", { detail: `mergeStateStatus ${read.status || "absent"}` }); const before = treeHash(); const clean = run("git", ["merge-tree", "--write-tree", "origin/main", "HEAD"]).status === 0; const fetched = run("git", ["fetch", "origin"]); if (fetched.status !== 0) return refuse("rebase", "land_conflict_manual", fetched.stderr || fetched.stdout); const rebased = run("git", ["rebase", "origin/main"]); if (rebased.status !== 0) { const resolved = resolveConflicts(); if (!resolved.ok) { for (const path of resolved.paths) manual.push(path); abortRebase(); if (round === REBASE_ROUNDS) { return refuse("rebase", "land_conflict_manual", manual.join(", ") || resolved.detail || "unresolved conflict"); } continue; } } const after = treeHash(); if (before && after && before !== after) { const docsCode = docsStage(); if (docsCode !== 0) return docsCode; } if (!clean) { const verifyCode = verifyStage(); if (verifyCode !== 0) return verifyCode; } forceLease = true; const pushCode = pushStage(); if (pushCode !== 0) return pushCode; } const read = readMergeStatus("rebase", "land_conflict_manual"); if (!read.ok) return refuse("rebase", read.code, read.detail); if (read.action === "ready") return pass("rebase", { detail: `mergeStateStatus ${read.status || "absent"}` }); if (read.action === "block") return refuse("rebase", "land_merge_not_ready", `mergeStateStatus ${read.status}`); const paths = [...new Set(manual)]; const detail = paths.length > 0 ? paths.join(", ") : `still ${read.status} after ${REBASE_ROUNDS} rounds`; return refuse("rebase", "land_conflict_manual", detail); } function failedRunId(rollup) { if (!Array.isArray(rollup)) return undefined; for (const item of rollup) { if (!item || typeof item !== "object") continue; const conclusion = String(item.conclusion ?? "").toUpperCase(); const status = String(item.status ?? "").toUpperCase(); const failed = conclusion === "FAILURE" || conclusion === "FAILED" || conclusion === "CANCELLED" || status === "FAILURE"; if (!failed) continue; const url = String(item.detailsUrl ?? item.url ?? ""); const match = url.match(/\/actions\/runs\/(\d+)/); if (match) return match[1]; if (item.databaseId !== undefined) return String(item.databaseId); return ""; } return undefined; } function readChecks() { const found = ensurePr(); if (!found.ok) return { ok: false, detail: found.detail }; if (!found.pr?.number) return { ok: false, detail: "no open pull request" }; prNumber = String(found.pr.number); const viewed = ghJson(["pr", "view", prNumber, "--json", "statusCheckRollup,reviewDecision"]); if (!viewed.ok) return { ok: false, detail: viewed.result.stderr || viewed.result.stdout }; return { ok: true, reviewDecision: String(viewed.value.reviewDecision ?? ""), rollup: viewed.value.statusCheckRollup, }; } function unblockStage() { if (dryRun) { return plan("unblock", [ "gh pr view --json statusCheckRollup,reviewDecision", "gh run rerun --failed <id>", ], "a required review is reported, not bypassed"); } const first = readChecks(); if (!first.ok) return refuse("unblock", "land_blocked", first.detail); if (first.reviewDecision === "REVIEW_REQUIRED" || first.reviewDecision === "CHANGES_REQUESTED") { return refuse("unblock", "land_blocked", `reviewDecision ${first.reviewDecision}`); } const runId = failedRunId(first.rollup); if (runId === undefined) return pass("unblock", { detail: "checks clear" }); if (!runId) return refuse("unblock", "land_blocked", "failed check has no run id"); const rerun = gh(["run", "rerun", "--failed", runId]); if (rerun.status !== 0) return refuse("unblock", "land_blocked", rerun.stderr || rerun.stdout); const second = readChecks(); if (!second.ok) return refuse("unblock", "land_blocked", second.detail); if (second.reviewDecision === "REVIEW_REQUIRED" || second.reviewDecision === "CHANGES_REQUESTED") { return refuse("unblock", "land_blocked", `reviewDecision ${second.reviewDecision}`); } if (failedRunId(second.rollup) !== undefined) { return refuse("unblock", "land_blocked", "failed check remained after one rerun"); } return pass("unblock", { detail: `reran ${runId}` }); } function nameWithOwner() { const viewed = ghJson(["repo", "view", "--json", "nameWithOwner"]); if (!viewed.ok || typeof viewed.value.nameWithOwner !== "string" || !viewed.value.nameWithOwner.includes("/")) { return undefined; } return viewed.value.nameWithOwner; } function versionCompare(left, right) { const split = (value) => value.replace(/^v/, "").split(/(\d+)/).filter(Boolean); const a = split(left); const b = split(right); const count = Math.max(a.length, b.length); for (let index = 0; index < count; index += 1) { const x = a[index] ?? ""; const y = b[index] ?? ""; const xn = /^\d+$/.test(x); const yn = /^\d+$/.test(y); if (xn && yn) { const delta = Number(x) - Number(y); if (delta !== 0) return delta; } else if (x !== y) { return x < y ? -1 : 1; } } return 0; } function newestTag() { const listed = run("git", ["ls-remote", "--tags", "origin"]); if (listed.status !== 0) return { ok: false, detail: listed.stderr || listed.stdout }; const tags = []; for (const line of listed.stdout.split("\n")) { if (!line.trim() || line.includes("^{}")) continue; const ref = line.split("\t")[1] ?? ""; const name = ref.replace(/^refs\/tags\//, ""); if (name.startsWith("v")) tags.push(name); } tags.sort(versionCompare); return { ok: true, tag: tags.length > 0 ? tags[tags.length - 1] : "" }; } function readReleaseContext() { return readJson(join(root, RELEASE_CONTEXT)) ?? {}; } function writeReleaseContext(patch) { const next = { ...readReleaseContext(), ...patch }; writeLog("land-release-context.json", next); } function poll(waitMs, read) { const attempts = Math.max(1, Math.ceil(waitMs / Math.max(POLL_MS, 1))); let last; for (let attempt = 0; attempt < attempts; attempt += 1) { last = read(); if (last.done) return last; if (attempt < attempts - 1) sleep(POLL_MS); } return last ?? { done: false }; } function mergeStage() { if (dryRun) { return plan("merge", [ "gh api graphql enablePullRequestAutoMerge mergeMethod SQUASH", "gh api -X PUT repos/{owner}/{repo}/pulls/{n}/merge -f merge_method=squash", "gh pr view --json state", ], "squash only; poll up to 20 minutes"); } const found = ensurePr(); if (!found.ok || !found.pr?.number) return refuse("merge", "land_merge_failed", found.detail || "no open pull request"); prNumber = String(found.pr.number); const read = readMergeStatus("merge", "land_merge_failed"); if (!read.ok) return refuse("merge", read.code, read.detail); if (read.action !== "ready") return refuse("merge", "land_merge_not_ready", `mergeStateStatus ${read.status}`); const title = String(found.pr.title ?? branchName()); const nodeId = String(found.pr.id ?? ""); const tags = newestTag(); writeReleaseContext({ tag: tags.ok ? tags.tag : "", title, recordedAt: new Date().toISOString() }); const ownerRepo = nameWithOwner(); if (!ownerRepo) return refuse("merge", "land_merge_failed", "gh repo view did not return nameWithOwner"); const query = "mutation($pullRequestId:ID!,$mergeMethod:PullRequestMergeMethod!){enablePullRequestAutoMerge(input:{pullRequestId:$pullRequestId,mergeMethod:$mergeMethod}){pullRequest{number}}}"; const auto = gh(["api", "graphql", "-f", `query=${query}`, "-f", `pullRequestId=${nodeId}`, "-f", "mergeMethod=SQUASH"]); const autoText = `${auto.stdout}\n${auto.stderr}`; if (auto.status !== 0 && /clean status/i.test(autoText)) { const merged = gh([ "api", "-X", "PUT", `repos/${ownerRepo}/pulls/${prNumber}/merge`, "-f", "merge_method=squash", "-f", `commit_title=${title} (#${prNumber})`, ]); if (merged.status !== 0) return refuse("merge", "land_merge_failed", merged.stderr || merged.stdout); } else if (auto.status !== 0) { return refuse("merge", "land_merge_failed", auto.stderr || auto.stdout); } const polled = poll(MERGE_WAIT_MS, () => { const viewed = ghJson(["pr", "view", prNumber, "--json", "state"]); if (viewed.ok && viewed.value.state === "MERGED") return { done: true, merged: true }; return { done: false }; }); if (!polled.merged) return refuse("merge", "land_merge_failed", "pull request was not MERGED within 20 minutes"); writeReleaseContext({ mergedAt: new Date().toISOString(), title }); return pass("merge", { detail: `squashed #${prNumber}` }); } function selectRun(runs, title, after, earliest = false) { if (!Array.isArray(runs)) return undefined; const matches = runs.filter((runRow) => { if (!runRow || typeof runRow !== "object") return false; const display = String(runRow.displayTitle ?? ""); if (!earliest && title && !display.includes(title)) return false; const created = Date.parse(String(runRow.createdAt ?? "")); if (earliest) { if (!Number.isFinite(after) || !Number.isFinite(created) || created <= after) return false; } else if (Number.isFinite(after) && Number.isFinite(created) && created < after) { return false; } return true; }); matches.sort((left, right) => { const delta = Date.parse(String(left.createdAt ?? "")) - Date.parse(String(right.createdAt ?? "")); return earliest ? delta : -delta; }); return matches[0]; } function workflowRunId(run) { if (!run || typeof run !== "object" || run.databaseId === undefined || run.databaseId === null) return ""; return String(run.databaseId); } function noteReleaseWait(workflow, started, logged) { const minutes = Math.floor((Date.now() - started) / RELEASE_PROGRESS_MS) * 2; if (minutes < 2 || minutes <= logged.minutes) return; logged.minutes = minutes; emit({ stage: "release", ok: true, detail: `waiting ${workflow} ${minutes}m` }); } function waitForWorkflow(workflow, title, after, earliest = false) { const started = Date.now(); const logged = { minutes: 0 }; let failure = ""; const polled = poll(RELEASE_WAIT_MS, () => { const listed = ghJson(["run", "list", `--workflow=${workflow}`, "--json", "databaseId,status,conclusion,displayTitle,createdAt"]); if (!listed.ok) { noteReleaseWait(workflow, started, logged); return { done: false, detail: listed.result.stderr || listed.result.stdout }; } const match = selectRun(listed.value, title, after, earliest); if (!match) { noteReleaseWait(workflow, started, logged); return { done: false }; } const conclusion = String(match.conclusion ?? "").toLowerCase(); const status = String(match.status ?? "").toLowerCase(); if (conclusion === "success") return { done: true, run: match }; if (conclusion && conclusion !== "success" && status === "completed") { failure = `${workflow} concluded ${conclusion}`; return { done: true, failed: true }; } noteReleaseWait(workflow, started, logged); return { done: false }; }); if (polled.failed) return { ok: false, detail: failure }; if (!polled.run) return { ok: false, detail: `${workflow} did not succeed` }; return { ok: true, run: polled.run }; } function releaseStage() { if (dryRun) { return plan("release", [ "gh run list --workflow=auto-release.yml --json databaseId,status,conclusion,displayTitle,createdAt", "git ls-remote --tags origin", "gh run list --workflow=release.yml --json databaseId,status,conclusion,displayTitle,createdAt", "npm view @kontextmind/kxm@<version> version", ], "npm poll is 10 minutes; Release is matched by time after Auto-Release"); } const context = readReleaseContext(); const found = ensurePr(); const title = String(context.title ?? found.pr?.title ?? ""); const after = Date.parse(String(context.mergedAt ?? "")); const auto = waitForWorkflow("auto-release.yml", title, after); if (!auto.ok) return refuse("release", "land_release_failed", auto.detail); const tags = newestTag(); if (!tags.ok) return refuse("release", "land_release_failed", tags.detail); const previous = String(context.tag ?? ""); if (!tags.tag || (previous && versionCompare(tags.tag, previous) <= 0)) { return refuse("release", "land_release_failed", `tag ${tags.tag || "(none)"} is not newer than ${previous || "(none)"}`); } const autoCreated = Date.parse(String(auto.run?.createdAt ?? "")); const release = waitForWorkflow("release.yml", "", autoCreated, true); if (!release.ok) return refuse("release", "land_release_failed", release.detail); const autoReleaseRunId = workflowRunId(auto.run); const releaseRunId = workflowRunId(release.run); writeReleaseContext({ autoReleaseRunId, releaseRunId }); const version = tags.tag.replace(/^v/, ""); const published = poll(PUBLISH_WAIT_MS, () => { const viewed = run("npm", ["view", `@kontextmind/kxm@${version}`, "version"]); if (viewed.status === 0 && viewed.stdout.trim() === version) return { done: true, version }; return { done: false }; }); if (!published.version) return refuse("release", "land_publish_timeout", `npm view @kontextmind/kxm@${version} version`); return pass("release", { detail: `PUBLISHED ${published.version} auto-release ${autoReleaseRunId} release ${releaseRunId}` }); } function taskStatuses(tasks) { if (Array.isArray(tasks)) { return tasks.map((task) => (task && typeof task === "object" && typeof task.status === "string" ? task.status : "open")); } if (tasks && typeof tasks === "object") { return Object.values(tasks).map((task) => (task && typeof task === "object" && typeof task.status === "string" ? task.status : "open")); } return []; } function phaseList(value) { const phases = value && typeof value === "object" ? value.phases : undefined; const listed = []; if (Array.isArray(phases)) { for (const phase of phases) { if (!phase || typeof phase !== "object") continue; listed.push({ id: typeof phase.id === "string" ? phase.id : "", statuses: taskStatuses(phase.tasks) }); } } else if (phases && typeof phases === "object") { for (const [id, phase] of Object.entries(phases)) { const body = phase && typeof phase === "object" ? phase : {}; listed.push({ id, statuses: taskStatuses(body.tasks) }); } } return listed; } function flippedPhase(before, after) { const prior = new Map(before.map((phase) => [phase.id, phase])); for (const phase of after) { const previous = prior.get(phase.id); if (!previous) continue; const hadOpen = previous.statuses.some((status) => status !== "done"); const allDone = phase.statuses.length > 0 && phase.statuses.every((status) => status === "done"); if (hadOpen && allDone) return phase.id; } return undefined; } function milestoneText() { if (bodyFile && existsSync(bodyFile.startsWith("/") ? bodyFile : join(root, bodyFile))) { const text = readFileSync(bodyFile.startsWith("/") ? bodyFile : join(root, bodyFile), "utf8"); return text.match(/^Milestone:\s*(.+)\s*$/m)?.[1]?.trim(); } const found = ensurePr(); const body = typeof found.pr?.body === "string" ? found.pr.body : ""; return body.match(/^Milestone:\s*(.+)\s*$/m)?.[1]?.trim(); } function milestoneStage() { if (dryRun) { return plan("milestone", [ `read ${STATE_FILE}`, "compare phases before and after docs", ], "deep review is /reanalyze-roadmap, not this script"); } const statePath = join(root, STATE_FILE); if (!existsSync(statePath)) { return pass("milestone", { detail: "milestone: skipped (state absent)", deep_review_required: false }); } const afterValue = readJson(statePath); if (!afterValue) return refuse("milestone", "land_milestone_failed", `${STATE_FILE} is not readable JSON`); const beforePath = join(root, PHASE_SNAPSHOT); const beforeValue = existsSync(beforePath) ? readJson(beforePath) : undefined; const phase = beforeValue ? flippedPhase(phaseList(beforeValue), phaseList(afterValue)) : undefined; let named; try { named = milestoneText(); } catch { named = undefined; } if (phase || named) { return pass("milestone", { deep_review_required: true, phase: phase || named, detail: "deep review is /reanalyze-roadmap and is not run here", }); } return pass("milestone", { deep_review_required: false }); } const STAGE_FN = { verify: verifyStage, docs: docsStage, push: pushStage, pr: prStage, rebase: rebaseStage, unblock: unblockStage, merge: mergeStage, release: releaseStage, milestone: milestoneStage, }; function main() { const parsed = parseArgs(process.argv.slice(2)); if (parsed.error) return usage(parsed.error); if (parsed.options.help) { emit({ stage: "usage", ok: true, detail: "node scripts/pr-land.mjs [--pr <n>] [--title <text>] [--body-file <path>] [--stage <name>] [--json] [--dry-run]", }); return 0; } dryRun = parsed.options.dryRun; bodyFile = parsed.options.bodyFile; requestedTitle = parsed.options.title; prNumber = parsed.options.pr; const names = parsed.options.stage ? [parsed.options.stage] : STAGES; for (const name of names) { const code = STAGE_FN[name](); if (code !== 0) return code; } return 0; } const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (invoked) { process.exitCode = main(); }