fix: support inline issue comment body

This commit is contained in:
Codex
2026-05-24 12:04:43 +00:00
parent e1817f506b
commit 3a1cc547e3
5 changed files with 222 additions and 13 deletions
+88 -1
View File
@@ -17,11 +17,12 @@ function assertCondition(condition: unknown, message: string, detail: unknown =
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function runCli(args: string[], env: Record<string, string> = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> {
function runCli(args: string[], env: Record<string, string> = {}, stdin?: string): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> {
return new Promise((resolve, reject) => {
const child = spawn("bun", ["scripts/cli.ts", ...args], {
cwd: process.cwd(),
env: { ...process.env, ...env },
stdio: ["pipe", "pipe", "pipe"],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
@@ -43,6 +44,8 @@ function runCli(args: string[], env: Record<string, string> = {}): Promise<{ sta
json,
});
});
if (stdin !== undefined) child.stdin.end(stdin);
else child.stdin.end();
});
}
@@ -58,6 +61,10 @@ function failedDataOf(response: JsonRecord): JsonRecord {
return response.data as JsonRecord;
}
function failureMessageOf(data: JsonRecord): string {
return String((data.details as JsonRecord | undefined)?.message ?? data.message ?? "");
}
function collectBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
@@ -582,6 +589,11 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 201, { id: 9001, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/20#issuecomment-9001", user: { login: "tester" }, created_at: "2026-05-20T06:00:00Z", updated_at: "2026-05-20T06:00:00Z" });
return;
}
if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues/36/comments") {
const parsed = JSON.parse(body) as JsonRecord;
sendJson(res, 201, { id: 9002, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/36#issuecomment-9002", user: { login: "tester" }, created_at: "2026-05-20T06:02:00Z", updated_at: "2026-05-20T06:02:00Z" });
return;
}
if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues") {
const parsed = JSON.parse(body) as JsonRecord;
const labels = Array.isArray(parsed.labels) ? parsed.labels.map(String) : [];
@@ -636,6 +648,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(usage.some((line) => line.includes("gh issue list")), "gh help should list issue list", { usage });
assertCondition(usage.some((line) => line.includes("gh issue read")), "gh help should list issue read", { usage });
assertCondition(usage.some((line) => line.includes("gh issue view")), "gh help should list issue view", { usage });
assertCondition(usage.some((line) => line.includes("gh issue comment create") && line.includes("--body <short-text>")), "gh help should list short inline issue comment body", { usage });
assertCondition(usage.some((line) => line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document issue shorthand and raw/full disclosure", { usage });
assertCondition(usage.some((line) => line.includes("gh issue board-row list")), "gh help should list board-row list", { usage });
assertCondition(usage.some((line) => line.includes("gh issue board-row update")), "gh help should list board-row update", { usage });
@@ -647,6 +660,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(notes.some((line) => line.includes("compatibility alias")), "gh help should state issue view is alias", { notes });
assertCondition(notes.some((line) => line.includes("owner/repo#number shorthand")), "gh help should explain read/view shorthand", { notes });
assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes });
assertCondition(notes.some((line) => line.includes("issue comment create accepts --body only for short single-line text")), "gh help should document issue comment inline safety limits", { notes });
assertCondition(notes.some((line) => line.includes("board-row update changes one table cell")), "gh help should describe board-row update safety", { notes });
assertCondition(notes.some((line) => line.includes("board-row upsert updates an existing row")), "gh help should describe board-row upsert safety", { notes });
assertCondition(notes.some((line) => line.includes("board-row add/move/delete are row-scoped")), "gh help should describe board-row row mutation safety", { notes });
@@ -1559,6 +1573,77 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(commentCreate.status === 0, "issue comment create should succeed", commentCreate.json ?? { stdout: commentCreate.stdout });
const commentCreateData = dataOf(commentCreate.json ?? {});
assertCondition(commentCreateData.command === "issue comment create", "comment create should use CRUD command name", commentCreateData);
assertCondition(commentCreateData.source === "body-file" && typeof commentCreateData.bodySha === "string", "issue comment body-file write should expose low-noise source and bodySha", commentCreateData);
const commentCreateSummary = commentCreateData.comment as JsonRecord;
assertCondition(commentCreateSummary.bodyOmitted === true && !("body" in commentCreateSummary), "issue comment write should not echo full comment body by default", commentCreateSummary);
const inlineBody = "短评:已完成 #76 CLI inline body dry-run";
const inlineDryRunRequestCountBefore = mock.requests.length;
const inlineDryRun = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", inlineBody, "--dry-run"], env);
assertCondition(inlineDryRun.status === 0, "issue comment inline body dry-run should succeed", inlineDryRun.json ?? { stdout: inlineDryRun.stdout });
assertCondition(inlineDryRun.json?.command === "gh issue comment create 36 --repo pikasTech/unidesk --body <body:redacted> --dry-run", "outer gh command should redact inline body", inlineDryRun.json ?? {});
const inlineDryRunData = dataOf(inlineDryRun.json ?? {});
assertCondition(inlineDryRunData.dryRun === true && inlineDryRunData.planned === true, "inline issue comment dry-run should be planned", inlineDryRunData);
assertCondition(inlineDryRunData.issueNumber === 36 && inlineDryRunData.source === "inline", "inline issue comment dry-run should preserve issue number and source", inlineDryRunData);
const inlineDryRunSource = inlineDryRunData.bodySource as JsonRecord;
assertCondition(inlineDryRunSource.kind === "inline" && inlineDryRunSource.maxInlineBodyChars === 1000, "inline issue comment dry-run should expose inline source policy", inlineDryRunSource);
assertCondition(Number(inlineDryRunData.bodyChars ?? 0) === inlineBody.length && typeof inlineDryRunData.bodySha === "string", "inline issue comment dry-run should expose bodyChars/bodySha", inlineDryRunData);
assertCondition(String(inlineDryRunData.bodyPreview ?? "") === inlineBody, "inline issue comment dry-run should provide bounded preview for short text", inlineDryRunData);
const inlineDryRunReadCommands = inlineDryRunData.readCommands as JsonRecord;
assertCondition(String(inlineDryRunReadCommands.comments ?? "").includes("gh issue read 36") && String(inlineDryRunReadCommands.comments ?? "").includes("--json comments"), "inline issue comment dry-run should expose comment read command", inlineDryRunReadCommands);
const inlineDryRunWriteCount = mock.requests.slice(inlineDryRunRequestCountBefore).filter((request) => request.method === "POST" && request.url.includes("/comments")).length;
assertCondition(inlineDryRunWriteCount === 0, "inline issue comment dry-run must not POST GitHub", { requests: mock.requests.slice(inlineDryRunRequestCountBefore) });
const inlineWriteRequestCountBefore = mock.requests.length;
const inlineWrite = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", inlineBody], env);
assertCondition(inlineWrite.status === 0, "issue comment inline body write should succeed", inlineWrite.json ?? { stdout: inlineWrite.stdout });
assertCondition(inlineWrite.json?.command === "gh issue comment create 36 --repo pikasTech/unidesk --body <body:redacted>", "outer gh command should redact inline body on write", inlineWrite.json ?? {});
const inlineWriteData = dataOf(inlineWrite.json ?? {});
assertCondition(inlineWriteData.command === "issue comment create" && inlineWriteData.source === "inline", "inline issue comment write should report source=inline", inlineWriteData);
assertCondition(Number(inlineWriteData.bodyChars ?? 0) === inlineBody.length && typeof inlineWriteData.bodySha === "string", "inline issue comment write should expose bounded body metadata", inlineWriteData);
const inlineWriteComment = inlineWriteData.comment as JsonRecord;
assertCondition(inlineWriteComment.bodyOmitted === true && inlineWriteComment.bodyPreview === inlineBody && !("body" in inlineWriteComment), "inline issue comment write should summarize without full body field", inlineWriteComment);
const inlinePost = mock.requests.slice(inlineWriteRequestCountBefore).find((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues/36/comments");
assertCondition(inlinePost !== undefined, "inline issue comment write should POST comments REST endpoint", { requests: mock.requests.slice(inlineWriteRequestCountBefore) });
const inlinePayload = JSON.parse(inlinePost?.body ?? "{}") as JsonRecord;
assertCondition(inlinePayload.body === inlineBody, "inline issue comment REST payload should preserve short text", inlinePayload);
const missingCommentBody = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--dry-run"], env);
assertCondition(missingCommentBody.status !== 0, "issue comment create without body source should fail", missingCommentBody.json ?? { stdout: missingCommentBody.stdout });
const missingCommentBodyData = failedDataOf(missingCommentBody.json ?? {});
assertCondition(missingCommentBodyData.degradedReason === "validation-failed" && failureMessageOf(missingCommentBodyData).includes("requires --body-file <file> or --body <text>"), "missing issue comment body should be structured validation failure", missingCommentBodyData);
const mutualCommentBody = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "inline", "--body-file", appendFile, "--dry-run"], env);
assertCondition(mutualCommentBody.status !== 0, "issue comment create with body and body-file should fail", mutualCommentBody.json ?? { stdout: mutualCommentBody.stdout });
const mutualCommentBodyData = failedDataOf(mutualCommentBody.json ?? {});
assertCondition(mutualCommentBodyData.degradedReason === "validation-failed" && failureMessageOf(mutualCommentBodyData).includes("accepts only one body source"), "mutual issue comment body sources should be rejected", mutualCommentBodyData);
const blankInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", " ", "--dry-run"], env);
assertCondition(blankInlineComment.status !== 0, "blank inline issue comment body should fail", blankInlineComment.json ?? { stdout: blankInlineComment.stdout });
const blankInlineCommentData = failedDataOf(blankInlineComment.json ?? {});
assertCondition(failureMessageOf(blankInlineCommentData).includes("must not be blank"), "blank inline issue comment body should name blank-body reason", blankInlineCommentData);
const multilineInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "line1\nline2", "--dry-run"], env);
assertCondition(multilineInlineComment.status !== 0, "multiline inline issue comment body should fail", multilineInlineComment.json ?? { stdout: multilineInlineComment.stdout });
const multilineInlineCommentData = failedDataOf(multilineInlineComment.json ?? {});
assertCondition(failureMessageOf(multilineInlineCommentData).includes("single-line text only"), "multiline inline issue comment body should point to body-file", multilineInlineCommentData);
const pollutedInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "literal \\n pollution", "--dry-run"], env);
assertCondition(pollutedInlineComment.status !== 0, "polluted inline issue comment body should fail", pollutedInlineComment.json ?? { stdout: pollutedInlineComment.stdout });
const pollutedInlineCommentData = failedDataOf(pollutedInlineComment.json ?? {});
assertCondition(failureMessageOf(pollutedInlineCommentData).includes("shell-pollution signals"), "polluted inline issue comment body should report shell pollution", pollutedInlineCommentData);
const secretInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "token=ghp_1234567890abcdef", "--dry-run"], env);
assertCondition(secretInlineComment.status !== 0, "secret-like inline issue comment body should fail", secretInlineComment.json ?? { stdout: secretInlineComment.stdout });
assertCondition(!secretInlineComment.stdout.includes("ghp_1234567890abcdef") && !secretInlineComment.stderr.includes("ghp_1234567890abcdef"), "secret-like inline issue comment failure must not print token value", {
stdout: secretInlineComment.stdout,
stderr: secretInlineComment.stderr,
});
const stdinInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body-file", "-", "--dry-run"], env, "stdin body");
assertCondition(stdinInlineComment.status !== 0, "issue comment body stdin should remain unsupported", stdinInlineComment.json ?? { stdout: stdinInlineComment.stdout });
const stdinInlineCommentData = failedDataOf(stdinInlineComment.json ?? {});
assertCondition(failureMessageOf(stdinInlineCommentData).includes("does not support --body-file - stdin"), "issue comment stdin rejection should be explicit", stdinInlineCommentData);
const commentDeleteDryRun = await runCli(["gh", "issue", "comment", "delete", "9001", "--repo", "pikasTech/unidesk", "--dry-run"], env);
assertCondition(commentDeleteDryRun.status === 0, "issue comment delete dry-run should succeed", commentDeleteDryRun.json ?? { stdout: commentDeleteDryRun.stdout });
@@ -1616,6 +1701,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"issue update replace/append modes preserve Markdown",
"issue update non-dry-run success defaults to compact output without full issue.body and exposes bodySha plus drill-down commands",
"issue update --full explicitly includes full issue.body",
"issue comment create supports short inline --body dry-run and write with bounded output",
"issue comment create still rejects missing, blank, multiline, polluted, secret-like, stdin, and mixed body sources",
"issue comment create/delete follows CRUD shape",
"issue hard delete is structurally unsupported",
],