fix(cli): bound large gh output

This commit is contained in:
Codex
2026-05-23 10:48:24 +00:00
parent 152ded3a7b
commit 431d6bd25b
4 changed files with 158 additions and 4 deletions
+33 -1
View File
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
@@ -218,6 +218,16 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
body: "# 普通任务\n\n## 背景\n\n- 不是指挥简报。\n",
html_url: "https://github.com/pikasTech/unidesk/issues/47",
};
const largeIssueBody = Array.from({ length: 900 }, (_, index) => `large-output-line-${String(index + 1).padStart(4, "0")} ${"x".repeat(60)}`).join("\n");
const largeIssue = {
...issue,
id: 2090,
number: 90,
title: "large issue output fixture",
body: largeIssueBody,
html_url: "https://github.com/pikasTech/unidesk/issues/90",
updated_at: "2026-05-20T09:00:00Z",
};
const issueList = [
{
id: 2001,
@@ -459,6 +469,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 200, nonBriefIssue);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/90") {
sendJson(res, 200, largeIssue);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/60") {
sendJson(res, 200, legacyBoardIssue);
return;
@@ -471,6 +485,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 200, comments);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/90/comments?per_page=100") {
sendJson(res, 200, []);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/issues/7/comments?per_page=100") {
sendJson(res, 200, [{ id: 7001, body: "shorthand comment", html_url: "https://github.com/pikasTech/HWLAB/issues/7#issuecomment-7001", user: { login: "tester" }, created_at: "2026-05-20T03:10:00Z", updated_at: "2026-05-20T03:10:00Z" }]);
return;
@@ -645,6 +663,19 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(Array.isArray(firstLabels) && firstLabels[0]?.name === "cli", "issue list default fields should include labels", listDefaultData);
assertCondition(defaultIssues.every((item) => typeof item.number === "number" && typeof item.url === "string"), "issue list default fields should expose stable JSON", listDefaultData);
const largeRead = await runCli(["gh", "issue", "read", "90", "--repo", "pikasTech/unidesk", "--full"], env);
assertCondition(largeRead.status === 0, "large issue read should succeed", largeRead.json ?? { stdout: largeRead.stdout });
assertCondition(largeRead.stdout.length < 20_000, "large issue read stdout should stay bounded", { bytes: largeRead.stdout.length });
const largeReadData = dataOf(largeRead.json ?? {});
assertCondition(largeReadData.outputTruncated === true, "large issue read should be dumped instead of printed fully", largeReadData);
const dump = largeReadData.dump as JsonRecord;
assertCondition(typeof dump.path === "string" && existsSync(String(dump.path)), "large issue dump file should exist", dump);
assertCondition(Number(dump.bytes ?? 0) > 20_000, "dump should record full output size", dump);
assertCondition(Number(dump.lines ?? 0) > 20, "dump should record total line count", dump);
assertCondition(String(dump.head ?? "").length > 0 && String(dump.tail ?? "").length > 0, "dump should include head and tail previews", dump);
const dumpText = readFileSync(String(dump.path), "utf8");
assertCondition(dumpText.includes("large-output-line-0900"), "dump file should contain full original JSON", { path: dump.path, tail: dumpText.slice(-500) });
const scanEscape = await runCli(["gh", "issue", "scan-escape", "--repo", "pikasTech/unidesk", "--limit", "4", "--dry-run"], env);
assertCondition(scanEscape.status === 0, "issue scan-escape dry-run should succeed", scanEscape.json ?? { stdout: scanEscape.stdout });
const scanData = dataOf(scanEscape.json ?? {});
@@ -1465,6 +1496,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"issue list supports state/limit/json with stable selected fields",
"acceptance issue list command succeeds under mock GitHub",
"issue list default fields include labels and filter pull requests",
"large gh issue read output is dumped to a temp file with bounded stdout and head/tail metadata",
"issue scan-escape classifies pollution, explanatory mentions, and body risks",
"issue cleanup-plan remains dry-run with body/comment cleanup suggestions",
"issue board-audit returns read-only board structure, disables OPEN/CLOSED coverage validation, and keeps compatibility fields empty without writes",