From b8cc847e48e5249e688c6c7419fc8a33345f6432 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 12:52:58 +0000 Subject: [PATCH] feat(cli): unify gh issue and pr crud --- docs/reference/cli.md | 8 +- docs/reference/code-queue-supervision.md | 6 +- scripts/gh-cli-issue-guard-contract-test.ts | 48 +++ scripts/gh-cli-pr-contract-test.ts | 72 ++++- scripts/src/gh.ts | 319 +++++++++++++++++--- scripts/src/help.ts | 2 +- 6 files changed, 397 insertions(+), 58 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2be55418..2857362e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -31,10 +31,10 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。`deploy-backend-core` 是 deprecated 兼容名,标准 backend-core prod CD 入口是 `deploy apply --env prod --service backend-core`。长期规则见 `docs/reference/artifact-registry.md`。 - `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary`、`missing-token`、`auth-failed`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response` 或 `unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应优先用该字段分流。 - `gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open`、`limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。 -- `gh issue view [--repo owner/name] [--json body,title,state,comments]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`gh issue create --title --body-file <file> [--dry-run]`、`gh issue edit <number> --body-file <file> [--title ...] [--dry-run]`、`gh issue comment <number> --body-file <file> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。 -- `gh issue edit <number> --body-file <file>` 默认拒绝字面量 `null`、空白正文和过短正文;只有真实需要写短正文时才允许显式加 `--allow-short-body`,返回 JSON 会报告该风险。#20 总看板和 #24 指挥简报是长期 body-only issue,`--body-profile auto` 会按 issue number 自动启用结构 guard:#20 必须包含 `## 看板(OPEN)`,#24 必须包含 `## 常驻观察与长期建议`;也可显式使用 `--body-profile code-queue-board|commander-brief`。`--dry-run` 不 PATCH GitHub,输出新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格和 shell 污染信号;若环境里有 `GH_TOKEN` 或 `GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入可带 `--expect-updated-at <updated_at>` 或 `--expect-body-sha <sha256>`,CLI 会先读当前 issue,匹配后才 PATCH,防止旧缓存覆盖新正文。 +- `gh issue view <number> [--repo owner/name] [--json body,title,state,comments]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`gh issue create --title <title> --body-file <file> [--dry-run]`、`gh issue update <number> --mode replace|append --body-file <file> [--title ...] [--dry-run]`、`gh issue comment create <number> --body-file <file> [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`。 +- `gh issue update <number> --mode replace|append --body-file <file>` 是正文更新主入口,`edit` 保留为兼容别名。`replace` 用文件正文替换现有 body;`append` 先读取当前 body,再按 UTF-8 文件字节追加,保留真实换行、反引号和 Markdown 表格。更新默认拒绝字面量 `null`、空白正文和过短正文;只有真实需要写短正文时才允许显式加 `--allow-short-body`,返回 JSON 会报告该风险。#20 总看板和 #24 指挥简报是长期 body-only issue,`--body-profile auto` 会按 issue number 自动启用结构 guard:#20 必须包含 `## 看板(OPEN)`,#24 必须包含 `## 常驻观察与长期建议`;也可显式使用 `--body-profile code-queue-board|commander-brief`。`--dry-run` 不 PATCH GitHub,输出新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格和 shell 污染信号;若环境里有 `GH_TOKEN` 或 `GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入可带 `--expect-updated-at <updated_at>` 或 `--expect-body-sha <sha256>`,CLI 会先读当前 issue,匹配后才 PATCH,防止旧缓存覆盖新正文。 - `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是指挥简报 #24 的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`,可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID` 和 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 覆盖。 -- `gh issue scan-escape [--repo owner/name] [--limit N]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape 和 ANSI escape 字符串,输出 issue/comment id、url、kind、snippet,不自动修复。`gh pr list|view` 继续提供 REST 只读列表和详情;`gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]` 与 `gh pr comment <number> --body-file <file>|--body <text> [--dry-run]` 是安全写入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr comment --dry-run` 只输出计划并保留 Markdown 原始换行和反引号;非 dry-run 会先确认 PR 存在再写入 issue comment。`gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`。 +- `gh issue scan-escape [--repo owner/name] [--limit N]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape 和 ANSI escape 字符串,输出 issue/comment id、url、kind、snippet,不自动修复。`gh pr list|view [--json ...]` 提供 REST 列表和详情,PR 字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt`。`gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]`、`gh pr update <number> --mode replace|append --body-file <file>|--body <text> [--title ...] [--dry-run]`、`gh pr comment create <number> --body-file <file>|--body <text> [--dry-run]`、`gh pr comment delete <commentId> [--dry-run]`、`gh pr close|reopen <number> [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr update --mode append` 会先读取当前 PR body 再追加正文。`gh pr delete <number>` 和 `gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`;PR 生命周期删除语义请使用 `close`。 - `ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs` 管理 D601 原生 k3s 上的 Tekton CI。`run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`publish-backend-core` 与 `publish-user-service` 从 pushed Git commit 构建并发布 `127.0.0.1:5000/unidesk/<service>:<commit>` commit-pinned artifacts,输出 `artifactSummary`(含 `serviceId`、`sourceCommit`、`sourceRepo`、`dockerfile`、`imageRef`、`tag`、`digest`、`digestRef`),但不部署生产;`run-dev-e2e` 的 Git 控制 runner、短 launcher、host fetch 边界、临时 smoke namespace 和 no-CD 规则只在 `docs/reference/dev-ci-runner.md` 定义;Tekton CI 通用规则见 `docs/reference/ci.md`。 - `codex deploy <commitId>` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`。 - `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向稳定 `code-queue` 用户服务路径提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求且不实际入队。提交确认和 dry-run 必须返回完整 prompt、字符数和 `truncated=false`,不能套用任务详情的预览截断策略,否则长任务 prompt 无法被人工验收。真实提交会经过本机本地串行化保护和短节流,避免同一指挥端并发 submit 把低内存主机或 `code-queue-mgr` 控制面打抖;返回值会附带 `submitConcurrencyGuard` 说明本次提交的锁与等待信息。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQL;D601 scheduler 只轮询并执行已入库任务。 @@ -69,7 +69,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI `microservice proxy` 是面向人工验证和受控调试的私有后端入口。默认 method 为 GET;使用 `--body-json JSON`、`--body-file path` 或 `--body-stdin` 时默认 method 切换为 POST,也可显式加 `--method POST|PUT|PATCH|DELETE`,但 GET/HEAD 不允许携带请求体。所有请求仍受 config 中的 `allowedMethods` 和 `allowedPathPrefixes` 限制。为了避免 Pipeline snapshot 这类超大业务 JSON 造成 CLI 输出爆炸,响应 body 超过默认阈值时会返回 `bodyOmitted=true`、`bodyPreview`、`bodyBytes` 和 `rawHint`;`--raw` 仍受默认硬限额保护,需要完整 body 时显式添加 `--raw --full`,或用 `--max-body-bytes <N>` 调整预览阈值。正式 frontend 展示仍应优先使用业务控件和 `__unideskArrayLimit` 这类展示级裁剪参数,而不是默认倾倒完整 JSON。 -GitHub issue 写操作必须优先使用 `bun scripts/cli.ts gh issue ... --body-file <file>`。不要把 Markdown 正文拼进 shell 参数、`gh issue comment --body` 或 `gh api -f body=...`;这些路径容易把真实换行污染成字面量 `\n`。`gh issue` 写命令第一阶段不接受 stdin 正文;需要从生成内容写入 issue 时,先落到临时 Markdown 文件或已审阅的工作文件,再把该文件路径传给 `--body-file`。PR 安全写入口同样优先 `--body-file`,`--body` 只适合短单行内容;`gh pr merge` 暂不开放,不存在 `--confirm` 可绕过的真实 merge 路径。CLI 会按 UTF-8 原样读取文件内容并用 JSON body 调用 REST API。 +GitHub issue/PR 写操作必须优先使用 `bun scripts/cli.ts gh issue|pr ... --body-file <file>`。不要把 Markdown 正文拼进 shell 参数、`gh issue comment --body` 或 `gh api -f body=...`;这些路径容易把真实换行污染成字面量 `\n`。`gh issue` 写命令第一阶段不接受 stdin 正文;需要从生成内容写入 issue 时,先落到临时 Markdown 文件或已审阅的工作文件,再把该文件路径传给 `--body-file`。PR 安全写入口同样优先 `--body-file`,`--body` 只适合短单行内容;`update --mode append` 用 REST 读取旧正文后追加文件字节,不引入 shell 拼接正文路径。`gh pr merge` 暂不开放,不存在 `--confirm` 可绕过的真实 merge 路径。CLI 会按 UTF-8 原样读取文件内容并用 JSON body 调用 REST API。 `network perf` 用于生成组网性能前后对比数据。标准 Code Queue overview 读路径基准命令是 `bun scripts/cli.ts network perf --service code-queue --path /api/tasks/overview?limit=30 --count 30 --concurrency 1 --label before`,远程主 server 可用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 network perf ...`。输出包含成功/失败数、状态码分布、`x-unidesk-cache`、`x-unidesk-proxy-mode`、`x-unidesk-upstream-proxy-mode` 分布和 min/p50/p90/p95/max;provider-gateway 长连接数据面验收应看到 `proxyModeCounts.provider-ws-http-tunnel`,adapter native Service 数据面验收应看到 upstream proxy mode 为 `kubernetes-native-service`,若出现 `kubernetes-api-service-proxy` 必须结合 `/api/control-plane.nativeServiceProxy.failedServices` 解释 fallback 原因。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 48696e26..2c1f3cf4 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -70,15 +70,15 @@ issue 内容必须自包含,至少写清楚背景、外部收益、当前观 如果某个 worker 任务需要依赖 GitHub issue 内容,但 runner 的 issue 可达性尚未被单独验证,指挥官不能默认 worker 已能读取该 issue。此时 worker prompt 必须直接内嵌完整需求、约束和验收点,issue URL 只能作为辅助引用。若要把 issue 作为任务输入源,先单独做可达性探测,再决定是否把 issue 作为常规前置条件。 -GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/view/create/edit/comment/close/reopen/scan-escape`、`gh pr list|view`、`gh pr create` 和 `gh pr comment`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `missing-binary`、`missing-token`、`auth-failed`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response`、`unsupported-command` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。`gh issue list --state open --limit N --json number,title,state,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue view <number> --json body` 是兼容入口,正文仍应从 `.data.issue.body` 读取;未知 `--json` 字段必须失败,不得让调用方把空正文误判为读取成功。issue 创建、编辑、评论和关闭以及 PR 创建和评论使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。`gh pr merge` 仍然不开放。 +GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/view/create/update/comment create/comment delete/close/reopen/scan-escape`、`gh pr list/view/create/update/comment create/comment delete/close/reopen`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `missing-binary`、`missing-token`、`auth-failed`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response`、`unsupported-command` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。`gh issue list --state open --limit N --json number,title,state,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue view <number> --json body` 是兼容入口,正文仍应从 `.data.issue.body` 读取;未知 `--json` 字段必须失败,不得让调用方把空正文误判为读取成功。`gh pr list|view --json ...` 支持 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt` 字段白名单。issue/PR 创建、更新、评论、评论删除、关闭和重开使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。GitHub 不支持 issue/PR 硬删除,`gh issue delete` 和 `gh pr delete` 必须结构化返回 `unsupported-command`;生命周期删除语义使用 `close`。`gh pr merge` 仍然不开放。 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。`gh issue` 写命令第一阶段不接受 stdin 正文;需要更新 #20 总看板或创建新 issue/comment 时,先把正文写入 Markdown 文件,再运行 `bun scripts/cli.ts gh issue edit|comment|create ... --body-file <file>`。`gh issue edit --body-file` 默认拒绝 `null`、空白和过短正文;#20 自动要求 `## 看板(OPEN)`,#24 自动要求 `## 常驻观察与长期建议`。更新 body-only issue 前优先跑 `--dry-run`,查看旧/新正文长度、body SHA、关键标题、字面量 `\n` 和 shell 污染信号;正式写入长期正文时优先带上 `--expect-updated-at` 或 `--expect-body-sha`,避免旧缓存覆盖新正文。更新 #24 指挥简报主体时使用 `bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff`,命令会先读取旧正文并只把本次新增的 `## 更新 ... 北京时间` 时间线段落推送给 ClaudeQQ;头部“常驻观察与长期建议”等非时间线修改不会单独通知。发送失败只体现在返回 JSON 的 `claudeqq.ok=false`,不回滚已经写入的 GitHub issue。提交前或巡检时可用 `gh issue scan-escape --limit N` 只读扫描污染,不自动修复。 +所有 GitHub Markdown 正文写入必须来自 `--body-file <file>`。不要使用 `gh issue comment --body`、`gh api -f body=...` 或把多行正文直接拼进 shell 参数;这些路径容易把真实换行、反引号和 Markdown 表格污染成字面量 `\n` 或 shell escape。`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)`,#24 自动要求 `## 常驻观察与长期建议`。更新 body-only issue 前优先跑 `--dry-run`,查看旧/新正文长度、body SHA、关键标题、字面量 `\n` 和 shell 污染信号;正式写入长期正文时优先带上 `--expect-updated-at` 或 `--expect-body-sha`,避免旧缓存覆盖新正文。更新 #24 指挥简报主体时仍可使用兼容命令 `bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff`,命令会先读取旧正文并只把本次新增的 `## 更新 ... 北京时间` 时间线段落推送给 ClaudeQQ;头部“常驻观察与长期建议”等非时间线修改不会单独通知。发送失败只体现在返回 JSON 的 `claudeqq.ok=false`,不回滚已经写入的 GitHub issue。提交前或巡检时可用 `gh issue scan-escape --limit N` 只读扫描污染,不自动修复。 PR 是审查型交付入口,不是所有 Code Queue 任务的默认出口。默认 master-only 交付仍按项目 Git 规则执行;当变更风险高、跨模块、需要人工审查、或任务目标明确要求 PR 交付时,worker 可以创建 PR。PR 型任务必须报告源分支、目标分支、PR URL、关联 issue、测试证据和未完成风险。禁止把 PR 当成隐藏分支仓库;PR 分支必须来自最新目标线,保持小而可审查,并在合并后确认目标分支远端 commit 可 fetch。 -PR 支持本身是 Code Queue 能力的一部分。当前 UniDesk CLI 支持 `gh pr list|view|create|comment`,其中 create 需要显式 `--title`、`--base`、`--head` 和正文来源,comment 需要显式 PR number 和正文来源,且推荐使用 `--body-file`。`pr create --dry-run` 与 `pr comment --dry-run` 只返回 planned operation,不创建 PR、不写评论;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态。`gh pr merge` 仍是后续范围,本阶段没有 `--confirm` 可以启用真实 merge。普通 worker 不应隐式依赖未实现的 PR 合并能力;需要 PR 交付时,prompt 必须明确允许的人工或后续工具路径,并报告未覆盖范围。 +PR 支持本身是 Code Queue 能力的一部分。当前 UniDesk CLI 支持 `gh pr list|view|create|update|comment create|comment delete|close|reopen`,其中 create 需要显式 `--title`、`--base`、`--head` 和正文来源,update 需要显式 PR number、正文来源和 `--mode replace|append`,comment create 需要显式 PR number 和正文来源,且推荐使用 `--body-file`。`pr create --dry-run`、`pr update --dry-run` 与 `pr comment create --dry-run` 只返回 planned operation,不创建 PR、不更新正文、不写评论;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,append 更新会先读取当前 PR body。`gh pr delete` 和 `gh pr merge` 仍是后续范围,本阶段没有 `--confirm` 可以启用真实 merge,也不能伪造硬删除;需要移除活跃 PR 时使用 `gh pr close`。普通 worker 不应隐式依赖未实现的 PR 合并能力;需要 PR 交付时,prompt 必须明确允许的人工或后续工具路径,并报告未覆盖范围。 ## 监控 diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts index a760eccb..01e2ade7 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -166,6 +166,16 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque sendJson(res, 200, { ...issue, body: String(parsed.body ?? issue.body), updated_at: "2026-05-20T01:05:00Z" }); return; } + if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues/20/comments") { + const parsed = JSON.parse(body) as JsonRecord; + sendJson(res, 201, { id: 9001, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/20#issuecomment-9001", user: { login: "tester" }, created_at: "2026-05-20T06:00:00Z", updated_at: "2026-05-20T06:00:00Z" }); + return; + } + if (req.method === "DELETE" && req.url === "/repos/pikasTech/unidesk/issues/comments/9001") { + res.statusCode = 204; + res.end(); + return; + } sendJson(res, 404, { message: "not found" }); }); await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -302,6 +312,41 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const staleData = failedDataOf(staleEdit.json ?? {}); assertCondition(staleData.degradedReason === "validation-failed", "stale guard should be validation-failed", staleData); + const appendFile = join(tmp, "append.md"); + writeFileSync(appendFile, "\n- appended `code`\n| c | d |\n| --- | --- |\n| 3 | 4 |\n", "utf8"); + const appendDryRun = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "append", "--body-file", appendFile, "--dry-run"], env); + assertCondition(appendDryRun.status === 0, "issue update append dry-run should succeed", appendDryRun.json ?? { stdout: appendDryRun.stdout }); + const appendData = dataOf(appendDryRun.json ?? {}); + assertCondition(appendData.command === "issue update", "update command should be primary", appendData); + assertCondition(appendData.mode === "append", "append mode should be explicit", appendData); + assertCondition(appendData.containsBackticks === true && appendData.containsMarkdownTable === true, "append should preserve markdown signals", appendData); + assertCondition(appendData.containsLiteralBackslashN === false, "append should preserve real newlines", appendData); + + const replaceDryRun = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", safeFile, "--dry-run"], env); + assertCondition(replaceDryRun.status === 0, "issue update replace dry-run should succeed", replaceDryRun.json ?? { stdout: replaceDryRun.stdout }); + const replaceData = dataOf(replaceDryRun.json ?? {}); + assertCondition(replaceData.command === "issue update" && replaceData.mode === "replace", "replace mode should be explicit", replaceData); + + const commentCreate = await runCli(["gh", "issue", "comment", "create", "20", "--repo", "pikasTech/unidesk", "--body-file", appendFile], env); + assertCondition(commentCreate.status === 0, "issue comment create should succeed", commentCreate.json ?? { stdout: commentCreate.stdout }); + const commentCreateData = dataOf(commentCreate.json ?? {}); + assertCondition(commentCreateData.command === "issue comment create", "comment create should use CRUD command name", commentCreateData); + + const commentDeleteDryRun = await runCli(["gh", "issue", "comment", "delete", "9001", "--repo", "pikasTech/unidesk", "--dry-run"], env); + assertCondition(commentDeleteDryRun.status === 0, "issue comment delete dry-run should succeed", commentDeleteDryRun.json ?? { stdout: commentDeleteDryRun.stdout }); + const commentDeleteDryRunData = dataOf(commentDeleteDryRun.json ?? {}); + assertCondition(commentDeleteDryRunData.command === "issue comment delete" && commentDeleteDryRunData.planned === true, "comment delete dry-run should plan DELETE", commentDeleteDryRunData); + + const commentDelete = await runCli(["gh", "issue", "comment", "delete", "9001", "--repo", "pikasTech/unidesk"], env); + assertCondition(commentDelete.status === 0, "issue comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout }); + const commentDeleteData = dataOf(commentDelete.json ?? {}); + assertCondition(commentDeleteData.deleted === true, "comment delete should report deleted", commentDeleteData); + + const issueDelete = await runCli(["gh", "issue", "delete", "20", "--repo", "pikasTech/unidesk"], env); + assertCondition(issueDelete.status !== 0, "issue hard delete should be unsupported", issueDelete.json ?? { stdout: issueDelete.stdout }); + const issueDeleteData = failedDataOf(issueDelete.json ?? {}); + assertCondition(issueDeleteData.degradedReason === "unsupported-command", "issue delete should be unsupported-command", issueDeleteData); + return { ok: true, checks: [ @@ -317,6 +362,9 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { "dry-run reports old/new body safety and does not PATCH", "multiline Markdown and backticks are not polluted", "expect-updated-at stale write protection blocks PATCH", + "issue update replace/append modes preserve Markdown", + "issue comment create/delete follows CRUD shape", + "issue hard delete is structurally unsupported", ], }; } finally { diff --git a/scripts/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts index e1165396..e30c6097 100644 --- a/scripts/gh-cli-pr-contract-test.ts +++ b/scripts/gh-cli-pr-contract-test.ts @@ -79,6 +79,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque const server = createServer(async (req, res) => { const body = await collectBody(req); requests.push({ method: req.method ?? "", url: req.url ?? "", body }); + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk") { + sendJson(res, 200, { id: 1, full_name: "pikasTech/unidesk", private: true, default_branch: "master", permissions: { pull: true, push: true } }); + return; + } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=all&per_page=4") { sendJson(res, 200, [pullRequest]); return; @@ -87,6 +91,21 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque sendJson(res, 200, pullRequest); return; } + if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/pulls/42") { + const parsed = JSON.parse(body) as JsonRecord; + sendJson(res, 200, { ...pullRequest, title: String(parsed.title ?? pullRequest.title), body: String(parsed.body ?? pullRequest.body), state: String(parsed.state ?? pullRequest.state), updated_at: "2026-05-20T06:00:00Z" }); + return; + } + if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues/42/comments") { + const parsed = JSON.parse(body) as JsonRecord; + 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 === "DELETE" && req.url === "/repos/pikasTech/unidesk/issues/comments/9101") { + res.statusCode = 204; + res.end(); + return; + } sendJson(res, 404, { message: "not found" }); }); await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -128,11 +147,13 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(Array.isArray(pullRequests) && pullRequests.length === 1, "pr list should return pullRequests", listData); assertCondition(pullRequests[0]?.number === 42 && pullRequests[0]?.base && pullRequests[0]?.head, "pr list should expose PR summary", pullRequests[0]); - const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk"], env); + const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env); assertCondition(view.status === 0, "pr view should succeed through REST", view.json ?? { stdout: view.stdout }); const viewData = dataOf(view.json ?? {}); const pullRequest = viewData.pullRequest as JsonRecord; assertCondition(pullRequest.number === 42 && pullRequest.url === "https://github.com/pikasTech/unidesk/pull/42", "pr view should expose PR details", viewData); + const selected = viewData.json as JsonRecord; + assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr view --json should select fields", viewData); } finally { await mock.close(); } @@ -163,12 +184,58 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(commentData.issueNumber === 42, "dry-run comment should preserve PR number", commentData); assertCondition(Number(commentData.bodyChars ?? 0) > 0, "dry-run comment should expose bodyChars", commentData); + const mock2 = await startMockGitHub(); + const env2 = { + GH_TOKEN: "contract-token", + UNIDESK_GITHUB_API_URL: mock2.baseUrl, + }; + try { + const updateReplace = await runCli(["gh", "pr", "update", "42", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", bodyFile, "--title", "updated"], env2); + assertCondition(updateReplace.status === 0, "pr update replace should succeed", updateReplace.json ?? { stdout: updateReplace.stdout }); + const updateReplaceData = dataOf(updateReplace.json ?? {}); + assertCondition(updateReplaceData.command === "pr update" && updateReplaceData.mode === "replace", "pr update replace should report mode", updateReplaceData); + + const updateAppend = await runCli(["gh", "pr", "update", "42", "--repo", "pikasTech/unidesk", "--mode", "append", "--body-file", bodyFile, "--dry-run"], env2); + assertCondition(updateAppend.status === 0, "pr update append dry-run should succeed", updateAppend.json ?? { stdout: updateAppend.stdout }); + const updateAppendData = dataOf(updateAppend.json ?? {}); + const finalBody = updateAppendData.finalBody as JsonRecord; + assertCondition(updateAppendData.mode === "append", "pr append mode should be explicit", updateAppendData); + assertCondition(finalBody.containsBackticks === true && finalBody.containsMarkdownTable === true, "pr append should preserve markdown signals", updateAppendData); + + const closePr = await runCli(["gh", "pr", "close", "42", "--repo", "pikasTech/unidesk"], env2); + assertCondition(closePr.status === 0, "pr close should succeed", closePr.json ?? { stdout: closePr.stdout }); + const closeData = dataOf(closePr.json ?? {}); + assertCondition(closeData.command === "pr close", "pr close command should be explicit", closeData); + + const reopenPr = await runCli(["gh", "pr", "reopen", "42", "--repo", "pikasTech/unidesk", "--dry-run"], env2); + assertCondition(reopenPr.status === 0, "pr reopen dry-run should succeed", reopenPr.json ?? { stdout: reopenPr.stdout }); + const reopenData = dataOf(reopenPr.json ?? {}); + assertCondition(reopenData.command === "pr reopen" && reopenData.dryRun === true, "pr reopen dry-run should be explicit", reopenData); + + const commentCreate = await runCli(["gh", "pr", "comment", "create", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile], env2); + assertCondition(commentCreate.status === 0, "pr comment create should succeed", commentCreate.json ?? { stdout: commentCreate.stdout }); + const commentCreateData = dataOf(commentCreate.json ?? {}); + assertCondition(commentCreateData.command === "pr comment create", "pr comment create should use CRUD command name", commentCreateData); + + 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 ?? {}); + assertCondition(commentDeleteData.deleted === true, "pr comment delete should report deleted", commentDeleteData); + } finally { + await mock2.close(); + } + const mergeBlocked = await runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk"]); assertCondition(mergeBlocked.status !== 0, "pr merge should fail", mergeBlocked.json ?? { stdout: mergeBlocked.stdout }); const mergeData = mergeBlocked.json?.data as JsonRecord | undefined; assertCondition(String(mergeData?.message ?? "").includes("intentionally unsupported"), "merge block message should be explicit", mergeData ?? {}); assertCondition(mergeData?.runnerDisposition === "business-failed", "merge block should classify as business-failed", mergeData ?? {}); + const deleteBlocked = await runCli(["gh", "pr", "delete", "42", "--repo", "pikasTech/unidesk"]); + assertCondition(deleteBlocked.status !== 0, "pr hard delete should fail", deleteBlocked.json ?? { stdout: deleteBlocked.stdout }); + const deleteData = deleteBlocked.json?.data as JsonRecord | undefined; + assertCondition(deleteData?.degradedReason === "unsupported-command", "pr delete should be unsupported-command", deleteData ?? {}); + const createMissingBody = await runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--base", "master", "--head", "feature/pr-contract", "--dry-run"]); assertCondition(createMissingBody.status !== 0, "pr create without body source should fail", createMissingBody.json ?? { stdout: createMissingBody.stdout }); const createMissingBodyData = createMissingBody.json?.data as JsonRecord | undefined; @@ -191,7 +258,10 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { "pr list/view work through REST with token and no gh binary dependency", "pr create dry-run exposes planned operation", "pr comment dry-run preserves markdown text", + "pr update replace/append and close/reopen are available", + "pr comment create/delete follows CRUD shape", "pr merge is blocked", + "pr hard delete is blocked", "pr create validation failures are structured", "unknown gh options are structured", ], diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 16998b0d..c0514eff 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -15,7 +15,9 @@ const CODE_QUEUE_BOARD_TARGET_ISSUE = 20; const COMMANDER_BRIEF_TARGET_ISSUE = 24; const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const; const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const; +const PR_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const; const ISSUE_LIST_STATES = ["open", "closed", "all"] as const; +const BODY_UPDATE_MODES = ["replace", "append"] as const; const ISSUE_BODY_PROFILES = { "code-queue-board": { label: "Code Queue long board issue #20", @@ -31,7 +33,9 @@ const ISSUE_BODY_PROFILES = { type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number]; type IssueListJsonField = typeof ISSUE_LIST_JSON_FIELDS[number]; +type PrJsonField = typeof PR_JSON_FIELDS[number]; type IssueListState = typeof ISSUE_LIST_STATES[number]; +type BodyUpdateMode = typeof BODY_UPDATE_MODES[number]; type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES; type IssueBodyProfileOption = "auto" | IssueBodyProfileName; @@ -134,7 +138,9 @@ interface GitHubOptions { head?: string; jsonFields?: IssueViewJsonField[]; issueListJsonFields?: IssueListJsonField[]; + prJsonFields?: PrJsonField[]; listState: IssueListState; + mode: BodyUpdateMode; expectUpdatedAt?: string; expectBodySha?: string; bodyProfile: IssueBodyProfileOption; @@ -269,12 +275,22 @@ function parseIssueListJsonFields(requested: string[] | undefined): IssueListJso return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS); } +function parsePrJsonFields(command: string, requested: string[] | undefined): PrJsonField[] | undefined { + return validateJsonFields(command, requested, PR_JSON_FIELDS); +} + function parseIssueListState(args: string[]): IssueListState { const raw = optionValue(args, "--state") ?? "open"; if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as IssueListState; throw new Error(`unsupported gh issue list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`); } +function parseBodyUpdateMode(args: string[]): BodyUpdateMode { + const raw = optionValue(args, "--mode") ?? "replace"; + if ((BODY_UPDATE_MODES as readonly string[]).includes(raw)) return raw as BodyUpdateMode; + throw new Error(`unsupported --mode ${raw}; supported modes: ${BODY_UPDATE_MODES.join(",")}`); +} + function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption { const raw = optionValue(args, "--body-profile") ?? "auto"; if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw; @@ -282,7 +298,7 @@ function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption { } function validateKnownOptions(args: string[]): void { - const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--expect-updated-at", "--expect-body-sha", "--body-profile"]); + const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--mode", "--expect-updated-at", "--expect-body-sha", "--body-profile"]); const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -314,7 +330,9 @@ function parseOptions(args: string[]): GitHubOptions { head: optionValue(args, "--head"), jsonFields: top === "issue" && sub === "view" ? parseIssueViewJsonFields(requestedJsonFields) : undefined, issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined, + prJsonFields: top === "pr" && (sub === "view" || sub === "list") ? parsePrJsonFields(`gh pr ${sub}`, requestedJsonFields) : undefined, listState: parseIssueListState(args), + mode: parseBodyUpdateMode(args), expectUpdatedAt: optionValue(args, "--expect-updated-at"), expectBodySha: optionValue(args, "--expect-body-sha"), bodyProfile: parseIssueBodyProfile(args), @@ -1110,6 +1128,13 @@ function prSummary(pr: GitHubPullRequest): Record<string, unknown> { }; } +function selectedPrJson(pr: GitHubPullRequest, fields: PrJsonField[]): Record<string, unknown> { + const summary = prSummary(pr) as Record<PrJsonField, unknown>; + const selected: Record<string, unknown> = {}; + for (const field of fields) selected[field] = summary[field]; + return selected; +} + function repoSummary(repo: GitHubRepository): Record<string, unknown> { return { id: repo.id ?? null, @@ -1217,6 +1242,25 @@ function prCommentPlannedOperation(repo: string, issueNumber: number, body: stri }; } +function bodyUpdatePlan(command: string, repo: string, number: number, mode: BodyUpdateMode, body: string, bodySource: Record<string, unknown>, existingBody: string | null): Record<string, unknown> { + const finalBody = mode === "append" ? `${existingBody ?? ""}${body}` : body; + return { + command, + repo, + number, + mode, + bodySource, + input: bodySafetySignals(body), + existingBody: existingBody === null ? { fetched: false } : { fetched: true, bodyChars: existingBody.length, bodySha: bodySha(existingBody) }, + finalBody: { + bodyChars: finalBody.length, + bodyPreview: preview(finalBody), + bodyPreviewLines: previewLines(finalBody), + ...bodySafetySignals(finalBody), + }, + }; +} + async function prCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> { if (options.title === undefined) return validationError("pr create", repo, "pr create requires --title <title>"); if (options.base === undefined) return validationError("pr create", repo, "pr create requires --base <branch>"); @@ -1319,11 +1363,11 @@ async function prComment(repo: string, token: string, issueNumber: number, optio const { owner, name } = repoParts(repo); const prResult = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${issueNumber}`); if (isGitHubError(prResult)) return commandError("pr comment", repo, prResult, { issueNumber, planned }); - const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body }); - if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned }); - return { - ok: true, - command: "pr comment", + const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body }); + if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned }); + return { + ok: true, + command: "pr comment create", repo, issueNumber, pr: prSummary(prResult), @@ -1342,6 +1386,65 @@ async function prComment(repo: string, token: string, issueNumber: number, optio }; } +async function prUpdate(repo: string, token: string, number: number, options: GitHubOptions): Promise<GitHubCommandResult> { + let bodySource: { body: string; bodySource: Record<string, unknown> }; + try { + bodySource = readMarkdownBody(options, "pr update"); + } catch (error) { + return validationError("pr update", repo, error instanceof Error ? error.message : String(error), { number }); + } + const { owner, name } = repoParts(repo); + let oldPr: GitHubPullRequest | null = null; + if (token.length > 0 && (options.mode === "append" || !options.dryRun)) { + const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); + if (isGitHubError(pr)) return commandError("pr update", repo, pr, { number, phase: "read-before-update" }); + oldPr = pr; + } + const finalBody = options.mode === "append" ? `${oldPr?.body ?? ""}${bodySource.body}` : bodySource.body; + const planned = bodyUpdatePlan("pr update", repo, number, options.mode, bodySource.body, bodySource.bodySource, oldPr?.body ?? null); + if (options.dryRun) { + return { + ok: true, + command: "pr update", + repo, + dryRun: true, + planned: true, + number, + title: options.title ?? null, + ...planned, + request: { + method: "PATCH", + path: `/repos/{owner}/{repo}/pulls/${number}`, + body: { title: options.title ?? null, bodyChars: finalBody.length }, + }, + }; + } + const payload: Record<string, unknown> = { body: finalBody }; + if (options.title !== undefined) payload.title = options.title; + const pr = await githubRequest<GitHubPullRequest>(token, "PATCH", `/repos/${owner}/${name}/pulls/${number}`, payload); + if (isGitHubError(pr)) return commandError("pr update", repo, pr, { number, planned }); + return { + ok: true, + command: "pr update", + repo, + number, + mode: options.mode, + pullRequest: prSummary(pr), + update: planned, + request: { method: "PATCH", path: `/repos/${owner}/${name}/pulls/${number}`, title: options.title ?? null, bodyChars: finalBody.length }, + rest: true, + }; +} + +async function prState(repo: string, token: string, number: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> { + const command = state === "closed" ? "pr close" : "pr reopen"; + if (dryRun) return { ok: true, command, repo, dryRun: true, number, wouldPatch: { state } }; + const { owner, name } = repoParts(repo); + const pr = await githubRequest<GitHubPullRequest>(token, "PATCH", `/repos/${owner}/${name}/pulls/${number}`, { state }); + if (isGitHubError(pr)) return commandError(command, repo, pr, { number }); + return { ok: true, command, repo, number, pullRequest: prSummary(pr), rest: true }; +} + async function listIssueComments(token: string, repo: string, issueNumber: number): Promise<GitHubComment[] | GitHubErrorPayload> { const { owner, name } = repoParts(repo); return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`); @@ -1427,30 +1530,37 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions): return { ok: true, command: "issue create", repo, issue: issueSummary(issue), rest: true }; } -async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { - const body = readBodyFile(options.bodyFile, "issue edit"); - const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options); - if (bodyGuard !== null) return bodyGuard; +async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName = "issue edit"): Promise<GitHubCommandResult> { + const body = readBodyFile(options.bodyFile, commandName); + if (options.mode === "replace") { + const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options); + if (bodyGuard !== null) return bodyGuard; + } const concurrencyOptionError = assertConcurrencyOptions(options); if (concurrencyOptionError !== null) return concurrencyOptionError; let oldIssue: GitHubIssue | null = null; let briefDiff: CommanderBriefDiff | null = null; const claudeQqConfig = commanderBriefClaudeQqConfig(); if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) { - return validationError("issue edit", repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber }); + return validationError(commandName, repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber }); } - const needsReadBeforeEdit = !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined); + const needsReadBeforeEdit = options.mode === "append" || !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined); if (needsReadBeforeEdit) { const issue = await getIssue(token, repo, issueNumber); - if (isGitHubError(issue)) return commandError("issue edit", repo, issue, { issueNumber, phase: "read-before-edit" }); + if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" }); oldIssue = issue; const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options); if (concurrencyError !== null) return concurrencyError; - if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", body); + if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", options.mode === "append" ? `${issue.body ?? ""}${body}` : body); + } + const finalBody = options.mode === "append" ? `${oldIssue?.body ?? ""}${body}` : body; + if (options.mode === "append") { + const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options); + if (bodyGuard !== null) return bodyGuard; } if (options.dryRun) { - const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", body) : null; - const guard = issueEditGuardSummary(issueNumber, body, options); + const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", finalBody) : null; + const guard = issueEditGuardSummary(issueNumber, finalBody, options); let dryRunOldBody: Record<string, unknown> = { fetched: false, bodyChars: null, @@ -1485,17 +1595,19 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio } return { ok: true, - command: "issue edit", + command: commandName, repo, dryRun: true, issueNumber, - ...dryRunBody(repo, options.title, body), + mode: options.mode, + ...dryRunBody(repo, options.title, finalBody), guard, + update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, { kind: "body-file", path: options.bodyFile ?? null }, oldIssue?.body ?? null), bodyOnlySafety: { oldBody: dryRunOldBody, newBody: { - bodyChars: body.length, - bodySha: bodySha(body), + bodyChars: finalBody.length, + bodySha: bodySha(finalBody), }, }, concurrency: { @@ -1507,7 +1619,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio wouldPatch: { title: options.title ?? null, bodyFromFile: options.bodyFile }, ...(options.notifyClaudeQqBriefDiff ? { - commanderBriefNotification: commanderBriefNotificationPlan(issueNumber, body, dryRunDiff ?? commanderBriefDiff("", body), claudeQqConfig), + commanderBriefNotification: commanderBriefNotificationPlan(issueNumber, finalBody, dryRunDiff ?? commanderBriefDiff("", finalBody), claudeQqConfig), dryRunOldBodySource: "not-fetched", dryRunDiffReliability: "new-body-only preview; non-dry-run reads the GitHub issue body before PATCH and sends only sections absent from the old body", dryRunNoWrite: true, @@ -1517,17 +1629,17 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio }; } const { owner, name } = repoParts(repo); - const payload: Record<string, unknown> = { body }; + const payload: Record<string, unknown> = { body: finalBody }; if (options.title !== undefined) payload.title = options.title; const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload); - if (isGitHubError(issue)) return commandError("issue edit", repo, issue, { issueNumber }); - const guard = issueEditGuardSummary(issueNumber, body, options); + if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber }); + const guard = issueEditGuardSummary(issueNumber, finalBody, options); const concurrency = oldIssue === null ? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null } : { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }; - if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: "issue edit", repo, issue: issueSummary(issue), guard, concurrency, rest: true }; + if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: commandName, repo, issue: issueSummary(issue), mode: options.mode, guard, concurrency, rest: true }; - const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", body); + const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", finalBody); const claudeqq = diff.ok ? await sendCommanderBriefClaudeQq(claudeQqConfig, diff.message) : { @@ -1539,7 +1651,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio } satisfies ClaudeQqSendResult; return { ok: true, - command: "issue edit", + command: commandName, repo, issue: issueSummary(issue), guard, @@ -1567,11 +1679,38 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { const body = readBodyFile(options.bodyFile, "issue comment"); - if (options.dryRun) return { ok: true, command: "issue comment", repo, dryRun: true, issueNumber, ...dryRunBody(repo, undefined, body) }; + if (options.dryRun) return { ok: true, command: "issue comment create", repo, dryRun: true, issueNumber, ...dryRunBody(repo, undefined, body) }; const { owner, name } = repoParts(repo); const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body }); if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber }); - return { ok: true, command: "issue comment", repo, comment: commentSummary(comment), rest: true }; + return { ok: true, command: "issue comment create", repo, comment: commentSummary(comment), 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); + if (dryRun) { + return { + ok: true, + command, + repo, + dryRun: true, + planned: true, + commentId, + request: { method: "DELETE", path: `/repos/{owner}/{repo}/issues/comments/${commentId}` }, + }; + } + const result = await githubRequest<null>(token, "DELETE", `/repos/${owner}/${name}/issues/comments/${commentId}`); + if (isGitHubError(result)) return commandError(command, repo, result, { commentId }); + return { + ok: true, + command, + repo, + commentId, + deleted: true, + request: { method: "DELETE", path: `/repos/${owner}/${name}/issues/comments/${commentId}` }, + rest: true, + }; } async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> { @@ -1722,20 +1861,23 @@ async function authStatus(repo: string): Promise<GitHubCommandResult> { }; } -async function prList(repo: string, token: string, limit: number): Promise<GitHubCommandResult> { +async function prList(repo: string, token: string, limit: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> { const { owner, name } = repoParts(repo); const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=all&per_page=${limit}`); if (isGitHubError(prs)) return commandError("pr list", repo, prs); + const fields = jsonFields ?? PR_JSON_FIELDS.slice(); return { ok: true, command: "pr list", repo, - plannedScope: "read-only REST support", - pullRequests: prs.map(prSummary), + limit, + count: prs.length, + jsonFields: fields, + pullRequests: prs.map((pr) => selectedPrJson(pr, fields)), }; } -async function prView(repo: string, token: string, number: number): Promise<GitHubCommandResult> { +async function prView(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> { const { owner, name } = repoParts(repo); const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); if (isGitHubError(pr)) return commandError("pr view", repo, pr, { number }); @@ -1743,8 +1885,8 @@ async function prView(repo: string, token: string, number: number): Promise<GitH ok: true, command: "pr view", repo, - plannedScope: "read-only REST support", pullRequest: prSummary(pr), + ...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(pr, jsonFields) }), }; } @@ -1757,29 +1899,39 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]", "bun scripts/cli.ts gh issue view <number> [--repo owner/name] [--json body,title,state,comments]", "bun scripts/cli.ts gh issue create --title <title> --body-file <file> [--repo owner/name] [--dry-run]", - "bun scripts/cli.ts gh issue edit <number> --body-file <file> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body]", + "bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body]", + "bun scripts/cli.ts gh issue edit <number> --body-file <file> [compat alias for issue update --mode replace]", "bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]", - "bun scripts/cli.ts gh issue comment <number> --body-file <file> [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh issue comment create <number> --body-file <file> [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh issue delete <number> [unsupported: use close]", "bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N]", - "bun scripts/cli.ts gh pr list [--repo owner/name] [--limit N]", - "bun scripts/cli.ts gh pr view <number> [--repo owner/name]", + "bun scripts/cli.ts gh pr list [--repo owner/name] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]", + "bun scripts/cli.ts gh pr view <number> [--repo owner/name] [--json body,title,state,head,base,draft]", "bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]", - "bun scripts/cli.ts gh pr comment <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh pr update <number> --mode replace|append --body-file <file>|--body <text> [--title title] [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh pr delete <number> [unsupported: use close]", ], defaults: { repo: DEFAULT_REPO }, notes: [ - "Issue create/edit/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.", + "Issue and PR create/read/update/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.", "Token values are never printed; auth status reports only token source and presence.", "issue list defaults to --state open and bounded --limit 30; supported --json fields are number,title,state,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.", "issue view supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.", "--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.", - "issue edit --body-file refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 and #24 body-only profiles require their stable headings.", - "issue edit dry-run reports old/new body length slots, body SHA, required heading checks, literal \\n detection, and shell-pollution signals. Non-dry-run can use --expect-updated-at or --expect-body-sha for stale-cache protection.", + "update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.", + "issue edit is a compatibility alias for issue update --mode replace.", + "issue update --body-file refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 and #24 body-only profiles require their stable headings.", + "issue update dry-run reports old/new body length slots, body SHA, required heading checks, literal \\n detection, and shell-pollution signals. Non-dry-run can use --expect-updated-at or --expect-body-sha for stale-cache protection.", "Issue body stdin is intentionally unsupported in this CLI; write generated Markdown to a file and pass --body-file.", "issue edit 24 --notify-claudeqq-brief-diff 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.", - "PR create/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.", + "comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.", + "PR create/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.", ], }; } @@ -1808,16 +1960,41 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult } if (optionWasProvided(args, "--json") && !(top === "issue" && (sub === "view" || sub === "list"))) { const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--json field selection is only supported by gh issue view and gh issue list"); + if (!(top === "pr" && (sub === "view" || sub === "list"))) { + return validationError(command, options.repo, "--json field selection is only supported by gh issue view/list and gh pr view/list"); + } } - if ((options.allowShortBody || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined || optionWasProvided(args, "--body-profile")) && !(top === "issue" && sub === "edit")) { + if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && sub === "update"))) { const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--allow-short-body, --expect-updated-at, --expect-body-sha, and --body-profile are only supported by gh issue edit"); + return validationError(command, options.repo, "--mode is only supported by gh issue update/edit and gh pr update"); + } + if ((options.allowShortBody || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined || optionWasProvided(args, "--body-profile")) && !(top === "issue" && (sub === "edit" || sub === "update"))) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--allow-short-body, --expect-updated-at, --expect-body-sha, and --body-profile are only supported by gh issue update/edit"); } if (top === "auth" && sub === "status") return authStatus(options.repo); if (top === "issue") { + if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics."); + if (sub === "comment" && third === "delete") { + const commentId = parseNumberForCommand(options.repo, args[3], "issue comment delete"); + if (typeof commentId !== "number") return commentId; + if (options.dryRun) return commentDelete(options.repo, "", "issue", commentId, true); + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, "issue comment delete", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true }); + return commentDelete(options.repo, token, "issue", commentId, false); + } + if (sub === "comment" && third === "create") { + const issueNumber = parseNumberForCommand(options.repo, args[3], "issue comment create"); + if (typeof issueNumber !== "number") return issueNumber; + if (options.dryRun) return issueComment(options.repo, "", issueNumber, options); + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, "issue comment create", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment create", { present: false, source: null, ghFallbackAttempted: true }); + return issueComment(options.repo, token, issueNumber, options); + } if (options.dryRun) { if (sub === "create") return issueCreate(options.repo, "", options); if (sub === "edit") { @@ -1825,6 +2002,11 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult const { token } = resolveToken(false); return issueEdit(options.repo, token ?? "", issueNumber, options); } + if (sub === "update") { + const issueNumber = parseNumber(third, "issue update"); + const { token } = resolveToken(false); + return issueEdit(options.repo, token ?? "", issueNumber, options, "issue update"); + } if (sub === "comment") return issueComment(options.repo, "", parseNumber(third, "issue comment"), options); if (sub === "close") return issueState(options.repo, "", parseNumber(third, "issue close"), "closed", true); if (sub === "reopen") return issueState(options.repo, "", parseNumber(third, "issue reopen"), "open", true); @@ -1837,13 +2019,34 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult if (sub === "view") return issueView(options.repo, token, parseNumber(third, "issue view"), options.jsonFields); if (sub === "create") return issueCreate(options.repo, token, options); if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options); + if (sub === "update") return issueEdit(options.repo, token, parseNumber(third, "issue update"), options, "issue update"); if (sub === "comment") return issueComment(options.repo, token, parseNumber(third, "issue comment"), options); if (sub === "close") return issueState(options.repo, token, parseNumber(third, "issue close"), "closed", options.dryRun); if (sub === "reopen") return issueState(options.repo, token, parseNumber(third, "issue reopen"), "open", options.dryRun); + if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics."); if (sub === "scan-escape") return issueScanEscape(options.repo, token, options.limit); } if (top === "pr") { + if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics."); + if (sub === "comment" && third === "delete") { + const commentId = parseNumberForCommand(options.repo, args[3], "pr comment delete"); + if (typeof commentId !== "number") return commentId; + if (options.dryRun) return commentDelete(options.repo, "", "pr", commentId, true); + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, "pr comment delete", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true }); + return commentDelete(options.repo, token, "pr", commentId, false); + } + if (sub === "comment" && third === "create") { + const number = parseNumberForCommand(options.repo, args[3], "pr comment create"); + if (typeof number !== "number") return number; + if (options.dryRun) return prComment(options.repo, "", number, options); + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, "pr comment create", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment create", { present: false, source: null, ghFallbackAttempted: true }); + return prComment(options.repo, token, number, options); + } if (sub === "create") { if (options.dryRun) return prCreate(options.repo, "", options); const { token, probe } = resolveToken(true); @@ -1864,17 +2067,35 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult if (typeof number !== "number") return number; return prComment(options.repo, token, number, options); } + if (sub === "update") { + const number = parseNumberForCommand(options.repo, third, "pr update"); + if (typeof number !== "number") return number; + if (options.dryRun && options.mode === "replace") return prUpdate(options.repo, "", number, options); + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, "pr update", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr update", { present: false, source: null, ghFallbackAttempted: true }); + return prUpdate(options.repo, token, number, options); + } + if (sub === "close" || sub === "reopen") { + const number = parseNumberForCommand(options.repo, third, `pr ${sub}`); + if (typeof number !== "number") return number; + if (options.dryRun) return prState(options.repo, "", number, sub === "close" ? "closed" : "open", true); + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, `pr ${sub}`, probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); + return prState(options.repo, token, number, sub === "close" ? "closed" : "open", false); + } if (sub === "merge") { return unsupportedCommand("pr merge", options.repo, "PR merge is intentionally unsupported in this phase; use create/comment/read only."); } if (sub !== "list" && sub !== "view") { - return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, view, create, comment, and unsupported merge."); + return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, view, create, update, close, reopen, comment create/delete, and unsupported merge/delete."); } const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, `pr ${sub}`, probe); if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); - if (sub === "list") return prList(options.repo, token, options.limit); - return prView(options.repo, token, parseNumber(third, "pr view")); + if (sub === "list") return prList(options.repo, token, options.limit, options.prJsonFields); + return prView(options.repo, token, parseNumber(third, "pr view"), options.prJsonFields); } return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() }); diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 13c56692..c6180fe7 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -43,7 +43,7 @@ export function rootHelp(): unknown { { command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from origin/master:deploy.json environments; --commit overrides one reviewed artifact consumer such as frontend for release/v1 validation or rollback. code-queue artifact consumption is dev-only." }, { command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." }, { command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." }, - { command: "gh auth|issue|pr", description: "Run safe GitHub issue and PR list/view/create/comment operations through REST with body-file support, token diagnostics, escape scanning, and merge blocked." }, + { command: "gh auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, hard delete unsupported, and merge blocked." }, { command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." }, { command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." }, { command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },