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
+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 当前关注点.",