fix: 支持 GitHub 评论原地编辑

This commit is contained in:
Codex
2026-06-09 10:09:33 +00:00
parent 195d33dbb5
commit 35eb888710
6 changed files with 194 additions and 27 deletions
+10 -2
View File
@@ -90,11 +90,17 @@ bun scripts/cli.ts gh issue comment create <number|owner/repo#number> \
评论正文
EOF
# 原地修正评论
bun scripts/cli.ts gh issue comment update <commentId> \
--repo owner/name --body-stdin <<'EOF'
新的评论正文
EOF
# 删除评论
bun scripts/cli.ts gh issue comment delete <commentId>
```
`--body <short-text>` 仅适合短单行
`edit``comment update` 的兼容别名。`--body <short-text>` 仅适合短单行。日常修正文案优先用 `update/edit` 保留评论 ID 和时间线;`delete` 只用于确实需要删除的评论
### 关闭/重开
@@ -193,12 +199,14 @@ guarded merge:先做 closeout 预检,拒绝非 ready PR。已 merged 返回
```bash
bun scripts/cli.ts gh pr update <number> --mode replace|append --body-stdin [--title ...]
bun scripts/cli.ts gh pr comment create <number> --body-stdin
bun scripts/cli.ts gh pr comment update <commentId> --body-stdin
bun scripts/cli.ts gh pr comment edit <commentId> --body-stdin
bun scripts/cli.ts gh pr comment delete <commentId>
bun scripts/cli.ts gh pr close <number> [--comment ...]
bun scripts/cli.ts gh pr reopen <number> [--comment ...]
```
与 issue 对应命令行为一致
`pr comment edit``pr comment update` 的兼容别名。与 issue 对应命令行为一致;PR 评论也是 GitHub issue comment,更新目标使用 commentId
---
+2 -2
View File
@@ -77,10 +77,10 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动
- `codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` 是派发前的本地 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` 处理。新任务走 AgentRun Queue,指挥官应把 lint 结果纳入 `agentrun v01 queue submit` payload 审查。长期规则见 `docs/reference/code-queue-supervision.md` 的 DEV 测试授权分级。
- `gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels] [--raw|--full]` 通过 GitHub REST 列出 issue,默认 `state=open``limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`owner/repo` 位置参数是 `--repo owner/repo` 的兼容别名;若位置 repo 与 `--repo` 冲突,或位置参数不是 `owner/repo`,必须结构化失败,禁止静默 fallback 到默认 repo。`--limit` 是 CLI 返回上限,不等同 GitHub 单页 `per_page`:当 `--limit > 100` 或默认页中混入 PR 时,CLI 必须分页抓取 GitHub REST/Search page,过滤 PR 后再返回 issue,并在输出中披露 `pagination.fetchedPages/rawCount/hasMore``hasMore=true` 时只能说明当前有界扫描未穷尽,禁止把它当作“仓库没有更多 issue”。`--search` 使用 GitHub Search Issues API,并自动追加 `repo:<owner>/<name>``type:issue` 和 state qualifier,用于创建新 issue 前做低摩擦查重;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed``--label` 是 GitHub REST `labels=label1,label2` 或 Search `label:` 服务端过滤,支持重复 `--label` 和逗号分隔;filter 不在本命令上下文中使用(如 `issue read``pr list`)必须结构化失败并指明 `gh issue create/list/stale-close` 才是合法作用域。GitHub issues API 可能混入 PRCLI 会从 `.data.issues` 中过滤 pull request。`--raw|--full``gh issue list` 上是绕过 20 KiB stdout 截断的显式开关:响应结果会带 `noDump=true``output.ts` 据此跳过 head/tail 替换并把完整数据 inline 输出;当响应未超阈值时 `--raw|--full` 行为等价默认。
- `gh issue lifecycle``--state` 只能作为 `gh issue list` / `gh issue board-row list` / `gh pr list` 的过滤参数;`gh issue update` / `gh issue edit` 只写 body/title**不接受** `--state` 改 open/closed。把 `gh issue update <n> --state closed` 落到错命令上时,CLI 必须返回 `validation-failed` 并显式提示 `gh issue close <n>` / `gh issue reopen <n>`PR 用 `gh pr close|reopen <n>`),并把 5 条受支持命令放进 `supportedCommands`,禁止把"无 `--state` 改 issue 状态"的命令升级为"接受 `--state`"。`gh issue close|reopen` 成功输出默认是 compact issue 摘要,不得回显完整 `issue.body`;需要正文时后续使用返回的 `readCommands``gh issue view --json body|--full|--raw`。生命周期 close/reopen 的评论推荐用 `--comment-stdin <<'EOF'` 直接写 heredoc/stdin;短单行可用 `--comment`,已有复用文件才用 `--comment-file`。需要附长篇 CLI 验收证据时,先用 `gh issue comment create <n> --body-stdin <<'EOF'` 写证据评论,再用 `gh issue close <n> --comment <短引用>` 关闭。issue 硬删除走 `close`PR 硬删除走 `close`,两者都没有"delete"语义。
- `gh issue comment create <number|owner/repo#number> --repo owner/name --body-stdin``gh issue comment delete <commentId|owner/repo#number> --repo owner/name``gh issue close <number|owner/repo#number> --repo owner/name [--comment <text>|--comment-stdin]``gh issue reopen <number|owner/repo#number> --repo owner/name [--comment <text>|--comment-stdin]``gh issue update <number|owner/repo#number> --repo owner/name [--title ...] [--body-stdin]``gh issue edit <number|owner/repo#number> ...``gh issue board-row get|update|add|move|delete|upsert <number|owner/repo#number> --repo owner/name ...` 都接受与 `gh issue view|read``gh pr *` 一致的 `owner/repo#number` 位置 shorthandshorthand 与显式 `--repo` 冲突时结构化失败并把两者都回显到错误对象里,避免静默改写目标 repo。`gh issue view|read``gh pr view|read|files|diff|preflight|closeout|comment create|comment delete|close|reopen|merge|edit|update` 已长期支持该 shorthandissue 写命令对齐后整个 `gh` 子命令在 shorthand 行为上保持一致,不再需要把 `pikasTech/HWLAB#621` 拆成 `621 --repo pikasTech/HWLAB`。来源:HWLAB #621 CLI 验收 `gh issue comment create pikasTech/HWLAB#621` 摩擦改进。
- `gh issue comment create <number|owner/repo#number> --repo owner/name --body-stdin``gh issue comment update|edit <commentId> --repo owner/name --body-stdin``gh issue comment delete <commentId|owner/repo#number> --repo owner/name``gh issue close <number|owner/repo#number> --repo owner/name [--comment <text>|--comment-stdin]``gh issue reopen <number|owner/repo#number> --repo owner/name [--comment <text>|--comment-stdin]``gh issue update <number|owner/repo#number> --repo owner/name [--title ...] [--body-stdin]``gh issue edit <number|owner/repo#number> ...``gh issue board-row get|update|add|move|delete|upsert <number|owner/repo#number> --repo owner/name ...` 都接受与 `gh issue view|read``gh pr *` 一致的 `owner/repo#number` 位置 shorthandshorthand 与显式 `--repo` 冲突时结构化失败并把两者都回显到错误对象里,避免静默改写目标 repo。`gh issue view|read``gh pr view|read|files|diff|preflight|closeout|comment create|comment update|comment edit|comment delete|close|reopen|merge|edit|update` 已长期支持该 shorthandcomment update/edit/delete 的 `--number` 表示 commentId,不是 issue/PR number。issue 写命令对齐后整个 `gh` 子命令在 shorthand 行为上保持一致,不再需要把 `pikasTech/HWLAB#621` 拆成 `621 --repo pikasTech/HWLAB`。来源:HWLAB #621 CLI 验收 `gh issue comment create pikasTech/HWLAB#621` 摩擦改进。
- `gh issue stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--dry-run]` 是可复用批量生命周期清理入口,用于“超过 N 小时无回复或修改的 open issue 一律关闭”这类策略。判定基准固定为 GitHub `updatedAt < observedAt - inactiveHours`issue comment、body/title 修改和 state 变化都会刷新 `updatedAt` 并视为活跃;PR 必须过滤,不参与 issue 关闭。默认 `inactive-hours=48`,默认扫描预算为 issue list 上限,输出必须包含 `observedAt``cutoffAt``scannedCount``staleCount``pagination.hasMore`、候选/关闭 issue 的 compact 摘要和失败列表,不得打印完整正文。正式关闭前建议先跑 `--dry-run`;真实执行后用同一命令加 `--dry-run` 验证 `staleCount=0`,且只有 `hasMore=false` 才能把当前扫描视为完整穷尽。HWLAB 当前长期策略使用 `bun scripts/cli.ts gh issue stale-close --repo pikasTech/HWLAB --inactive-hours 48 --dry-run` 观察,再移除 `--dry-run` 关闭。
- `gh issue view <number|url|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON`read` 只保留为 UniDesk 兼容别名。`view` 对齐 GitHub CLI 标准位置参数:接受正整数、`https://github.com/owner/repo/issues/<number>` URL 或 `owner/repo#number` shorthand。`--number N` 也作为低摩擦兼容别名用于单 issue/comment 数字目标命令,并在成功响应里返回 `standardSyntaxHint` 提示标准位置参数写法;comment delete 中的 `--number` 表示 commentId,不是 issue number`list/create/scan-escape/cleanup-plan/board-audit/board-row list` 这类没有单数字目标的命令仍拒绝 `--number`。URL 和 shorthand 会自动派生 `--repo owner/repo` 与 issue number;若同时提供冲突的显式 `--repo`CLI 必须结构化失败并给出 `gh issue view <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` 是显式完整披露别名:view/read 会选择完整支持字段集;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/view 输出仍不得扩散到无界非 JSON 文本。`gh issue create --title <title> --body-stdin [--label label[,label...]]... [--dry-run]``gh issue update <number> --mode replace|append --body-stdin [--title ...] [--dry-run] [--full|--raw]``gh issue comment create <number> (--body-stdin|--body <short-text>) [--dry-run]``gh issue comment delete <commentId> [--dry-run]``gh issue close|reopen <number> [--comment <short-text>|--comment-stdin] [--dry-run]``gh issue stale-close [--inactive-hours N] [--dry-run]` 都走 REST,不依赖 `gh` binary。`--body-stdin``--comment-stdin` 是多行 Markdown 的第一等 heredoc/stdin 入口;`--body-file` / `--comment-file` 只在已有复用文件时使用。`--body` 仅用于 issue comment 的短单行文本;空白、多行、疑似 shell 污染、secret-like 或过长 inline body 必须结构化失败。`--label` 用于 `issue create``issue list``issue stale-close`,支持重复传入和逗号分隔;`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 view <number|url|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON`read` 只保留为 UniDesk 兼容别名。`view` 对齐 GitHub CLI 标准位置参数:接受正整数、`https://github.com/owner/repo/issues/<number>` URL 或 `owner/repo#number` shorthand。`--number N` 也作为低摩擦兼容别名用于单 issue/comment 数字目标命令,并在成功响应里返回 `standardSyntaxHint` 提示标准位置参数写法;comment update/edit/delete 中的 `--number` 表示 commentId,不是 issue number`list/create/scan-escape/cleanup-plan/board-audit/board-row list` 这类没有单数字目标的命令仍拒绝 `--number`。URL 和 shorthand 会自动派生 `--repo owner/repo` 与 issue number;若同时提供冲突的显式 `--repo`CLI 必须结构化失败并给出 `gh issue view <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` 是显式完整披露别名:view/read 会选择完整支持字段集;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/view 输出仍不得扩散到无界非 JSON 文本。`gh issue create --title <title> --body-stdin [--label label[,label...]]... [--dry-run]``gh issue update <number> --mode replace|append --body-stdin [--title ...] [--dry-run] [--full|--raw]``gh issue comment create <number> (--body-stdin|--body <short-text>) [--dry-run]``gh issue comment update|edit <commentId> (--body-stdin|--body <short-text>) [--dry-run]``gh issue comment delete <commentId> [--dry-run]``gh issue close|reopen <number> [--comment <short-text>|--comment-stdin] [--dry-run]``gh issue stale-close [--inactive-hours N] [--dry-run]` 都走 REST,不依赖 `gh` binary。`--body-stdin``--comment-stdin` 是多行 Markdown 的第一等 heredoc/stdin 入口;`--body-file` / `--comment-file` 只在已有复用文件时使用。`--body` 仅用于 issue comment 的短单行文本;空白、多行、疑似 shell 污染、secret-like 或过长 inline body 必须结构化失败。`comment update/edit` 使用 GitHub issue comment PATCH 端点并保留评论 ID,日常修正文案优先用 update/edit,delete 只用于确实需要删除的评论。`--label` 用于 `issue create``issue list``issue stale-close`,支持重复传入和逗号分隔;`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-stdin` 是正文更新主入口,`edit` 保留为兼容别名。`replace` 用 heredoc/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 view/read 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-stdin --notify-claudeqq-brief-diff [--dry-run] <<'EOF'` 是 legacy #24 指挥简报的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 heredoc/stdin 读取新正文;随后先 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` 调整开关、目标和超时。
+1 -1
View File
@@ -187,7 +187,7 @@ GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts
CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不好用、兼容性不足、安全 guard 不够或输出不利于 runner/指挥官使用时,应默认创建 GitHub issue 并用 Code Queue 推进;这类 CLI 问题走 `master`、remote commit、轻量 contract test 和文档更新,不套用 backend-core、Code Queue runtime 这类运行态服务的重部署门禁。若 CLI 缺陷已经阻塞当前指挥,可以先做最小安全绕行,同时把长期修复写入 issue 并派单。
所有 GitHub Markdown 正文写入必须来自 `--body-file <file>`。不要使用 `gh issue comment --body``gh api -f body=...` 或把多行正文直接拼进 shell 参数;这些路径容易把真实换行、反引号和 Markdown 表格污染成字面量 `\n` 或 shell escape。从 shell 生成正文文件时使用 quoted heredoc,例如 `cat <<'EOF' > /tmp/body.md`,保证反引号和反斜杠不被展开;JSON 请求体场景优先使用对应 CLI 的 `--body-file``--body-stdin`,不要把长 JSON 塞进命令行参数。`gh issue` 写命令不接受 stdin 正文;需要更新 #20 总看板或创建新 issue/comment 时,先把正文写入 Markdown 文件,再运行 `bun scripts/cli.ts gh issue update|comment create|create ... --body-file <file>``gh issue update --mode replace|append --body-file` 是主更新入口`edit` 只是兼容别名;`append` 会先读取当前正文再追加文件字节,保留真实换行、反引号和 Markdown 表格,不走 shell 拼接。`gh issue update --body-file` 默认拒绝 `null`、空白和过短正文;#20 自动要求 `## 看板(OPEN`,指挥简报 profile 自动要求 `## 常驻观察与长期建议`,并允许 #24 legacy 或每日滚动简报 issue。更新 body-only issue 前优先跑 `--dry-run`,查看旧/新正文长度、body SHA、关键标题、字面量 `\n` 和 shell 污染信号;正式写入长期正文时优先带上 `--expect-updated-at``--expect-body-sha`,避免旧缓存覆盖新正文。指挥简报更新正文时默认只写 GitHub issue,不自动向 ClaudeQQ 推送;#24 legacy 可用 `--notify-claudeqq-brief-diff` 通知 helper,如确需提醒用户,按本文的 ClaudeQQ 通知门槛单独发送。提交前或巡检时可用 `gh issue scan-escape --limit N --dry-run``gh issue cleanup-plan --limit N` 只读扫描污染并生成建议,不自动修复。
所有 GitHub Markdown 正文写入优先使用 `--body-stdin` `--body-file <file|->`。不要使用 `gh issue comment --body``gh api -f body=...` 或把多行正文直接拼进 shell 参数;这些路径容易把真实换行、反引号和 Markdown 表格污染成字面量 `\n` 或 shell escape。从 shell 生成正文文件时使用 quoted heredoc,例如 `cat <<'EOF' > /tmp/body.md`,保证反引号和反斜杠不被展开;JSON 请求体场景优先使用对应 CLI 的 `--body-file``--body-stdin`,不要把长 JSON 塞进命令行参数。`gh issue comment create|update|edit``gh pr comment create|update|edit` 都支持 `--body-stdin` 作为多行 Markdown 的第一等入口,`--body` 仅适合短单行文本。`gh issue` 正文更新主入口仍是 `update --mode replace|append --body-stdin|--body-file``edit` 只是兼容别名;`append` 会先读取当前正文再追加文件字节,保留真实换行、反引号和 Markdown 表格,不走 shell 拼接。`gh issue update --body-file` 默认拒绝 `null`、空白和过短正文;#20 自动要求 `## 看板(OPEN`,指挥简报 profile 自动要求 `## 常驻观察与长期建议`,并允许 #24 legacy 或每日滚动简报 issue。更新 body-only issue 前优先跑 `--dry-run`,查看旧/新正文长度、body SHA、关键标题、字面量 `\n` 和 shell 污染信号;正式写入长期正文时优先带上 `--expect-updated-at``--expect-body-sha`,避免旧缓存覆盖新正文。指挥简报更新正文时默认只写 GitHub issue,不自动向 ClaudeQQ 推送;#24 legacy 可用 `--notify-claudeqq-brief-diff` 通知 helper,如确需提醒用户,按本文的 ClaudeQQ 通知门槛单独发送。提交前或巡检时可用 `gh issue scan-escape --limit N --dry-run``gh issue cleanup-plan --limit N` 只读扫描污染并生成建议,不自动修复。
PR 是审查型交付入口,不是所有 Code Queue 任务的默认出口。默认 master-only 交付仍按项目 Git 规则执行;当变更风险高、跨模块、需要人工审查、或任务目标明确要求 PR 交付时,worker 可以创建 PR。PR 型任务必须报告源分支、目标分支、PR URL、关联 issue、测试证据和未完成风险。禁止把 PR 当成隐藏分支仓库;PR 分支必须来自最新目标线,保持小而可审查,并在合并后确认目标分支远端 commit 可 fetch。
+35 -2
View File
@@ -597,6 +597,11 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
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 === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/comments/9002") {
const parsed = JSON.parse(body) as JsonRecord;
sendJson(res, 200, { 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:04: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) : [];
@@ -665,7 +670,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(notes.some((line) => line.includes("GitHub issue URLs") && line.includes("owner/repo#number shorthand")), "gh help should explain issue view/read URL and shorthand targets", { notes });
assertCondition(notes.some((line) => line.includes("--number is accepted on single issue/comment numeric target commands") && line.includes("Comment delete treats --number as commentId")), "gh help should document issue --number compatibility scope", { notes });
assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes });
assertCondition(notes.some((line) => line.includes("issue comment create accepts --body-stdin") && line.includes("--body only for short single-line text")), "gh help should document issue comment heredoc stdin and inline safety limits", { notes });
assertCondition(notes.some((line) => line.includes("issue comment create/update/edit accept --body-stdin") && line.includes("--body only for short single-line text")), "gh help should document issue comment heredoc stdin and inline safety limits", { notes });
assertCondition(notes.some((line) => line.includes("board-row update changes one table cell")), "gh help should describe board-row update safety", { notes });
assertCondition(notes.some((line) => line.includes("board-row upsert updates an existing row")), "gh help should describe board-row upsert safety", { notes });
assertCondition(notes.some((line) => line.includes("board-row add/move/delete are row-scoped")), "gh help should describe board-row row mutation safety", { notes });
@@ -1707,6 +1712,34 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const inlinePayload = JSON.parse(inlinePost?.body ?? "{}") as JsonRecord;
assertCondition(inlinePayload.body === inlineBody, "inline issue comment REST payload should preserve short text", inlinePayload);
const commentUpdateBody = "修正:保留评论 ID 的原地编辑";
const commentUpdateDryRunRequestCountBefore = mock.requests.length;
const commentUpdateDryRun = await runCli(["gh", "issue", "comment", "update", "9002", "--repo", "pikasTech/unidesk", "--body", commentUpdateBody, "--dry-run"], env);
assertCondition(commentUpdateDryRun.status === 0, "issue comment update dry-run should succeed", commentUpdateDryRun.json ?? { stdout: commentUpdateDryRun.stdout });
assertCondition(commentUpdateDryRun.json?.command === "gh issue comment update 9002 --repo pikasTech/unidesk --body <body:redacted> --dry-run", "outer gh command should redact issue comment update inline body", commentUpdateDryRun.json ?? {});
const commentUpdateDryRunData = dataOf(commentUpdateDryRun.json ?? {});
assertCondition(commentUpdateDryRunData.command === "issue comment update" && commentUpdateDryRunData.dryRun === true && commentUpdateDryRunData.commentId === 9002, "issue comment update dry-run should plan by commentId", commentUpdateDryRunData);
assertCondition(typeof commentUpdateDryRunData.bodySha === "string" && String(commentUpdateDryRunData.bodySha).length === 64 && Number(commentUpdateDryRunData.bodyChars ?? 0) === commentUpdateBody.length, "issue comment update dry-run should expose body metadata", commentUpdateDryRunData);
const commentUpdateRequest = commentUpdateDryRunData.request as JsonRecord;
assertCondition(commentUpdateRequest.method === "PATCH" && String(commentUpdateRequest.path ?? "").includes("/issues/comments/{comment_id}"), "issue comment update dry-run should plan PATCH comment endpoint", commentUpdateRequest);
const commentUpdateDryRunWriteCount = mock.requests.slice(commentUpdateDryRunRequestCountBefore).filter((request) => request.method === "PATCH" && request.url.includes("/issues/comments/")).length;
assertCondition(commentUpdateDryRunWriteCount === 0, "issue comment update dry-run must not PATCH GitHub", { requests: mock.requests.slice(commentUpdateDryRunRequestCountBefore) });
const commentEditStdinBody = "编辑别名:stdin 正文\n\n- 保留 `code`\n";
const commentEditRequestCountBefore = mock.requests.length;
const commentEdit = await runCli(["gh", "issue", "comment", "edit", "--number", "9002", "--repo", "pikasTech/unidesk", "--body-stdin"], env, commentEditStdinBody);
assertCondition(commentEdit.status === 0, "issue comment edit should accept --number compatibility alias and stdin", commentEdit.json ?? { stdout: commentEdit.stdout });
const commentEditData = dataOf(commentEdit.json ?? {});
assertCondition(commentEditData.command === "issue comment edit" && commentEditData.commentId === 9002, "issue comment edit should report alias command and commentId", commentEditData);
const commentEditHint = commentEditData.standardSyntaxHint as JsonRecord;
assertCondition(String(commentEditHint.standardCommand ?? "").includes("gh issue comment edit 9002 --repo pikasTech/unidesk"), "issue comment edit --number should point to positional commentId syntax", commentEditHint);
const commentEditSummary = commentEditData.comment as JsonRecord;
assertCondition(commentEditSummary.id === 9002 && commentEditSummary.bodyOmitted === true && !("body" in commentEditSummary), "issue comment edit should preserve id and omit full body", commentEditSummary);
const commentEditPatch = mock.requests.slice(commentEditRequestCountBefore).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/comments/9002");
assertCondition(commentEditPatch !== undefined, "issue comment edit should PATCH issue comments endpoint", { requests: mock.requests.slice(commentEditRequestCountBefore) });
const commentEditPayload = JSON.parse(commentEditPatch?.body ?? "{}") as JsonRecord;
assertCondition(commentEditPayload.body === commentEditStdinBody, "issue comment edit payload should preserve stdin Markdown", commentEditPayload);
const closeComment = "收口:CLI close --comment contract";
const closeDryRunRequestCountBefore = mock.requests.length;
const closeDryRun = await runCli(["gh", "issue", "close", "20", "--repo", "pikasTech/unidesk", "--comment", closeComment, "--dry-run"], env);
@@ -1864,7 +1897,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"issue close/reopen supports --comment-stdin dry-run without writes",
"issue comment create supports short inline --body dry-run and write with bounded output",
"issue comment create supports --body-stdin and compatible --body-file -, and still rejects missing, blank, multiline inline, polluted inline, secret-like inline, and mixed body sources",
"issue comment create/delete follows CRUD shape",
"issue comment create/update/edit/delete follows CRUD shape",
"issue hard delete is structurally unsupported",
],
};
+34 -2
View File
@@ -242,6 +242,11 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 201, { id: 9101, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/pull/42#issuecomment-9101", user: { login: "runner" }, created_at: "2026-05-20T06:10:00Z", updated_at: "2026-05-20T06:10:00Z" });
return;
}
if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/comments/9101") {
const parsed = JSON.parse(body) as JsonRecord;
sendJson(res, 200, { id: 9101, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/pull/42#issuecomment-9101", user: { login: "runner" }, created_at: "2026-05-20T06:10:00Z", updated_at: "2026-05-20T06:12:00Z" });
return;
}
if (req.method === "DELETE" && req.url === "/repos/pikasTech/unidesk/issues/comments/9101") {
res.statusCode = 204;
res.end();
@@ -311,7 +316,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(notes.some((line) => line.includes("PR view is the canonical")), "gh help should state pr view is canonical", { notes });
assertCondition(notes.some((line) => line.includes("read remains") && line.includes("compatibility alias")), "gh help should state pr read is alias", { notes });
assertCondition(notes.some((line) => line.includes("GitHub PR URLs") && line.includes("owner/repo#number shorthand")), "gh help should explain pr view/read URL and shorthand targets", { notes });
assertCondition(notes.some((line) => line.includes("--number is accepted on single PR/comment numeric target commands") && line.includes("PR comment delete treats --number as commentId")), "gh help should document --number compatibility hint", { notes });
assertCondition(notes.some((line) => line.includes("--number is accepted on single PR/comment numeric target commands") && line.includes("PR comment update/edit/delete treat --number as commentId")), "gh help should document --number compatibility hint", { 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("PR list defaults to --state all")), "gh help should document pr list default state", { notes });
assertCondition(notes.some((line) => line.includes("stateDetail") && line.includes("mergedAt")), "gh help should describe closeout field normalization", { notes });
@@ -741,6 +746,33 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
const prInlinePayload = JSON.parse(prInlineCommentRequest?.body ?? "{}") as JsonRecord;
assertCondition(prInlinePayload.body === prInlineCommentBody, "pr inline comment payload should preserve --body text", prInlinePayload);
const prCommentUpdateBody = "PR 评论原地修正";
const prCommentUpdateDryRunRequestCountBefore = mock2.requests.length;
const prCommentUpdateDryRun = await runCli(["gh", "pr", "comment", "update", "9101", "--repo", "pikasTech/unidesk", "--body", prCommentUpdateBody, "--dry-run"], env2);
assertCondition(prCommentUpdateDryRun.status === 0, "pr comment update dry-run should succeed", prCommentUpdateDryRun.json ?? { stdout: prCommentUpdateDryRun.stdout });
assertCondition(prCommentUpdateDryRun.json?.command === "gh pr comment update 9101 --repo pikasTech/unidesk --body <body:redacted> --dry-run", "outer gh command should redact PR comment update inline body", prCommentUpdateDryRun.json ?? {});
const prCommentUpdateDryRunData = dataOf(prCommentUpdateDryRun.json ?? {});
assertCondition(prCommentUpdateDryRunData.command === "pr comment update" && prCommentUpdateDryRunData.commentId === 9101 && prCommentUpdateDryRunData.dryRun === true, "pr comment update dry-run should report commentId", prCommentUpdateDryRunData);
const prCommentUpdateRequest = prCommentUpdateDryRunData.request as JsonRecord;
assertCondition(prCommentUpdateRequest.method === "PATCH" && String(prCommentUpdateRequest.path ?? "").includes("/issues/comments/{comment_id}"), "pr comment update dry-run should plan PATCH comment endpoint", prCommentUpdateRequest);
const prCommentUpdateDryRunWriteCount = mock2.requests.slice(prCommentUpdateDryRunRequestCountBefore).filter((request) => request.method === "PATCH" && request.url.includes("/issues/comments/")).length;
assertCondition(prCommentUpdateDryRunWriteCount === 0, "pr comment update dry-run must not PATCH GitHub", { requests: mock2.requests.slice(prCommentUpdateDryRunRequestCountBefore) });
const prCommentEditBody = "PR edit 别名\n\n- 保留 `code`\n";
const prCommentEditRequestCountBefore = mock2.requests.length;
const prCommentEdit = await runCli(["gh", "pr", "comment", "edit", "--number", "9101", "--repo", "pikasTech/unidesk", "--body-stdin"], env2, prCommentEditBody);
assertCondition(prCommentEdit.status === 0, "pr comment edit should accept --number compatibility alias and stdin", prCommentEdit.json ?? { stdout: prCommentEdit.stdout });
const prCommentEditData = dataOf(prCommentEdit.json ?? {});
assertCondition(prCommentEditData.command === "pr comment edit" && prCommentEditData.commentId === 9101, "pr comment edit should report alias command and commentId", prCommentEditData);
const prCommentEditHint = prCommentEditData.standardSyntaxHint as JsonRecord;
assertCondition(String(prCommentEditHint.standardCommand ?? "").includes("gh pr comment edit 9101 --repo pikasTech/unidesk"), "pr comment edit --number should point to positional commentId syntax", prCommentEditHint);
const prCommentEditSummary = prCommentEditData.comment as JsonRecord;
assertCondition(prCommentEditSummary.id === 9101 && prCommentEditSummary.bodyOmitted === true && !("body" in prCommentEditSummary), "pr comment edit should preserve id and omit full body", prCommentEditSummary);
const prCommentEditPatch = mock2.requests.slice(prCommentEditRequestCountBefore).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/comments/9101");
assertCondition(prCommentEditPatch !== undefined, "pr comment edit should PATCH issue comments endpoint", { requests: mock2.requests.slice(prCommentEditRequestCountBefore) });
const prCommentEditPayload = JSON.parse(prCommentEditPatch?.body ?? "{}") as JsonRecord;
assertCondition(prCommentEditPayload.body === prCommentEditBody, "pr comment edit payload should preserve stdin Markdown", prCommentEditPayload);
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 ?? {});
@@ -799,7 +831,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
"pr update/edit use low-noise REST PATCH without GraphQL projectCards",
"pr edit supports --body-stdin without echoing full body",
"pr update append and close/reopen are available",
"pr comment create/delete follows CRUD shape, --body-stdin, and --body remains supported",
"pr comment create/update/edit/delete follows CRUD shape, --body-stdin, and --body remains supported",
"pr merge is guarded by preflight and uses REST",
"pr hard delete is blocked",
"pr create validation failures are structured",
+112 -18
View File
@@ -1339,32 +1339,32 @@ function secretLikeInlineFindings(body: string): string[] {
return findings;
}
function readIssueCommentBody(options: GitHubOptions): { body: string; bodySource: Record<string, unknown> } {
function readIssueCommentBody(options: GitHubOptions, command = "issue comment create"): { 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/--body-stdin or --body");
throw new Error(`${command} accepts only one body source: --body-file/--body-stdin or --body`);
}
if (options.bodyFile !== undefined) {
return readMarkdownBodyFileOrStdin(options.bodyFile);
}
if (options.body === undefined) throw new Error("issue comment create requires --body-stdin, --body-file <file|->, or --body <text>");
if (options.body === undefined) throw new Error(`${command} requires --body-stdin, --body-file <file|->, or --body <text>`);
const body = options.body;
const trimmed = body.trim();
const shellPollution = shellPollutionEvidence(body);
const secretLike = secretLikeInlineFindings(body);
if (trimmed.length === 0) {
throw new Error("issue comment create --body must not be blank; use --body-stdin with a quoted heredoc for reviewed Markdown");
throw new Error(`${command} --body must not be blank; use --body-stdin with a quoted heredoc for reviewed Markdown`);
}
if (body.length > MAX_INLINE_ISSUE_COMMENT_BODY_CHARS) {
throw new Error(`issue comment create --body is limited to ${MAX_INLINE_ISSUE_COMMENT_BODY_CHARS} characters; use --body-stdin with a quoted heredoc for long Markdown`);
throw new Error(`${command} --body is limited to ${MAX_INLINE_ISSUE_COMMENT_BODY_CHARS} characters; use --body-stdin with a quoted heredoc for long Markdown`);
}
if (body.includes("\n") || body.includes("\r")) {
throw new Error("issue comment create --body supports short single-line text only; use --body-stdin with a quoted heredoc for multiline Markdown");
throw new Error(`${command} --body supports short single-line text only; use --body-stdin with a quoted heredoc for multiline Markdown`);
}
if (shellPollution.length > 0) {
throw new Error(`issue comment create --body contains shell-pollution signals (${shellPollution.join(",")}); use --body-stdin with a quoted heredoc for reviewed Markdown bytes`);
throw new Error(`${command} --body contains shell-pollution signals (${shellPollution.join(",")}); use --body-stdin with a quoted heredoc for reviewed Markdown bytes`);
}
if (secretLike.length > 0) {
throw new Error(`issue comment create --body appears to contain secret-like text (${secretLike.join(",")}); refusing to print or submit it`);
throw new Error(`${command} --body appears to contain secret-like text (${secretLike.join(",")}); refusing to print or submit it`);
}
return {
body,
@@ -1902,8 +1902,9 @@ 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";
function writeBodyPlan(command: "issue create" | "issue comment create" | "issue comment update" | "pr create" | "pr comment" | "pr comment update", repo: string, body: string, bodySource: Record<string, unknown>, extra: Record<string, unknown> = {}): Record<string, unknown> {
const isIssueWrite = command === "issue create" || command === "issue comment create" || command === "issue comment update";
const isCommentUpdate = command === "issue comment update" || command === "pr comment update";
const source = String(bodySource.kind ?? "unknown");
const requestBody: Record<string, unknown> = {
bodyChars: body.length,
@@ -1919,10 +1920,18 @@ function writeBodyPlan(command: "issue create" | "issue comment create" | "pr cr
bodyPreviewLines: previewLines(body),
...bodySafetySignals(body),
request: {
method: "POST",
method: isCommentUpdate ? "PATCH" : "POST",
path: isIssueWrite
? (command === "issue create" ? "/repos/{owner}/{repo}/issues" : "/repos/{owner}/{repo}/issues/{issue_number}/comments")
: (command === "pr create" ? "/repos/{owner}/{repo}/pulls" : "/repos/{owner}/{repo}/issues/{issue_number}/comments"),
? (command === "issue create"
? "/repos/{owner}/{repo}/issues"
: isCommentUpdate
? "/repos/{owner}/{repo}/issues/comments/{comment_id}"
: "/repos/{owner}/{repo}/issues/{issue_number}/comments")
: (command === "pr create"
? "/repos/{owner}/{repo}/pulls"
: isCommentUpdate
? "/repos/{owner}/{repo}/issues/comments/{comment_id}"
: "/repos/{owner}/{repo}/issues/{issue_number}/comments"),
body: requestBody,
},
validation: {
@@ -5910,7 +5919,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
let bodyInput: { body: string; bodySource: Record<string, unknown> };
try {
bodyInput = readIssueCommentBody(options);
bodyInput = readIssueCommentBody(options, "issue comment create");
} catch (error) {
return validationError("issue comment create", repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
@@ -5946,6 +5955,63 @@ async function issueComment(repo: string, token: string, issueNumber: number, op
};
}
async function commentUpdate(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, options: GitHubOptions, commandName?: string): Promise<GitHubCommandResult> {
const command = commandName ?? `${ownerKind} comment update`;
let bodyInput: { body: string; bodySource: Record<string, unknown> };
try {
bodyInput = readIssueCommentBody(options, command);
} catch (error) {
return validationError(command, repo, error instanceof Error ? error.message : String(error), { commentId });
}
const { body, bodySource } = bodyInput;
const readCommands = ownerKind === "issue"
? {
comments: `bun scripts/cli.ts gh issue view <issueNumber> --repo ${repo} --json comments`,
full: `bun scripts/cli.ts gh issue view <issueNumber> --repo ${repo} --full`,
note: "GitHub comment update targets commentId directly; use the owning issue number to read the surrounding comments.",
}
: {
comments: `bun scripts/cli.ts gh issue view <prNumber> --repo ${repo} --json comments`,
full: `bun scripts/cli.ts gh issue view <prNumber> --repo ${repo} --full`,
note: "GitHub PR comments are issue comments; use the owning PR number through gh issue view to read the surrounding comments.",
};
const writePlanCommand = ownerKind === "issue" ? "issue comment update" : "pr comment update";
if (options.dryRun) {
return {
ok: true,
command,
repo,
dryRun: true,
planned: true,
commentId,
readCommands,
...writeBodyPlan(writePlanCommand, repo, body, bodySource, { commentId }),
};
}
const { owner, name } = repoParts(repo);
const comment = await githubRequest<GitHubComment>(token, "PATCH", `/repos/${owner}/${name}/issues/comments/${commentId}`, { body });
if (isGitHubError(comment)) return commandError(command, repo, comment, { commentId });
return {
ok: true,
command,
repo,
commentId,
comment: compactCommentSummary(comment),
bodySource,
bodyChars: body.length,
bodySha: bodySha(body),
bodyPreview: preview(body),
source: String(bodySource.kind ?? "unknown"),
readCommands,
request: {
method: "PATCH",
path: `/repos/${owner}/${name}/issues/comments/${commentId}`,
bodyChars: body.length,
},
rest: true,
};
}
async function commentDelete(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, dryRun: boolean): Promise<GitHubCommandResult> {
const command = `${ownerKind} comment delete`;
const { owner, name } = repoParts(repo);
@@ -6719,6 +6785,8 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue edit <number> (--body-stdin|--body-file <file|->) [--repo owner/name] [--number N compat] [--full|--raw] [compat alias for issue update --mode replace]",
"bun scripts/cli.ts gh issue edit 24 --body-stdin --notify-claudeqq-brief-diff [--dry-run]",
"bun scripts/cli.ts gh issue comment create <number> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue comment update <commentId> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue comment edit <commentId> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for issue comment update]",
"bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--number N compat] [--comment <short-text>|--comment-stdin|--comment-file <file|->] [--dry-run]",
"bun scripts/cli.ts gh issue stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--dry-run]",
@@ -6745,6 +6813,8 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh pr edit <number> [--title title] [--body-stdin|--body-file <file|->|--body <text>] [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-stdin|--body-file <file|->|--body <text>] [--title title] [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr comment create <number> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr comment update <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr comment edit <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for pr comment update]",
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]",
@@ -6767,7 +6837,7 @@ export function ghHelp(): unknown {
"issue edit is a compatibility alias for issue update --mode replace.",
"issue update accepts --body-stdin or --body-file <file|->, refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading, warns when HWLAB product/user issue routing appears in favor of pikasTech/HWLAB, and still rejects commander brief update sections; commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).",
"issue update dry-run reports bounded bodyPreview/bodyPreviewLines, old/new body length slots, body SHA, required heading checks, literal \\n detection, shell-pollution signals, guard/concurrency summary, wouldPatch, and readCommands without printing an unbounded full body. Non-dry-run automatically reads current issue metadata before PATCH and returns oldBodySha/updatedAt; --expect-updated-at or --expect-body-sha remain available for explicit stale-cache protection.",
"issue comment create accepts --body-stdin or --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally.",
"issue comment create/update/edit accept --body-stdin or --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally. Use comment update/edit to correct existing wording in place; delete remains for intentional removal.",
"issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text>, --comment-stdin, or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-stdin is the first-class heredoc path for generated Markdown closeout evidence; --comment remains the short inline form. Use gh issue view <number> --json body or --full/--raw on view when full text is needed.",
"issue stale-close is the reusable lifecycle cleanup path for policies such as closing open issues inactive for more than 48 hours. It selects open issues by GitHub updatedAt older than observedAt - --inactive-hours, treats comments and state changes as activity, filters pull requests, supports --dry-run, and returns bounded candidate/closed/failure summaries without echoing full bodies.",
"For one-shot issue writes, prefer quoted heredoc stdin: bun scripts/cli.ts gh issue update <number> --repo owner/name --body-stdin <<'EOF' ... EOF or gh issue comment create <number> --body-stdin <<'EOF' ... EOF. --body-file <file|-> remains available for reusable files and pipes.",
@@ -6779,10 +6849,10 @@ export function ghHelp(): unknown {
"issue board-row add/move/delete are row-scoped #20 table mutations. add validates a one-line --row-file against the target table column count, Issue column, and GitHub 状态 column; move refuses duplicate/ambiguous rows and can update GitHub 状态 via --status; delete removes only the matched row. All three default to dry-run and require --expect-body-sha or --expect-updated-at before PATCH. add/move/delete return old/new row, body SHA, and line/section plan details for the parsed table mutation, and the shared #20 guard warns about HWLAB product/user issue routing in favor of pikasTech/HWLAB without refusing the write.",
"issue edit 24 --notify-claudeqq-brief-diff remains the legacy #24 notification helper: it reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.",
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
"comment update/edit PATCHes /repos/{owner}/{repo}/issues/comments/{comment_id} and preserves the comment id/timeline; comment delete is supported because GitHub supports deleting issue comments, but routine wording fixes should use update/edit. issue/pr hard delete is unsupported and close is the lifecycle alternative.",
"PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.",
"PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.",
"PR view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. PR view/read accept positional numbers, GitHub PR URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single PR/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create do not accept it. PR comment delete treats --number as commentId, not a PR number. PR view/read supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
"PR view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. PR view/read accept positional numbers, GitHub PR URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single PR/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create do not accept it. PR comment update/edit/delete treat --number as commentId, not a PR number. PR view/read supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
"PR preflight/closeout accept the same owner/repo#number shorthand as PR view/read so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.",
"PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.",
"PR preflight is a low-noise read-only closeout helper. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and an explicit read-only policy. Use --full or --raw to include all fetched status contexts; gh pr merge is the separate guarded write path.",
@@ -6957,6 +7027,19 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(commentDelete(resolved.repo, token, "issue", commentId, false), resolved);
}
if (sub === "comment" && (third === "update" || third === "edit")) {
const commandName = `issue comment ${third}`;
const resolved = resolvePositionalNumberReference("issue", args, 3, commandName, options);
if (isGitHubCommandResult(resolved)) return resolved;
const commentId = resolved.number;
if (typeof commentId !== "number") return commentId;
const updateOptions = { ...options, repo: resolved.repo };
if (options.dryRun) return withNumberOptionHint(commentUpdate(resolved.repo, "", "issue", commentId, updateOptions, commandName), resolved);
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, commandName, probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(commentUpdate(resolved.repo, token, "issue", commentId, updateOptions, commandName), resolved);
}
if (sub === "comment" && third === "create") {
const resolved = resolvePositionalIssueReference(args, 3, "issue comment create", options);
if (isGitHubCommandResult(resolved)) return resolved;
@@ -7120,6 +7203,17 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(commentDelete(resolved.repo, token, "pr", resolved.number, false), resolved);
}
if (sub === "comment" && (third === "update" || third === "edit")) {
const commandName = `pr comment ${third}`;
const resolved = resolvePositionalNumberReference("pr", args, 3, commandName, options);
if (isGitHubCommandResult(resolved)) return resolved;
const updateOptions = { ...options, repo: resolved.repo };
if (options.dryRun) return withNumberOptionHint(commentUpdate(resolved.repo, "", "pr", resolved.number, updateOptions, commandName), resolved);
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, commandName, probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
return withNumberOptionHint(commentUpdate(resolved.repo, token, "pr", resolved.number, updateOptions, commandName), resolved);
}
if (sub === "comment" && third === "create") {
const resolved = resolvePositionalPrReference(args, 3, "pr comment create", options);
if (isGitHubCommandResult(resolved)) return resolved;
@@ -7177,7 +7271,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
return withNumberOptionHint(prMerge(resolved.repo, token, resolved.number, options), resolved);
}
if (sub !== "list" && !isPrReadCommand(sub)) {
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, merge, comment create/delete, and unsupported delete.");
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, merge, comment create/update/edit/delete, and unsupported delete.");
}
if (sub === "read" || sub === "view") {
const resolved = resolveReadViewNumberReference("pr", sub, third, options, args);