feat: add heredoc body input to gh cli

This commit is contained in:
Codex
2026-06-06 01:27:52 +00:00
parent 879facb933
commit 0bae3af934
5 changed files with 113 additions and 65 deletions
+2 -2
View File
@@ -122,7 +122,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- P0: 对 GitHub issue/PR 做正式写入时必须优先使用 `bun scripts/cli.ts gh ...`;禁止用原生 `gh issue edit/create/comment` 直接写 UniDesk/HWLAB 长期看板、指挥简报或用户反馈 issue。事故和 CLI 补强需求见 [pikasTech/unidesk#142](https://github.com/pikasTech/unidesk/issues/142)。
- P0: GitHub PR/issue 读写、PR 合并、评论、状态观察和收口动作必须走 UniDesk `gh` 子命令;禁止绕过为原生 `gh`、手写 `curl`/GraphQL/REST 请求或临时脚本直连 GitHub。若 `bun scripts/cli.ts gh ...` 不顺手、字段不够、merge 不支持或可见性不足,必须先改进 UniDesk `gh` 子命令并用它完成任务,不能跳过该入口。
- #20、HWLAB #7 和指挥简报类正文不得使用原生 `gh issue edit --body-file -`、手写 GitHub API 或无 guard 的整篇替换;需要管道化写入时使用 UniDesk `gh issue update|comment create --body-file -`,由 CLI 读取 stdin、执行 body guard、自动读取当前 issue 元数据并输出 old/new body SHA。
- #20、HWLAB #7 和指挥简报类正文不得使用原生 `gh issue edit --body-file -`、手写 GitHub API 或无 guard 的整篇替换;需要多行写入时优先使用 UniDesk `gh issue update|comment create --body-stdin <<'EOF'` 形式,由 CLI 读取 heredoc/stdin、执行 body guard、自动读取当前 issue 元数据并输出 old/new body SHA`--body-file` 只作为复用已有文件的兼容入口
- 长任务和跨回合排障应把专题 issue 评论区作为进展锚点;调查、计划、rollout、阻塞、复测 trace 和最终验收结论都追加到同一个 issue 评论中,细则见 `docs/reference/code-queue-supervision.md`
## Critical Git / Multi-Repo Sync Rule
@@ -213,7 +213,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏和 dev workload manifests,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md` 与 `docs/reference/microservices.md`。
- `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod`met-nonlinear` 和 `k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`。
- `bun scripts/cli.ts auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run`:查看 Auth Broker P0 Rust skeleton 与 CLI adapter contractrunner 无 `GH_TOKEN`/`GITHUB_TOKEN` 时返回结构化 `auth-missing`/`broker-needed`,不读取或打印 token 值,规则见 `docs/reference/auth-broker.md`。
- `bun scripts/cli.ts gh preflight|auth status|issue ...|pr list|files|diff --stat|read|view|preflight|closeout|create|edit|update|comment|merge` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、分页 issue list、inactive issue stale-close、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)、低噪声 PR 收口 preflight、guarded PR merge 与 runner PR preflight`gh issue/pr read|view` 支持 `owner/repo#number` shorthand`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON`gh pr merge` 会先执行 closeout 预检并拒绝非 open、draft、冲突、非 CLEAN、失败或 pending checks 的 PR,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。
- `bun scripts/cli.ts gh preflight|auth status|issue ...|pr list|files|diff --stat|read|view|preflight|closeout|create|edit|update|comment|merge` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、分页 issue list、inactive issue stale-close、脱敏 auth/status 诊断、heredoc/stdin Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)、低噪声 PR 收口 preflight、guarded PR merge 与 runner PR preflight`gh issue/pr read|view` 支持 `owner/repo#number` shorthand`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON`gh pr merge` 会先执行 closeout 预检并拒绝非 open、draft、冲突、非 CLEAN、失败或 pending checks 的 PR,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。
- `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合、ClaudeQQ 高风险请示草案和 GPT-5.5 PR prompt 边界辅助 lint;当前只返回 dry-run 计划和 backend-core `microservice proxy claudeqq` 授权后候选命令,不接 live bridge、不接管人工指挥官,不发送消息,`prompt-lint` 不作为业务 PR 门禁也不改变 `codex submit` 默认行为,规则见 `docs/reference/host-codex-commander.md`。
- `bun scripts/cli.ts hwlab g14 monitor-prs [--lane g14|v02]`:一行启动异步监控 HWLAB PR;默认 base=G14 合并后滚动 G14 DEV 并写每日简报,`--lane v02` 监控 base=v0.2CI/preflight 通过后自动合并、触发 v0.2 CD,并在 PR 下评论 pending/冲突/成功/失败/超时状态,规则见 `docs/reference/g14.md` 与 `docs/reference/cli.md`。
- `bun scripts/cli.ts agentrun v01 control-plane status|trigger-current [--dry-run|--confirm]`:通过 G14 route 只读观察或手动触发 AgentRun `v0.1` commit-pinned Tekton/Argo PipelineRun,规则见 `docs/reference/agentrun.md` 与 `docs/reference/cli.md`。
File diff suppressed because one or more lines are too long
+32 -8
View File
@@ -665,7 +665,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(notes.some((line) => line.includes("GitHub issue URLs") && line.includes("owner/repo#number shorthand")), "gh help should explain issue view/read URL and shorthand targets", { notes });
assertCondition(notes.some((line) => line.includes("--number is accepted on single issue/comment numeric target commands") && line.includes("Comment delete treats --number as commentId")), "gh help should document issue --number compatibility scope", { 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-file <file|->") && line.includes("--body only for short single-line text")), "gh help should document issue comment stdin and inline safety limits", { notes });
assertCondition(notes.some((line) => line.includes("issue comment create accepts --body-stdin") && line.includes("--body only for short single-line text")), "gh help should document issue comment heredoc stdin and 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 });
@@ -785,7 +785,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(scanFindings.some((finding) => finding.issueNumber === 53 && finding.kind === "null-body" && finding.classification === "risk"), "null body should be guarded as risk", scanFindings);
const cleanupSuggestions = scanData.cleanupSuggestions as JsonRecord[];
assertCondition(Array.isArray(cleanupSuggestions), "scan should expose cleanupSuggestions", scanData);
assertCondition(cleanupSuggestions.some((suggestion) => suggestion.issueNumber === 51 && suggestion.type === "issue-body" && typeof suggestion.bodyId === "string" && suggestion.action === "rewrite-issue-body-with-body-file"), "issue body cleanup suggestion should use body-file rewrite with body id", cleanupSuggestions);
assertCondition(cleanupSuggestions.some((suggestion) => suggestion.issueNumber === 51 && suggestion.type === "issue-body" && typeof suggestion.bodyId === "string" && suggestion.action === "rewrite-issue-body-with-body-stdin"), "issue body cleanup suggestion should use heredoc/stdin rewrite with body id", cleanupSuggestions);
assertCondition(cleanupSuggestions.some((suggestion) => suggestion.commentId === 5101 && suggestion.type === "comment-body" && String(suggestion.bodyId ?? "").includes("comment:5101") && suggestion.action === "review-comment-manually"), "comment cleanup suggestion should be manual review with body id", cleanupSuggestions);
assertCondition(cleanupSuggestions.every((suggestion) => suggestion.issueNumber !== 52), "explanatory mention should not create cleanup suggestion", cleanupSuggestions);
const scanPatchCount = mock.requests.filter((request) => request.method === "PATCH" || request.method === "DELETE" || request.method === "POST").length;
@@ -1471,7 +1471,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"",
"## 常驻观察与长期建议",
"",
"- 保持滚动简报正文只通过 body-file 更新。",
"- 保持滚动简报正文只通过 heredoc/stdin 更新。",
"",
"## 更新 2026-05-21 15:18 北京时间",
"",
@@ -1570,10 +1570,16 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const issueCreateStdinWriteCount = mock.requests.slice(issueCreateStdinRequestCountBefore).filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues").length;
assertCondition(issueCreateStdinWriteCount === 0, "issue create stdin dry-run must not POST GitHub", { requests: mock.requests.slice(issueCreateStdinRequestCountBefore) });
const issueCreateBodyStdin = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "body-stdin issue create", "--body-stdin", "--dry-run"], env, issueCreateStdinBody);
assertCondition(issueCreateBodyStdin.status === 0, "issue create dry-run should accept --body-stdin", issueCreateBodyStdin.json ?? { stdout: issueCreateBodyStdin.stdout });
const issueCreateBodyStdinData = dataOf(issueCreateBodyStdin.json ?? {});
const issueCreateBodyStdinSource = issueCreateBodyStdinData.bodySource as JsonRecord;
assertCondition(issueCreateBodyStdinSource.kind === "stdin" && issueCreateBodyStdinSource.path === "-", "issue create --body-stdin should expose stdin source", issueCreateBodyStdinData);
const issueCreateInline = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "inline rejected", "--body", "inline body", "--dry-run"], env);
assertCondition(issueCreateInline.status !== 0, "issue create inline --body should fail", issueCreateInline.json ?? { stdout: issueCreateInline.stdout });
const issueCreateInlineData = failedDataOf(issueCreateInline.json ?? {});
assertCondition(failureMessageOf(issueCreateInlineData).includes("does not support --body"), "issue create inline --body should point to body-file", issueCreateInlineData);
assertCondition(failureMessageOf(issueCreateInlineData).includes("does not support --body"), "issue create inline --body should point to body-stdin", issueCreateInlineData);
const issueCreateMissingLabel = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "bad label", "--body-file", appendFile, "--label", "missing-label"], env);
assertCondition(issueCreateMissingLabel.status !== 0, "issue create missing label should fail structurally", issueCreateMissingLabel.json ?? { stdout: issueCreateMissingLabel.stdout });
@@ -1729,6 +1735,17 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const closePatchPayload = JSON.parse(closeWriteRequests[1]?.body ?? "{}") as JsonRecord;
assertCondition(closeCommentPayload.body === closeComment && closePatchPayload.state === "closed", "issue close --comment payloads should preserve comment and state", { closeCommentPayload, closePatchPayload });
const reopenCommentStdinBody = "reopen heredoc comment\n\n- keeps `code`\n";
const reopenStdinRequestCountBefore = mock.requests.length;
const reopenStdin = await runCli(["gh", "issue", "reopen", "20", "--repo", "pikasTech/unidesk", "--comment-stdin", "--dry-run"], env, reopenCommentStdinBody);
assertCondition(reopenStdin.status === 0, "issue reopen --comment-stdin dry-run should succeed", reopenStdin.json ?? { stdout: reopenStdin.stdout });
const reopenStdinData = dataOf(reopenStdin.json ?? {});
const reopenStdinComment = reopenStdinData.comment as JsonRecord;
const reopenStdinCommentSource = (reopenStdinComment.bodySource ?? {}) as JsonRecord;
assertCondition(reopenStdinCommentSource.kind === "stdin" && reopenStdinCommentSource.path === "-", "issue reopen --comment-stdin should expose stdin source", reopenStdinComment);
const reopenStdinWriteCount = mock.requests.slice(reopenStdinRequestCountBefore).filter((request) => request.method === "POST" || request.method === "PATCH").length;
assertCondition(reopenStdinWriteCount === 0, "issue reopen --comment-stdin dry-run must not write GitHub", { requests: mock.requests.slice(reopenStdinRequestCountBefore) });
const closeCommentWrongCommand = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--comment", closeComment, "--dry-run"], env);
assertCondition(closeCommentWrongCommand.status !== 0, "--comment outside issue close/reopen should fail structurally", closeCommentWrongCommand.json ?? { stdout: closeCommentWrongCommand.stdout });
const closeCommentWrongData = failedDataOf(closeCommentWrongCommand.json ?? {});
@@ -1737,7 +1754,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
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);
assertCondition(missingCommentBodyData.degradedReason === "validation-failed" && failureMessageOf(missingCommentBodyData).includes("requires --body-stdin"), "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 });
@@ -1752,7 +1769,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
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);
assertCondition(failureMessageOf(multilineInlineCommentData).includes("single-line text only"), "multiline inline issue comment body should point to body-stdin", 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 });
@@ -1774,6 +1791,12 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(stdinCommentSource.kind === "stdin" && stdinCommentSource.path === "-" && stdinCommentData.source === "stdin", "stdin issue comment dry-run should expose stdin source", stdinCommentData);
assertCondition(stdinCommentData.containsBackticks === true && stdinCommentData.containsLiteralBackslashN === false, "stdin issue comment should preserve Markdown signals", stdinCommentData);
const bodyStdinComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body-stdin", "--dry-run"], env, stdinCommentBody);
assertCondition(bodyStdinComment.status === 0, "issue comment --body-stdin should be supported", bodyStdinComment.json ?? { stdout: bodyStdinComment.stdout });
const bodyStdinCommentData = dataOf(bodyStdinComment.json ?? {});
const bodyStdinCommentSource = bodyStdinCommentData.bodySource as JsonRecord;
assertCondition(bodyStdinCommentSource.kind === "stdin" && bodyStdinCommentSource.path === "-" && bodyStdinCommentData.source === "stdin", "issue comment --body-stdin should expose stdin source", bodyStdinCommentData);
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 });
const commentDeleteDryRunData = dataOf(commentDeleteDryRun.json ?? {});
@@ -1819,7 +1842,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"issue board-row update rejects literal backslash-n cell values",
"issue board-row update escapes markdown table pipes and performs guarded PATCH with --expect-body-sha",
"issue board-row move is supported, defaults to dry-run, and can migrate OPEN rows into CLOSED",
"issue create dry-run parses repeated/comma labels, supports stdin, rejects inline --body, and exposes request plan",
"issue create dry-run parses repeated/comma labels, supports --body-stdin and compatible --body-file -, rejects inline --body, and exposes request plan",
"issue create sends labels through REST and preserves GitHub validation errors for missing labels",
"issue list unsupported fields and states fail structurally",
"issue read supports body,title,state,closed,closedAt,comments selection",
@@ -1838,8 +1861,9 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"issue update replace/append modes preserve Markdown and support stdin with automatic current issue metadata checks",
"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 close/reopen supports --comment-stdin dry-run without writes",
"issue comment create supports short inline --body dry-run and write with bounded output",
"issue comment create supports stdin and still rejects missing, blank, multiline inline, polluted inline, secret-like inline, and mixed body sources",
"issue comment create supports --body-stdin and compatible --body-file -, and still rejects missing, blank, multiline inline, polluted inline, secret-like inline, and mixed body sources",
"issue comment create/delete follows CRUD shape",
"issue hard delete is structurally unsupported",
],
+13 -3
View File
@@ -684,7 +684,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
const editStdinBody = "stdin line\n`stdin code`\n| c | d |\n";
const beforeEditRequests = mock2.requests.length;
const editStdin = await runCli(["gh", "pr", "edit", "42", "--repo", "pikasTech/unidesk", "--title", "stdin title", "--body-file", "-"], env2, editStdinBody);
const editStdin = await runCli(["gh", "pr", "edit", "42", "--repo", "pikasTech/unidesk", "--title", "stdin title", "--body-stdin"], env2, editStdinBody);
assertCondition(editStdin.status === 0, "pr edit stdin should succeed", editStdin.json ?? { stdout: editStdin.stdout });
const editStdinData = dataOf(editStdin.json ?? {});
assertCondition(editStdinData.command === "pr edit" && editStdinData.pr === 42 && editStdinData.url === "https://github.com/pikasTech/unidesk/pull/42", "pr edit stdin should return low-noise summary", editStdinData);
@@ -720,6 +720,16 @@ 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 prBodyStdinCommentBody = "PR heredoc comment line\n\n- keeps `code`\n";
const prBodyStdinComment = await runCli(["gh", "pr", "comment", "create", "42", "--repo", "pikasTech/unidesk", "--body-stdin"], env2, prBodyStdinCommentBody);
assertCondition(prBodyStdinComment.status === 0, "pr comment create --body-stdin should succeed", prBodyStdinComment.json ?? { stdout: prBodyStdinComment.stdout });
const prBodyStdinCommentData = dataOf(prBodyStdinComment.json ?? {});
const prBodyStdinSource = prBodyStdinCommentData.bodySource as JsonRecord;
assertCondition(prBodyStdinSource.kind === "stdin" && prBodyStdinSource.path === "-", "pr comment create --body-stdin should expose stdin source", prBodyStdinCommentData);
const prBodyStdinRequest = mock2.requests.filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues/42/comments").at(-1);
const prBodyStdinPayload = JSON.parse(prBodyStdinRequest?.body ?? "{}") as JsonRecord;
assertCondition(prBodyStdinPayload.body === prBodyStdinCommentBody, "pr comment --body-stdin payload should preserve stdin markdown", prBodyStdinPayload);
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 });
@@ -787,9 +797,9 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
"pr create dry-run exposes planned operation",
"pr comment dry-run preserves markdown text",
"pr update/edit use low-noise REST PATCH without GraphQL projectCards",
"pr edit supports --body-file - stdin without echoing full body",
"pr edit supports --body-stdin without echoing full body",
"pr update append and close/reopen are available",
"pr comment create/delete follows CRUD shape and --body remains supported",
"pr comment create/delete follows CRUD shape, --body-stdin, and --body remains supported",
"pr merge is guarded by preflight and uses REST",
"pr hard delete is blocked",
"pr create validation failures are structured",
+57 -43
View File
@@ -60,7 +60,7 @@ const GH_VALUE_OPTIONS = new Set([
"--tasks", "--summary", "--focus", "--validation", "--progress", "--number", "--pr",
"--search", "--inactive-hours", "--comment", "--comment-file", "--description",
]);
const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--private", "--public", "--auto-init"]);
const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--body-stdin", "--comment-stdin", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--private", "--public", "--auto-init"]);
const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS;
const ISSUE_SCAN_MAX_FINDINGS = 60;
const ISSUE_BODY_PROFILES = {
@@ -100,7 +100,7 @@ type IssueBodyProfileOption = "auto" | IssueBodyProfileName;
type EscapeFindingClassification = "suspected-pollution" | "explanatory-mention" | "risk";
type EscapeFindingSeverity = "low" | "medium" | "high";
type EscapeBodyKind = "issue-body" | "comment-body";
type CleanupSuggestionAction = "rewrite-issue-body-with-body-file" | "review-comment-manually" | "review-body-length" | "no-cleanup-needed";
type CleanupSuggestionAction = "rewrite-issue-body-with-body-stdin" | "review-comment-manually" | "review-body-length" | "no-cleanup-needed";
type BoardSectionKind = "open" | "closed";
type BoardRequiredColumn = typeof BOARD_AUDIT_REQUIRED_COLUMNS[number];
type BoardColumnKind = BoardRequiredColumn | "focus" | "githubStatus" | "category" | "summary";
@@ -219,7 +219,7 @@ interface EscapeCleanupSuggestion {
reason: string;
findings: Array<Pick<EscapeMatchFinding, "kind" | "classification" | "severity" | "lineNumber" | "column" | "snippet" | "match">>;
plannedCommand?: string;
bodyFileHint?: string;
bodyInputHint?: string;
preview?: {
before: string;
after: string;
@@ -868,6 +868,15 @@ function resolveRepoOption(args: string[]): string {
return explicitRepo ?? DEFAULT_REPO;
}
function stdinAliasFileOption(args: string[], fileOption: string, stdinFlag: string): string | undefined {
const file = optionValue(args, fileOption);
const stdin = hasFlag(args, stdinFlag);
if (file !== undefined && stdin) {
throw new Error(`${fileOption} and ${stdinFlag} are mutually exclusive; use one body source`);
}
return stdin ? "-" : file;
}
function parseOptions(args: string[]): GitHubOptions {
validateKnownOptions(args);
const [top, sub] = args;
@@ -894,9 +903,9 @@ function parseOptions(args: string[]): GitHubOptions {
search: optionValue(args, "--search"),
title: optionValue(args, "--title"),
body: optionValue(args, "--body"),
bodyFile: optionValue(args, "--body-file"),
bodyFile: stdinAliasFileOption(args, "--body-file", "--body-stdin"),
comment: optionValue(args, "--comment"),
commentFile: optionValue(args, "--comment-file"),
commentFile: stdinAliasFileOption(args, "--comment-file", "--comment-stdin"),
repoDescription: optionValue(args, "--description"),
repoVisibility: parseRepoVisibility(args),
repoAutoInit: hasFlag(args, "--auto-init"),
@@ -1299,7 +1308,7 @@ function readMarkdownBodyFileOrStdin(path: string): { body: string; bodySource:
}
function readOptionalMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null {
if (options.bodyFile !== undefined && options.body !== undefined) throw new Error(`${command} accepts only one body source: --body-file or --body`);
if (options.bodyFile !== undefined && options.body !== undefined) throw new Error(`${command} accepts only one body source: --body-file/--body-stdin or --body`);
if (options.bodyFile !== undefined) {
return readMarkdownBodyFileOrStdin(options.bodyFile);
}
@@ -1308,7 +1317,7 @@ function readOptionalMarkdownBody(options: GitHubOptions, command: string): { bo
body: options.body,
bodySource: {
kind: "inline",
warning: options.body.includes("\n") ? "inline body contains real newlines; --body-file is safer for generated Markdown" : "inline body is intended only for short single-line text",
warning: options.body.includes("\n") ? "inline body contains real newlines; --body-stdin with a quoted heredoc is safer for generated Markdown" : "inline body is intended only for short single-line text",
},
};
}
@@ -1318,7 +1327,7 @@ function readOptionalMarkdownBody(options: GitHubOptions, command: string): { bo
function readMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } {
const body = readOptionalMarkdownBody(options, command);
if (body !== null) return body;
throw new Error(`${command} requires --body-file <file> or --body <text>`);
throw new Error(`${command} requires --body-stdin, --body-file <file|->, or --body <text>`);
}
function secretLikeInlineFindings(body: string): string[] {
@@ -1332,27 +1341,27 @@ function secretLikeInlineFindings(body: string): string[] {
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");
throw new Error("issue comment create accepts only one body source: --body-file/--body-stdin or --body");
}
if (options.bodyFile !== undefined) {
return readMarkdownBodyFileOrStdin(options.bodyFile);
}
if (options.body === undefined) throw new Error("issue comment create requires --body-file <file> or --body <text>");
if (options.body === undefined) throw new Error("issue comment create requires --body-stdin, --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");
throw new Error("issue comment create --body must not be blank; use --body-stdin with a quoted heredoc 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`);
throw new Error(`issue comment create --body is limited to ${MAX_INLINE_ISSUE_COMMENT_BODY_CHARS} characters; use --body-stdin with a quoted heredoc 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");
throw new Error("issue comment create --body supports short single-line text only; use --body-stdin with a quoted heredoc 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`);
throw new Error(`issue comment create --body contains shell-pollution signals (${shellPollution.join(",")}); use --body-stdin with a quoted heredoc for 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`);
@@ -1362,7 +1371,7 @@ function readIssueCommentBody(options: GitHubOptions): { body: string; bodySourc
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",
warning: "inline issue comments are intended only for short single-line text; use --body-stdin with a quoted heredoc for Markdown or generated content",
},
};
}
@@ -1370,10 +1379,10 @@ function readIssueCommentBody(options: GitHubOptions): { body: string; bodySourc
function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null {
if (options.comment === undefined && options.commentFile === undefined) return null;
if (options.comment !== undefined && options.commentFile !== undefined) {
throw new Error(`${command} --comment and --comment-file are mutually exclusive`);
throw new Error(`${command} --comment and --comment-file/--comment-stdin are mutually exclusive`);
}
if (options.body !== undefined || options.bodyFile !== undefined) {
throw new Error(`${command} --comment or --comment-file cannot be combined with --body or --body-file`);
throw new Error(`${command} --comment/--comment-file/--comment-stdin cannot be combined with --body/--body-file/--body-stdin`);
}
if (options.commentFile !== undefined) {
return readIssueCommentBody({ ...options, body: undefined, bodyFile: options.commentFile });
@@ -1593,7 +1602,7 @@ function codeQueueBoardCommanderBriefHint(boardIssueNumber: number, body?: strin
? "Move HWLAB product/user issue tracking to pikasTech/HWLAB; keep #20 limited to UniDesk commander/Code Queue/CLI/infra governance."
: "Use #20 only for UniDesk commander/Code Queue/CLI/infra governance rows.",
forbiddenHeadings: ["## 更新 YYYY-MM-DD HH:mm 北京时间", "## YYYY-MM-DD HH:mm 北京时间指挥更新", "### YYYY-MM-DD HH:mm CST..."],
suggestedCommand: "bun scripts/cli.ts gh issue update <daily-brief-issue> --mode replace --body-file <file> --body-profile commander-brief --expect-body-sha <sha>",
suggestedCommand: "bun scripts/cli.ts gh issue update <daily-brief-issue> --mode replace --body-stdin --body-profile commander-brief --expect-body-sha <sha> <<'EOF'\n<reviewed body>\nEOF",
detected: commanderBriefDetected || hwlabProductDetected,
detectedHeadings: headings,
commanderBrief: {
@@ -5275,6 +5284,11 @@ async function prComment(repo: string, token: string, issueNumber: number, optio
issueNumber,
pr: prSummary(prResult),
comment: commentSummary(comment),
bodySource: bodySource.bodySource,
source: String(bodySource.bodySource.kind ?? "unknown"),
bodyChars: body.length,
bodySha: bodySha(body),
bodyPreview: preview(body),
request: {
method: "POST",
path: `/repos/${owner}/${name}/issues/${issueNumber}/comments`,
@@ -5297,7 +5311,7 @@ async function prUpdate(repo: string, token: string, number: number, options: Gi
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { number });
}
if (bodySource === null && options.title === undefined) {
return validationError(commandName, repo, `${commandName} requires --title <title>, --body-file <file|->, or --body <text>`, { number });
return validationError(commandName, repo, `${commandName} requires --title <title>, --body-stdin, --body-file <file|->, or --body <text>`, { number });
}
if (options.mode === "append" && bodySource === null) {
return validationError(commandName, repo, `${commandName} --mode append requires a body source`, { number });
@@ -5657,7 +5671,7 @@ async function issueBoardAudit(repo: string, token: string, options: GitHubOptio
async function issueCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
if (options.title === undefined) throw new Error("issue create requires --title <title>");
if (options.body !== undefined) {
return validationError("issue create", repo, "issue create does not support --body; use --body-file <file|-> for Markdown", {
return validationError("issue create", repo, "issue create does not support --body; use --body-stdin with a quoted heredoc for Markdown", {
title: options.title,
bodySource: "inline",
});
@@ -5699,7 +5713,7 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions):
async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName = "issue edit"): Promise<GitHubCommandResult> {
if (options.bodyFile === undefined) {
return validationError(commandName, repo, `${commandName} requires --body-file <file|->`, { issueNumber });
return validationError(commandName, repo, `${commandName} requires --body-stdin or --body-file <file|->`, { issueNumber });
}
let bodyInput: { body: string; bodySource: Record<string, unknown> };
try {
@@ -6401,10 +6415,10 @@ function summarizeCleanupSuggestion(findings: EscapeScanEntry[]): EscapeCleanupS
url: first.url,
classification: suspected ? "suspected-pollution" : "risk",
action: first.bodyKind === "issue-body"
? (suspected ? "rewrite-issue-body-with-body-file" : "review-body-length")
? (suspected ? "rewrite-issue-body-with-body-stdin" : "review-body-length")
: "review-comment-manually",
reason: suspected
? "suspected shell escape pollution should be rewritten from a body file"
? "suspected shell escape pollution should be rewritten from heredoc/stdin"
: bodyRisk
? "body length/null risk should be reviewed before any write"
: "no cleanup needed",
@@ -6419,8 +6433,8 @@ function summarizeCleanupSuggestion(findings: EscapeScanEntry[]): EscapeCleanupS
})),
...(first.bodyKind === "issue-body"
? {
plannedCommand: `bun scripts/cli.ts gh issue update ${first.issueNumber} --mode replace --body-file <file> --dry-run`,
bodyFileHint: "write a cleaned Markdown body to a file, then pass it with --body-file; use a quoted heredoc to stage the file instead of inline shell text",
plannedCommand: `bun scripts/cli.ts gh issue update ${first.issueNumber} --mode replace --body-stdin --dry-run <<'EOF'\n<cleaned body>\nEOF`,
bodyInputHint: "pass cleaned Markdown through --body-stdin with a quoted heredoc; --body-file is only for reusable files",
preview: {
before: first.cleanupPreview?.before ?? first.snippet,
after: first.cleanupPreview?.after ?? first.snippet,
@@ -6700,13 +6714,13 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels] [--raw|--full]",
"bun scripts/cli.ts gh issue view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,closed,closedAt,comments] [--raw|--full]",
"bun scripts/cli.ts gh issue read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for issue view]",
"bun scripts/cli.ts gh issue create --title <title> --body-file <file|-> [--label label[,label...]]... [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file|-> [--title title] [--repo owner/name] [--number N compat] [--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|-> [--repo owner/name] [--number N compat] [--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|->|--body <short-text> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue create --title <title> (--body-stdin|--body-file <file|->) [--label label[,label...]]... [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue update <number> --mode replace|append (--body-stdin|--body-file <file|->) [--title title] [--repo owner/name] [--number N compat] [--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-stdin|--body-file <file|->) [--repo owner/name] [--number N compat] [--full|--raw] [compat alias for issue update --mode replace]",
"bun scripts/cli.ts gh issue edit 24 --body-stdin --notify-claudeqq-brief-diff [--dry-run]",
"bun scripts/cli.ts gh issue comment create <number> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--number N compat] [--comment <short-text>|--comment-file <file|->] [--dry-run]",
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--number N compat] [--comment <short-text>|--comment-stdin|--comment-file <file|->] [--dry-run]",
"bun scripts/cli.ts gh issue stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--dry-run]",
"bun scripts/cli.ts gh issue delete <number> [unsupported: use close]",
"bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]",
@@ -6727,10 +6741,10 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh pr read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for pr view]",
"bun scripts/cli.ts gh pr preflight <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options]",
"bun scripts/cli.ts gh pr closeout <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options; compatibility alias for pr preflight]",
"bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
"bun scripts/cli.ts gh pr edit <number> [--title title] [--body-file <file>|--body-file -|--body <text>] [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title title] [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr create --title <title> (--body-stdin|--body-file <file|->|--body <text>) --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
"bun scripts/cli.ts gh pr edit <number> [--title title] [--body-stdin|--body-file <file|->|--body <text>] [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-stdin|--body-file <file|->|--body <text>] [--title title] [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr comment create <number> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]",
@@ -6747,17 +6761,17 @@ export function ghHelp(): unknown {
"issue view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
"--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.",
"GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.",
"issue create accepts --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
"--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
"issue create accepts --body-stdin or --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
"--body-stdin is the first-class heredoc/stdin source for Markdown bodies. Use quoted heredoc syntax such as bun scripts/cli.ts gh issue comment create 1 --body-stdin <<'EOF' so real newlines, backticks, and tables are read as stdin bytes instead of shell arguments.",
"update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.",
"issue edit is a compatibility alias for issue update --mode replace.",
"issue update --body-file accepts files or - for stdin, 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 accepts --body-stdin or --body-file <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 automatically reads current issue metadata before PATCH and returns oldBodySha/updatedAt; --expect-updated-at or --expect-body-sha remain available for explicit stale-cache protection.",
"issue comment create accepts --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally.",
"issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text> or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-file is the recommended path for generated Markdown closeout evidence; --comment remains the short inline form. Use gh issue view <number> --json body or --full/--raw on view when full text is needed.",
"issue comment create accepts --body-stdin or --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally.",
"issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text>, --comment-stdin, or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-stdin is the first-class heredoc path for generated Markdown closeout evidence; --comment remains the short inline form. Use gh issue view <number> --json body or --full/--raw on view when full text is needed.",
"issue stale-close is the reusable lifecycle cleanup path for policies such as closing open issues inactive for more than 48 hours. It selects open issues by GitHub updatedAt older than observedAt - --inactive-hours, treats comments and state changes as activity, filters pull requests, supports --dry-run, and returns bounded candidate/closed/failure summaries without echoing full bodies.",
"For one-shot issue writes, pipe reviewed Markdown through stdin: cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - or gh issue comment create <number> --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/PR Markdown writes use --body-file <file|-> for long or multiline content.",
"For one-shot issue writes, prefer quoted heredoc stdin: bun scripts/cli.ts gh issue update <number> --repo owner/name --body-stdin <<'EOF' ... EOF or gh issue comment create <number> --body-stdin <<'EOF' ... EOF. --body-file <file|-> remains available for reusable files and pipes.",
"For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments. GitHub issue/PR Markdown writes use --body-stdin for heredoc/stdin or --body-file <file|-> for reusable files.",
"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 当前关注点.",
@@ -6813,7 +6827,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
"bun scripts/cli.ts gh issue list --repo owner/name --limit 200 --full",
"bun scripts/cli.ts gh issue view owner/name#<number> --raw",
"bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state,comments",
"cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - --full",
"bun scripts/cli.ts gh issue update <number> --repo owner/name --body-stdin --full <<'EOF'\n<reviewed body>\nEOF",
"bun scripts/cli.ts gh pr list --repo owner/name --limit 100 --full",
"bun scripts/cli.ts gh pr view owner/name#<number> --raw",
`bun scripts/cli.ts gh pr view <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`,