diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f32a67c4..1055f597 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -35,7 +35,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `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 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。 -- `gh issue read [--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 --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 --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> [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payload,GitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`。 +- `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 payload,GitHub 返回不存在 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,防止旧缓存覆盖新正文。 - #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 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 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` 调整开关、目标和超时。 @@ -98,7 +98,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 issue comment --body` 或 `gh api -f body=...`;这些路径容易把真实换行污染成字面量 `\n`。从 shell 生成正文文件时使用 quoted heredoc,例如 `cat <<'EOF' > /tmp/body.md`,保证反引号、反斜杠和 Markdown 表格不被 shell 展开;之后再把文件交给 `--body-file`。`gh issue` 写命令不接受 stdin 正文;需要从生成内容写入 issue 时,先落到临时 Markdown 文件或已审阅的工作文件,再传给 `--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` 暂不开放,不存在 `--confirm` 可绕过的真实 merge 路径。CLI 会按 UTF-8 原样读取文件或 stdin 内容并用 JSON body 调用 REST API;PR 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 展开;之后再把文件交给 `--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` 暂不开放,不存在 `--confirm` 可绕过的真实 merge 路径。CLI 会按 UTF-8 原样读取文件或 stdin 内容并用 JSON body 调用 REST API;PR edit/update 输出不会默认回显完整正文。 `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/max;provider-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 原因。 diff --git a/scripts/cli.ts b/scripts/cli.ts index bc796101..fdd75eb8 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -114,6 +114,18 @@ function displayCommandName(parts: string[]): string { } return shown.join(" "); } + if (parts[0] === "gh") { + const shown: string[] = []; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index] ?? ""; + shown.push(part); + if (part === "--body") { + shown.push("<body:redacted>"); + index += 1; + } + } + return shown.join(" "); + } return parts.join(" "); } diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts index 7b7202f5..87cd9155 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -17,11 +17,12 @@ function assertCondition(condition: unknown, message: string, detail: unknown = if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); } -function runCli(args: string[], env: Record<string, string> = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> { +function runCli(args: string[], env: Record<string, string> = {}, stdin?: string): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> { return new Promise((resolve, reject) => { const child = spawn("bun", ["scripts/cli.ts", ...args], { cwd: process.cwd(), env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; @@ -43,6 +44,8 @@ function runCli(args: string[], env: Record<string, string> = {}): Promise<{ sta json, }); }); + if (stdin !== undefined) child.stdin.end(stdin); + else child.stdin.end(); }); } @@ -58,6 +61,10 @@ function failedDataOf(response: JsonRecord): JsonRecord { return response.data as JsonRecord; } +function failureMessageOf(data: JsonRecord): string { + return String((data.details as JsonRecord | undefined)?.message ?? data.message ?? ""); +} + function collectBody(req: IncomingMessage): Promise<string> { return new Promise((resolve) => { const chunks: Buffer[] = []; @@ -582,6 +589,11 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque sendJson(res, 201, { id: 9001, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/20#issuecomment-9001", user: { login: "tester" }, created_at: "2026-05-20T06:00:00Z", updated_at: "2026-05-20T06:00:00Z" }); return; } + if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues/36/comments") { + const parsed = JSON.parse(body) as JsonRecord; + sendJson(res, 201, { id: 9002, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/36#issuecomment-9002", user: { login: "tester" }, created_at: "2026-05-20T06:02:00Z", updated_at: "2026-05-20T06:02:00Z" }); + return; + } if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues") { const parsed = JSON.parse(body) as JsonRecord; const labels = Array.isArray(parsed.labels) ? parsed.labels.map(String) : []; @@ -636,6 +648,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { assertCondition(usage.some((line) => line.includes("gh issue list")), "gh help should list issue list", { usage }); assertCondition(usage.some((line) => line.includes("gh issue read")), "gh help should list issue read", { usage }); assertCondition(usage.some((line) => line.includes("gh issue view")), "gh help should list issue view", { usage }); + assertCondition(usage.some((line) => line.includes("gh issue comment create") && line.includes("--body <short-text>")), "gh help should list short inline issue comment body", { usage }); assertCondition(usage.some((line) => line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document issue shorthand and raw/full disclosure", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row list")), "gh help should list board-row list", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row update")), "gh help should list board-row update", { usage }); @@ -647,6 +660,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { assertCondition(notes.some((line) => line.includes("compatibility alias")), "gh help should state issue view is alias", { notes }); assertCondition(notes.some((line) => line.includes("owner/repo#number shorthand")), "gh help should explain read/view shorthand", { notes }); assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes }); + assertCondition(notes.some((line) => line.includes("issue comment create accepts --body only for short single-line text")), "gh help should document issue comment inline safety limits", { notes }); assertCondition(notes.some((line) => line.includes("board-row update changes one table cell")), "gh help should describe board-row update safety", { notes }); assertCondition(notes.some((line) => line.includes("board-row upsert updates an existing row")), "gh help should describe board-row upsert safety", { notes }); assertCondition(notes.some((line) => line.includes("board-row add/move/delete are row-scoped")), "gh help should describe board-row row mutation safety", { notes }); @@ -1559,6 +1573,77 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { assertCondition(commentCreate.status === 0, "issue comment create should succeed", commentCreate.json ?? { stdout: commentCreate.stdout }); const commentCreateData = dataOf(commentCreate.json ?? {}); assertCondition(commentCreateData.command === "issue comment create", "comment create should use CRUD command name", commentCreateData); + assertCondition(commentCreateData.source === "body-file" && typeof commentCreateData.bodySha === "string", "issue comment body-file write should expose low-noise source and bodySha", commentCreateData); + const commentCreateSummary = commentCreateData.comment as JsonRecord; + assertCondition(commentCreateSummary.bodyOmitted === true && !("body" in commentCreateSummary), "issue comment write should not echo full comment body by default", commentCreateSummary); + + const inlineBody = "短评:已完成 #76 CLI inline body dry-run"; + const inlineDryRunRequestCountBefore = mock.requests.length; + const inlineDryRun = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", inlineBody, "--dry-run"], env); + assertCondition(inlineDryRun.status === 0, "issue comment inline body dry-run should succeed", inlineDryRun.json ?? { stdout: inlineDryRun.stdout }); + assertCondition(inlineDryRun.json?.command === "gh issue comment create 36 --repo pikasTech/unidesk --body <body:redacted> --dry-run", "outer gh command should redact inline body", inlineDryRun.json ?? {}); + const inlineDryRunData = dataOf(inlineDryRun.json ?? {}); + assertCondition(inlineDryRunData.dryRun === true && inlineDryRunData.planned === true, "inline issue comment dry-run should be planned", inlineDryRunData); + assertCondition(inlineDryRunData.issueNumber === 36 && inlineDryRunData.source === "inline", "inline issue comment dry-run should preserve issue number and source", inlineDryRunData); + const inlineDryRunSource = inlineDryRunData.bodySource as JsonRecord; + assertCondition(inlineDryRunSource.kind === "inline" && inlineDryRunSource.maxInlineBodyChars === 1000, "inline issue comment dry-run should expose inline source policy", inlineDryRunSource); + assertCondition(Number(inlineDryRunData.bodyChars ?? 0) === inlineBody.length && typeof inlineDryRunData.bodySha === "string", "inline issue comment dry-run should expose bodyChars/bodySha", inlineDryRunData); + assertCondition(String(inlineDryRunData.bodyPreview ?? "") === inlineBody, "inline issue comment dry-run should provide bounded preview for short text", inlineDryRunData); + const inlineDryRunReadCommands = inlineDryRunData.readCommands as JsonRecord; + assertCondition(String(inlineDryRunReadCommands.comments ?? "").includes("gh issue read 36") && String(inlineDryRunReadCommands.comments ?? "").includes("--json comments"), "inline issue comment dry-run should expose comment read command", inlineDryRunReadCommands); + const inlineDryRunWriteCount = mock.requests.slice(inlineDryRunRequestCountBefore).filter((request) => request.method === "POST" && request.url.includes("/comments")).length; + assertCondition(inlineDryRunWriteCount === 0, "inline issue comment dry-run must not POST GitHub", { requests: mock.requests.slice(inlineDryRunRequestCountBefore) }); + + const inlineWriteRequestCountBefore = mock.requests.length; + const inlineWrite = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", inlineBody], env); + assertCondition(inlineWrite.status === 0, "issue comment inline body write should succeed", inlineWrite.json ?? { stdout: inlineWrite.stdout }); + assertCondition(inlineWrite.json?.command === "gh issue comment create 36 --repo pikasTech/unidesk --body <body:redacted>", "outer gh command should redact inline body on write", inlineWrite.json ?? {}); + const inlineWriteData = dataOf(inlineWrite.json ?? {}); + assertCondition(inlineWriteData.command === "issue comment create" && inlineWriteData.source === "inline", "inline issue comment write should report source=inline", inlineWriteData); + assertCondition(Number(inlineWriteData.bodyChars ?? 0) === inlineBody.length && typeof inlineWriteData.bodySha === "string", "inline issue comment write should expose bounded body metadata", inlineWriteData); + const inlineWriteComment = inlineWriteData.comment as JsonRecord; + assertCondition(inlineWriteComment.bodyOmitted === true && inlineWriteComment.bodyPreview === inlineBody && !("body" in inlineWriteComment), "inline issue comment write should summarize without full body field", inlineWriteComment); + const inlinePost = mock.requests.slice(inlineWriteRequestCountBefore).find((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues/36/comments"); + assertCondition(inlinePost !== undefined, "inline issue comment write should POST comments REST endpoint", { requests: mock.requests.slice(inlineWriteRequestCountBefore) }); + const inlinePayload = JSON.parse(inlinePost?.body ?? "{}") as JsonRecord; + assertCondition(inlinePayload.body === inlineBody, "inline issue comment REST payload should preserve short text", inlinePayload); + + const missingCommentBody = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--dry-run"], env); + assertCondition(missingCommentBody.status !== 0, "issue comment create without body source should fail", missingCommentBody.json ?? { stdout: missingCommentBody.stdout }); + const missingCommentBodyData = failedDataOf(missingCommentBody.json ?? {}); + assertCondition(missingCommentBodyData.degradedReason === "validation-failed" && failureMessageOf(missingCommentBodyData).includes("requires --body-file <file> or --body <text>"), "missing issue comment body should be structured validation failure", missingCommentBodyData); + + const mutualCommentBody = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "inline", "--body-file", appendFile, "--dry-run"], env); + assertCondition(mutualCommentBody.status !== 0, "issue comment create with body and body-file should fail", mutualCommentBody.json ?? { stdout: mutualCommentBody.stdout }); + const mutualCommentBodyData = failedDataOf(mutualCommentBody.json ?? {}); + assertCondition(mutualCommentBodyData.degradedReason === "validation-failed" && failureMessageOf(mutualCommentBodyData).includes("accepts only one body source"), "mutual issue comment body sources should be rejected", mutualCommentBodyData); + + const blankInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", " ", "--dry-run"], env); + assertCondition(blankInlineComment.status !== 0, "blank inline issue comment body should fail", blankInlineComment.json ?? { stdout: blankInlineComment.stdout }); + const blankInlineCommentData = failedDataOf(blankInlineComment.json ?? {}); + assertCondition(failureMessageOf(blankInlineCommentData).includes("must not be blank"), "blank inline issue comment body should name blank-body reason", blankInlineCommentData); + + const multilineInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "line1\nline2", "--dry-run"], env); + assertCondition(multilineInlineComment.status !== 0, "multiline inline issue comment body should fail", multilineInlineComment.json ?? { stdout: multilineInlineComment.stdout }); + const multilineInlineCommentData = failedDataOf(multilineInlineComment.json ?? {}); + assertCondition(failureMessageOf(multilineInlineCommentData).includes("single-line text only"), "multiline inline issue comment body should point to body-file", multilineInlineCommentData); + + const pollutedInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "literal \\n pollution", "--dry-run"], env); + assertCondition(pollutedInlineComment.status !== 0, "polluted inline issue comment body should fail", pollutedInlineComment.json ?? { stdout: pollutedInlineComment.stdout }); + const pollutedInlineCommentData = failedDataOf(pollutedInlineComment.json ?? {}); + assertCondition(failureMessageOf(pollutedInlineCommentData).includes("shell-pollution signals"), "polluted inline issue comment body should report shell pollution", pollutedInlineCommentData); + + const secretInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "token=ghp_1234567890abcdef", "--dry-run"], env); + assertCondition(secretInlineComment.status !== 0, "secret-like inline issue comment body should fail", secretInlineComment.json ?? { stdout: secretInlineComment.stdout }); + assertCondition(!secretInlineComment.stdout.includes("ghp_1234567890abcdef") && !secretInlineComment.stderr.includes("ghp_1234567890abcdef"), "secret-like inline issue comment failure must not print token value", { + stdout: secretInlineComment.stdout, + stderr: secretInlineComment.stderr, + }); + + const stdinInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body-file", "-", "--dry-run"], env, "stdin body"); + assertCondition(stdinInlineComment.status !== 0, "issue comment body stdin should remain unsupported", stdinInlineComment.json ?? { stdout: stdinInlineComment.stdout }); + const stdinInlineCommentData = failedDataOf(stdinInlineComment.json ?? {}); + assertCondition(failureMessageOf(stdinInlineCommentData).includes("does not support --body-file - stdin"), "issue comment stdin rejection should be explicit", stdinInlineCommentData); const commentDeleteDryRun = await runCli(["gh", "issue", "comment", "delete", "9001", "--repo", "pikasTech/unidesk", "--dry-run"], env); assertCondition(commentDeleteDryRun.status === 0, "issue comment delete dry-run should succeed", commentDeleteDryRun.json ?? { stdout: commentDeleteDryRun.stdout }); @@ -1616,6 +1701,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { "issue update replace/append modes preserve Markdown", "issue update non-dry-run success defaults to compact output without full issue.body and exposes bodySha plus drill-down commands", "issue update --full explicitly includes full issue.body", + "issue comment create supports short inline --body dry-run and write with bounded output", + "issue comment create still rejects missing, blank, multiline, polluted, secret-like, stdin, and mixed body sources", "issue comment create/delete follows CRUD shape", "issue hard delete is structurally unsupported", ], diff --git a/scripts/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts index 3a3fedaa..7a70a648 100644 --- a/scripts/gh-cli-pr-contract-test.ts +++ b/scripts/gh-cli-pr-contract-test.ts @@ -662,6 +662,17 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { const commentCreateData = dataOf(commentCreate.json ?? {}); assertCondition(commentCreateData.command === "pr comment create", "pr comment create should use CRUD command name", commentCreateData); + const prInlineCommentBody = "short PR inline comment remains supported"; + const prInlineComment = await runCli(["gh", "pr", "comment", "create", "42", "--repo", "pikasTech/unidesk", "--body", prInlineCommentBody], env2); + assertCondition(prInlineComment.status === 0, "pr comment create --body should remain supported", prInlineComment.json ?? { stdout: prInlineComment.stdout }); + assertCondition(prInlineComment.json?.command === "gh pr comment create 42 --repo pikasTech/unidesk --body <body:redacted>", "outer gh command should redact PR inline comment body", prInlineComment.json ?? {}); + const prInlineCommentData = dataOf(prInlineComment.json ?? {}); + assertCondition(prInlineCommentData.command === "pr comment create", "pr inline comment should use CRUD command name", prInlineCommentData); + const prInlineCommentRequest = mock2.requests.filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues/42/comments").at(-1); + assertCondition(prInlineCommentRequest !== undefined, "pr inline comment should POST to issue comments endpoint", mock2.requests); + const prInlinePayload = JSON.parse(prInlineCommentRequest?.body ?? "{}") as JsonRecord; + assertCondition(prInlinePayload.body === prInlineCommentBody, "pr inline comment payload should preserve --body text", prInlinePayload); + const commentDelete = await runCli(["gh", "pr", "comment", "delete", "9101", "--repo", "pikasTech/unidesk"], env2); assertCondition(commentDelete.status === 0, "pr comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout }); const commentDeleteData = dataOf(commentDelete.json ?? {}); @@ -723,7 +734,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { "pr update/edit use low-noise REST PATCH without GraphQL projectCards", "pr edit supports --body-file - stdin without echoing full body", "pr update append and close/reopen are available", - "pr comment create/delete follows CRUD shape", + "pr comment create/delete follows CRUD shape and --body remains supported", "pr merge is blocked", "pr hard delete is blocked", "pr create validation failures are structured", diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 83078734..c8a80545 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -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 当前关注点.",