diff --git a/.agents/skills/unidesk-gh/SKILL.md b/.agents/skills/unidesk-gh/SKILL.md index 59899b67..946b4493 100644 --- a/.agents/skills/unidesk-gh/SKILL.md +++ b/.agents/skills/unidesk-gh/SKILL.md @@ -90,11 +90,17 @@ bun scripts/cli.ts gh issue comment create \ 评论正文 EOF +# 原地修正评论 +bun scripts/cli.ts gh issue comment update \ + --repo owner/name --body-stdin <<'EOF' +新的评论正文 +EOF + # 删除评论 bun scripts/cli.ts gh issue comment delete ``` -`--body ` 仅适合短单行。 +`edit` 是 `comment update` 的兼容别名。`--body ` 仅适合短单行。日常修正文案优先用 `update/edit` 保留评论 ID 和时间线;`delete` 只用于确实需要删除的评论。 ### 关闭/重开 @@ -193,12 +199,14 @@ guarded merge:先做 closeout 预检,拒绝非 ready PR。已 merged 返回 ```bash bun scripts/cli.ts gh pr update --mode replace|append --body-stdin [--title ...] bun scripts/cli.ts gh pr comment create --body-stdin +bun scripts/cli.ts gh pr comment update --body-stdin +bun scripts/cli.ts gh pr comment edit --body-stdin bun scripts/cli.ts gh pr comment delete bun scripts/cli.ts gh pr close [--comment ...] bun scripts/cli.ts gh pr reopen [--comment ...] ``` -与 issue 对应命令行为一致。 +`pr comment edit` 是 `pr comment update` 的兼容别名。与 issue 对应命令行为一致;PR 评论也是 GitHub issue comment,更新目标使用 commentId。 --- diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e8ec1a59..ecf5530e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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:/`、`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 可能混入 PR,CLI 会从 `.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 --state closed` 落到错命令上时,CLI 必须返回 `validation-failed` 并显式提示 `gh issue close ` / `gh issue reopen `(PR 用 `gh pr close|reopen `),并把 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 --body-stdin <<'EOF'` 写证据评论,再用 `gh issue close --comment <短引用>` 关闭。issue 硬删除走 `close`,PR 硬删除走 `close`,两者都没有"delete"语义。 -- `gh issue comment create --repo owner/name --body-stdin`、`gh issue comment delete --repo owner/name`、`gh issue close --repo owner/name [--comment |--comment-stdin]`、`gh issue reopen --repo owner/name [--comment |--comment-stdin]`、`gh issue update --repo owner/name [--title ...] [--body-stdin]`、`gh issue edit ...`、`gh issue board-row get|update|add|move|delete|upsert --repo owner/name ...` 都接受与 `gh issue view|read`、`gh pr *` 一致的 `owner/repo#number` 位置 shorthand;shorthand 与显式 `--repo` 冲突时结构化失败并把两者都回显到错误对象里,避免静默改写目标 repo。`gh issue view|read`、`gh pr view|read|files|diff|preflight|closeout|comment create|comment delete|close|reopen|merge|edit|update` 已长期支持该 shorthand,issue 写命令对齐后整个 `gh` 子命令在 shorthand 行为上保持一致,不再需要把 `pikasTech/HWLAB#621` 拆成 `621 --repo pikasTech/HWLAB`。来源:HWLAB #621 CLI 验收 `gh issue comment create pikasTech/HWLAB#621` 摩擦改进。 +- `gh issue comment create --repo owner/name --body-stdin`、`gh issue comment update|edit --repo owner/name --body-stdin`、`gh issue comment delete --repo owner/name`、`gh issue close --repo owner/name [--comment |--comment-stdin]`、`gh issue reopen --repo owner/name [--comment |--comment-stdin]`、`gh issue update --repo owner/name [--title ...] [--body-stdin]`、`gh issue edit ...`、`gh issue board-row get|update|add|move|delete|upsert --repo owner/name ...` 都接受与 `gh issue view|read`、`gh pr *` 一致的 `owner/repo#number` 位置 shorthand;shorthand 与显式 `--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` 已长期支持该 shorthand;comment 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 [--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/` 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 --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 --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 payload,GitHub 返回不存在 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 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-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 北京时间` 段落发送给 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` 调整开关、目标和超时。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 62a809db..23df2834 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -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。 diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts index 7718894b..0d894f5f 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -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", ], }; diff --git a/scripts/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts index ed751751..52e57c25 100644 --- a/scripts/gh-cli-pr-contract-test.ts +++ b/scripts/gh-cli-pr-contract-test.ts @@ -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", diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 96af1b34..9e3fc7c3 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -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);