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
+12
View File
@@ -114,6 +114,18 @@ function displayCommandName(parts: string[]): string {
}
return shown.join(" ");
}
if (parts[0] === "gh") {
const shown: string[] = [];
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index] ?? "";
shown.push(part);
if (part === "--body") {
shown.push("<body:redacted>");
index += 1;
}
}
return shown.join(" ");
}
return parts.join(" ");
}
+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",
],
+12 -1
View File
@@ -662,6 +662,17 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
const commentCreateData = dataOf(commentCreate.json ?? {});
assertCondition(commentCreateData.command === "pr comment create", "pr comment create should use CRUD command name", commentCreateData);
const prInlineCommentBody = "short PR inline comment remains supported";
const prInlineComment = await runCli(["gh", "pr", "comment", "create", "42", "--repo", "pikasTech/unidesk", "--body", prInlineCommentBody], env2);
assertCondition(prInlineComment.status === 0, "pr comment create --body should remain supported", prInlineComment.json ?? { stdout: prInlineComment.stdout });
assertCondition(prInlineComment.json?.command === "gh pr comment create 42 --repo pikasTech/unidesk --body <body:redacted>", "outer gh command should redact PR inline comment body", prInlineComment.json ?? {});
const prInlineCommentData = dataOf(prInlineComment.json ?? {});
assertCondition(prInlineCommentData.command === "pr comment create", "pr inline comment should use CRUD command name", prInlineCommentData);
const prInlineCommentRequest = mock2.requests.filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues/42/comments").at(-1);
assertCondition(prInlineCommentRequest !== undefined, "pr inline comment should POST to issue comments endpoint", mock2.requests);
const prInlinePayload = JSON.parse(prInlineCommentRequest?.body ?? "{}") as JsonRecord;
assertCondition(prInlinePayload.body === prInlineCommentBody, "pr inline comment payload should preserve --body text", prInlinePayload);
const commentDelete = await runCli(["gh", "pr", "comment", "delete", "9101", "--repo", "pikasTech/unidesk"], env2);
assertCondition(commentDelete.status === 0, "pr comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout });
const commentDeleteData = dataOf(commentDelete.json ?? {});
@@ -723,7 +734,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
"pr update/edit use low-noise REST PATCH without GraphQL projectCards",
"pr edit supports --body-file - stdin without echoing full body",
"pr update append and close/reopen are available",
"pr comment create/delete follows CRUD shape",
"pr comment create/delete follows CRUD shape and --body remains supported",
"pr merge is blocked",
"pr hard delete is blocked",
"pr create validation failures are structured",
+108 -9
View File
@@ -9,6 +9,7 @@ const USER_AGENT = "unidesk-cli-gh";
const PREVIEW_CHARS = 240;
const REQUEST_TIMEOUT_MS = 20_000;
const MIN_SAFE_ISSUE_BODY_CHARS = 20;
const MAX_INLINE_ISSUE_COMMENT_BODY_CHARS = 1000;
const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL = "http://backend-core:8080/api/microservices/claudeqq/proxy";
const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID = "645275593";
const CODE_QUEUE_BOARD_TARGET_ISSUE = 20;
@@ -968,6 +969,53 @@ function readMarkdownBody(options: GitHubOptions, command: string): { body: stri
throw new Error(`${command} requires --body-file <file> or --body <text>`);
}
function secretLikeInlineFindings(body: string): string[] {
const findings: string[] = [];
if (/\bgh[pousr]_[A-Za-z0-9_]{6,}\b/u.test(body)) findings.push("github-token-like-value");
if (/\bgithub_pat_[A-Za-z0-9_]{6,}\b/u.test(body)) findings.push("github-pat-like-value");
if (/\b(?:token|password|passwd|secret|api[_-]?key)\s*[:=]\s*["']?[^"'\s,;]{6,}/iu.test(body)) findings.push("credential-assignment-like-value");
if (/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/iu.test(body)) findings.push("bearer-token-like-value");
return findings;
}
function readIssueCommentBody(options: GitHubOptions): { body: string; bodySource: Record<string, unknown> } {
if (options.bodyFile !== undefined && options.body !== undefined) {
throw new Error("issue comment create accepts only one body source: --body-file or --body");
}
if (options.bodyFile !== undefined) {
if (options.bodyFile === "-") throw new Error("issue comment create does not support --body-file - stdin; write Markdown to a file and pass --body-file <file>");
return readMarkdownBodyFileOrStdin(options.bodyFile);
}
if (options.body === undefined) throw new Error("issue comment create requires --body-file <file> or --body <text>");
const body = options.body;
const trimmed = body.trim();
const shellPollution = shellPollutionEvidence(body);
const secretLike = secretLikeInlineFindings(body);
if (trimmed.length === 0) {
throw new Error("issue comment create --body must not be blank; use --body-file for reviewed Markdown");
}
if (body.length > MAX_INLINE_ISSUE_COMMENT_BODY_CHARS) {
throw new Error(`issue comment create --body is limited to ${MAX_INLINE_ISSUE_COMMENT_BODY_CHARS} characters; use --body-file for long Markdown`);
}
if (body.includes("\n") || body.includes("\r")) {
throw new Error("issue comment create --body supports short single-line text only; use --body-file for multiline Markdown");
}
if (shellPollution.length > 0) {
throw new Error(`issue comment create --body contains shell-pollution signals (${shellPollution.join(",")}); use --body-file with reviewed Markdown bytes`);
}
if (secretLike.length > 0) {
throw new Error(`issue comment create --body appears to contain secret-like text (${secretLike.join(",")}); refusing to print or submit it`);
}
return {
body,
bodySource: {
kind: "inline",
maxInlineBodyChars: MAX_INLINE_ISSUE_COMMENT_BODY_CHARS,
warning: "inline issue comments are intended only for short single-line text; use --body-file for Markdown or generated content",
},
};
}
function tokenFromEnvironment(): GitHubTokenProbe {
if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) {
return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false };
@@ -1021,7 +1069,7 @@ function preview(text: string): string {
}
function previewLines(text: string, maxLines = 12): string[] {
return text.split(/\r?\n/).slice(0, maxLines);
return text.split(/\r?\n/).slice(0, maxLines).map((line) => preview(line));
}
function bodySha(text: string): string {
@@ -1482,6 +1530,7 @@ function commanderBriefNotificationPlan(issueNumber: number, body: string, diff:
function writeBodyPlan(command: "issue create" | "issue comment create" | "pr create" | "pr comment", repo: string, body: string, bodySource: Record<string, unknown>, extra: Record<string, unknown> = {}): Record<string, unknown> {
const isIssueWrite = command === "issue create" || command === "issue comment create";
const source = String(bodySource.kind ?? "unknown");
const requestBody: Record<string, unknown> = {
bodyChars: body.length,
bodySource,
@@ -1491,6 +1540,7 @@ function writeBodyPlan(command: "issue create" | "issue comment create" | "pr cr
return {
repo,
bodySource,
source,
bodyPreview: preview(body),
bodyPreviewLines: previewLines(body),
...bodySafetySignals(body),
@@ -1502,8 +1552,12 @@ function writeBodyPlan(command: "issue create" | "issue comment create" | "pr cr
body: requestBody,
},
validation: {
source: String(bodySource.kind ?? "unknown"),
rawText: "read from file bytes without shell interpolation",
source,
rawText: source === "body-file"
? "read from file bytes without shell interpolation"
: source === "stdin"
? "read from stdin bytes"
: "short inline argument accepted only by command-specific safety rules",
},
...extra,
};
@@ -1818,6 +1872,14 @@ function issueBodyReadCommands(repo: string, issueNumber: number): Record<string
};
}
function issueCommentReadCommands(repo: string, issueNumber: number): Record<string, string> {
return {
comments: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --json comments`,
full: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --full`,
raw: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --raw`,
};
}
function issueWriteDisclosure(options: GitHubOptions, repo: string, issueNumber: number, dryRun: boolean): Record<string, unknown> {
const explicitFullDisclosure = options.raw || options.full;
return {
@@ -3892,6 +3954,23 @@ function commentSummary(comment: GitHubComment): Record<string, unknown> {
};
}
function compactCommentSummary(comment: GitHubComment): Record<string, unknown> {
const body = comment.body ?? "";
return {
id: comment.id,
url: comment.html_url,
author: comment.user?.login ?? null,
createdAt: comment.created_at ?? null,
updatedAt: comment.updated_at ?? null,
bodyChars: body.length,
bodySha: bodySha(body),
bodyPreview: preview(body),
bodyPreviewLines: previewLines(body, 4),
bodyOmitted: true,
fullBodyIncluded: false,
};
}
function prStateDetail(pr: GitHubPullRequest): "open" | "closed" | "merged" {
if (pr.merged === true || pr.merged_at !== null && pr.merged_at !== undefined) return "merged";
return pr.state === "closed" ? "closed" : "open";
@@ -5073,8 +5152,13 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
}
async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const body = readBodyFile(options.bodyFile, "issue comment");
const bodySource = { kind: "body-file", path: options.bodyFile ?? null };
let bodyInput: { body: string; bodySource: Record<string, unknown> };
try {
bodyInput = readIssueCommentBody(options);
} catch (error) {
return validationError("issue comment create", repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
const { body, bodySource } = bodyInput;
if (options.dryRun) {
return {
ok: true,
@@ -5083,13 +5167,27 @@ async function issueComment(repo: string, token: string, issueNumber: number, op
dryRun: true,
planned: true,
issueNumber,
readCommands: issueCommentReadCommands(repo, issueNumber),
...writeBodyPlan("issue comment create", repo, body, bodySource, { issueNumber }),
};
}
const { owner, name } = repoParts(repo);
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber });
return { ok: true, command: "issue comment create", repo, issueNumber, comment: commentSummary(comment), bodySource, rest: true };
return {
ok: true,
command: "issue comment create",
repo,
issueNumber,
comment: compactCommentSummary(comment),
bodySource,
bodyChars: body.length,
bodySha: bodySha(body),
bodyPreview: preview(body),
source: String(bodySource.kind ?? "unknown"),
readCommands: issueCommentReadCommands(repo, issueNumber),
rest: true,
};
}
async function commentDelete(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, dryRun: boolean): Promise<GitHubCommandResult> {
@@ -5681,7 +5779,7 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]",
"bun scripts/cli.ts gh issue edit <number> --body-file <file> [--full|--raw] [compat alias for issue update --mode replace]",
"bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]",
"bun scripts/cli.ts gh issue comment create <number> --body-file <file> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue comment create <number> --body-file <file>|--body <short-text> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue delete <number> [unsupported: use close]",
@@ -5727,9 +5825,10 @@ export function ghHelp(): unknown {
"issue edit is a compatibility alias for issue update --mode replace.",
"issue update --body-file refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading, warns when HWLAB product/user issue routing appears in favor of pikasTech/HWLAB, and still rejects commander brief update sections; commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).",
"issue update dry-run reports bounded bodyPreview/bodyPreviewLines, old/new body length slots, body SHA, required heading checks, literal \\n detection, shell-pollution signals, guard/concurrency summary, wouldPatch, and readCommands without printing an unbounded full body. Non-dry-run can use --expect-updated-at or --expect-body-sha for stale-cache protection.",
"Issue body stdin is intentionally unsupported in this CLI; write generated Markdown to a file and pass --body-file.",
"issue comment create accepts --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally; use --body-file for Markdown, generated content, or long comments.",
"Issue body stdin is intentionally unsupported in this CLI; issue comment create also rejects --body-file - stdin. Write generated Markdown to a file and pass --body-file.",
"When staging a body file from a shell, use a quoted heredoc such as cat <<'EOF' > /tmp/body.md so backticks and backslashes are not expanded before --body-file reads the file.",
"For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments. GitHub issue Markdown writes intentionally use --body-file only; PR edit/update also accepts --body-file - for stdin when a runner already has reviewed Markdown on stdin.",
"For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments. GitHub issue Markdown writes intentionally use --body-file for long or multiline content; PR edit/update also accepts --body-file - for stdin when a runner already has reviewed Markdown on stdin.",
"issue scan-escape classifies literal \\n findings as suspected-pollution, explanatory-mention, or risk, and emits cleanupSuggestions with body/comment ids plus diff-like previews. cleanup-plan is an alias that remains dry-run/read-only.",
"issue board-audit is read-only and defaults to repo pikasTech/unidesk plus board issue #20. It reads only the board issue body, returns body size/SHA and parsed Markdown board sections, and no longer validates GitHub open/closed issue coverage against OPEN/CLOSED tables. The legacy coverage fields remain present as empty arrays/zero counts for compatibility.",
"issue board-row list/get reuse the board-audit table parser and are read-only. board-row update changes one table cell by issue number, returns old/new row, body SHA, body guard and request plan, and defaults to dry-run unless --expect-updated-at or --expect-body-sha is supplied for the guarded PATCH. Field aliases map status and validation to the 验收状态 column, tasks to 相关 Code Queue 任务, and focus to 当前关注点.",