import { spawnSync } from "node:child_process"; import { writeFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; type JsonRecord = Record; function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); } function runCli(args: string[]): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } { const result = spawnSync("bun", ["scripts/cli.ts", ...args], { cwd: process.cwd(), encoding: "utf8", }); const stdout = String(result.stdout || ""); let json: JsonRecord | null = null; try { json = JSON.parse(stdout) as JsonRecord; } catch { json = null; } return { status: result.status, stdout, stderr: String(result.stderr || ""), json, }; } function dataOf(response: JsonRecord): JsonRecord { assertCondition(response.ok === true, "CLI command should succeed", response); assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "response data should be object", response); return response.data as JsonRecord; } export function runGhCliPrContract(): JsonRecord { const help = runCli(["gh", "help"]); assertCondition(help.status === 0, "gh help should succeed", help.json ?? { stdout: help.stdout }); const helpData = dataOf(help.json ?? {}); const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : []; assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage }); assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage }); const title = "contract pr create"; const bodyFile = join(tmpdir(), `unidesk-gh-pr-contract-${process.pid}.md`); writeFileSync(bodyFile, "Line 1\n`code`\n| a | b |\n", "utf8"); try { const createDryRun = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--draft", "--dry-run"]); assertCondition(createDryRun.status === 0, "pr create dry-run should succeed", createDryRun.json ?? { stdout: createDryRun.stdout }); const createData = dataOf(createDryRun.json ?? {}); assertCondition(createData.dryRun === true, "dry-run create must set dryRun=true", createData); assertCondition(createData.planned === true, "dry-run create must set planned=true", createData); assertCondition(createData.repo === "pikasTech/unidesk", "dry-run create should preserve repo", createData); assertCondition(createData.base === "master", "dry-run create should preserve base", createData); assertCondition(createData.head === "feature/pr-contract", "dry-run create should preserve head", createData); assertCondition(createData.draft === true, "dry-run create should preserve draft", createData); assertCondition(Number(createData.bodyChars ?? 0) > 0, "dry-run create should expose bodyChars", createData); assertCondition(Array.isArray(createData.bodyPreviewLines), "dry-run create should expose bodyPreviewLines", createData); assertCondition(String(createData.bodyPreview ?? "").includes("`code`"), "dry-run create should preserve backticks in preview", createData); assertCondition(createData.request && typeof createData.request === "object", "dry-run create should include request plan", createData); const commentDryRun = runCli(["gh", "pr", "comment", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile, "--dry-run"]); assertCondition(commentDryRun.status === 0, "pr comment dry-run should succeed", commentDryRun.json ?? { stdout: commentDryRun.stdout }); const commentData = dataOf(commentDryRun.json ?? {}); assertCondition(commentData.dryRun === true, "dry-run comment must set dryRun=true", commentData); assertCondition(commentData.planned === true, "dry-run comment must set planned=true", commentData); assertCondition(commentData.issueNumber === 42, "dry-run comment should preserve PR number", commentData); assertCondition(Number(commentData.bodyChars ?? 0) > 0, "dry-run comment should expose bodyChars", commentData); const mergeBlocked = runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk"]); assertCondition(mergeBlocked.status !== 0, "pr merge should fail", mergeBlocked.json ?? { stdout: mergeBlocked.stdout }); const mergeData = mergeBlocked.json?.data as JsonRecord | undefined; assertCondition(String(mergeData?.message ?? "").includes("intentionally unsupported"), "merge block message should be explicit", mergeData ?? {}); assertCondition(mergeData?.runnerDisposition === "business-failed", "merge block should classify as business-failed", mergeData ?? {}); const createMissingBody = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--base", "master", "--head", "feature/pr-contract", "--dry-run"]); assertCondition(createMissingBody.status !== 0, "pr create without body source should fail", createMissingBody.json ?? { stdout: createMissingBody.stdout }); const createMissingBodyData = createMissingBody.json?.data as JsonRecord | undefined; assertCondition(createMissingBodyData?.degradedReason === "validation-failed", "missing body source should be validation-failed", createMissingBodyData ?? {}); assertCondition(createMissingBodyData?.runnerDisposition === "business-failed", "validation should classify as business-failed", createMissingBodyData ?? {}); const unknownOption = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--dry-run", "--bad-option"]); assertCondition(unknownOption.status !== 0, "unknown gh option should fail", unknownOption.json ?? { stdout: unknownOption.stdout }); const unknownOptionData = unknownOption.json?.data as JsonRecord | undefined; assertCondition(unknownOptionData?.degradedReason === "unsupported-command", "unknown option should be unsupported-command", unknownOptionData ?? {}); assertCondition(unknownOptionData?.runnerDisposition === "business-failed", "unknown option should classify as business-failed", unknownOptionData ?? {}); } finally { unlinkSync(bodyFile); } return { ok: true, checks: [ "gh help lists pr create/comment", "pr create dry-run exposes planned operation", "pr comment dry-run preserves markdown text", "pr merge is blocked", "pr create validation failures are structured", "unknown gh options are structured", ], }; } if (import.meta.main) { const result = runGhCliPrContract(); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); }