feat: 支持 gh issue stdin 写入

This commit is contained in:
Codex
2026-05-29 09:04:06 +00:00
parent 93477a3330
commit 93d305b0f6
4 changed files with 81 additions and 38 deletions
+1 -1
View File
@@ -100,7 +100,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 -`shell 管道 stdin 或无 guard 的整篇替换。当前 CLI 局部替换能力未完成前,必须先 dry-run、保留 before body、确认 body guard,再写入
- #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
## Critical Git / Multi-Repo Sync Rule
+3 -3
View File
@@ -49,8 +49,8 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P
- `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary``missing-token``auth-failed``github-transient``network-proxy-failed``permission-denied``repo-not-found``repo-forbidden``issue-not-found``pr-not-found``scope-insufficient``validation-failed``invalid-response``unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`runner 应优先用该字段分流。`github-transient` 表示 GitHub DNS/API 连接在收到 HTTP 状态前失败,输出应带 `retryable=true` 或等价 commander action;这不是缺 token、认证失败、权限不足或 PR 语义失败。
- `codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` 是派发/steer 前的本地 dry-run prompt lint。它只读取 prompt 文本,返回 `dryRun=true``mutation=false``declaredClass``effectiveClass``requiredClass``dispatchDisposition`、缺失或矛盾项和有界 evidence,不访问 live service、不提交任务、不打印完整 prompt。分级固定为 `read-only``live-read``live-mutating`;未声明时按 `read-only` 处理。`codex submit --dry-run``codex steer --dry-run` 会嵌入同一 `promptLint` 结果,帮助指挥官在 dispatch/steer 前发现缺失或矛盾的 live mutation 授权。长期规则见 `docs/reference/code-queue-supervision.md` 的 DEV 测试授权分级。
- `gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open``limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`owner/repo` 位置参数是 `--repo owner/repo` 的兼容别名;若位置 repo 与 `--repo` 冲突,或位置参数不是 `owner/repo`,必须结构化失败,禁止静默 fallback 到默认 repo。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PRCLI 会从 `.data.issues` 中过滤 pull request。
- `gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON`view` 只保留为兼容别名。`owner/repo#number` shorthand 会自动派生 `--repo owner/repo` 和 issue number;若同时提供冲突的显式 `--repo`CLI 必须结构化失败并给出 `gh issue read <number> --repo owner/repo --json body,title,state,comments` 与 shorthand raw 的可执行命令。兼容旧脚本的 `--json body``--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed``--raw``--full` 是显式完整披露别名:read/view 会选择完整支持字段集;issue update/edit 只有显式传入时才在成功响应里包含完整 `.data.issue.body`。当最终 `gh` JSON 超过 20 KiB 时,CLI 必须把完整 JSON 写入 `/tmp/unidesk-cli-output/*.json`stdout 只返回 `outputTruncated=true`、dump path、总 bytes/lines 和 head/tail 预览。默认 list/read 输出仍不得扩散到无界非 JSON 文本。`gh issue create --title <title> --body-file <file> [--label label[,label...]]... [--dry-run]``gh issue update <number> --mode replace|append --body-file <file> [--title ...] [--dry-run] [--full|--raw]``gh issue comment create <number> (--body-file <file>|--body <short-text>) [--dry-run]``gh issue comment delete <commentId> [--dry-run]``gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--body` 仅用于 issue comment 的短单行文本;空白、多行、疑似 shell 污染、secret-like 或过长 inline body 必须结构化失败,Markdown/生成内容/长评论继续用 `--body-file``--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payloadGitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`
- `gh issue update <number> --mode replace|append --body-file <file>` 是正文更新主入口,`edit` 保留为兼容别名。`replace` 用文件正文替换现有 body`append` 先读取当前 body,再按 UTF-8 文件字节追加,保留真实换行、反引号和 Markdown 表格。更新默认拒绝字面量 `null`、空白正文和过短正文;只有真实需要写短正文时才允许显式加 `--allow-short-body`,返回 JSON 会报告该风险。#20 总看板和指挥简报类 issue 是长期 body-only issue`--body-profile auto` 会按 issue number 自动启用 #20/#24 legacy guard#20 必须包含 `## 看板(OPEN`#24 legacy 指挥简报必须包含 `## 常驻观察与长期建议`。显式 `--body-profile commander-brief` 不再固定 #24#24 仍兼容,标题为 `YYYY-MM-DD 指挥简报(北京时间)` 或既有正文首行/关键 heading 表明为每日滚动指挥简报的 issue 也合法,并仍必须包含 `## 常驻观察与长期建议`。对非简报 issue 显式使用 `commander-brief` 会结构化失败为 `profile-issue-mismatch``--dry-run` 不 PATCH GitHub,输出有界 `bodyPreview`/`bodyPreviewLines`、新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格、shell 污染信号、`guard``concurrency``bodyOnlySafety``wouldPatch`;若环境里有 `GH_TOKEN``GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入默认返回 compact issue 摘要,不包含完整 `issue.body`,只包含 number/title/state/url/updatedAt、bodyChars/bodySha/bodyPreview/bodyPreviewLines、guard/concurrency 和 `readCommands`;完整正文必须显式 `--full|--raw` 或后续执行 `readCommands.body/full/raw` 获取。正式写入可带 `--expect-updated-at <updated_at>``--expect-body-sha <sha256>`,CLI 会先读当前 issue,匹配后才 PATCH,防止旧缓存覆盖新正文
- `gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON`view` 只保留为兼容别名。`owner/repo#number` shorthand 会自动派生 `--repo owner/repo` 和 issue number;若同时提供冲突的显式 `--repo`CLI 必须结构化失败并给出 `gh issue read <number> --repo owner/repo --json body,title,state,comments` 与 shorthand raw 的可执行命令。兼容旧脚本的 `--json body``--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed``--raw``--full` 是显式完整披露别名:read/view 会选择完整支持字段集;issue update/edit 只有显式传入时才在成功响应里包含完整 `.data.issue.body`。当最终 `gh` JSON 超过 20 KiB 时,CLI 必须把完整 JSON 写入 `/tmp/unidesk-cli-output/*.json`stdout 只返回 `outputTruncated=true`、dump path、总 bytes/lines 和 head/tail 预览。默认 list/read 输出仍不得扩散到无界非 JSON 文本。`gh issue create --title <title> --body-file <file|-> [--label label[,label...]]... [--dry-run]``gh issue update <number> --mode replace|append --body-file <file|-> [--title ...] [--dry-run] [--full|--raw]``gh issue comment create <number> (--body-file <file|->|--body <short-text>) [--dry-run]``gh issue comment delete <commentId> [--dry-run]``gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--body` 仅用于 issue comment 的短单行文本;空白、多行、疑似 shell 污染、secret-like 或过长 inline body 必须结构化失败,Markdown/生成内容/长评论继续用 `--body-file <file|->``--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payloadGitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`
- `gh issue update <number> --mode replace|append --body-file <file|->` 是正文更新主入口,`edit` 保留为兼容别名。`replace` 用文件或 stdin 正文替换现有 body`append` 先读取当前 body,再按 UTF-8 文件或 stdin 字节追加,保留真实换行、反引号和 Markdown 表格。更新默认拒绝字面量 `null`、空白正文和过短正文;只有真实需要写短正文时才允许显式加 `--allow-short-body`,返回 JSON 会报告该风险。#20 总看板和指挥简报类 issue 是长期 body-only issue`--body-profile auto` 会按 issue number 自动启用 #20/#24 legacy guard#20 必须包含 `## 看板(OPEN`#24 legacy 指挥简报必须包含 `## 常驻观察与长期建议`。显式 `--body-profile commander-brief` 不再固定 #24#24 仍兼容,标题为 `YYYY-MM-DD 指挥简报(北京时间)` 或既有正文首行/关键 heading 表明为每日滚动指挥简报的 issue 也合法,并仍必须包含 `## 常驻观察与长期建议`。对非简报 issue 显式使用 `commander-brief` 会结构化失败为 `profile-issue-mismatch``--dry-run` 不 PATCH GitHub,输出有界 `bodyPreview`/`bodyPreviewLines`、新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格、shell 污染信号、`guard``concurrency``bodyOnlySafety``wouldPatch`;若环境里有 `GH_TOKEN``GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入默认先读取当前 issue,执行 guard 和显式 `--expect-*` 并发校验,再 PATCH;成功输出 compact issue 摘要、old/new body SHA、updatedAt、bodySource 和 drill-down `readCommands`,不包含完整 `issue.body`。完整正文必须显式 `--full|--raw` 或后续执行 `readCommands.body/full/raw` 获取
- #20 只允许承担长期 UniDesk 指挥官 / Code Queue / CLI / infra 治理总看板职责;每日进展必须写入当天滚动指挥简报 issue,并由 #20 顶部“指挥简报索引”引用。HWLAB 用户反馈、Cloud Workbench、DEV-LIVE、M3 虚拟硬件可信闭环等产品 issue 必须写到 `pikasTech/HWLAB`#20 只可记录 UniDesk 侧 commander/Code Queue/CLI/infra 支撑工作。`gh issue read/view 20` 会返回 `codeQueueBoardHint``gh issue update/edit 20` 的 body guard 会拒绝 `## 更新 YYYY-MM-DD HH:mm 北京时间``## YYYY-MM-DD HH:mm 北京时间指挥更新``### YYYY-MM-DD HH:mm CST...` 这类简报段落;把 `pikasTech/HWLAB#N``HWLAB#N` 或 HWLAB 产品/live 验证行写入 #20 时只返回 warning 和 `codeQueueBoardHint`,不再拒绝正文 replace,以避免历史正文或治理交叉引用造成次生阻塞;`gh issue board-row list|get|update|add|move|delete|upsert --board-issue 20` 也会返回同一 hint,提醒不要把每日简报或 HWLAB 产品看板混入 #20
- `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是 legacy #24 指挥简报的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update <number> --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL` 只接受 backend-core `/api/microservices/claudeqq/proxy` 等价路径,非 proxy URL 会结构化为 `notification-path-unavailable`。可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 调整开关、目标和超时。
- `gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]` 是总看板只读结构审计入口,默认 repo 为 `pikasTech/unidesk`、board issue 为 `20`、输出 JSON 且不 PATCH/POST/DELETE GitHub。它只读取目标 board issue 正文,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表是否覆盖全部 issue。兼容字段 `missingOpenIssues``closedInOpenRows``missingClosedRows``openInClosedRows``rowValidationWarnings``ignoredIssues``recommendedActions` 仍保留,但固定为空数组或 0,用于避免旧调用方因字段缺失失败。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list|get|update|add|move|delete|upsert` 的行级结构化入口。
@@ -112,7 +112,7 @@ UniDesk 仓库自带 `scripts/playwright-cli.ts` 作为 host commander 浏览器
`microservice proxy` 是面向人工验证和受控调试的私有后端入口。默认 method 为 GET;使用 `--body-json JSON``--body-file path``--body-stdin` 时默认 method 切换为 POST,也可显式加 `--method POST|PUT|PATCH|DELETE`,但 GET/HEAD 不允许携带请求体。所有请求仍受 config 中的 `allowedMethods``allowedPathPrefixes` 限制。为了避免 Pipeline snapshot 这类超大业务 JSON 造成 CLI 输出爆炸,响应 body 超过默认阈值时会返回 `bodyOmitted=true``bodyPreview``bodyBytes``rawHint``--raw` 仍受默认硬限额保护,需要完整 body 时显式添加 `--raw --full`,或用 `--max-body-bytes <N>` 调整预览阈值。正式 frontend 展示仍应优先使用业务控件和 `__unideskArrayLimit` 这类展示级裁剪参数,而不是默认倾倒完整 JSON。
GitHub issue/PR 写操作必须优先使用 `bun scripts/cli.ts gh issue|pr ... --body-file <file>`。不要把 Markdown 正文拼进 shell 参数或 `gh api -f body=...`;这些路径容易把真实换行污染成字面量 `\n`。从 shell 生成正文文件时使用 quoted heredoc,例如 `cat <<'EOF' > /tmp/body.md`,保证反引号、反斜杠和 Markdown 表格不被 shell 展开;之后再把文件交给 `--body-file``gh issue` 写命令不接受 stdin 正文,`gh issue comment create --body-file -` 也不支持;需要从生成内容写入 issue 或 issue comment 时,先落到临时 Markdown 文件或已审阅的工作文件,再传给 `--body-file``gh issue comment create --body <short-text>` 只适合人工短单行评论,默认输出只给 bounded preview、bodyChars、bodySha、source 和 readCommands,不回显长正文;同时传 `--body``--body-file` 必须结构化失败。PR 安全写入口同样优先 `--body-file``gh pr edit/update --body-file -` 可从 stdin 读取已审阅 Markdown,适合 runner 管道化更新 PR title/body。`--body` 只适合短单行内容。JSON 请求体场景使用各命名空间自己的 `--body-file``--body-stdin`,避免长 JSON 直接塞进 shell 参数GitHub issue Markdown 写入仍只走 `--body-file``update --mode append` 用 REST 读取旧正文后追加文件字节,不引入 shell 拼接正文路径。`gh pr merge` 是 guarded write:先读 closeout metadata 并拒绝非 ready PR`--dry-run` 只输出计划不写远端;没有 `--confirm` 之类绕过 preflight 的路径。CLI 会按 UTF-8 原样读取文件或 stdin 内容并用 JSON body 调用 REST APIPR edit/update 输出不会默认回显完整正文。
GitHub issue/PR 写操作必须优先使用 `bun scripts/cli.ts gh issue|pr ... --body-file <file|->`。不要把 Markdown 正文拼进 shell 参数或 `gh api -f body=...`;这些路径容易把真实换行污染成字面量 `\n`。从 shell 生成正文文件时使用 quoted heredoc,例如 `cat <<'EOF' > /tmp/body.md`,保证反引号、反斜杠和 Markdown 表格不被 shell 展开;一次性写入可直接走 stdin,例如 `cat /tmp/body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file -``cat /tmp/body.md | bun scripts/cli.ts gh issue comment create <number> --repo owner/name --body-file -``gh issue update/edit` 正式写入默认先读取当前 issue 元数据,执行 body guard 并在结果里返回旧 `bodySha`/`updatedAt` 与新正文摘要;一般看板和评论写入不需要人工先 `read` 再手填 `--expect-body-sha`。对高风险整篇替换仍可显式加 `--expect-body-sha``--expect-updated-at`,CLI 会在 PATCH 前校验,不匹配则结构化失败`gh issue comment create --body <short-text>` 只适合人工短单行评论,默认输出只给 bounded preview、bodyChars、bodySha、source 和 readCommands,不回显长正文;同时传 `--body``--body-file` 必须结构化失败。PR 安全写入口同样支持 `--body-file <file|->``--body` 只适合短单行内容。JSON 请求体场景使用各命名空间自己的 `--body-file``--body-stdin`,避免长 JSON 直接塞进 shell 参数。`update --mode append` 用 REST 读取旧正文后追加文件或 stdin 字节,不引入 shell 拼接正文路径。`gh pr merge` 是 guarded write:先读 closeout metadata 并拒绝非 ready PR`--dry-run` 只输出计划不写远端;没有 `--confirm` 之类绕过 preflight 的路径。CLI 会按 UTF-8 原样读取文件或 stdin 内容并用 JSON body 调用 REST APIissue/PR 写入输出不会默认回显完整正文。
`network perf` 用于生成组网性能前后对比数据。标准 Code Queue overview 读路径基准命令是 `bun scripts/cli.ts network perf --service code-queue --path /api/tasks/overview?limit=30 --count 30 --concurrency 1 --label before`,远程主 server 可用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 network perf ...`。输出包含成功/失败数、状态码分布、`x-unidesk-cache``x-unidesk-proxy-mode``x-unidesk-upstream-proxy-mode` 分布和 min/p50/p90/p95/maxprovider-gateway 长连接数据面验收应看到 `proxyModeCounts.provider-ws-http-tunnel`adapter native Service 数据面验收应看到 upstream proxy mode 为 `kubernetes-native-service`,若出现 `kubernetes-api-service-proxy` 必须结合 `/api/control-plane.nativeServiceProxy.failedServices` 解释 fallback 原因。
+42 -9
View File
@@ -660,7 +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("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("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 });
@@ -1502,6 +1502,22 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const issueCreatePayload = JSON.parse(issueCreateRequest?.body ?? "{}") as JsonRecord;
assertCondition(Array.isArray(issueCreatePayload.labels) && (issueCreatePayload.labels as unknown[]).join(",") === "cli,infra,ops", "issue create REST payload should include labels", issueCreatePayload);
const issueCreateStdinBody = "# stdin issue create\n\n- preserves `code`\n";
const issueCreateStdinRequestCountBefore = mock.requests.length;
const issueCreateStdin = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "stdin issue create", "--body-file", "-", "--dry-run"], env, issueCreateStdinBody);
assertCondition(issueCreateStdin.status === 0, "issue create dry-run should accept --body-file - stdin", issueCreateStdin.json ?? { stdout: issueCreateStdin.stdout });
const issueCreateStdinData = dataOf(issueCreateStdin.json ?? {});
const issueCreateStdinSource = issueCreateStdinData.bodySource as JsonRecord;
assertCondition(issueCreateStdinSource.kind === "stdin" && issueCreateStdinSource.path === "-", "issue create stdin dry-run should expose stdin source", issueCreateStdinData);
assertCondition(issueCreateStdinData.containsBackticks === true && issueCreateStdinData.containsLiteralBackslashN === false, "issue create stdin should preserve Markdown signals", issueCreateStdinData);
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 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);
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 });
const missingLabelData = failedDataOf(issueCreateMissingLabel.json ?? {});
@@ -1547,7 +1563,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(typeof compactIssue.bodySha === "string" && String(compactIssue.bodySha).length === 64, "compact issue summary should include bodySha", compactIssue);
assertCondition(String(compactIssue.bodyPreview ?? "").includes("compact-success-line-0001") && !String(compactIssue.bodyPreview ?? "").includes("compact-success-line-0260"), "compact issue summary should include only bounded preview", compactIssue);
const compactConcurrency = compactUpdateData.concurrency as JsonRecord;
assertCondition(compactConcurrency.checked === false && compactConcurrency.expectBodySha === null, "compact update should still report concurrency summary", compactConcurrency);
assertCondition(compactConcurrency.checked === true && typeof compactConcurrency.oldBodySha === "string" && compactConcurrency.expectBodySha === null, "compact update should automatically read old issue metadata before PATCH", compactConcurrency);
const compactGuard = compactUpdateData.guard as JsonRecord;
assertCondition(compactGuard.ok === true && typeof compactGuard.bodySha === "string", "compact update should keep guard/body sha summary", compactGuard);
const compactDisclosure = compactUpdateData.disclosure as JsonRecord;
@@ -1558,6 +1574,20 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const compactUpdatePatchCount = mock.requests.slice(compactUpdateRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/HWLAB/issues/7").length;
assertCondition(compactUpdatePatchCount === 1, "compact update should PATCH GitHub exactly once", { requests: mock.requests.slice(compactUpdateRequestCountBefore) });
const stdinIssueBody = "# Code Queue\n\n## 看板(OPEN\n\n- stdin issue body keeps `code`.\n\n| s | t |\n| --- | --- |\n| 5 | 6 |\n";
const stdinUpdateRequestCountBefore = mock.requests.length;
const stdinUpdate = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", "-"], env, stdinIssueBody);
assertCondition(stdinUpdate.status === 0, "issue update should accept --body-file - stdin", stdinUpdate.json ?? { stdout: stdinUpdate.stdout, stderr: stdinUpdate.stderr });
const stdinUpdateData = dataOf(stdinUpdate.json ?? {});
const stdinUpdateBodySource = stdinUpdateData.bodySource as JsonRecord;
assertCondition(stdinUpdateBodySource.kind === "stdin" && stdinUpdateBodySource.path === "-", "stdin issue update should report stdin bodySource", stdinUpdateData);
const stdinUpdateConcurrency = stdinUpdateData.concurrency as JsonRecord;
assertCondition(stdinUpdateConcurrency.checked === true && typeof stdinUpdateConcurrency.oldBodySha === "string", "stdin issue update should automatically read current issue metadata before PATCH", stdinUpdateConcurrency);
const stdinUpdatePatch = mock.requests.slice(stdinUpdateRequestCountBefore).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20");
assertCondition(stdinUpdatePatch !== undefined, "stdin issue update should PATCH issue body", { requests: mock.requests.slice(stdinUpdateRequestCountBefore) });
const stdinUpdatePayload = JSON.parse(stdinUpdatePatch?.body ?? "{}") as JsonRecord;
assertCondition(stdinUpdatePayload.body === stdinIssueBody, "stdin issue update should preserve exact stdin Markdown", stdinUpdatePayload);
const explicitFullBody = "# compact full body\n\nThis body is intentionally short enough to avoid global dump while still proving explicit disclosure.\n";
const explicitFullFile = join(tmp, "explicit-full-body.md");
writeFileSync(explicitFullFile, explicitFullBody, "utf8");
@@ -1640,10 +1670,13 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
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 stdinCommentBody = "stdin comment line 1\n\n- keeps `code`\n";
const stdinComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body-file", "-", "--dry-run"], env, stdinCommentBody);
assertCondition(stdinComment.status === 0, "issue comment body stdin should be supported", stdinComment.json ?? { stdout: stdinComment.stdout });
const stdinCommentData = dataOf(stdinComment.json ?? {});
const stdinCommentSource = stdinCommentData.bodySource as 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 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 });
@@ -1682,7 +1715,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 and exposes request plan",
"issue create dry-run parses repeated/comma labels, supports stdin, 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,comments selection",
@@ -1698,11 +1731,11 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"dry-run reports old/new body safety and does not PATCH",
"multiline Markdown and backticks are not polluted",
"expect-updated-at stale write protection blocks PATCH",
"issue update replace/append modes preserve Markdown",
"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 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 supports stdin 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",
],
+35 -25
View File
@@ -943,12 +943,6 @@ function unknownGhOptionDetails(args: string[], option: string): Record<string,
return details;
}
function readBodyFile(path: string | undefined, command: string): string {
if (path === undefined) throw new Error(`${command} requires --body-file <file>`);
if (!existsSync(path)) throw new Error(`body file not found: ${path}`);
return readFileSync(path, "utf8");
}
function readMarkdownBodyFileOrStdin(path: string): { body: string; bodySource: Record<string, unknown> } {
if (path === "-") {
return { body: readFileSync(0, "utf8"), bodySource: { kind: "stdin", path: "-" } };
@@ -994,7 +988,6 @@ function readIssueCommentBody(options: GitHubOptions): { body: string; bodySourc
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>");
@@ -5026,8 +5019,16 @@ 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>");
const body = readBodyFile(options.bodyFile, "issue create");
const bodySource = { kind: "body-file", path: options.bodyFile ?? null };
if (options.body !== undefined) {
return validationError("issue create", repo, "issue create does not support --body; use --body-file <file|-> for Markdown", {
title: options.title,
bodySource: "inline",
});
}
const { body, bodySource } = readMarkdownBody({
...options,
body: undefined,
}, "issue create");
const labels = options.labels;
if (options.dryRun) {
return {
@@ -5060,7 +5061,16 @@ 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> {
const body = readBodyFile(options.bodyFile, commandName);
if (options.bodyFile === undefined) {
return validationError(commandName, repo, `${commandName} requires --body-file <file|->`, { issueNumber });
}
let bodyInput: { body: string; bodySource: Record<string, unknown> };
try {
bodyInput = readMarkdownBodyFileOrStdin(options.bodyFile);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
const { body, bodySource } = bodyInput;
const needsProfileMetadata = issueProfileNeedsMetadata(issueNumber, options.bodyProfile);
if (options.mode === "replace" && !needsProfileMetadata) {
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options);
@@ -5077,7 +5087,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
}
const needsReadBeforeEdit = options.mode === "append"
|| needsProfileMetadata && token.length > 0
|| !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined);
|| !options.dryRun;
if (needsReadBeforeEdit) {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" });
@@ -5146,7 +5156,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
disclosure: issueWriteDisclosure(options, repo, issueNumber, true),
readCommands: issueBodyReadCommands(repo, issueNumber),
guard,
update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, { kind: "body-file", path: options.bodyFile ?? null }, oldIssue?.body ?? null),
update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, bodySource, oldIssue?.body ?? null),
bodyOnlySafety: {
oldBody: dryRunOldBody,
newBody: {
@@ -5165,7 +5175,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
wouldPatch: {
issueNumber,
title: options.title ?? null,
bodyFromFile: options.bodyFile,
bodySource,
mode: options.mode,
bodyChars: finalBody.length,
bodySha: bodySha(finalBody),
@@ -5197,6 +5207,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
repo,
issue: issueSummary(issue, { includeBody: options.raw || options.full }),
mode: options.mode,
bodySource,
guard,
concurrency,
disclosure: issueWriteDisclosure(options, repo, issueNumber, false),
@@ -5869,11 +5880,11 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]",
"bun scripts/cli.ts gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]",
"bun scripts/cli.ts gh issue view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for issue read]",
"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] [--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 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] [--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>|--body <short-text> [--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]",
@@ -5914,16 +5925,15 @@ export function ghHelp(): unknown {
"issue read is the canonical read path; view remains a compatibility alias. Read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. Read supports 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 read/view/update/edit and gh pr 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.",
"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 repeatable --label values and comma-separated labels; dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
"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.",
"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 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 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 for long or multiline content; PR edit/update also accepts --body-file - for stdin when a runner already has reviewed Markdown on stdin.",
"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 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.",
"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.",
"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 当前关注点.",
@@ -5977,7 +5987,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
supportedCommands: [
"bun scripts/cli.ts gh issue read owner/name#<number> --raw",
"bun scripts/cli.ts gh issue read <number> --repo owner/name --json body,title,state,comments",
"bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file <file> --expect-body-sha <sha> --full",
"cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - --full",
"bun scripts/cli.ts gh pr read owner/name#<number> --raw",
`bun scripts/cli.ts gh pr read <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`,
"bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full",