import { spawn } from "node:child_process"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; type JsonRecord = Record; interface MockRequest { method: string; url: string; body: string; } function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); } function runBun(args: string[], env: Record = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> { return new Promise((resolve, reject) => { const child = spawn("bun", args, { cwd: process.cwd(), env: { ...process.env, ...env }, }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); child.on("error", reject); child.on("close", (status) => { const stdout = Buffer.concat(stdoutChunks).toString("utf8"); let json: JsonRecord | null = null; try { json = JSON.parse(stdout) as JsonRecord; } catch { json = null; } resolve({ status, stdout, stderr: Buffer.concat(stderrChunks).toString("utf8"), json, }); }); }); } function runCli(args: string[], env: Record = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> { return runBun(["scripts/cli.ts", ...args], env); } function collectBody(req: IncomingMessage): Promise { return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); }); } function sendJson(res: ServerResponse, status: number, payload: unknown): void { res.statusCode = status; res.setHeader("content-type", "application/json"); res.end(JSON.stringify(payload)); } function prFixture(): JsonRecord { return { id: 4200, number: 42, title: "CLI summary fixture", body: "fixture body", state: "open", html_url: "https://github.example/pikasTech/unidesk/pull/42", draft: false, user: { login: "runner" }, head: { ref: "feature/pr-files", sha: "abc123" }, base: { ref: "master", sha: "def456" }, additions: 12, deletions: 3, changed_files: 2, commits: 1, created_at: "2026-05-23T00:00:00Z", updated_at: "2026-05-23T00:10:00Z", }; } function prFilesFixture(): JsonRecord[] { return [ { sha: "aaa", filename: "scripts/src/gh.ts", status: "modified", additions: 10, deletions: 2, changes: 12, blob_url: "https://github.example/blob/scripts/src/gh.ts", raw_url: "https://github.example/raw/scripts/src/gh.ts", contents_url: "https://api.github.example/contents/scripts/src/gh.ts", patch: "@@ raw diff must not be returned @@", }, { sha: "bbb", filename: "scripts/gh-cli-pr-files-contract-test.ts", status: "added", additions: 2, deletions: 1, changes: 3, blob_url: "https://github.example/blob/scripts/gh-cli-pr-files-contract-test.ts", raw_url: "https://github.example/raw/scripts/gh-cli-pr-files-contract-test.ts", contents_url: "https://api.github.example/contents/scripts/gh-cli-pr-files-contract-test.ts", patch: "@@ raw diff must not be returned either @@", }, ]; } async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise }> { const requests: MockRequest[] = []; const server = createServer(async (req, res) => { const body = await collectBody(req); requests.push({ method: req.method ?? "", url: req.url ?? "", body }); const url = new URL(req.url ?? "/", "http://localhost"); if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/pulls/42") { sendJson(res, 200, prFixture()); return; } if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/pulls/42/files") { const perPage = Number(url.searchParams.get("per_page") ?? "30"); const page = Number(url.searchParams.get("page") ?? "1"); const offset = (page - 1) * perPage; sendJson(res, 200, prFilesFixture().slice(offset, offset + perPage)); return; } sendJson(res, 404, { message: `unexpected ${req.method} ${req.url}` }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); assertCondition(typeof address === "object" && address !== null, "mock server should expose address"); const port = (address as AddressInfo).port; assertCondition(typeof port === "number", "mock server should expose port"); return { baseUrl: `http://127.0.0.1:${port}`, requests, close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), }; } 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; } function failedDataOf(response: JsonRecord): JsonRecord { assertCondition(response.ok === false, "CLI command should fail", response); assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "failure data should be object", response); return response.data as JsonRecord; } export async function runGhCliPrFilesContract(): Promise { const help = await 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)) : []; const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : []; assertCondition(usage.some((line) => line.includes("gh pr files ")), "help should document gh pr files", { usage }); assertCondition(usage.some((line) => line.includes("gh pr diff --stat")), "help should document gh pr diff --stat", { usage }); assertCondition(notes.some((line) => line.includes("PR files is the canonical compact changed-file/stat summary")), "help should document PR files disclosure boundary", { notes }); const mock = await startMockGitHub(); const env = { GH_TOKEN: "contract-token", UNIDESK_GITHUB_API_URL: mock.baseUrl, }; const checks: string[] = ["gh help documents pr files and pr diff --stat"]; try { mock.requests.length = 0; const files = await runCli(["gh", "pr", "files", "42", "--repo", "pikasTech/unidesk", "--limit", "1"], env); assertCondition(files.status === 0, "gh pr files should succeed", files.json ?? { stdout: files.stdout, stderr: files.stderr }); const filesData = dataOf(files.json ?? {}); assertCondition(filesData.command === "pr files", "command name should be pr files", filesData); assertCondition(filesData.rawDiffIncluded === false, "rawDiffIncluded must be false", filesData); const summary = filesData.summary as JsonRecord; assertCondition(summary.files === 2, "summary must include total changed files", summary); assertCondition(summary.additions === 12, "summary additions should come from PR REST", summary); assertCondition(summary.deletions === 3, "summary deletions should come from PR REST", summary); assertCondition(summary.changes === 15, "summary changes should include additions plus deletions", summary); assertCondition(filesData.filesReturned === 1, "limit 1 should return one file", filesData); const fileRows = filesData.files as JsonRecord[]; assertCondition(Array.isArray(fileRows) && fileRows.length === 1, "files list should be bounded", filesData); assertCondition(fileRows[0]?.filename === "scripts/src/gh.ts", "first file filename mismatch", fileRows[0]); assertCondition(fileRows[0]?.patch === undefined, "raw patch must not be emitted", fileRows[0]); assertCondition(fileRows[0]?.raw_url === undefined, "raw_url field casing must not leak", fileRows[0]); const truncation = filesData.truncation as JsonRecord; assertCondition(truncation.truncated === true, "limit 1 should mark truncation", truncation); assertCondition(truncation.totalFiles === 2, "truncation should expose totalFiles", truncation); const next = filesData.next as JsonRecord; assertCondition(String(next.command).includes("--limit 2"), "next command should request the bounded full file count", next); assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls/42/files?per_page=1&page=1"), "files endpoint should use bounded per_page", mock.requests); checks.push("gh pr files returns bounded REST file/stat JSON without raw patches"); mock.requests.length = 0; const filesOptionsFirst = await runCli(["gh", "pr", "files", "--repo", "pikasTech/unidesk", "--limit", "1", "42"], env); assertCondition(filesOptionsFirst.status === 0, "gh pr files should accept PR number after options", filesOptionsFirst.json ?? { stdout: filesOptionsFirst.stdout, stderr: filesOptionsFirst.stderr }); const filesOptionsFirstData = dataOf(filesOptionsFirst.json ?? {}); assertCondition(filesOptionsFirstData.command === "pr files" && filesOptionsFirstData.filesReturned === 1, "options-first pr files should return compact file summary", filesOptionsFirstData); assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls/42/files?per_page=1&page=1"), "options-first pr files should query the requested PR number", mock.requests); checks.push("gh pr files accepts the PR number after options"); mock.requests.length = 0; const stat = await runCli(["gh", "pr", "diff", "42", "--stat", "--repo", "pikasTech/unidesk", "--limit", "2"], env); assertCondition(stat.status === 0, "gh pr diff --stat should succeed", stat.json ?? { stdout: stat.stdout, stderr: stat.stderr }); const statData = dataOf(stat.json ?? {}); assertCondition(statData.command === "pr diff --stat", "diff --stat should report alias command name", statData); assertCondition(statData.rawDiffIncluded === false, "diff --stat must not include raw diff", statData); assertCondition(statData.filesReturned === 2, "diff --stat should return requested files", statData); const statRows = statData.files as JsonRecord[]; assertCondition(statRows.every((file) => file.patch === undefined), "diff --stat file rows must not include patch", statRows); checks.push("gh pr diff --stat is a compact summary alias"); mock.requests.length = 0; const statOptionsFirst = await runCli(["gh", "pr", "diff", "--repo", "pikasTech/unidesk", "--stat", "--limit", "2", "42"], env); assertCondition(statOptionsFirst.status === 0, "gh pr diff --stat should accept PR number after options", statOptionsFirst.json ?? { stdout: statOptionsFirst.stdout, stderr: statOptionsFirst.stderr }); const statOptionsFirstData = dataOf(statOptionsFirst.json ?? {}); assertCondition(statOptionsFirstData.command === "pr diff --stat" && statOptionsFirstData.filesReturned === 2, "options-first pr diff --stat should return compact file summary", statOptionsFirstData); assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls/42/files?per_page=2&page=1"), "options-first pr diff --stat should query the requested PR number", mock.requests); checks.push("gh pr diff --stat accepts the PR number after options"); const rawDiff = await runCli(["gh", "pr", "diff", "42", "--repo", "pikasTech/unidesk"], env); assertCondition(rawDiff.status !== 0, "gh pr diff without --stat should fail closed", rawDiff.json ?? { stdout: rawDiff.stdout, stderr: rawDiff.stderr }); const rawData = failedDataOf(rawDiff.json ?? {}); assertCondition(rawData.degradedReason === "unsupported-command", "raw diff should fail as unsupported-command", rawData); assertCondition(rawData.rawDiffIncluded === false, "raw diff failure should state rawDiffIncluded=false", rawData); checks.push("gh pr diff without --stat fails closed without raw diff output"); return { ok: true, checks }; } finally { await mock.close(); } } if (import.meta.main) { runGhCliPrFilesContract() .then((result) => console.log(JSON.stringify(result, null, 2))) .catch((error) => { console.error(error instanceof Error ? error.stack ?? error.message : String(error)); process.exitCode = 1; }); }