diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 246e8389..f32a67c4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -35,8 +35,8 @@ 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 上可用,是显式完整披露别名,会选择完整支持字段集并保持结构化 JSON 输出;当最终 `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]`、`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 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,输出新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格和 shell 污染信号;若环境里有 `GH_TOKEN` 或 `GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入可带 `--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> [--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 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` 调整开关、目标和超时。 - `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` 的行级结构化入口。 diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts index 1284ccf1..7b7202f5 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -74,6 +74,8 @@ function sendJson(res: ServerResponse, status: number, payload: unknown): void { async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise<void> }> { const requests: MockRequest[] = []; + let shorthandIssueBody = "HWLAB-style shorthand body fixture\n\nThis is generic CLI coverage, not product data."; + let shorthandIssueUpdatedAt = "2026-05-20T03:00:00Z"; const issue = { id: 2000, number: 20, @@ -90,13 +92,13 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque id: 7000, number: 7, title: "generic shorthand fixture", - body: "HWLAB-style shorthand body fixture\n\nThis is generic CLI coverage, not product data.", + body: shorthandIssueBody, state: "open", html_url: "https://github.com/pikasTech/HWLAB/issues/7", comments: 1, user: { login: "tester" }, created_at: "2026-05-20T02:00:00Z", - updated_at: "2026-05-20T03:00:00Z", + updated_at: shorthandIssueUpdatedAt, }; const boardIssueBodyInitial = [ "# Code Queue", @@ -469,7 +471,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque return; } if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/issues/7") { - sendJson(res, 200, shorthandIssue); + sendJson(res, 200, { ...shorthandIssue, body: shorthandIssueBody, updated_at: shorthandIssueUpdatedAt }); return; } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/24") { @@ -568,6 +570,13 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque sendJson(res, 200, { ...upsertBoardIssue, body: upsertBoardIssueBody, updated_at: upsertBoardIssueUpdatedAt }); return; } + if (req.method === "PATCH" && req.url === "/repos/pikasTech/HWLAB/issues/7") { + const parsed = JSON.parse(body) as JsonRecord; + shorthandIssueBody = String(parsed.body ?? shorthandIssueBody); + shorthandIssueUpdatedAt = "2026-05-20T03:05:00Z"; + sendJson(res, 200, { ...shorthandIssue, body: shorthandIssueBody, updated_at: shorthandIssueUpdatedAt }); + return; + } if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues/20/comments") { const parsed = JSON.parse(body) as JsonRecord; 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" }); @@ -1499,6 +1508,52 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { assertCondition(replaceDryRun.status === 0, "issue update replace dry-run should succeed", replaceDryRun.json ?? { stdout: replaceDryRun.stdout }); const replaceData = dataOf(replaceDryRun.json ?? {}); assertCondition(replaceData.command === "issue update" && replaceData.mode === "replace", "replace mode should be explicit", replaceData); + const replaceDisclosure = replaceData.disclosure as JsonRecord; + const replaceReadCommands = replaceData.readCommands as JsonRecord; + assertCondition(replaceDisclosure.bodyOmitted === true && replaceDisclosure.dryRunBoundedPreview === true, "issue update dry-run should disclose compact body policy", replaceDisclosure); + assertCondition(typeof replaceReadCommands.full === "string" && String(replaceReadCommands.full).includes("gh issue read 20"), "issue update dry-run should expose full body drill-down", replaceReadCommands); + const replaceWouldPatch = replaceData.wouldPatch as JsonRecord; + assertCondition(typeof replaceWouldPatch.bodySha === "string" && String(replaceWouldPatch.bodySha).length === 64, "issue update dry-run should include wouldPatch body sha", replaceWouldPatch); + assertCondition(Number(replaceWouldPatch.bodyChars ?? 0) === Number(replaceData.bodyChars ?? 0), "issue update dry-run wouldPatch should include final body chars", replaceWouldPatch); + + const compactLongBody = Array.from({ length: 260 }, (_, index) => `compact-success-line-${String(index + 1).padStart(4, "0")} ${"x".repeat(80)}`).join("\n"); + const compactLongFile = join(tmp, "compact-long-body.md"); + writeFileSync(compactLongFile, compactLongBody, "utf8"); + const compactUpdateRequestCountBefore = mock.requests.length; + const compactUpdate = await runCli(["gh", "issue", "update", "7", "--repo", "pikasTech/HWLAB", "--mode", "replace", "--body-file", compactLongFile], env); + assertCondition(compactUpdate.status === 0, "issue update non-dry-run compact success should succeed", compactUpdate.json ?? { stdout: compactUpdate.stdout, stderr: compactUpdate.stderr }); + assertCondition(compactUpdate.stdout.length < 20_000, "issue update compact success stdout should stay bounded for long bodies", { bytes: compactUpdate.stdout.length }); + assertCondition(!compactUpdate.stdout.includes("compact-success-line-0260"), "default issue update success stdout must not echo the full long body tail", { tail: compactUpdate.stdout.slice(-1000) }); + const compactUpdateData = dataOf(compactUpdate.json ?? {}); + const compactIssue = compactUpdateData.issue as JsonRecord; + assertCondition(compactUpdateData.command === "issue update" && compactUpdateData.rest === true, "compact update should report REST success", compactUpdateData); + assertCondition(!("body" in compactIssue), "default issue update success should omit issue.body", compactIssue); + assertCondition(compactIssue.bodyOmitted === true && compactIssue.fullBodyIncluded === false, "compact issue summary should mark omitted full body", compactIssue); + assertCondition(Number(compactIssue.bodyChars ?? 0) === compactLongBody.length, "compact issue summary should include bodyChars", compactIssue); + 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); + 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; + assertCondition(compactDisclosure.bodyOmitted === true && compactDisclosure.fullBodyIncluded === false && compactDisclosure.defaultCompact === true, "compact update disclosure should be explicit", compactDisclosure); + const compactCommands = compactUpdateData.readCommands as JsonRecord; + assertCondition(String(compactCommands.body ?? "").includes("gh issue read 7 --repo pikasTech/HWLAB --json body"), "compact update should expose body read command", compactCommands); + assertCondition(String(compactCommands.full ?? "").includes("--full") && String(compactCommands.raw ?? "").includes("--raw"), "compact update should expose full/raw drill-down", compactCommands); + 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 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"); + const explicitFullUpdate = await runCli(["gh", "issue", "update", "7", "--repo", "pikasTech/HWLAB", "--mode", "replace", "--body-file", explicitFullFile, "--full"], env); + assertCondition(explicitFullUpdate.status === 0, "issue update --full should succeed", explicitFullUpdate.json ?? { stdout: explicitFullUpdate.stdout, stderr: explicitFullUpdate.stderr }); + const explicitFullData = dataOf(explicitFullUpdate.json ?? {}); + const explicitFullIssue = explicitFullData.issue as JsonRecord; + assertCondition(typeof explicitFullIssue.body === "string" && explicitFullIssue.body === explicitFullBody, "issue update --full should explicitly include the full body", explicitFullIssue); + const explicitFullDisclosure = explicitFullData.disclosure as JsonRecord; + assertCondition(explicitFullDisclosure.fullBodyIncluded === true && explicitFullDisclosure.explicitFullDisclosure === true, "issue update --full disclosure should mark full body inclusion", explicitFullDisclosure); const commentCreate = await runCli(["gh", "issue", "comment", "create", "20", "--repo", "pikasTech/unidesk", "--body-file", appendFile], env); assertCondition(commentCreate.status === 0, "issue comment create should succeed", commentCreate.json ?? { stdout: commentCreate.stdout }); @@ -1559,6 +1614,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { "multiline Markdown and backticks are not polluted", "expect-updated-at stale write protection blocks PATCH", "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/delete follows CRUD shape", "issue hard delete is structurally unsupported", ], diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index d07e028f..83078734 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -1810,18 +1810,54 @@ function isGitHubError(value: unknown): value is GitHubErrorPayload { return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === false && "degradedReason" in value; } -function issueSummary(issue: GitHubIssue): Record<string, unknown> { +function issueBodyReadCommands(repo: string, issueNumber: number): Record<string, string> { return { + body: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --json body`, + 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 { + defaultCompact: dryRun || !explicitFullDisclosure, + explicitFullDisclosure, + fullBodyIncluded: explicitFullDisclosure && !dryRun, + bodyOmitted: dryRun || !explicitFullDisclosure, + dryRunBoundedPreview: dryRun, + note: explicitFullDisclosure && !dryRun + ? "The returned issue object includes the full body because --full or --raw was explicitly requested." + : "Default issue write output omits full issue.body; use readCommands.full/raw or gh issue read --json body when full text is needed.", + readCommands: issueBodyReadCommands(repo, issueNumber), + }; +} + +function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; previewLineCount?: number } = {}): Record<string, unknown> { + const body = issue.body ?? ""; + const includeBody = options.includeBody ?? true; + const summary: Record<string, unknown> = { id: issue.id, number: issue.number, title: issue.title, - body: issue.body ?? "", state: issue.state, url: issue.html_url, author: issue.user?.login ?? null, createdAt: issue.created_at ?? null, updatedAt: issue.updated_at ?? null, + commentCount: issue.comments ?? null, + bodyChars: body.length, + bodySha: bodySha(body), }; + if (includeBody) { + summary.body = body; + } else { + summary.bodyOmitted = true; + summary.fullBodyIncluded = false; + summary.bodyPreview = preview(body); + summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8); + } + return summary; } function labelSummary(label: string | { name?: string; color?: string; description?: string | null }): Record<string, unknown> { @@ -3388,6 +3424,8 @@ async function issueBoardRowMutation( return { ...base, dryRun: true, + disclosure: issueWriteDisclosure(options, repo, options.boardIssue, true), + readCommands: issueBodyReadCommands(repo, options.boardIssue), wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length }, note: !hasConcurrencyGuard ? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified." @@ -3403,7 +3441,9 @@ async function issueBoardRowMutation( ...base, dryRun: false, planned: false, - issue: issueSummary(issue), + issue: issueSummary(issue, { includeBody: options.raw || options.full }), + disclosure: issueWriteDisclosure(options, repo, options.boardIssue, false), + readCommands: issueBodyReadCommands(repo, options.boardIssue), concurrency: { checked: true, oldIssueUpdatedAt: boardIssue.updated_at ?? null, @@ -3543,6 +3583,8 @@ async function issueBoardRowUpdate(repo: string, token: string, issueNumber: num return { ...base, dryRun: true, + disclosure: issueWriteDisclosure(options, repo, options.boardIssue, true), + readCommands: issueBodyReadCommands(repo, options.boardIssue), wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length }, note: options.expectBodySha === undefined && options.expectUpdatedAt === undefined ? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified." @@ -3558,7 +3600,9 @@ async function issueBoardRowUpdate(repo: string, token: string, issueNumber: num ...base, dryRun: false, planned: false, - issue: issueSummary(issue), + issue: issueSummary(issue, { includeBody: options.raw || options.full }), + disclosure: issueWriteDisclosure(options, repo, options.boardIssue, false), + readCommands: issueBodyReadCommands(repo, options.boardIssue), concurrency: { checked: true, oldIssueUpdatedAt: boardIssue.updated_at ?? null, @@ -4926,6 +4970,8 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio issueNumber, mode: options.mode, ...dryRunBody(repo, options.title, finalBody), + 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), bodyOnlySafety: { @@ -4933,6 +4979,8 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio newBody: { bodyChars: finalBody.length, bodySha: bodySha(finalBody), + bodyPreview: preview(finalBody), + bodyPreviewLines: previewLines(finalBody), }, }, concurrency: { @@ -4941,7 +4989,14 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio expectBodySha: options.expectBodySha ?? null, note: "non-dry-run checks --expect-updated-at and --expect-body-sha against the current GitHub issue before PATCH", }, - wouldPatch: { title: options.title ?? null, bodyFromFile: options.bodyFile }, + wouldPatch: { + issueNumber, + title: options.title ?? null, + bodyFromFile: options.bodyFile, + mode: options.mode, + bodyChars: finalBody.length, + bodySha: bodySha(finalBody), + }, ...(options.notifyClaudeQqBriefDiff ? { commanderBriefNotification: commanderBriefNotificationPlan(issueNumber, finalBody, dryRunDiff ?? commanderBriefDiff("", finalBody), claudeQqConfig), @@ -4962,7 +5017,20 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio const concurrency = oldIssue === null ? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null } : { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }; - if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: commandName, repo, issue: issueSummary(issue), mode: options.mode, guard, concurrency, rest: true }; + if (!options.notifyClaudeQqBriefDiff) { + return { + ok: true, + command: commandName, + repo, + issue: issueSummary(issue, { includeBody: options.raw || options.full }), + mode: options.mode, + guard, + concurrency, + disclosure: issueWriteDisclosure(options, repo, issueNumber, false), + readCommands: issueBodyReadCommands(repo, issueNumber), + rest: true, + }; + } const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", finalBody); const claudeqq = diff.ok @@ -4978,9 +5046,11 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio ok: true, command: commandName, repo, - issue: issueSummary(issue), + issue: issueSummary(issue, { includeBody: options.raw || options.full }), guard, concurrency, + disclosure: issueWriteDisclosure(options, repo, issueNumber, false), + readCommands: issueBodyReadCommands(repo, issueNumber), rest: true, commanderBriefNotification: { issueNumber, @@ -5608,8 +5678,8 @@ export function ghHelp(): unknown { "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]", - "bun scripts/cli.ts gh issue edit <number> --body-file <file> [compat alias for issue update --mode replace]", + "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 delete <commentId> [--repo owner/name] [--dry-run]", @@ -5649,14 +5719,14 @@ export function ghHelp(): unknown { "issue list defaults to --state open and bounded --limit 30; supported --json fields are number,title,state,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.", "PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.", "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 and gh pr read/view. They request the full supported read/view JSON field set while keeping default command output structured JSON.", + "--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.", "--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 old/new body length slots, body SHA, required heading checks, literal \\n detection, and shell-pollution signals. Non-dry-run can use --expect-updated-at or --expect-body-sha for stale-cache protection.", + "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.", "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.", @@ -5707,12 +5777,13 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult return validationError(command, options.repo, "--json field selection is only supported by gh issue read/view/list and gh pr read/view/list"); } } - if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && isIssueReadCommand(sub)) || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "preflight" || sub === "closeout")))) { + if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "update" || sub === "edit")) || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "preflight" || sub === "closeout")))) { const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue read/view, gh pr read/view, and gh pr preflight/closeout.", { + return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue read/view/update/edit, gh pr read/view, and gh pr preflight/closeout.", { 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", "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",