diff --git a/TEST.md b/TEST.md index 2d5041f6..96f6bd1d 100644 --- a/TEST.md +++ b/TEST.md @@ -141,11 +141,11 @@ ## T26 GitHub CLI PR 安全写入口 -阅读 `AGENTS.md` 和 `docs/reference/cli.md`,然后用 cli 手动测试以下内容:准备一份包含真实换行、反引号和 Markdown 表格的临时正文文件,运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh pr create`、`gh pr edit`、`gh pr comment`、`gh pr read `、`gh pr preflight `、`gh preflight `、`--raw|--full`、`gh pr files ` 和 `gh pr diff --stat`。运行 `bun scripts/gh-cli-pr-contract-test.ts`,确认 mock GitHub 覆盖 PR read/view 的 `owner/repo#number` shorthand、`--raw` 完整披露、冲突 `--repo` 结构化失败、PR closeout GraphQL 字段、低噪声 `gh pr preflight`/`gh preflight`、PR edit/update REST PATCH payload、stdin `--body-file -` 和不回显完整正文。运行 `bun scripts/gh-cli-pr-files-contract-test.ts`,确认 mock GitHub 覆盖 `gh pr files` 的 REST changed-file/stat JSON、bounded file list、truncation metadata、next command、无 raw patch,以及 `gh pr diff --stat` 兼容别名和无 `--stat` raw diff 的结构化拒绝。对真实仓库只读观察可运行 `bun scripts/cli.ts gh pr files --repo pikasTech/unidesk --limit 30`、`bun scripts/cli.ts gh pr diff --repo pikasTech/unidesk --stat --limit 30` 或 `bun scripts/cli.ts gh pr preflight --repo pikasTech/unidesk`,确认输出固定 JSON 且默认不含 raw diff 或完整 status contexts;需要完整 status contexts 时显式加 `--full`。运行 `bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title --body-file <file> --base master --head <branch> --draft --dry-run`,确认命令不访问 GitHub、不创建 PR,JSON 中包含 `dryRun=true`、`planned=true`、repo、title、base、head、draft、bodyChars、bodyPreviewLines、request plan,并且正文预览保留真实换行和反引号。运行 `bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --title <title> --body-file <file> --dry-run`,确认命令使用 REST PATCH 计划、不访问 GitHub Projects Classic GraphQL/projectCards,JSON 只包含 repo、PR number、changedFields、url、body 长度/SHA/source 和 request plan,不默认回显完整正文;再运行 `cat <file> | bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --body-file - --dry-run`,确认 stdin source 标记为 `kind=stdin` 且同样低噪声。运行 `bun scripts/cli.ts gh pr comment <number> --repo pikasTech/unidesk --body-file <file> --dry-run`,确认命令不写评论,JSON 中包含 PR number、bodyChars、bodySource 和 request plan,且没有把换行污染成字面量 `\n`。运行 `bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk`,确认返回非零状态和结构化 JSON,`degradedReason=unsupported-command`、`runnerDisposition=business-failed`,且不会真实 merge。需要测试真实创建、编辑或评论时,只允许使用明确的 throwaway 源分支和 PR,并在记录中写明 PR URL、number、源/目标分支和清理动作;默认验收只做 dry-run,不创建或修改真实 PR。 +阅读 `AGENTS.md` 和 `docs/reference/cli.md`,然后用 cli 手动测试以下内容:准备一份包含真实换行、反引号和 Markdown 表格的临时正文文件,运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh pr create`、`gh pr edit`、`gh pr comment`、`gh pr view <number|url|owner/repo#number>`、`gh pr read ... [compatibility alias for pr view]`、`--number N compat`、`gh pr preflight <number>`、`gh preflight <prNumber>`、`--raw|--full`、`gh pr files <number>` 和 `gh pr diff <number> --stat`。运行 `bun scripts/gh-cli-pr-contract-test.ts`,确认 mock GitHub 覆盖 PR view/read 的 GitHub URL、`owner/repo#number` shorthand、`--number` 兼容提示、`--raw` 完整披露、冲突 `--repo` 结构化失败、PR closeout GraphQL 字段、低噪声 `gh pr preflight`/`gh preflight`、PR edit/update REST PATCH payload、stdin `--body-file -` 和不回显完整正文。运行 `bun scripts/gh-cli-pr-files-contract-test.ts`,确认 mock GitHub 覆盖 `gh pr files` 的 REST changed-file/stat JSON、bounded file list、truncation metadata、next command、无 raw patch,以及 `gh pr diff --stat` 兼容别名和无 `--stat` raw diff 的结构化拒绝。对真实仓库只读观察可运行 `bun scripts/cli.ts gh pr files <number> --repo pikasTech/unidesk --limit 30`、`bun scripts/cli.ts gh pr diff <number> --repo pikasTech/unidesk --stat --limit 30` 或 `bun scripts/cli.ts gh pr preflight <number> --repo pikasTech/unidesk`,确认输出固定 JSON 且默认不含 raw diff 或完整 status contexts;需要完整 status contexts 时显式加 `--full`。运行 `bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title <title> --body-file <file> --base master --head <branch> --draft --dry-run`,确认命令不访问 GitHub、不创建 PR,JSON 中包含 `dryRun=true`、`planned=true`、repo、title、base、head、draft、bodyChars、bodyPreviewLines、request plan,并且正文预览保留真实换行和反引号。运行 `bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --title <title> --body-file <file> --dry-run` 和 `bun scripts/cli.ts gh pr edit --number <number> --repo pikasTech/unidesk --title <title> --body-file <file> --dry-run`,确认命令使用 REST PATCH 计划、不访问 GitHub Projects Classic GraphQL/projectCards,`--number` 兼容路径返回 `standardSyntaxHint`,JSON 只包含 repo、PR number、changedFields、url、body 长度/SHA/source 和 request plan,不默认回显完整正文;再运行 `cat <file> | bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --body-file - --dry-run`,确认 stdin source 标记为 `kind=stdin` 且同样低噪声。运行 `bun scripts/cli.ts gh pr comment <number> --repo pikasTech/unidesk --body-file <file> --dry-run`,确认命令不写评论,JSON 中包含 PR number、bodyChars、bodySource 和 request plan,且没有把换行污染成字面量 `\n`;运行 `bun scripts/cli.ts gh pr comment delete --number <commentId> --repo pikasTech/unidesk --dry-run`,确认 `--number` 按 commentId 兼容并返回 `standardSyntaxHint`。运行 `bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk`,确认 guarded merge plan 或结构化失败不会绕过预检。需要测试真实创建、编辑、评论或 merge 时,只允许使用明确的 throwaway 源分支和 PR,并在记录中写明 PR URL、number、源/目标分支和清理动作;默认验收只做 dry-run,不创建或修改真实 PR。 ## T27 GitHub Issue/Comment 换行转义卫生扫描 -阅读 `AGENTS.md` 和 `docs/reference/cli.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh issue create --title <title> --body-file <file> [--label label[,label...]]...`、`gh issue read <number|owner/repo#number>`、`--raw|--full`、`gh issue scan-escape`、`gh issue cleanup-plan`、`gh issue board-row list` 和 `gh issue board-row update`,notes 中明确推荐 `--body-file`、quoted heredoc、只读 cleanup-plan、read/view shorthand、raw/full 显式完整披露、board-row update 默认 dry-run 和 `--expect-body-sha`/`--expect-updated-at` 并发保护。运行 `bun scripts/gh-cli-issue-guard-contract-test.ts`,确认 mock GitHub 覆盖 issue read/view 的 `owner/repo#number` shorthand、`--raw` 完整披露、冲突 `--repo` 结构化失败、污染命中、说明性 `\n` 命中不误报、短 body/null body guard、body-file dry-run 写入路径、`issue create --label cli,infra --label ops --dry-run` labels 解析和 request plan、真实 create REST payload labels、missing label 的结构化 `validation-failed`、comment-id/body-id 定位和 cleanupSuggestions、board-row list/get 复用 #20 表格解析、board-row update 给出 old/new row、body SHA、guard 结果、表格管道转义、默认 dry-run 不写入、带 `--expect-body-sha` 时只对 mock server PATCH、以及 board-row move 迁移支持。对真实仓库只允许运行 `bun scripts/cli.ts gh issue scan-escape --repo pikasTech/unidesk --limit <N> --dry-run`、`bun scripts/cli.ts gh issue cleanup-plan --repo pikasTech/unidesk --limit <N>`、`bun scripts/cli.ts gh issue board-row list --repo pikasTech/unidesk --board-issue 20 --state open --dry-run` 或 `bun scripts/cli.ts gh issue board-row get <issueNumber> --repo pikasTech/unidesk --board-issue 20` 这类只读命令;不得运行真实历史评论清理、不得真实改写 #20/#24 正文,除非另有明确人工指令并先审阅 dry-run 输出和 body SHA。 +阅读 `AGENTS.md` 和 `docs/reference/cli.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh issue create --title <title> --body-file <file> [--label label[,label...]]...`、`gh issue view <number|url|owner/repo#number>`、`gh issue read ... [compatibility alias for issue view]`、`--number N compat`、`--raw|--full`、`gh issue scan-escape`、`gh issue cleanup-plan`、`gh issue board-row list` 和 `gh issue board-row update`,notes 中明确推荐 `--body-file`、quoted heredoc、只读 cleanup-plan、view/read shorthand、GitHub URL、`--number` 兼容提示、raw/full 显式完整披露、board-row update 默认 dry-run 和 `--expect-body-sha`/`--expect-updated-at` 并发保护。运行 `bun scripts/gh-cli-issue-guard-contract-test.ts`,确认 mock GitHub 覆盖 issue view/read 的 GitHub URL、`owner/repo#number` shorthand、`--number` 兼容提示、`--raw` 完整披露、冲突 `--repo` 结构化失败、污染命中、说明性 `\n` 命中不误报、短 body/null body guard、body-file dry-run 写入路径、`issue create --label cli,infra --label ops --dry-run` labels 解析和 request plan、真实 create REST payload labels、missing label 的结构化 `validation-failed`、comment-id/body-id 定位、comment delete --number commentId dry-run 和 cleanupSuggestions、board-row list/get 复用 #20 表格解析、board-row update 给出 old/new row、body SHA、guard 结果、表格管道转义、默认 dry-run 不写入、带 `--expect-body-sha` 时只对 mock server PATCH、以及 board-row move 迁移支持。对真实仓库只允许运行 `bun scripts/cli.ts gh issue scan-escape --repo pikasTech/unidesk --limit <N> --dry-run`、`bun scripts/cli.ts gh issue cleanup-plan --repo pikasTech/unidesk --limit <N>`、`bun scripts/cli.ts gh issue board-row list --repo pikasTech/unidesk --board-issue 20 --state open --dry-run` 或 `bun scripts/cli.ts gh issue board-row get <issueNumber> --repo pikasTech/unidesk --board-issue 20` 这类只读命令;不得运行真实历史评论清理、不得真实改写 #20/#24 正文,除非另有明确人工指令并先审阅 dry-run 输出和 body SHA。 ## T28 Host Codex Commander Skeleton Contract diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 27e0cf75..721b72c2 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -60,24 +60,25 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P - `hwlab g14 control-plane cleanup-released-pvs --lane all [--limit N] [--dry-run|--confirm]` 是 local-path 未自动回收后的补充 retention 入口;只列并删除 `Released`、`local-path`、`Delete`、`claimNamespace=hwlab-ci` 且 claim 名称形如 Tekton 临时 `pvc-*` 的 PV。 - `hwlab g14 git-mirror status|apply|sync|flush [--dry-run|--confirm]` 是 `devops-infra` git mirror/relay 的受控维护入口:`apply` 渲染并 server-side apply `devops-infra/git-mirror.yaml`,同时删除遗留 `git-mirror-hwlab-sync` CronJob;`sync` 创建一次性 manual Job,把 GitHub allowlist refs 拉入本地 mirror;`flush` 创建一次性 manual Job,把本地 `v0.2-gitops` 快进推回 GitHub。 `status` 返回 read/write URL、last sync/write/flush、本地 ref、GitHub staging ref 和 pending flush 状态,并在 `cache.summary` 给出 `localV02`、`localGitops`、`githubGitops`、`pendingFlush`、`flushNeeded`、`githubInSync` 和下一条受控 `flushCommand`。confirmed `sync` 和 `flush` 默认创建 `.state/jobs/` 异步 job 并立刻返回可查询状态,只有现场同步调试才显式加 `--wait`;mirror 不设置 CronJob。 +- `hwlab g14 observability status|apply|query [--promql <expr>] [--dry-run|--confirm]` 是 G14 `devops-infra` 共享监控基础设施的受控入口。`apply` 固定安装 Prometheus Operator `v0.91.0`、Prometheus `v3.12.0`、Prometheus 发现 RBAC、`devops-infra` 内 Prometheus 实例和 ClusterIP query Service,并给被允许发现的 workload namespace 打低风险 label;它不把 Prometheus、Grafana 或 Alertmanager 部署到 `hwlab-v02`,也不接管 HWLAB runtime Deployment/Service。`status` 只读汇总 CRD、operator Deployment、Prometheus CR/pod/service、`hwlab-v02` ServiceMonitor/PrometheusRule 和 bounded `up` 查询;`query` 只通过 Kubernetes service proxy 查询 Prometheus,不开放 FRP、NodePort 或 LoadBalancer。长期边界见 `docs/reference/g14-observability-infra.md`。 - `hwlab g14 tools-image status|build --name ci-node-tools --tag <tag> [--dockerfile deploy/ci/hwlab-ci-node-tools.Dockerfile] [--dry-run|--confirm]` 是 G14 固定 HWLAB CI tools image 的受控 host build/push 入口;构建和 push 只发生在 G14 host 与本地 registry,不在 master server 构建,也不把 `apk add`/runtime install 塞进 Tekton PipelineRun。 - `trans gh:/owner/repo ...` 把 GitHub issue/PR 映射成只读/受控写入的虚拟文本目录,适合日报、PR 正文和 issue 正文的小补丁维护:`trans gh:/pikasTech/HWLAB ls` 展示 `pr/` 与 `issue/`,`trans gh:/pikasTech/HWLAB/pr ls [--limit N] [--full]` 和 `trans gh:/pikasTech/HWLAB/issue ls [--limit N] [--full]` 展示条目状态、楼层数、正文长度和标题,`trans gh:/pikasTech/HWLAB/pr/507 ls` 展示单个 PR 的一楼正文文件,`trans gh:/pikasTech/HWLAB/505/1 cat|rg|patch-apply` 兼容旧式 issue/PR number route。`patch-apply` 使用 UniDesk 默认 apply-patch v2 的虚拟文件 executor,把正文一楼映射为 `body.md`,写回仍走 `bun scripts/cli.ts gh issue/pr update` 的 guard/concurrency 规则;`rm` 对正文一楼结构化拒绝,避免误删 issue/PR 正文。大正文读取必须展开 UniDesk gh dump 文件,否则 `cat/rg/patch-apply` 会误读为空,这是 `gh:` 虚拟文件接口的 P0 可见性契约。 - `hwlab cd status|audit|preflight|apply --env dev [--dry-run]` 是旧 D601 HWLAB DEV CD 指挥侧 wrapper,仅用于显式 legacy 诊断和迁移对照。默认通过 UniDesk provider `host.ssh` 进入 D601,再调用 HWLAB repo-owned `scripts/dev-cd-apply.mjs`,不内嵌发布 kubectl 逻辑:`status` 汇总固定 CD mirror、Git clean/main/origin-main、`deploy/deploy.json`/artifact catalog/report、D601 native k3s guard 和 CD Lease lock,并用 `scripts/dev-cd-apply.mjs --status --skip-live-verify` 取得 target/promotion 摘要;`audit` 在 k3s/CD 恢复后做只读健康审计,返回有界 JSON 的 blocker 分类、D601 guard/node、SecretRef 存在性、registry 可达性、Lease phase/holder/staleness、deploy.json 与 artifact/workload image 收敛、current Deployment image/revision/rollout、16666/16667 public health commit/readiness 和 DB/runtime durability 摘要;`preflight` 进一步检查必需 SecretRef 对象/键存在性并运行 HWLAB `scripts/dev-cd-apply.mjs --dry-run --skip-live-verify` 受控事务摘要。完整远端 stdout/stderr 写入 D601 `~/.state/unidesk-hwlab-cd/<run-id>/` 和本地 `.state/hwlab-cd/<run-id>/` task dump,stdout 只返回有界摘要。默认 HWLAB CD repo 是 `/home/ubuntu/hwlab_cd`,`/home/ubuntu/hwlab` runner 历史目录不得作为发布真相。wrapper 强制 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并只以这个显式目标作为 gate;显式目标出现 `docker-desktop`、`desktop-control-plane` 或 `127.0.0.1:11700` 信号会结构化拒绝,audit/preflight/apply --dry-run 都必须观察到 node `d601`。真实 apply 只暴露 `scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report` 命令形状并标注 host-commander-only,本 runner 不执行 live apply、rollout、Lease mutation 或 DEV deploy apply。长期规则见 `docs/reference/hwlab.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`、`github-transient`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response` 或 `unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应优先用该字段分流。`github-transient` 表示 GitHub DNS/API 连接在收到 HTTP 状态前失败,输出应带 `retryable=true` 或等价 commander action;这不是缺 token、认证失败、权限不足或 PR 语义失败。 - `codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` 是派发/steer 前的本地 dry-run prompt lint。它只读取 prompt 文本,返回 `dryRun=true`、`mutation=false`、`declaredClass`、`effectiveClass`、`requiredClass`、`dispatchDisposition`、缺失或矛盾项和有界 evidence,不访问 live service、不提交任务、不打印完整 prompt。分级固定为 `read-only`、`live-read`、`live-mutating`;未声明时按 `read-only` 处理。`codex submit --dry-run` 与 `codex steer --dry-run` 会嵌入同一 `promptLint` 结果,帮助指挥官在 dispatch/steer 前发现缺失或矛盾的 live mutation 授权。长期规则见 `docs/reference/code-queue-supervision.md` 的 DEV 测试授权分级。 - `gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels] [--raw|--full]` 通过 GitHub REST 列出 issue,默认 `state=open`、`limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`owner/repo` 位置参数是 `--repo owner/repo` 的兼容别名;若位置 repo 与 `--repo` 冲突,或位置参数不是 `owner/repo`,必须结构化失败,禁止静默 fallback 到默认 repo。`--limit` 是 CLI 返回上限,不等同 GitHub 单页 `per_page`:当 `--limit > 100` 或默认页中混入 PR 时,CLI 必须分页抓取 GitHub REST/Search page,过滤 PR 后再返回 issue,并在输出中披露 `pagination.fetchedPages/rawCount/hasMore`;`hasMore=true` 时只能说明当前有界扫描未穷尽,禁止把它当作“仓库没有更多 issue”。`--search` 使用 GitHub Search Issues API,并自动追加 `repo:<owner>/<name>`、`type:issue` 和 state qualifier,用于创建新 issue 前做低摩擦查重;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。`--label` 是 GitHub REST `labels=label1,label2` 或 Search `label:` 服务端过滤,支持重复 `--label` 和逗号分隔;filter 不在本命令上下文中使用(如 `issue read`、`pr list`)必须结构化失败并指明 `gh issue create/list/stale-close` 才是合法作用域。GitHub issues API 可能混入 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 <n> --state closed` 落到错命令上时,CLI 必须返回 `validation-failed` 并显式提示 `gh issue close <n>` / `gh issue reopen <n>`(PR 用 `gh pr close|reopen <n>`),并把 5 条受支持命令放进 `supportedCommands`,禁止把"无 `--state` 改 issue 状态"的命令升级为"接受 `--state`"。`gh issue close|reopen` 成功输出默认是 compact issue 摘要,不得回显完整 `issue.body`;需要正文时后续使用返回的 `readCommands` 或 `gh issue read --json body|--full|--raw`。生命周期 close/reopen 的评论只接受短 `--comment`(或无评论),不接受 `--comment-file` 或 `--body-file`;需要附长篇 CLI 验收证据时,先用 `gh issue comment create <n> --body-file <file|->` 写证据评论,再用 `gh issue close <n> --comment <短引用>` 关闭。issue 硬删除走 `close`,PR 硬删除走 `close`,两者都没有"delete"语义。 -- `gh issue comment create <number|owner/repo#number> --repo owner/name|--body-file <file|->`、`gh issue comment delete <commentId|owner/repo#number> --repo owner/name`、`gh issue close <number|owner/repo#number> --repo owner/name [--comment <text>]`、`gh issue reopen <number|owner/repo#number> --repo owner/name [--comment <text>]`、`gh issue update <number|owner/repo#number> --repo owner/name [--title ...] [--body-file <file|->]`、`gh issue edit <number|owner/repo#number> ...`、`gh issue board-row get|update|add|move|delete|upsert <number|owner/repo#number> --repo owner/name ...` 都接受与 `gh issue read|view`、`gh pr *` 一致的 `owner/repo#number` 位置 shorthand;shorthand 与显式 `--repo` 冲突时结构化失败并把两者都回显到错误对象里,避免静默改写目标 repo。`gh issue read|view`、`gh pr read|view|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 lifecycle`:`--state` 只能作为 `gh issue list` / `gh issue board-row list` / `gh pr list` 的过滤参数;`gh issue update` / `gh issue edit` 只写 body/title,**不接受** `--state` 改 open/closed。把 `gh issue update <n> --state closed` 落到错命令上时,CLI 必须返回 `validation-failed` 并显式提示 `gh issue close <n>` / `gh issue reopen <n>`(PR 用 `gh pr close|reopen <n>`),并把 5 条受支持命令放进 `supportedCommands`,禁止把"无 `--state` 改 issue 状态"的命令升级为"接受 `--state`"。`gh issue close|reopen` 成功输出默认是 compact issue 摘要,不得回显完整 `issue.body`;需要正文时后续使用返回的 `readCommands` 或 `gh issue view --json body|--full|--raw`。生命周期 close/reopen 的评论只接受短 `--comment`(或无评论),不接受 `--comment-file` 或 `--body-file`;需要附长篇 CLI 验收证据时,先用 `gh issue comment create <n> --body-file <file|->` 写证据评论,再用 `gh issue close <n> --comment <短引用>` 关闭。issue 硬删除走 `close`,PR 硬删除走 `close`,两者都没有"delete"语义。 +- `gh issue comment create <number|owner/repo#number> --repo owner/name|--body-file <file|->`、`gh issue comment delete <commentId|owner/repo#number> --repo owner/name`、`gh issue close <number|owner/repo#number> --repo owner/name [--comment <text>]`、`gh issue reopen <number|owner/repo#number> --repo owner/name [--comment <text>]`、`gh issue update <number|owner/repo#number> --repo owner/name [--title ...] [--body-file <file|->]`、`gh issue edit <number|owner/repo#number> ...`、`gh issue board-row get|update|add|move|delete|upsert <number|owner/repo#number> --repo owner/name ...` 都接受与 `gh issue view|read`、`gh pr *` 一致的 `owner/repo#number` 位置 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 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 read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;`view` 只保留为兼容别名。`owner/repo#number` shorthand 会自动派生 `--repo owner/repo` 和 issue number;若同时提供冲突的显式 `--repo`,CLI 必须结构化失败并给出 `gh issue read <number> --repo owner/repo --json body,title,state,comments` 与 shorthand raw 的可执行命令。兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`--raw` 与 `--full` 是显式完整披露别名:read/view 会选择完整支持字段集;issue update/edit 只有显式传入时才在成功响应里包含完整 `.data.issue.body`。当最终 `gh` JSON 超过 20 KiB 时,CLI 必须把完整 JSON 写入 `/tmp/unidesk-cli-output/*.json`,stdout 只返回 `outputTruncated=true`、dump path、总 bytes/lines 和 head/tail 预览。默认 list/read 输出仍不得扩散到无界非 JSON 文本。`gh issue create --title <title> --body-file <file|-> [--label label[,label...]]... [--dry-run]`、`gh issue update <number> --mode replace|append --body-file <file|-> [--title ...] [--dry-run] [--full|--raw]`、`gh issue comment create <number> (--body-file <file|->|--body <short-text>) [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--comment <short-text>] [--dry-run]`、`gh issue stale-close [--inactive-hours N] [--dry-run]` 都走 REST,不依赖 `gh` binary。`--body` 仅用于 issue comment 的短单行文本;空白、多行、疑似 shell 污染、secret-like 或过长 inline body 必须结构化失败,Markdown/生成内容/长评论继续用 `--body-file <file|->`。`--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 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-file <file|-> [--label label[,label...]]... [--dry-run]`、`gh issue update <number> --mode replace|append --body-file <file|-> [--title ...] [--dry-run] [--full|--raw]`、`gh issue comment create <number> (--body-file <file|->|--body <short-text>) [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--comment <short-text>] [--dry-run]`、`gh issue stale-close [--inactive-hours N] [--dry-run]` 都走 REST,不依赖 `gh` binary。`--body` 仅用于 issue comment 的短单行文本;空白、多行、疑似 shell 污染、secret-like 或过长 inline body 必须结构化失败,Markdown/生成内容/长评论继续用 `--body-file <file|->`。`--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-file <file|->` 是正文更新主入口,`edit` 保留为兼容别名。`replace` 用文件或 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 read/view 20` 会返回 `codeQueueBoardHint`;`gh issue update/edit 20` 的 body guard 会拒绝 `## 更新 YYYY-MM-DD HH:mm 北京时间`、`## YYYY-MM-DD HH:mm 北京时间指挥更新` 和 `### YYYY-MM-DD HH:mm CST:...` 这类简报段落;把 `pikasTech/HWLAB#N`、`HWLAB#N` 或 HWLAB 产品/live 验证行写入 #20 时只返回 warning 和 `codeQueueBoardHint`,不再拒绝正文 replace,以避免历史正文或治理交叉引用造成次生阻塞;`gh issue board-row list|get|update|add|move|delete|upsert --board-issue 20` 也会返回同一 hint,提醒不要把每日简报或 HWLAB 产品看板混入 #20。 +- #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-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是 legacy #24 指挥简报的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update <number> --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`;`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL` 只接受 backend-core `/api/microservices/claudeqq/proxy` 等价路径,非 proxy URL 会结构化为 `notification-path-unavailable`。可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID` 和 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 调整开关、目标和超时。 - `gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]` 是总看板只读结构审计入口,默认 repo 为 `pikasTech/unidesk`、board issue 为 `20`、输出 JSON 且不 PATCH/POST/DELETE GitHub。它只读取目标 board issue 正文,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表是否覆盖全部 issue。兼容字段 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`openInClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions` 仍保留,但固定为空数组或 0,用于避免旧调用方因字段缺失失败。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list|get|update|add|move|delete|upsert` 的行级结构化入口。 - `gh issue board-row list --board-issue 20 [--state open|closed|all] [--dry-run]`、`gh issue board-row get <issueNumber> --board-issue 20` 和 `gh issue board-row update <issueNumber> --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]` 是 #20 看板表格单行结构化入口。list/get 复用 board-audit parser,只读返回 row、cells、fields、section、lineNumber、bodySha 和 rowValidationWarnings。update 只替换命中的一行里一个单元格,返回 old/new row、old/new body SHA、body guard、request plan 和 parser 结果;默认没有并发期望时即使不写 `--dry-run` 也只做 dry-run,正式 PATCH 必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射固定为:`branch` -> Branch,`progress` -> 进度,`status`/`validation` -> 验收状态,`tasks` -> 相关 Code Queue 任务,`focus` -> 当前关注点。单元格值中的 Markdown 表格管道会转义为 `\|`,真实换行会折叠为空格,避免新增字面量 `\n` 污染。`gh issue board-row upsert <issueNumber> --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]` 是行级补齐入口:若 issue 已存在则只更新传入字段并返回 `operation=update`,未传字段保留原值;若不存在则按目标 section 表头生成完整行并返回 `operation=add`。新增时 `--section` 必需,且目标表头中的 category/branch/tasks/summary/focus/validation/progress 列都必须有对应值;若表没有独立 Summary/摘要列,`--summary` 会并入 Issue 单元格。upsert 不关闭、不删除、不重开 GitHub issue,也不做 OPEN/CLOSED 迁移;已存在行的 `--section` 或 `--status` 与当前 section 冲突时会结构化失败并提示使用 `board-row move`。`gh issue board-row add <issueNumber> --board-issue 20 --section open|closed --row-file <file> [--dry-run] [--expect-body-sha|--expect-updated-at]`、`move <issueNumber> --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]` 和 `delete <issueNumber> --board-issue 20 [--dry-run] [--expect-body-sha|--expect-updated-at]` 是 row-scoped #20 结构化写入口。add 校验一行 `--row-file` 的 Issue 列、列数和 GitHub 状态列与目标 section 一致;move 允许跨 OPEN/CLOSED 表迁移并在需要时同步 GitHub 状态列;delete 仅删除匹配行。四类写入口默认 dry-run,非 dry-run 必须带 `--expect-body-sha` 或 `--expect-updated-at`,并返回 old/new row、body SHA、line/section 计划和 parser 结果;duplicate/ambiguous row、列数不匹配、缺少新增必填字段、section/status 冲突或 body SHA 不匹配都会结构化失败,不会 fallback 到整篇 body 手工替换。 -- `gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape、escaped backtick、ANSI escape 字符串、短 body、blank body 和 null body。输出固定 JSON,`findings` 会带 `bodyKind=issue-body|comment-body`、`issueNumber`、`issueId`、`commentId`、`lineNumber`、`column`、`kind`、`snippet` 和 `classification=suspected-pollution|explanatory-mention|risk`,用于区分说明性提到 `\n` 和疑似污染;`cleanupSuggestions` 只给 dry-run 清理建议、body/comment 定位和 diff-like preview,不 PATCH、不 DELETE、不真实清理历史 comment。`gh issue cleanup-plan` 是同一只读能力的别名,默认 `dryRun=true`。`gh pr list [--state open|closed|all] [--json ...] [--raw|--full]` 提供 REST 列表,默认 `state=all` 以保持既有 UniDesk CLI 行为,字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt,headRefName,baseRefName`;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。`--raw|--full` 在 `gh pr list` 上等价 `gh issue list --raw|--full`:响应带 `noDump=true`,inline 输出完整数据,绕开 20 KiB stdout 截断。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 不属于 list 字段,请对具体 PR 使用 `gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`,避免列表默认拉取 noisy/raw 状态汇总。`gh pr files <number> [--limit N]` 是 PR changed-file/stat summary 的稳定 REST 入口,返回 bounded `files`、`filesReturned`、`summary.files/additions/deletions/changes/commits`、`truncation` 和 `next.command`,默认不输出 raw diff 或 patch;`gh pr diff <number> --stat` 是兼容别名,返回同一 JSON,未带 `--stat` 的 raw diff 请求会结构化拒绝。`gh pr read|view <number|owner/repo#number> [--json ...] [--raw|--full]` 继续稳定返回这些字段,并额外支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`owner/repo#number` shorthand 和冲突 `--repo` 规则与 issue read/view 相同。`stateDetail` 是 UniDesk 归一化生命周期值 `open|closed|merged`,用于区分 REST `state=closed` 中的普通关闭和已合并;`closed`、`closedAt`、`merged`、`mergedAt`、`mergeCommit`、`headRefName` 与 `baseRefName` 都来自 REST,不需要 GraphQL。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 只在 read/view 明确请求这些字段或用 `--raw|--full` 显式完整披露时通过 GitHub GraphQL 查询,GraphQL 权限不足或网络失败会结构化失败;GitHub 暂未计算完成时仍保留原始 `UNKNOWN`/null,并额外返回 `closeoutMetadata.ok=false`、`missingOrUnknownFields`、advice 和 `mergeBoundary.unideskCliMergeSupported=true`。此时收口人员应优先重试一次;若仍缺失,应继续改进 UniDesk `gh` 子命令或使用人工 GitHub UI 做最终交叉确认,禁止原生 `gh` 或手拼 GitHub API 绕过。`gh pr preflight <number|owner/repo#number> [--repo owner/name] [--full|--raw]` 是低噪声 PR 收口入口,`gh preflight <number|owner/repo#number>` 和 `gh pr closeout <number|owner/repo#number>` 是兼容别名;shorthand 与 `gh pr view` 一致,已规范化为 `pikasTech/HWLAB#624` 这类形式时不需要再重复 `--repo`。它先输出脱敏 auth capability,再读取 PR state/draft/head/base、mergeable、mergeStateStatus 和 statusCheckRollup,默认只给 status check 计数与失败/等待预览,完整 contexts 和原始读取摘要必须显式加 `--full` 或 `--raw`。该命令固定 `readOnly=true`、`writesRemote=false`、`policy.mergesPr=false`,不会创建、评论、更新或 merge PR。`gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]`、`gh pr edit <number> [--title ...] [--body-file <file>|--body-file -|--body <text>] [--dry-run]`、`gh pr update <number> --mode replace|append [--body-file <file>|--body-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]` 和 `gh pr merge <number> [--merge|--squash|--rebase] [--delete-branch] [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr edit/update` 使用 REST `PATCH /repos/{owner}/{repo}/pulls/{number}`,只发送显式提供的 `title` 和/或 `body` 字段,完全避开 GitHub Projects Classic GraphQL/projectCards;输出低噪声 JSON:`ok`、`repo`、PR number、`changedFields`、`url`、body 长度/SHA/source 元数据和 request plan,不默认回显完整正文。`pr update --mode append` 会先读取当前 PR body 再追加正文。`pr merge` 会先执行同源 closeout 预检,拒绝非 open、draft、冲突、非 CLEAN、失败或 pending checks 的 PR,只有 ready 时才调用 GitHub REST merge;`--delete-branch` 只删除同 repo head ref。`gh pr delete <number>` 不开放,PR 生命周期删除语义请使用 `close`。 -- `gh pr list` 与 `gh issue list` 一样接受单个位置参数 `owner/repo` 作为 `--repo owner/repo` 兼容别名;位置 repo 与显式 `--repo` 冲突时会结构化失败,输出里的 `repo` 始终反映真实请求目标。`gh pr read|view --number N --repo owner/repo` 是位置 PR number 的兼容别名,不能在 list/create/edit/comment 等命令中被静默忽略。 +- `gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape、escaped backtick、ANSI escape 字符串、短 body、blank body 和 null body。输出固定 JSON,`findings` 会带 `bodyKind=issue-body|comment-body`、`issueNumber`、`issueId`、`commentId`、`lineNumber`、`column`、`kind`、`snippet` 和 `classification=suspected-pollution|explanatory-mention|risk`,用于区分说明性提到 `\n` 和疑似污染;`cleanupSuggestions` 只给 dry-run 清理建议、body/comment 定位和 diff-like preview,不 PATCH、不 DELETE、不真实清理历史 comment。`gh issue cleanup-plan` 是同一只读能力的别名,默认 `dryRun=true`。`gh pr list [--state open|closed|all] [--json ...] [--raw|--full]` 提供 REST 列表,默认 `state=all` 以保持既有 UniDesk CLI 行为,字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt,headRefName,baseRefName`;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。`--raw|--full` 在 `gh pr list` 上等价 `gh issue list --raw|--full`:响应带 `noDump=true`,inline 输出完整数据,绕开 20 KiB stdout 截断。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 不属于 list 字段,请对具体 PR 使用 `gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`,避免列表默认拉取 noisy/raw 状态汇总。`gh pr files <number> [--limit N]` 是 PR changed-file/stat summary 的稳定 REST 入口,返回 bounded `files`、`filesReturned`、`summary.files/additions/deletions/changes/commits`、`truncation` 和 `next.command`,默认不输出 raw diff 或 patch;`gh pr diff <number> --stat` 是兼容别名,返回同一 JSON,未带 `--stat` 的 raw diff 请求会结构化拒绝。`gh pr view|read <number|url|owner/repo#number> [--json ...] [--raw|--full]` 继续稳定返回这些字段,并额外支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`owner/repo#number` shorthand 和冲突 `--repo` 规则与 issue view/read 相同。`stateDetail` 是 UniDesk 归一化生命周期值 `open|closed|merged`,用于区分 REST `state=closed` 中的普通关闭和已合并;`closed`、`closedAt`、`merged`、`mergedAt`、`mergeCommit`、`headRefName` 与 `baseRefName` 都来自 REST,不需要 GraphQL。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 只在 view/read 明确请求这些字段或用 `--raw|--full` 显式完整披露时通过 GitHub GraphQL 查询,GraphQL 权限不足或网络失败会结构化失败;GitHub 暂未计算完成时仍保留原始 `UNKNOWN`/null,并额外返回 `closeoutMetadata.ok=false`、`missingOrUnknownFields`、advice 和 `mergeBoundary.unideskCliMergeSupported=true`。此时收口人员应优先重试一次;若仍缺失,应继续改进 UniDesk `gh` 子命令或使用人工 GitHub UI 做最终交叉确认,禁止原生 `gh` 或手拼 GitHub API 绕过。`gh pr preflight <number|owner/repo#number> [--repo owner/name] [--full|--raw]` 是低噪声 PR 收口入口,`gh preflight <number|owner/repo#number>` 和 `gh pr closeout <number|owner/repo#number>` 是兼容别名;shorthand 与 `gh pr view` 一致,已规范化为 `pikasTech/HWLAB#624` 这类形式时不需要再重复 `--repo`。它先输出脱敏 auth capability,再读取 PR state/draft/head/base、mergeable、mergeStateStatus 和 statusCheckRollup,默认只给 status check 计数与失败/等待预览,完整 contexts 和原始读取摘要必须显式加 `--full` 或 `--raw`。该命令固定 `readOnly=true`、`writesRemote=false`、`policy.mergesPr=false`,不会创建、评论、更新或 merge PR。`gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]`、`gh pr edit <number> [--title ...] [--body-file <file>|--body-file -|--body <text>] [--dry-run]`、`gh pr update <number> --mode replace|append [--body-file <file>|--body-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]` 和 `gh pr merge <number> [--merge|--squash|--rebase] [--delete-branch] [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr edit/update` 使用 REST `PATCH /repos/{owner}/{repo}/pulls/{number}`,只发送显式提供的 `title` 和/或 `body` 字段,完全避开 GitHub Projects Classic GraphQL/projectCards;输出低噪声 JSON:`ok`、`repo`、PR number、`changedFields`、`url`、body 长度/SHA/source 元数据和 request plan,不默认回显完整正文。`pr update --mode append` 会先读取当前 PR body 再追加正文。`pr merge` 会先执行同源 closeout 预检,拒绝非 open、draft、冲突、非 CLEAN、失败或 pending checks 的 PR,只有 ready 时才调用 GitHub REST merge;`--delete-branch` 只删除同 repo head ref。`gh pr delete <number>` 不开放,PR 生命周期删除语义请使用 `close`。 +- `gh pr list` 与 `gh issue list` 一样接受单个位置参数 `owner/repo` 作为 `--repo owner/repo` 兼容别名;位置 repo 与显式 `--repo` 冲突时会结构化失败,输出里的 `repo` 始终反映真实请求目标。`--number N --repo owner/repo` 是单 PR/comment 数字目标命令的位置参数兼容别名,适用于 `view/read/files/diff/preflight/closeout/edit/update/comment create/comment delete/close/reopen/merge`,成功输出必须带 `standardSyntaxHint`;comment delete 中的 `--number` 表示 commentId,不是 PR number;`list/create` 不能静默忽略 `--number`。 - PR dry-run/probe 的最小手动序列是:`bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 只读检查 token 来源、GitHub REST egress、repo 可见性和 issue read;`bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title <title> --body-file <file> --base master --head <head> --dry-run` 检查创建计划;`bun scripts/cli.ts gh pr list --repo pikasTech/unidesk --state open --limit 5 --json number,title,state,url,head,base`、`bun scripts/cli.ts gh pr files <number> --repo pikasTech/unidesk --limit 30`、`bun scripts/cli.ts gh pr view <number> --repo pikasTech/unidesk --json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup` 和 `bun scripts/cli.ts gh pr preflight <number> --repo pikasTech/unidesk` 做只读 PR 观察、文件统计和收口元数据检查;`bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --title <title> --body-file <file> --dry-run` 或 `cat <file> | bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --body-file - --dry-run` 检查低噪声 PR 标题/正文编辑计划;`bun scripts/cli.ts gh pr comment <number> --repo pikasTech/unidesk --body-file <file> --dry-run` 检查评论计划;`bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --dry-run` 检查 guarded merge plan,真实 merge 只能在任务边界明确允许且 preflight ready 后执行。Code Queue runner 可用 `bun scripts/code-queue-pr-preflight-example.ts --repo pikasTech/unidesk --base master --head <head> --comment-pr <number>` 一次性跑只读 auth status 与 PR create/comment dry-run;该脚本不得输出 token 值,也不会创建、评论或 merge PR。 - `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`。 - `schedule list|get|runs|run|retry-run|delete|upsert-pgdata-backup` 管理 backend-core 定时任务和运行历史。`schedule list`、`schedule get`、`schedule runs --limit N` 和 `schedule runs <scheduleId> --limit N` 是只读观察入口;`schedule run`、`schedule retry-run`、`schedule delete` 和 `schedule upsert-pgdata-backup` 会触发运行或写入配置,生产恢复时必须有明确授权。`schedule runs --limit N` 是全局历史视图,返回 `scope=global` 和 `scheduleId=null`;`schedule runs <scheduleId> --limit N` 是指定 schedule 历史视图,返回 `scope=schedule` 和对应 `scheduleId`。CLI 必须拒绝 `schedule runs 50` 这类纯数字位置参数,并提示使用 `schedule runs --limit 50`,避免把空数组误判成“没有历史 run”。`schedule run <id> --wait-ms N` 触发同一 schedule,并且即使 wait 超时也必须返回 `newRunId` 和 `observeCommand`;`schedule retry-run <failedRunId>` 只接受 failed run,从原 run 反查 `scheduleId` 后重触发同一 schedule,并输出 `originalRunId`、`scheduleId`、`newRunId` 和 `observeCommand`。当 backend-core 目标容器缺失或只观察到 verify-only 容器时,schedule/microservice 命令必须以非零退出并返回 `failureKind=target-stack-not-running`、`runnerDisposition=infra-blocked`、`readOnlyCommands` 和 `authorizationRequiredForRecovery`,不得把 Docker 的 `No such container` 当成成功的空历史。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 9036991a..1153b0a9 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -183,7 +183,7 @@ issue 内容必须自包含,至少写清楚背景、外部收益、当前观 `#20` 当前仍受 body profile 保护,正文必须保留 `## 看板(OPEN)` heading;该 heading 可用于承载紧凑的 P0/P1 直达表或当前活跃入口,不再要求恢复旧式 OPEN/CLOSED 全覆盖明细表。`gh issue board-audit` 只做只读结构审计,不再负责检查 GitHub open/closed issue 是否被表格完全覆盖;维护旧式表格时才使用 `board-row` 系列命令。 -GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/read/view/create/update/comment create/comment delete/close/reopen/scan-escape/cleanup-plan/board-audit/board-row list/board-row get/board-row update`、`gh pr list/read/view/create/update/comment create/comment delete/close/reopen`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `missing-binary`、`missing-token`、`auth-failed`、`github-transient`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response`、`unsupported-command` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。`github-transient` 专指 GitHub DNS 或 API 连接在收到 HTTP 状态前失败,例如 `Temporary failure in name resolution`、`Could not resolve host: github.com/api.github.com` 或 `error connecting to api.github.com`;它必须带 `retryable=true` 或等价 retry/backoff 指示,并且不是 `missing-token`、`auth-failed`、`scope-insufficient`、`validation-failed` 或 PR 语义失败。指挥官看到这类结果时,优先重试或退避;如果对应 Code Queue 任务 heartbeat/trace 仍新鲜,应保持任务运行并继续监督,不要立即 close/requeue 业务工作。runner 不应直接运行系统 `gh auth status` 并把输出贴入 Code Queue 日志;系统 `gh` 的 masked token 行仍会暴露 token 前缀和 scope 片段。需要验证当前 runner GitHub auth 时使用 `bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 或 `bun scripts/cli.ts codex pr-preflight --remote`,输出只能保留 token 是否存在、来源、长度和掩码,不得打印 token 值或 token 片段。Code Queue 输出层必须在保留 command output、trace、raw output 页面和 commander 摘要前 redaction `gh auth status` 风格 token 行,并给出 UniDesk CLI wrapper 提示。`gh issue list --state open --limit N --json number,title,state,closed,closedAt,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue read <number> --json body,title,state,closed,closedAt` 是 canonical 入口,正文仍应从 `.data.issue.body` 读取;`view` 只保留为兼容别名。未知 `--json` 字段必须失败,不得让调用方把空正文或缺失生命周期字段误判为读取成功。`gh issue scan-escape --limit N [--dry-run]` 与 `gh issue cleanup-plan` 只读扫描 issue body/comments 的字面量 `\n`、shell escape、短 body、blank/null body,输出 `classification=suspected-pollution|explanatory-mention|risk`、body/comment id、预览和清理建议;说明性提到 `\n` 不应被当成污染,cleanup-plan 永远不真实清理历史评论。`gh issue board-audit --board-issue 20 --limit N --dry-run` 只读审计目标 board issue 正文结构,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;它不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表覆盖关系。兼容字段 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions` 仍保留为空数组或 0。显式 `gh issue update <number> --body-profile commander-brief` 可用于 #24 legacy 简报和每日滚动简报 issue;每日简报 issue 应用标题 `YYYY-MM-DD 指挥简报(北京时间)` 或在既有正文首行/关键 heading 中标明简报身份,且新正文必须包含 `## 常驻观察与长期建议`。对非简报 issue 使用该 profile 应失败为 `profile-issue-mismatch`。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list --board-issue 20 --state open|closed|all`、`gh issue board-row get <issueNumber> --board-issue 20` 和 `gh issue board-row update <issueNumber> --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text>`;`board-row update` 只替换一行一个单元格,输出 old/new row、body SHA、body guard 和 request plan,且默认 dry-run,正式写入必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射中 `status`/`validation` 都指向 `验收状态`,`tasks` 指向 `相关 Code Queue 任务`,`focus` 指向 `当前关注点`;单元格管道会转义、真实换行会折叠为空格,避免新增字面量 `\n`。`gh issue board-row upsert` 可更新既有行或按 section 生成完整新行;`board-row add/move/delete` 已支持行级新增、OPEN/CLOSED 迁移和删除,全部默认 dry-run,正式 PATCH 必须带 `--expect-body-sha` 或 `--expect-updated-at`。`gh pr list --json ...` 支持 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt` 字段白名单;`gh pr read|view --json ...` 还支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`stateDetail=open|closed|merged` 用于区分 REST `state=closed` 中的普通关闭和已合并;`closed*`、`merged*`、`mergeCommit` 和分支名字段都来自 REST。只有 mergeability/check rollup 需要请求 GraphQL,适合 PR 收口前判断可合并性和检查汇总。GraphQL 权限不足、网络失败、GitHub 仍返回 `UNKNOWN`/null、或需要 UniDesk CLI 尚未开放的官方字段、review/merge 操作时,回退系统 `gh` 只读观察或 GitHub UI;不要把缺失元数据当成已可合并。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` 仍然不开放。 +GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/read/view/create/update/comment create/comment delete/close/reopen/scan-escape/cleanup-plan/board-audit/board-row list/board-row get/board-row update`、`gh pr list/read/view/create/update/comment create/comment delete/close/reopen`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `missing-binary`、`missing-token`、`auth-failed`、`github-transient`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response`、`unsupported-command` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。`github-transient` 专指 GitHub DNS 或 API 连接在收到 HTTP 状态前失败,例如 `Temporary failure in name resolution`、`Could not resolve host: github.com/api.github.com` 或 `error connecting to api.github.com`;它必须带 `retryable=true` 或等价 retry/backoff 指示,并且不是 `missing-token`、`auth-failed`、`scope-insufficient`、`validation-failed` 或 PR 语义失败。指挥官看到这类结果时,优先重试或退避;如果对应 Code Queue 任务 heartbeat/trace 仍新鲜,应保持任务运行并继续监督,不要立即 close/requeue 业务工作。runner 不应直接运行系统 `gh auth status` 并把输出贴入 Code Queue 日志;系统 `gh` 的 masked token 行仍会暴露 token 前缀和 scope 片段。需要验证当前 runner GitHub auth 时使用 `bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 或 `bun scripts/cli.ts codex pr-preflight --remote`,输出只能保留 token 是否存在、来源、长度和掩码,不得打印 token 值或 token 片段。Code Queue 输出层必须在保留 command output、trace、raw output 页面和 commander 摘要前 redaction `gh auth status` 风格 token 行,并给出 UniDesk CLI wrapper 提示。`gh issue list --state open --limit N --json number,title,state,closed,closedAt,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue view <number> --json body,title,state,closed,closedAt` 是 canonical 入口,`read` 只保留为兼容别名,正文仍应从 `.data.issue.body` 读取。单 issue/PR/comment 数字目标命令兼容 `--number N`,但成功响应必须带 `standardSyntaxHint` 提示标准位置参数写法;comment delete 中的 `--number` 表示 commentId。未知 `--json` 字段必须失败,不得让调用方把空正文或缺失生命周期字段误判为读取成功。`gh issue scan-escape --limit N [--dry-run]` 与 `gh issue cleanup-plan` 只读扫描 issue body/comments 的字面量 `\n`、shell escape、短 body、blank/null body,输出 `classification=suspected-pollution|explanatory-mention|risk`、body/comment id、预览和清理建议;说明性提到 `\n` 不应被当成污染,cleanup-plan 永远不真实清理历史评论。`gh issue board-audit --board-issue 20 --limit N --dry-run` 只读审计目标 board issue 正文结构,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;它不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表覆盖关系。兼容字段 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions` 仍保留为空数组或 0。显式 `gh issue update <number> --body-profile commander-brief` 可用于 #24 legacy 简报和每日滚动简报 issue;每日简报 issue 应用标题 `YYYY-MM-DD 指挥简报(北京时间)` 或在既有正文首行/关键 heading 中标明简报身份,且新正文必须包含 `## 常驻观察与长期建议`。对非简报 issue 使用该 profile 应失败为 `profile-issue-mismatch`。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list --board-issue 20 --state open|closed|all`、`gh issue board-row get <issueNumber> --board-issue 20` 和 `gh issue board-row update <issueNumber> --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text>`;`board-row update` 只替换一行一个单元格,输出 old/new row、body SHA、body guard 和 request plan,且默认 dry-run,正式写入必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射中 `status`/`validation` 都指向 `验收状态`,`tasks` 指向 `相关 Code Queue 任务`,`focus` 指向 `当前关注点`;单元格管道会转义、真实换行会折叠为空格,避免新增字面量 `\n`。`gh issue board-row upsert` 可更新既有行或按 section 生成完整新行;`board-row add/move/delete` 已支持行级新增、OPEN/CLOSED 迁移和删除,全部默认 dry-run,正式 PATCH 必须带 `--expect-body-sha` 或 `--expect-updated-at`。`gh pr list --json ...` 支持 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt` 字段白名单;`gh pr read|view --json ...` 还支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`stateDetail=open|closed|merged` 用于区分 REST `state=closed` 中的普通关闭和已合并;`closed*`、`merged*`、`mergeCommit` 和分支名字段都来自 REST。只有 mergeability/check rollup 需要请求 GraphQL,适合 PR 收口前判断可合并性和检查汇总。GraphQL 权限不足、网络失败、GitHub 仍返回 `UNKNOWN`/null、或需要 UniDesk CLI 尚未开放的官方字段、review/merge 操作时,回退系统 `gh` 只读观察或 GitHub UI;不要把缺失元数据当成已可合并。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` 是 guarded write:先执行 closeout preflight,只在 open、非 draft、无冲突、merge state CLEAN 且 checks 无失败/pending 时调用 GitHub REST merge;`--dry-run` 只输出计划不写远端。 CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不好用、兼容性不足、安全 guard 不够或输出不利于 runner/指挥官使用时,应默认创建 GitHub issue 并用 Code Queue 推进;这类 CLI 问题走 `master`、remote commit、轻量 contract test 和文档更新,不套用 backend-core、Code Queue runtime 这类运行态服务的重部署门禁。若 CLI 缺陷已经阻塞当前指挥,可以先做最小安全绕行,同时把长期修复写入 issue 并派单。 @@ -193,7 +193,7 @@ PR 是审查型交付入口,不是所有 Code Queue 任务的默认出口。 PR handoff 的职责默认分开:runner 实现、测试、提交、push head branch 并创建 PR;指挥官监督并发、steer、审阅、确认 checks 和合并裁决。短期内 GPT-5.5 runner 如果收到明确 PR 收口授权,并且 PR 是普通 UniDesk source 变更、checks 满足任务要求、无冲突且不涉及 prod/runtime/release/security/database/破坏性回滚,可以自行用 repo-owned GitHub merge/close 路径完成收口并报告 SHA。高风险、边界不清、checks 失败或用户/指挥官保留 final action 的 PR 仍必须交回 commander 审查。host commander 也不把直接编辑业务代码当成常规 PR 替代路径。 -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 收口观察应使用 `gh pr view <number> --json state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup` 获取普通关闭/已合并区分、merge commit、目标分支、源分支、mergeability 和检查汇总;该路径仍是只读元数据,不执行 merge,且 `closeoutMetadata.ok=false`、`missingOrUnknownFields` 或 GraphQL failure 都只能作为“需要人工复核/重试”的信号。`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 list` 不开放 mergeability/statusCheckRollup 列表字段,避免默认拉取 noisy/raw 状态汇总。`gh pr delete` 和 `gh pr merge` 在 UniDesk REST CLI 中仍返回 `unsupported-command`,没有 `--confirm` 可以启用真实 merge,也不能伪造硬删除;需要移除活跃 PR 时使用 `gh pr close`。获得收口授权的 GPT-5.5 runner 可使用系统 `gh pr merge`、GitHub UI 或等价 repo-owned GitHub merge path 处理普通 PR;普通 worker 不应隐式依赖未实现的 UniDesk REST CLI merge 能力。需要 PR 交付时,prompt 必须明确允许的人工、runner 或后续工具路径,并报告未覆盖范围。 +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 收口观察应使用 `gh pr view <number> --json state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup` 获取普通关闭/已合并区分、merge commit、目标分支、源分支、mergeability 和检查汇总;该路径仍是只读元数据,不执行 merge,且 `closeoutMetadata.ok=false`、`missingOrUnknownFields` 或 GraphQL failure 都只能作为“需要人工复核/重试”的信号。`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 list` 不开放 mergeability/statusCheckRollup 列表字段,避免默认拉取 noisy/raw 状态汇总。`gh pr delete` 在 UniDesk REST CLI 中返回 `unsupported-command`,不能伪造硬删除;需要移除活跃 PR 时使用 `gh pr close`。`gh pr merge` 是 guarded REST write,没有 `--confirm` 可以绕过 preflight;获得收口授权的 GPT-5.5 runner 可使用 UniDesk `gh pr merge`、GitHub UI 或等价 repo-owned GitHub merge path 处理普通 PR,并报告结果 SHA。需要 PR 交付时,prompt 必须明确允许的人工、runner 或后续工具路径,并报告未覆盖范围。 ### PR 驱动派单模板 @@ -236,7 +236,7 @@ bun scripts/cli.ts codex pr-preflight --remote --issue 20 `codex pr-preflight --remote` 的 `auth-missing` 只表示 scheduler/runtime preflight surface(`scheduler-runner-env`)没有看到 `GH_TOKEN/GITHUB_TOKEN` 或 auth-broker,不得被简化成“当前 active runner/dev container 不能创建 PR”。默认输出必须优先给出有界的 `authScopeSummary`、`scopeBoundary`、`activeRunnerDevContainer` 和 `recommendedActions`:`authScopeSummary` 用一句话说明 scheduler auth missing 只是 scoped finding;`scopeBoundary` 明确 scheduler env 与当前 CLI/dev container 是独立 scope;`activeRunnerDevContainer` 只报告当前 CLI 进程是否看见 token,且不打印 token 值;`recommendedActions` 保持低噪声,先给 active task 内的 `bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 和 `gh pr create --dry-run`,再给 scheduler 侧长期的 auth-broker 或 runtime secret 修复。指挥官看到 remote preflight `auth-missing` 时,应继续用当前 runner 内的 `bun scripts/cli.ts gh auth status --repo pikasTech/unidesk`、`gh pr create --dry-run`、`gh pr comment create --dry-run` 验证实际 PR 能力;只有这些 active runner 检查也失败时,才能把它判成当前 turn 不能 PR。 -该命令经 backend-core 稳定 `code-queue` proxy 访问 D601 scheduler 的 `/api/runtime-preflight`,报告 scheduler/runner 环境里的 `GH_TOKEN`/`GITHUB_TOKEN` 覆盖、工具、Git worktree、GitHub egress、repo/issue/PR 只读探测和可选 push dry-run。需要复核 PR body/创建命令 guard 时追加 `--pr-create-dry-run --pr-create-dry-run-head <head>`;该 guard 只执行 dry-run,不创建 PR。缺少 env token 时必须返回 `ok=false`、`runnerDisposition=infra-blocked`、`tokenCoverage.missing=["GH_TOKEN","GITHUB_TOKEN"]` 和 `authBroker.source="broker/auth-broker-needed"`,因为 provider dev container 只能转发 scheduler 已经拥有的 token,除非后续接入 broker-held GitHub credential。GitHub DNS/API 瞬时连接失败必须返回 `failureKind=github-transient`、`degradedReason=github-dns-api-transient`、`retryable=true` 和低噪声 `githubTransient.failedProbes` 摘要;这与 `auth-missing`、`git-remote-gap`、`proxy-gap`、`validation-failed` 或 PR 语义失败分开处理。系统 `gh` binary 缺失只能作为 `tools.systemGhBinary.ok=false` 观测,不得把它误判为 UniDesk REST `bun scripts/cli.ts gh` 不可用。`--remote` 在 runner-like 环境里不再要求本地 `unidesk-backend-core`、`unidesk-database`、`baidu-netdisk-backend` 容器存在;这些本地 target stack 缺失只作为证据,不是最终主阻塞,并应额外标成 `blockingDisposition=runner-local-observation-gap` 或 `localObservationGap.kind=runner-local-observation-gap`。若远程控制面可达,输出继续保留 ready preflight;若远程控制面不可达,结构化失败归类为 `failureKind=control-plane-missing` / `degradedReason=remote-control-plane-unreachable`,并额外标成 `blockingDisposition=control-plane-observation-gap`。`runnerDisposition` 可以为了旧调用方兼容继续保持 `infra-blocked`,但 observation-gap 字段才是判断“观测路径缺口,不是 scheduler 停摆”的稳定口径。输出中的 `prCapabilityContract` 用于指挥官快速审查 runner handoff:目标分支固定显示、push/PR create dry-run 标记为不写远端、系统 `gh` binary 与 UniDesk REST `bun scripts/cli.ts gh` 可用性分开报告,且 merge 明确保持 `unsupported-command`。 +该命令经 backend-core 稳定 `code-queue` proxy 访问 D601 scheduler 的 `/api/runtime-preflight`,报告 scheduler/runner 环境里的 `GH_TOKEN`/`GITHUB_TOKEN` 覆盖、工具、Git worktree、GitHub egress、repo/issue/PR 只读探测和可选 push dry-run。需要复核 PR body/创建命令 guard 时追加 `--pr-create-dry-run --pr-create-dry-run-head <head>`;该 guard 只执行 dry-run,不创建 PR。缺少 env token 时必须返回 `ok=false`、`runnerDisposition=infra-blocked`、`tokenCoverage.missing=["GH_TOKEN","GITHUB_TOKEN"]` 和 `authBroker.source="broker/auth-broker-needed"`,因为 provider dev container 只能转发 scheduler 已经拥有的 token,除非后续接入 broker-held GitHub credential。GitHub DNS/API 瞬时连接失败必须返回 `failureKind=github-transient`、`degradedReason=github-dns-api-transient`、`retryable=true` 和低噪声 `githubTransient.failedProbes` 摘要;这与 `auth-missing`、`git-remote-gap`、`proxy-gap`、`validation-failed` 或 PR 语义失败分开处理。系统 `gh` binary 缺失只能作为 `tools.systemGhBinary.ok=false` 观测,不得把它误判为 UniDesk REST `bun scripts/cli.ts gh` 不可用。`--remote` 在 runner-like 环境里不再要求本地 `unidesk-backend-core`、`unidesk-database`、`baidu-netdisk-backend` 容器存在;这些本地 target stack 缺失只作为证据,不是最终主阻塞,并应额外标成 `blockingDisposition=runner-local-observation-gap` 或 `localObservationGap.kind=runner-local-observation-gap`。若远程控制面可达,输出继续保留 ready preflight;若远程控制面不可达,结构化失败归类为 `failureKind=control-plane-missing` / `degradedReason=remote-control-plane-unreachable`,并额外标成 `blockingDisposition=control-plane-observation-gap`。`runnerDisposition` 可以为了旧调用方兼容继续保持 `infra-blocked`,但 observation-gap 字段才是判断“观测路径缺口,不是 scheduler 停摆”的稳定口径。输出中的 `prCapabilityContract` 用于指挥官快速审查 runner handoff:目标分支固定显示、push/PR create dry-run 标记为不写远端、系统 `gh` binary 与 UniDesk REST `bun scripts/cli.ts gh` 可用性分开报告,且 merge 能力必须指向 guarded `gh pr merge --dry-run` 预检路径。 本地 runner preflight 示例: @@ -269,7 +269,7 @@ bun scripts/cli.ts codex pr-preflight --remote --issue <issue-number> - PR base 是声明的目标分支,head branch 命名可追踪,远端 head commit 可 fetch。 - diff 只覆盖派单 ownership,未混入 release/v1 运行态服务或无关 dirty worktree。 - PR body 和 final response 都包含关联 issue、修改文件、验证证据、未完成风险和 merge/close 状态。 -- contract/dry-run 证据覆盖本次 PR 能力:`pr create --dry-run`、`pr list/view`、`pr comment --dry-run`,并确认 UniDesk REST `gh pr merge` 返回结构化 unsupported;若 runner 被授权最终 merge/close,还要报告使用的 repo-owned GitHub 路径和结果 SHA。 +- contract/dry-run 证据覆盖本次 PR 能力:`pr create --dry-run`、`pr list/view`、`pr comment --dry-run` 和 `pr merge --dry-run` guarded plan;若 runner 被授权最终 merge/close,还要报告使用的 repo-owned GitHub 路径和结果 SHA。 - 没有 token、凭证、临时日志或构建产物进入 commit、PR body 或评论。 - 未授权 runner 收口的 PR 由指挥官审查并决定是否 merge;已授权 runner 收口的普通 PR 在合并后仍要验证目标分支远端 commit 可见,并按态势更新 issue/#20/#24。 diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts index a4e808c4..56df45e5 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -649,8 +649,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : []; const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : []; assertCondition(usage.some((line) => line.includes("gh issue list")), "gh help should list issue list", { usage }); - assertCondition(usage.some((line) => line.includes("gh issue read")), "gh help should list issue read", { usage }); - assertCondition(usage.some((line) => line.includes("gh issue view")), "gh help should list issue view", { usage }); + assertCondition(usage.some((line) => line.includes("gh issue view") && line.includes("number|url|owner/repo#number")), "gh help should list standard issue view target forms", { usage }); + assertCondition(usage.some((line) => line.includes("gh issue read") && line.includes("compatibility alias for issue view")), "gh help should list issue read compatibility alias", { usage }); assertCondition(usage.some((line) => line.includes("gh issue comment create") && line.includes("--body <short-text>")), "gh help should list short inline issue comment body", { usage }); assertCondition(usage.some((line) => line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document issue shorthand and raw/full disclosure", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row list")), "gh help should list board-row list", { usage }); @@ -660,9 +660,10 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { assertCondition(usage.some((line) => line.includes("gh issue board-row move")), "gh help should list board-row move", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row delete")), "gh help should list board-row delete", { usage }); assertCondition(usage.some((line) => line.includes("gh issue list") && line.includes("--search text")), "gh help should list issue list search", { usage }); - assertCondition(notes.some((line) => line.includes("canonical read path")), "gh help should state issue read is canonical", { notes }); - assertCondition(notes.some((line) => line.includes("compatibility alias")), "gh help should state issue view is alias", { notes }); - assertCondition(notes.some((line) => line.includes("owner/repo#number shorthand")), "gh help should explain read/view shorthand", { notes }); + assertCondition(notes.some((line) => line.includes("issue view is the canonical")), "gh help should state issue view is canonical", { notes }); + assertCondition(notes.some((line) => line.includes("read remains") && line.includes("compatibility alias")), "gh help should state issue read is alias", { notes }); + 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-file <file|->") && line.includes("--body only for short single-line text")), "gh help should document issue comment 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 }); @@ -1256,12 +1257,33 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { assertCondition(!("comments" in selectedJson), "--json body should not imply comments field", selectedJson); const viewBody = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env); - assertCondition(viewBody.status === 0, "issue view alias should succeed", viewBody.json ?? { stdout: viewBody.stdout }); + assertCondition(viewBody.status === 0, "issue view should succeed as canonical read path", viewBody.json ?? { stdout: viewBody.stdout }); const viewBodyData = dataOf(viewBody.json ?? {}); const viewIssue = viewBodyData.issue as JsonRecord; - assertCondition(typeof viewIssue.body === "string" && viewIssue.body.includes("## 看板(OPEN)"), "issue view alias should keep .data.issue.body readable", viewBodyData); + assertCondition(typeof viewIssue.body === "string" && viewIssue.body.includes("## 看板(OPEN)"), "issue view should keep .data.issue.body readable", viewBodyData); const viewSelectedJson = viewBodyData.json as JsonRecord; - assertCondition(typeof viewSelectedJson.body === "string" && viewSelectedJson.body === readIssue.body, "issue view alias should preserve selected json body", viewBodyData); + assertCondition(typeof viewSelectedJson.body === "string" && viewSelectedJson.body === readIssue.body, "issue view should preserve selected json body", viewBodyData); + + const issueUrlView = await runCli(["gh", "issue", "view", "https://github.com/pikasTech/HWLAB/issues/7", "--json", "body,title,state"], env); + assertCondition(issueUrlView.status === 0, "issue view should accept GitHub issue URL target", issueUrlView.json ?? { stdout: issueUrlView.stdout }); + const issueUrlViewData = dataOf(issueUrlView.json ?? {}); + assertCondition(issueUrlViewData.repo === "pikasTech/HWLAB", "issue URL target should derive repo", issueUrlViewData); + assertCondition((issueUrlViewData.issue as JsonRecord).number === 7, "issue URL target should derive issue number", issueUrlViewData); + const issueUrlDisclosure = issueUrlViewData.disclosure as JsonRecord; + assertCondition(issueUrlDisclosure.shorthand && (issueUrlDisclosure.shorthand as JsonRecord).source === "github-url", "issue URL target should be disclosed", issueUrlDisclosure); + + const issuePrUrlMismatch = await runCli(["gh", "issue", "view", "https://github.com/pikasTech/HWLAB/pull/7", "--json", "body"], env); + assertCondition(issuePrUrlMismatch.status !== 0, "issue view should reject PR URLs", issuePrUrlMismatch.json ?? { stdout: issuePrUrlMismatch.stdout }); + const issuePrUrlMismatchData = failedDataOf(issuePrUrlMismatch.json ?? {}); + assertCondition(failureMessageOf(issuePrUrlMismatchData).includes("GitHub pr URL"), "issue view PR URL mismatch should be explicit", issuePrUrlMismatchData); + + const issueNumberOption = await runCli(["gh", "issue", "view", "--repo", "pikasTech/HWLAB", "--number", "7", "--json", "body"], env); + assertCondition(issueNumberOption.status === 0, "issue view should accept --number compatibility alias", issueNumberOption.json ?? { stdout: issueNumberOption.stdout }); + const issueNumberOptionData = dataOf(issueNumberOption.json ?? {}); + assertCondition(issueNumberOptionData.repo === "pikasTech/HWLAB", "issue view --number should preserve explicit repo", issueNumberOptionData); + assertCondition((issueNumberOptionData.issue as JsonRecord).number === 7, "issue view --number should read the requested issue", issueNumberOptionData); + const issueNumberOptionHint = issueNumberOptionData.standardSyntaxHint as JsonRecord; + assertCondition(issueNumberOptionHint.compatibility === true && String(issueNumberOptionHint.standardCommand ?? "").includes("gh issue view 7 --repo pikasTech/HWLAB"), "issue view --number should return standard syntax hint", issueNumberOptionHint); const shorthandRaw = await runCli(["gh", "issue", "view", "pikasTech/HWLAB#7", "--raw"], env); assertCondition(shorthandRaw.status === 0, "issue view should accept owner/repo#number shorthand with --raw", shorthandRaw.json ?? { stdout: shorthandRaw.stdout }); @@ -1281,7 +1303,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { assertCondition(shorthandConflictData.degradedReason === "validation-failed", "conflicting --repo should be validation-failed", shorthandConflictData); assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "conflict message should name the derived repo", shorthandConflictData); const issueConflictCommands = shorthandConflictData.supportedCommands as string[]; - assertCondition(Array.isArray(issueConflictCommands) && issueConflictCommands.some((command) => command === "bun scripts/cli.ts gh issue read 7 --repo pikasTech/HWLAB --json body,title,state,closed,closedAt,comments,number,url,author,createdAt,updatedAt"), "conflict should include the exact supported issue read command", shorthandConflictData); + assertCondition(Array.isArray(issueConflictCommands) && issueConflictCommands.some((command) => command === "bun scripts/cli.ts gh issue view 7 --repo pikasTech/HWLAB --json body,title,state,closed,closedAt,comments,number,url,author,createdAt,updatedAt"), "conflict should include the exact supported issue view command", shorthandConflictData); const rawIssueList = await runCli(["gh", "issue", "list", "--raw"], env); assertCondition(rawIssueList.status === 0, "issue list --raw should be a supported explicit list disclosure path", rawIssueList.json ?? { stdout: rawIssueList.stdout }); @@ -1576,11 +1598,17 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const replaceDisclosure = replaceData.disclosure as JsonRecord; const replaceReadCommands = replaceData.readCommands as JsonRecord; assertCondition(replaceDisclosure.bodyOmitted === true && replaceDisclosure.dryRunBoundedPreview === true, "issue update dry-run should disclose compact body policy", replaceDisclosure); - assertCondition(typeof replaceReadCommands.full === "string" && String(replaceReadCommands.full).includes("gh issue read 20"), "issue update dry-run should expose full body drill-down", replaceReadCommands); + assertCondition(typeof replaceReadCommands.full === "string" && String(replaceReadCommands.full).includes("gh issue view 20"), "issue update dry-run should expose full body drill-down", replaceReadCommands); const replaceWouldPatch = replaceData.wouldPatch as JsonRecord; assertCondition(typeof replaceWouldPatch.bodySha === "string" && String(replaceWouldPatch.bodySha).length === 64, "issue update dry-run should include wouldPatch body sha", replaceWouldPatch); assertCondition(Number(replaceWouldPatch.bodyChars ?? 0) === Number(replaceData.bodyChars ?? 0), "issue update dry-run wouldPatch should include final body chars", replaceWouldPatch); + const replaceNumberDryRun = await runCli(["gh", "issue", "update", "--number", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", safeFile, "--dry-run"], env); + assertCondition(replaceNumberDryRun.status === 0, "issue update should accept --number compatibility alias", replaceNumberDryRun.json ?? { stdout: replaceNumberDryRun.stdout }); + const replaceNumberData = dataOf(replaceNumberDryRun.json ?? {}); + const replaceNumberHint = replaceNumberData.standardSyntaxHint as JsonRecord; + assertCondition(String(replaceNumberHint.standardCommand ?? "").includes("gh issue update 20 --repo pikasTech/unidesk"), "issue update --number should return standard syntax hint", replaceNumberHint); + const compactLongBody = Array.from({ length: 260 }, (_, index) => `compact-success-line-${String(index + 1).padStart(4, "0")} ${"x".repeat(80)}`).join("\n"); const compactLongFile = join(tmp, "compact-long-body.md"); writeFileSync(compactLongFile, compactLongBody, "utf8"); @@ -1604,7 +1632,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const compactDisclosure = compactUpdateData.disclosure as JsonRecord; assertCondition(compactDisclosure.bodyOmitted === true && compactDisclosure.fullBodyIncluded === false && compactDisclosure.defaultCompact === true, "compact update disclosure should be explicit", compactDisclosure); const compactCommands = compactUpdateData.readCommands as JsonRecord; - assertCondition(String(compactCommands.body ?? "").includes("gh issue read 7 --repo pikasTech/HWLAB --json body"), "compact update should expose body read command", compactCommands); + assertCondition(String(compactCommands.body ?? "").includes("gh issue view 7 --repo pikasTech/HWLAB --json body"), "compact update should expose body view command", compactCommands); assertCondition(String(compactCommands.full ?? "").includes("--full") && String(compactCommands.raw ?? "").includes("--raw"), "compact update should expose full/raw drill-down", compactCommands); const compactUpdatePatchCount = mock.requests.slice(compactUpdateRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/HWLAB/issues/7").length; assertCondition(compactUpdatePatchCount === 1, "compact update should PATCH GitHub exactly once", { requests: mock.requests.slice(compactUpdateRequestCountBefore) }); @@ -1655,7 +1683,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { assertCondition(Number(inlineDryRunData.bodyChars ?? 0) === inlineBody.length && typeof inlineDryRunData.bodySha === "string", "inline issue comment dry-run should expose bodyChars/bodySha", inlineDryRunData); assertCondition(String(inlineDryRunData.bodyPreview ?? "") === inlineBody, "inline issue comment dry-run should provide bounded preview for short text", inlineDryRunData); const inlineDryRunReadCommands = inlineDryRunData.readCommands as JsonRecord; - assertCondition(String(inlineDryRunReadCommands.comments ?? "").includes("gh issue read 36") && String(inlineDryRunReadCommands.comments ?? "").includes("--json comments"), "inline issue comment dry-run should expose comment read command", inlineDryRunReadCommands); + assertCondition(String(inlineDryRunReadCommands.comments ?? "").includes("gh issue view 36") && String(inlineDryRunReadCommands.comments ?? "").includes("--json comments"), "inline issue comment dry-run should expose comment view command", inlineDryRunReadCommands); const inlineDryRunWriteCount = mock.requests.slice(inlineDryRunRequestCountBefore).filter((request) => request.method === "POST" && request.url.includes("/comments")).length; assertCondition(inlineDryRunWriteCount === 0, "inline issue comment dry-run must not POST GitHub", { requests: mock.requests.slice(inlineDryRunRequestCountBefore) }); @@ -1751,6 +1779,13 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const commentDeleteDryRunData = dataOf(commentDeleteDryRun.json ?? {}); assertCondition(commentDeleteDryRunData.command === "issue comment delete" && commentDeleteDryRunData.planned === true, "comment delete dry-run should plan DELETE", commentDeleteDryRunData); + const commentDeleteNumberDryRun = await runCli(["gh", "issue", "comment", "delete", "--number", "9001", "--repo", "pikasTech/unidesk", "--dry-run"], env); + assertCondition(commentDeleteNumberDryRun.status === 0, "issue comment delete should accept --number commentId compatibility alias", commentDeleteNumberDryRun.json ?? { stdout: commentDeleteNumberDryRun.stdout }); + const commentDeleteNumberData = dataOf(commentDeleteNumberDryRun.json ?? {}); + assertCondition(commentDeleteNumberData.commentId === 9001 && commentDeleteNumberData.standardSyntaxHint, "issue comment delete --number should return commentId and standard syntax hint", commentDeleteNumberData); + const commentDeleteNumberHint = commentDeleteNumberData.standardSyntaxHint as JsonRecord; + assertCondition(String(commentDeleteNumberHint.standardCommand ?? "").includes("gh issue comment delete 9001 --repo pikasTech/unidesk"), "issue comment delete --number should point to positional commentId syntax", commentDeleteNumberHint); + 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 ?? {}); @@ -1764,15 +1799,16 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { return { ok: true, checks: [ - "issue read --json body preserves .data.issue.body", - "issue view remains a compatibility alias", - "issue read/view accept owner/repo#number shorthand and reject conflicting --repo", - "issue read/view --raw is explicit full disclosure", + "issue view --json body preserves .data.issue.body", + "issue read remains a compatibility alias", + "issue view/read accept GitHub URL and owner/repo#number targets and reject conflicting --repo", + "issue single numeric target commands accept --number compatibility with a standard syntax hint", + "issue view/read --raw is explicit full disclosure", "issue list supports state/limit/json with stable selected fields", "issue list positional owner/repo targets the requested repo and conflicting --repo fails", "acceptance issue list command succeeds under mock GitHub", "issue list default fields include labels and filter pull requests", - "large gh issue read output is dumped to a temp file with bounded stdout and head/tail metadata", + "large gh issue view/read output is dumped to a temp file with bounded stdout and head/tail metadata", "issue scan-escape classifies pollution, explanatory mentions, and body risks", "issue cleanup-plan remains dry-run with body/comment cleanup suggestions", "issue board-audit returns read-only board structure, disables OPEN/CLOSED coverage validation, and keeps compatibility fields empty without writes", diff --git a/scripts/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts index 0e4cbe81..b5ab34c5 100644 --- a/scripts/gh-cli-pr-contract-test.ts +++ b/scripts/gh-cli-pr-contract-test.ts @@ -288,6 +288,10 @@ function failedDataOf(response: JsonRecord): JsonRecord { return response.data as JsonRecord; } +function failureMessageOf(data: JsonRecord): string { + return String((data.details as JsonRecord | undefined)?.message ?? data.message ?? ""); +} + export async function runGhCliPrContract(): Promise<JsonRecord> { const help = await runCli(["gh", "help"]); assertCondition(help.status === 0, "gh help should succeed", help.json ?? { stdout: help.stdout }); @@ -295,9 +299,8 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : []; const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : []; assertCondition(usage.some((line) => line.includes("gh pr list")), "gh help should list pr list", { usage }); - assertCondition(usage.some((line) => line.includes("gh pr read")), "gh help should list pr read", { usage }); - assertCondition(usage.some((line) => line.includes("gh pr view")), "gh help should list pr view", { usage }); - assertCondition(usage.some((line) => line.includes("gh pr read") && line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document pr shorthand and raw/full disclosure", { usage }); + assertCondition(usage.some((line) => line.includes("gh pr view") && line.includes("number|url|owner/repo#number") && line.includes("--raw|--full")), "gh help should document standard pr view targets and raw/full disclosure", { usage }); + assertCondition(usage.some((line) => line.includes("gh pr read") && line.includes("compatibility alias for pr view")), "gh help should list pr read compatibility alias", { usage }); assertCondition(usage.some((line) => line.includes("gh preflight")), "gh help should list top-level preflight alias", { usage }); assertCondition(usage.some((line) => line.includes("gh pr preflight")), "gh help should list pr preflight", { usage }); assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage }); @@ -305,9 +308,10 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage }); assertCondition(usage.some((line) => line.includes("gh pr list") && line.includes("--state open|closed|all")), "gh help should document pr list state filtering", { usage }); assertCondition(usage.some((line) => line.includes("mergedAt") && line.includes("mergeCommit")), "gh help should document merged PR closeout fields", { usage }); - assertCondition(notes.some((line) => line.includes("canonical read path")), "gh help should state pr read is canonical", { notes }); - assertCondition(notes.some((line) => line.includes("compatibility alias")), "gh help should state pr view is alias", { notes }); - assertCondition(notes.some((line) => line.includes("PR read/view accept owner/repo#number shorthand")), "gh help should explain pr read/view shorthand", { notes }); + 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("--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 }); @@ -383,15 +387,17 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr read --json should select fields", readData); const readNumberAlias = await runCli(["gh", "pr", "read", "--repo", "pikasTech/HWLAB", "--number", "7", "--json", "body,title,state,head,base"], env); - assertCondition(readNumberAlias.status === 0, "pr read should accept --number alias", readNumberAlias.json ?? { stdout: readNumberAlias.stdout }); + assertCondition(readNumberAlias.status === 0, "pr read should accept --number compatibility alias", readNumberAlias.json ?? { stdout: readNumberAlias.stdout }); const readNumberAliasData = dataOf(readNumberAlias.json ?? {}); assertCondition(readNumberAliasData.repo === "pikasTech/HWLAB", "pr read --number should preserve explicit repo", readNumberAliasData); const readNumberAliasPr = readNumberAliasData.pullRequest as JsonRecord; assertCondition(readNumberAliasPr.number === 7 && readNumberAliasPr.url === "https://github.com/pikasTech/HWLAB/pull/7", "pr read --number should read the requested PR", readNumberAliasData); + const readNumberAliasDisclosure = readNumberAliasData.disclosure as JsonRecord; + assertCondition(String(readNumberAliasDisclosure.compatibilityHint ?? "").includes("standard gh syntax") && String(readNumberAliasDisclosure.standardCommand ?? "").includes("gh pr view 7 --repo pikasTech/HWLAB"), "pr read --number should return standard syntax hint", readNumberAliasDisclosure); assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/pulls/7"), "pr read --number should call explicit repo REST path", mock.requests); const numberAliasUnsupported = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--number", "7"], env); - assertCondition(numberAliasUnsupported.status !== 0, "--number should not be silently ignored outside pr read/view", numberAliasUnsupported.json ?? { stdout: numberAliasUnsupported.stdout }); + assertCondition(numberAliasUnsupported.status !== 0, "--number should not be silently ignored outside standard view/read", numberAliasUnsupported.json ?? { stdout: numberAliasUnsupported.stdout }); const numberAliasUnsupportedData = failedDataOf(numberAliasUnsupported.json ?? {}); assertCondition(numberAliasUnsupportedData.degradedReason === "validation-failed", "unsupported --number should be validation-failed", numberAliasUnsupportedData); @@ -404,11 +410,24 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(openLifecycleJson.merged === false && openLifecycleJson.mergedAt === null && openLifecycleJson.mergeCommit === null, "open pr should expose merged=false", openLifecycleData); const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env); - assertCondition(view.status === 0, "pr view alias should succeed through REST", view.json ?? { stdout: view.stdout }); + assertCondition(view.status === 0, "pr view should succeed as canonical read path", view.json ?? { stdout: view.stdout }); const viewData = dataOf(view.json ?? {}); - assertCondition((viewData.pullRequest as JsonRecord).number === 42, "pr view alias should expose PR details", viewData); + assertCondition((viewData.pullRequest as JsonRecord).number === 42, "pr view should expose PR details", viewData); const viewSelected = viewData.json as JsonRecord; - assertCondition(viewSelected.body === "PR body" && viewSelected.title === "contract PR", "pr view alias should preserve selected fields", viewData); + assertCondition(viewSelected.body === "PR body" && viewSelected.title === "contract PR", "pr view should preserve selected fields", viewData); + + const prUrlView = await runCli(["gh", "pr", "view", "https://github.com/pikasTech/HWLAB/pull/7", "--json", "body,title,state,head,base"], env); + assertCondition(prUrlView.status === 0, "pr view should accept GitHub PR URL target", prUrlView.json ?? { stdout: prUrlView.stdout }); + const prUrlViewData = dataOf(prUrlView.json ?? {}); + assertCondition(prUrlViewData.repo === "pikasTech/HWLAB", "PR URL target should derive repo", prUrlViewData); + assertCondition((prUrlViewData.pullRequest as JsonRecord).number === 7, "PR URL target should derive PR number", prUrlViewData); + const prUrlDisclosure = prUrlViewData.disclosure as JsonRecord; + assertCondition(prUrlDisclosure.shorthand && (prUrlDisclosure.shorthand as JsonRecord).source === "github-url", "PR URL target should be disclosed", prUrlDisclosure); + + const prIssueUrlMismatch = await runCli(["gh", "pr", "view", "https://github.com/pikasTech/HWLAB/issues/7", "--json", "body"], env); + assertCondition(prIssueUrlMismatch.status !== 0, "pr view should reject issue URLs", prIssueUrlMismatch.json ?? { stdout: prIssueUrlMismatch.stdout }); + const prIssueUrlMismatchData = failedDataOf(prIssueUrlMismatch.json ?? {}); + assertCondition(failureMessageOf(prIssueUrlMismatchData).includes("GitHub issue URL"), "pr view issue URL mismatch should be explicit", prIssueUrlMismatchData); const shorthandRaw = await runCli(["gh", "pr", "view", "pikasTech/HWLAB#7", "--raw"], env); assertCondition(shorthandRaw.status === 0, "pr view should accept owner/repo#number shorthand with --raw", shorthandRaw.json ?? { stdout: shorthandRaw.stdout }); @@ -428,7 +447,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(shorthandConflictData.degradedReason === "validation-failed", "pr conflicting --repo should be validation-failed", shorthandConflictData); assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "pr conflict message should name the derived repo", shorthandConflictData); const prConflictCommands = shorthandConflictData.supportedCommands as string[]; - assertCondition(Array.isArray(prConflictCommands) && prConflictCommands.some((command) => command === "bun scripts/cli.ts gh pr read 7 --repo pikasTech/HWLAB --json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"), "pr conflict should include exact supported read command", shorthandConflictData); + assertCondition(Array.isArray(prConflictCommands) && prConflictCommands.some((command) => command === "bun scripts/cli.ts gh pr view 7 --repo pikasTech/HWLAB --json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"), "pr conflict should include exact supported view command", shorthandConflictData); const closeout = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "mergeable,mergeStateStatus,statusCheckRollup,headRefName,baseRefName"], env); assertCondition(closeout.status === 0, "pr view closeout metadata fields should not be rejected", closeout.json ?? { stdout: closeout.stdout }); @@ -648,6 +667,12 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(appendBody.mode === "append", "pr append mode should be explicit", updateAppendData); assertCondition(finalBody.containsBackticks === true && finalBody.containsMarkdownTable === true, "pr append should preserve markdown signals", updateAppendData); + const updateNumberDryRun = await runCli(["gh", "pr", "update", "--number", "42", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", bodyFile, "--dry-run"], env2); + assertCondition(updateNumberDryRun.status === 0, "pr update should accept --number compatibility alias", updateNumberDryRun.json ?? { stdout: updateNumberDryRun.stdout }); + const updateNumberData = dataOf(updateNumberDryRun.json ?? {}); + const updateNumberHint = updateNumberData.standardSyntaxHint as JsonRecord; + assertCondition(String(updateNumberHint.standardCommand ?? "").includes("gh pr update 42 --repo pikasTech/unidesk"), "pr update --number should return standard syntax hint", updateNumberHint); + const editStdinBody = "stdin line\n`stdin code`\n| c | d |\n"; const beforeEditRequests = mock2.requests.length; const editStdin = await runCli(["gh", "pr", "edit", "42", "--repo", "pikasTech/unidesk", "--title", "stdin title", "--body-file", "-"], env2, editStdinBody); @@ -701,6 +726,12 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { 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); + + const commentDeleteNumber = await runCli(["gh", "pr", "comment", "delete", "--number", "9101", "--repo", "pikasTech/unidesk", "--dry-run"], env2); + assertCondition(commentDeleteNumber.status === 0, "pr comment delete should accept --number commentId compatibility alias", commentDeleteNumber.json ?? { stdout: commentDeleteNumber.stdout }); + const commentDeleteNumberData = dataOf(commentDeleteNumber.json ?? {}); + const commentDeleteNumberHint = commentDeleteNumberData.standardSyntaxHint as JsonRecord; + assertCondition(commentDeleteNumberData.commentId === 9101 && String(commentDeleteNumberHint.standardCommand ?? "").includes("gh pr comment delete 9101 --repo pikasTech/unidesk"), "pr comment delete --number should point to positional commentId syntax", commentDeleteNumberData); } finally { await mock2.close(); } @@ -731,9 +762,9 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { "gh help lists pr create/comment", "pr list/read/view work through REST with token and no gh binary dependency", "pr list positional owner/repo targets the requested repo and conflicting --repo fails", - "pr read supports --number alias without silently ignoring it elsewhere", - "pr read/view accept owner/repo#number shorthand and reject conflicting --repo", - "pr read/view --raw is explicit full disclosure", + "pr single numeric target commands accept --number compatibility with a standard syntax hint", + "pr view/read accept GitHub URL and owner/repo#number targets and reject conflicting --repo", + "pr view/read --raw is explicit full disclosure", "pr list rejects closeout fields and points to pr view", "pr read normalizes open and merged lifecycle fields from REST", "GitHub DNS/API transients are retryable and distinct from auth or PR semantic failures", diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 88eb16be..ca68fade 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -382,6 +382,9 @@ interface GitHubShorthandReference { input: string; repo: string; number: number; + source?: "owner-repo-number" | "github-url" | "number-option"; + urlKind?: "issue" | "pr"; + standardCommand?: string; } interface GitHubResolvedNumberReference { @@ -695,6 +698,21 @@ function isPrReadCommand(sub: string | undefined): boolean { return sub === "read" || sub === "view"; } +function allowsNumberTargetAlias(top: string | undefined, sub: string | undefined, third: string | undefined): boolean { + if (top === "preflight") return true; + if (top === "issue") { + if (sub === "read" || sub === "view" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "delete") return true; + if (sub === "comment") return true; + if (sub === "board-row" && ["get", "update", "add", "move", "delete", "upsert"].includes(third ?? "")) return true; + return false; + } + if (top === "pr") { + if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "merge" || sub === "delete") return true; + if (sub === "comment") return true; + } + return false; +} + function parseIssueListState(args: string[]): IssueListState { const raw = optionValue(args, "--state") ?? "open"; if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as IssueListState; @@ -889,24 +907,92 @@ function parsePositionalNumberForCommand(repo: string, args: string[], startInde return parseNumberForCommand(repo, targets[0], label); } -function resolvePositionalPrReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { +function resolvedNumberOptionReference(kind: "issue" | "pr", command: string, repo: string, raw: string): GitHubResolvedNumberReference | GitHubCommandResult { + const number = parseNumberForCommand(repo, raw, command); + if (typeof number !== "number") return number; + return { + repo, + number, + shorthand: { + input: `--number ${number}`, + repo, + number, + source: "number-option", + standardCommand: `bun scripts/cli.ts gh ${command} ${number} --repo ${repo}`, + urlKind: kind, + }, + }; +} + +function resolvePositionalNumberReference(kind: "issue" | "pr", args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { const targets = positionalArgs(args.slice(startIndex)); - const prOption = optionValue(args, "--pr"); - if (targets.length > 0 && prOption !== undefined) { - return validationError(label, options.repo, `${label} accepts either a positional PR target or --pr, not both`, { + const numberOption = optionValue(args, "--number"); + if (targets.length > 0 && numberOption !== undefined) { + return validationError(label, options.repo, `${label} accepts either a positional numeric target or --number, not both`, { supportedCommands: [ `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, ], }); } + if (numberOption !== undefined) return resolvedNumberOptionReference(kind, label, options.repo, numberOption); + if (targets.length !== 1) { + return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, + ], + }); + } + const shorthand = parseOwnerRepoNumberShorthand(targets[0]); + if (shorthand !== null) { + const explicitRepo = optionValue(args, "--repo"); + if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { + return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, { + shorthand, + explicitRepo, + }); + } + return { repo: shorthand.repo, number: shorthand.number, shorthand }; + } + const number = parseNumberForCommand(options.repo, targets[0], label); + if (typeof number !== "number") return number; + return { repo: options.repo, number }; +} + +function resolvePositionalPrReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { + const targets = positionalArgs(args.slice(startIndex)); + const prOption = optionValue(args, "--pr"); + const numberOption = optionValue(args, "--number"); + if (prOption !== undefined && numberOption !== undefined) { + return validationError(label, options.repo, `${label} accepts either --pr or --number as a compatibility alias, not both`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, + ], + }); + } + if (targets.length > 0 && (prOption !== undefined || numberOption !== undefined)) { + return validationError(label, options.repo, `${label} accepts either a positional PR target or a compatibility number option, not both`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, + ], + }); + } + if (numberOption !== undefined) return resolvedNumberOptionReference("pr", label, options.repo, numberOption); const effectiveTargets = prOption !== undefined ? [prOption] : targets; if (effectiveTargets.length !== 1) { return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { supportedCommands: [ `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, ], }); @@ -927,12 +1013,24 @@ function resolvePositionalPrReference(args: string[], startIndex: number, label: return { repo: options.repo, number }; } -function resolvePositionalIssueReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { +function resolvePositionalIssueReference(args: string[], startIndex: number, label: string, options: GitHubOptions, allowNumberOption = true): GitHubResolvedNumberReference | GitHubCommandResult { const targets = positionalArgs(args.slice(startIndex)); + const numberOption = allowNumberOption ? optionValue(args, "--number") : undefined; + if (targets.length > 0 && numberOption !== undefined) { + return validationError(label, options.repo, `${label} accepts either a positional issue target or --number, not both`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, + ], + }); + } + if (numberOption !== undefined) return resolvedNumberOptionReference("issue", label, options.repo, numberOption); if (targets.length !== 1) { return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { supportedCommands: [ `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + ...(allowNumberOption ? [`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`] : []), `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, ], }); @@ -961,13 +1059,43 @@ function parseOwnerRepoNumberShorthand(raw: string | undefined): GitHubShorthand input: raw, repo: `${match[1]}/${match[2]}`, number: Number(match[3]), + source: "owner-repo-number", }; } +function parseGitHubIssueOrPrUrl(raw: string | undefined): GitHubShorthandReference | null { + if (raw === undefined) return null; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + if (parsed.hostname.toLowerCase() !== "github.com" && parsed.hostname.toLowerCase() !== "www.github.com") return null; + const parts = parsed.pathname.split("/").filter((part) => part.length > 0); + if (parts.length < 4) return null; + const [owner, repoName, kind, numberRaw] = parts; + if (!owner || !repoName || (kind !== "issues" && kind !== "pull")) return null; + if (!/^[1-9]\d*$/u.test(numberRaw ?? "")) return null; + return { + input: raw, + repo: `${owner}/${repoName}`, + number: Number(numberRaw), + source: "github-url", + urlKind: kind === "issues" ? "issue" : "pr", + }; +} + +function parseReadViewTarget(raw: string | undefined): GitHubShorthandReference | null { + return parseOwnerRepoNumberShorthand(raw) ?? parseGitHubIssueOrPrUrl(raw); +} + function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: number): string[] { return [ - `bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)}`, + `bun scripts/cli.ts gh ${kind} view ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)}`, `bun scripts/cli.ts gh ${kind} view ${repo}#${number} --raw`, + `bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)} [compatibility alias]`, ]; } @@ -981,28 +1109,50 @@ function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "vie const command = `${kind} ${sub}`; const targets = positionalArgs(args.slice(2)); if (targets.length > 1) { - return validationError(command, options.repo, `${command} accepts one number or owner/repo#number target; use --repo owner/name and --number N when passing options first`, { + return validationError(command, options.repo, `${command} accepts one positional target: number, GitHub ${kind} URL, or owner/repo#number`, { supportedCommands: [ - `bun scripts/cli.ts gh ${kind} read <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, - `bun scripts/cli.ts gh ${kind} read owner/name#<number> --raw`, + `bun scripts/cli.ts gh ${kind} view <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, + `bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/<number> --raw`, + `bun scripts/cli.ts gh ${kind} view owner/name#<number> --raw`, ], }); } const raw = targets[0]; - const numberAliasRaw = kind === "pr" ? optionValue(args, "--number") : undefined; - const shorthand = parseOwnerRepoNumberShorthand(raw); - if (shorthand !== null) { - if (numberAliasRaw !== undefined) { - const aliasNumber = parseNumberForCommand(shorthand.repo, numberAliasRaw, command); - if (typeof aliasNumber !== "number") return aliasNumber; - if (aliasNumber !== shorthand.number) { - return validationError(command, shorthand.repo, `${command} target ${shorthand.input} conflicts with --number ${aliasNumber}; use one PR number target.`, { - shorthand, + if (optionWasProvided(args, "--number")) { + const numberAliasRaw = optionValue(args, "--number"); + const aliasNumber = parseNumberForCommand(options.repo, numberAliasRaw, command); + if (typeof aliasNumber !== "number") return aliasNumber; + if (raw !== undefined) { + const parsedRaw = parseNumberForCommand(options.repo, raw, command); + if (typeof parsedRaw !== "number") return parsedRaw; + if (parsedRaw !== aliasNumber) { + return validationError(command, options.repo, `${command} positional number ${parsedRaw} conflicts with --number ${aliasNumber}; use one target number.`, { + positionalNumber: parsedRaw, numberAlias: aliasNumber, - supportedCommands: readViewSupportedCommands(kind, shorthand.repo, shorthand.number), + supportedCommands: readViewSupportedCommands(kind, options.repo, aliasNumber), }); } } + return { + repo: options.repo, + number: aliasNumber, + shorthand: { + input: `--number ${aliasNumber}`, + repo: options.repo, + number: aliasNumber, + source: "number-option", + standardCommand: `bun scripts/cli.ts gh ${kind} view ${aliasNumber} --repo ${options.repo}`, + }, + }; + } + const shorthand = parseReadViewTarget(raw); + if (shorthand !== null) { + if (shorthand.urlKind !== undefined && shorthand.urlKind !== kind) { + return validationError(command, shorthand.repo, `${command} target ${shorthand.input} is a GitHub ${shorthand.urlKind} URL, not a ${kind} URL.`, { + shorthand, + supportedCommands: readViewSupportedCommands(shorthand.urlKind, shorthand.repo, shorthand.number), + }); + } const explicitRepo = optionValue(args, "--repo"); if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { const message = `${command} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided. Use either the shorthand or a matching --repo, not both.`; @@ -1015,38 +1165,14 @@ function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "vie } return { repo: shorthand.repo, number: shorthand.number, shorthand }; } - if (numberAliasRaw !== undefined) { - const aliasNumber = parseNumberForCommand(options.repo, numberAliasRaw, command); - if (typeof aliasNumber !== "number") return { - ...aliasNumber, - supportedCommands: [ - `bun scripts/cli.ts gh pr read --repo owner/name --number <number> --json ${readViewSupportedJsonFields("pr")}`, - "bun scripts/cli.ts gh pr read owner/name#<number> --raw", - ], - }; - if (raw !== undefined) { - const parsedRaw = parseNumberForCommand(options.repo, raw, command); - if (typeof parsedRaw !== "number") return parsedRaw; - if (parsedRaw !== aliasNumber) { - return validationError(command, options.repo, `${command} positional number ${parsedRaw} conflicts with --number ${aliasNumber}; use one PR number target.`, { - positionalNumber: parsedRaw, - numberAlias: aliasNumber, - supportedCommands: [ - `bun scripts/cli.ts gh pr read ${aliasNumber} --repo ${options.repo} --json ${readViewSupportedJsonFields("pr")}`, - `bun scripts/cli.ts gh pr read --repo ${options.repo} --number ${aliasNumber} --full`, - ], - }); - } - } - return { repo: options.repo, number: aliasNumber }; - } const parsed = parseNumberForCommand(options.repo, raw, command); if (typeof parsed !== "number") { return { ...parsed, supportedCommands: [ - `bun scripts/cli.ts gh ${kind} read <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, - `bun scripts/cli.ts gh ${kind} read owner/name#<number> --raw`, + `bun scripts/cli.ts gh ${kind} view <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, + `bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/<number> --raw`, + `bun scripts/cli.ts gh ${kind} view owner/name#<number> --raw`, ], }; } @@ -1068,6 +1194,30 @@ function readDisclosureOptions(options: GitHubOptions, shorthand: GitHubShorthan ...(options.full ? { full: true } : {}), fullDisclosure: options.raw || options.full, shorthand: shorthand ?? null, + ...(shorthand?.source === "number-option" ? { + compatibilityHint: "--number is accepted for low-friction compatibility; standard gh syntax uses a positional number or URL.", + standardCommand: shorthand.standardCommand, + } : {}), + }; +} + +function numberOptionCompatibilityHint(resolved: GitHubResolvedNumberReference): Record<string, unknown> | null { + if (resolved.shorthand?.source !== "number-option") return null; + return { + acceptedOption: "--number", + compatibility: true, + message: "--number is accepted for low-friction compatibility; prefer the standard positional number target shown in standardCommand.", + standardCommand: resolved.shorthand.standardCommand ?? null, + }; +} + +async function withNumberOptionHint(result: GitHubCommandResult | Promise<GitHubCommandResult>, resolved: GitHubResolvedNumberReference): Promise<GitHubCommandResult> { + const output = await result; + const hint = numberOptionCompatibilityHint(resolved); + if (hint === null) return output; + return { + ...output, + standardSyntaxHint: hint, }; } @@ -1082,14 +1232,14 @@ function unknownGhOptionDetails(args: string[], option: string): Record<string, helpCommand: "bun scripts/cli.ts gh help", }; if ((top === "issue" || top === "pr") && (sub === "read" || sub === "view")) { - const shorthand = parseOwnerRepoNumberShorthand(third); + const shorthand = parseReadViewTarget(third); const repo = shorthand?.repo ?? optionValue(args, "--repo") ?? "owner/name"; const number = shorthand?.number ?? (third !== undefined && /^\d+$/u.test(third) ? Number(third) : 0); details.supportedCommands = number > 0 ? readViewSupportedCommands(top, repo, number) : [ - `bun scripts/cli.ts gh ${top} read <number> --repo owner/name --json ${readViewSupportedJsonFields(top)}`, - `bun scripts/cli.ts gh ${top} read owner/name#<number> --raw`, + `bun scripts/cli.ts gh ${top} view <number> --repo owner/name --json ${readViewSupportedJsonFields(top)}`, + `bun scripts/cli.ts gh ${top} view owner/name#<number> --raw`, ]; } return details; @@ -2036,17 +2186,17 @@ function isGitHubError(value: unknown): value is GitHubErrorPayload { function issueBodyReadCommands(repo: string, issueNumber: number): Record<string, string> { return { - body: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --json body`, - full: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --full`, - raw: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --raw`, + body: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json body`, + full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`, + raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`, }; } function issueCommentReadCommands(repo: string, issueNumber: number): Record<string, string> { return { - comments: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --json comments`, - full: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --full`, - raw: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --raw`, + 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`, + raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`, }; } @@ -2060,7 +2210,7 @@ function issueWriteDisclosure(options: GitHubOptions, repo: string, issueNumber: dryRunBoundedPreview: dryRun, note: explicitFullDisclosure && !dryRun ? "The returned issue object includes the full body because --full or --raw was explicitly requested." - : "Default issue write output omits full issue.body; use readCommands.full/raw or gh issue read --json body when full text is needed.", + : "Default issue write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.", readCommands: issueBodyReadCommands(repo, issueNumber), }; } @@ -2122,7 +2272,7 @@ function issueLifecycleDisclosure(repo: string, issueNumber: number, dryRun: boo fullBodyIncluded: false, bodyOmitted: true, dryRunBoundedPreview: dryRun, - note: "Issue lifecycle write output omits full issue.body; use readCommands.full/raw or gh issue read --json body when full text is needed.", + note: "Issue lifecycle write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.", readCommands: issueBodyReadCommands(repo, issueNumber), }; } @@ -6313,7 +6463,7 @@ async function prFiles(repo: string, token: string, number: number, limit: numbe const nextLimit = totalFiles === null ? MAX_PR_FILES_LIMIT : Math.min(totalFiles, MAX_PR_FILES_LIMIT); const nextCommand = truncated ? `bun scripts/cli.ts gh pr files ${number} --repo ${repo} --limit ${nextLimit}` - : `bun scripts/cli.ts gh pr read ${number} --repo ${repo} --json body,title,state,head,base`; + : `bun scripts/cli.ts gh pr view ${number} --repo ${repo} --json body,title,state,head,base`; return { ok: true, command: commandName, @@ -6384,42 +6534,42 @@ export function ghHelp(): unknown { usage: [ "bun scripts/cli.ts gh auth status [--repo owner/name]", "bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels] [--raw|--full]", - "bun scripts/cli.ts gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,closed,closedAt,comments] [--raw|--full]", - "bun scripts/cli.ts gh issue view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for issue read]", + "bun scripts/cli.ts gh issue view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,closed,closedAt,comments] [--raw|--full]", + "bun scripts/cli.ts gh issue read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for issue view]", "bun scripts/cli.ts gh issue create --title <title> --body-file <file|-> [--label label[,label...]]... [--repo owner/name] [--dry-run]", - "bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file|-> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]", - "bun scripts/cli.ts gh issue edit <number> --body-file <file|-> [--full|--raw] [compat alias for issue update --mode replace]", + "bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file|-> [--title title] [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]", + "bun scripts/cli.ts gh issue edit <number> --body-file <file|-> [--repo owner/name] [--number N compat] [--full|--raw] [compat alias for issue update --mode replace]", "bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]", - "bun scripts/cli.ts gh issue comment create <number> --body-file <file|->|--body <short-text> [--repo owner/name] [--dry-run]", - "bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--dry-run]", - "bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--comment <short-text>|--comment-file <file|->] [--dry-run]", + "bun scripts/cli.ts gh issue comment create <number> --body-file <file|->|--body <short-text> [--repo owner/name] [--number N compat] [--dry-run]", + "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-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]", "bun scripts/cli.ts gh issue delete <number> [unsupported: use close]", "bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]", "bun scripts/cli.ts gh issue cleanup-plan [--repo owner/name] [--limit N] [--dry-run]", "bun scripts/cli.ts gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]", "bun scripts/cli.ts gh issue board-row list [--repo owner/name] --board-issue 20 [--state open|closed|all] [--dry-run]", - "bun scripts/cli.ts gh issue board-row get <issueNumber> [--repo owner/name] --board-issue 20", - "bun scripts/cli.ts gh issue board-row update <issueNumber> [--repo owner/name] --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", - "bun scripts/cli.ts gh issue board-row add <issueNumber> [--repo owner/name] --board-issue 20 --section open|closed --row-file <markdown-row-file> [--dry-run] [--expect-body-sha sha256]", - "bun scripts/cli.ts gh issue board-row upsert <issueNumber> [--repo owner/name] --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", - "bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]", - "bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] --board-issue 20 [--dry-run] [--expect-body-sha sha256]", - "bun scripts/cli.ts gh preflight <prNumber|owner/repo#number> [--repo owner/name] [--full|--raw] [compatibility alias for gh pr preflight]", + "bun scripts/cli.ts gh issue board-row get <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20", + "bun scripts/cli.ts gh issue board-row update <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", + "bun scripts/cli.ts gh issue board-row add <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --section open|closed --row-file <markdown-row-file> [--dry-run] [--expect-body-sha sha256]", + "bun scripts/cli.ts gh issue board-row upsert <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", + "bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]", + "bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 [--dry-run] [--expect-body-sha sha256]", + "bun scripts/cli.ts gh preflight <prNumber|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [compatibility alias for gh pr preflight]", "bun scripts/cli.ts gh pr list [owner/repo] [--repo owner/name] [--state open|closed|all] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]", - "bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--limit N] [number may appear before or after options]", - "bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--limit N] [number may appear before or after options; compatibility alias for pr files; no raw diff]", - "bun scripts/cli.ts gh pr read <number|owner/repo#number> [--repo owner/name] [--number N] [--json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]", - "bun scripts/cli.ts gh pr view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for pr read]", - "bun scripts/cli.ts gh pr preflight <number|owner/repo#number> [--repo owner/name] [--full|--raw] [number may appear before or after options]", - "bun scripts/cli.ts gh pr closeout <number|owner/repo#number> [--repo owner/name] [--full|--raw] [number may appear before or after options; compatibility alias for pr preflight]", + "bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options]", + "bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options; compatibility alias for pr files; no raw diff]", + "bun scripts/cli.ts gh pr view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]", + "bun scripts/cli.ts gh pr read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for pr view]", + "bun scripts/cli.ts gh pr preflight <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options]", + "bun scripts/cli.ts gh pr closeout <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options; compatibility alias for pr preflight]", "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 edit <number> [--title title] [--body-file <file>|--body-file -|--body <text>] [--repo owner/name] [--dry-run]", - "bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-file <file>|--body-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 merge <number> [--repo owner/name] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]", + "bun scripts/cli.ts gh pr edit <number> [--title title] [--body-file <file>|--body-file -|--body <text>] [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title title] [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--number N compat] [--dry-run]", + "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]", "bun scripts/cli.ts gh pr delete <number> [unsupported: use close]", ], defaults: { repo: DEFAULT_REPO }, @@ -6429,7 +6579,7 @@ export function ghHelp(): unknown { "issue list and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.", "issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. Supported --json fields are number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.", "PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.", - "issue read is the canonical read path; view remains a compatibility alias. Read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. Read supports lifecycle fields closed/closedAt plus legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.", + "issue view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.", "--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.", "GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.", "issue create accepts --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.", @@ -6439,7 +6589,7 @@ export function ghHelp(): unknown { "issue update --body-file accepts files or - for stdin, 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-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 close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text> or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-file is the recommended path for generated Markdown closeout evidence; --comment remains the short inline form. Use gh issue read <number> --json body or --full/--raw on read when full text is needed.", + "issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text> or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-file is the recommended 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, pipe reviewed Markdown through stdin: cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - or gh issue comment create <number> --body-file -. When staging a body file from a shell, use a quoted heredoc such as cat <<'EOF' > /tmp/body.md so backticks and backslashes are not expanded before --body-file reads the file.", "For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments. GitHub issue/PR Markdown writes use --body-file <file|-> for long or multiline content.", @@ -6453,10 +6603,10 @@ export function ghHelp(): unknown { "comment delete is supported because GitHub supports deleting issue comments; 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 read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and --number N as a compatibility alias for the positional PR number; shorthand derives --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. PR read/view 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 read/view so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.", + "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 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 the explicit UniDesk REST CLI no-merge policy. Use --full or --raw to include all fetched status contexts.", + "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.", "PR merge is a guarded write operation: it first reads closeout metadata, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. Use --dry-run to see the exact merge plan without writing.", ], }; @@ -6496,12 +6646,12 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue list/read/view/update/edit, gh pr list/read/view, and gh pr preflight/closeout.", { supportedCommands: [ "bun scripts/cli.ts gh issue list --repo owner/name --limit 200 --full", - "bun scripts/cli.ts gh issue read owner/name#<number> --raw", - "bun scripts/cli.ts gh issue read <number> --repo owner/name --json body,title,state,comments", + "bun scripts/cli.ts gh issue view owner/name#<number> --raw", + "bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state,comments", "cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - --full", "bun scripts/cli.ts gh pr list --repo owner/name --limit 100 --full", - "bun scripts/cli.ts gh pr read owner/name#<number> --raw", - `bun scripts/cli.ts gh pr read <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`, + "bun scripts/cli.ts gh pr view owner/name#<number> --raw", + `bun scripts/cli.ts gh pr view <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`, "bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full", ], }); @@ -6515,13 +6665,17 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult ], }); } - if (optionWasProvided(args, "--number") && !(top === "pr" && isPrReadCommand(sub))) { + if (optionWasProvided(args, "--number") && !allowsNumberTargetAlias(top, sub, third)) { const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--number is only supported by gh pr read/view; use gh pr read --repo owner/name --number N.", { + const standardViewCommand = top === "issue" || top === "pr" ? `gh ${top} view` : "gh issue/pr view"; + return validationError(command, options.repo, `--number is only a compatibility alias for single numeric target commands; standard ${standardViewCommand} uses a positional number or URL target.`, { supportedCommands: [ - "bun scripts/cli.ts gh pr read --repo owner/name --number <number> --full", - "bun scripts/cli.ts gh pr read <number> --repo owner/name --json body,title,state,head,base", + "bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state", + "bun scripts/cli.ts gh issue view https://github.com/owner/name/issues/<number> --raw", + "bun scripts/cli.ts gh pr view <number> --repo owner/name --json body,title,state,head,base", + "bun scripts/cli.ts gh pr view https://github.com/owner/name/pull/<number> --raw", ], + rejectedOption: "--number", }); } if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && (sub === "update" || sub === "edit")))) { @@ -6586,32 +6740,32 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult if (top === "preflight") { const resolved = resolvePositionalPrReference(args, 1, "preflight", options); if (isGitHubCommandResult(resolved)) return resolved; - return prPreflight(resolved.repo, resolved.number, "preflight", options.full || options.raw); + return withNumberOptionHint(prPreflight(resolved.repo, resolved.number, "preflight", options.full || options.raw), resolved); } 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 resolved = resolvePositionalIssueReference(args, 3, "issue comment delete", options); + const resolved = resolvePositionalNumberReference("issue", args, 3, "issue comment delete", options); if (isGitHubCommandResult(resolved)) return resolved; const commentId = resolved.number; if (typeof commentId !== "number") return commentId; - if (options.dryRun) return commentDelete(options.repo, "", "issue", commentId, true); + if (options.dryRun) return withNumberOptionHint(commentDelete(resolved.repo, "", "issue", commentId, true), resolved); 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); + const missing = authRequired(resolved.repo, "issue comment delete", probe); + 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 === "create") { const resolved = resolvePositionalIssueReference(args, 3, "issue comment create", options); if (isGitHubCommandResult(resolved)) return resolved; const issueNumber = resolved.number; if (typeof issueNumber !== "number") return issueNumber; - if (options.dryRun) return issueComment(options.repo, "", issueNumber, options); + if (options.dryRun) return withNumberOptionHint(issueComment(resolved.repo, "", issueNumber, { ...options, repo: resolved.repo }), resolved); const { token, probe } = resolveToken(true); - const missing = authRequired(options.repo, "issue comment create", probe); + const missing = authRequired(resolved.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); + return withNumberOptionHint(issueComment(resolved.repo, token, issueNumber, { ...options, repo: resolved.repo }), resolved); } if (sub === "scan-escape" || sub === "cleanup-plan") { const { token, probe } = resolveToken(true); @@ -6640,12 +6794,13 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult if (isGitHubCommandResult(resolvedBoardRow)) return resolvedBoardRow; const issueNumber = resolvedBoardRow.number; if (typeof issueNumber !== "number") return issueNumber; - if (action === "get") return issueBoardRowGet(options.repo, token, issueNumber, options); - if (action === "update") return issueBoardRowUpdate(options.repo, token, issueNumber, options); - if (action === "add") return issueBoardRowAdd(options.repo, token, issueNumber, options); - if (action === "upsert") return issueBoardRowUpsert(options.repo, token, issueNumber, options); - if (action === "move") return issueBoardRowMove(options.repo, token, issueNumber, options); - return issueBoardRowDelete(options.repo, token, issueNumber, options); + const boardRowOptions = { ...options, repo: resolvedBoardRow.repo }; + if (action === "get") return withNumberOptionHint(issueBoardRowGet(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); + if (action === "update") return withNumberOptionHint(issueBoardRowUpdate(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); + if (action === "add") return withNumberOptionHint(issueBoardRowAdd(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); + if (action === "upsert") return withNumberOptionHint(issueBoardRowUpsert(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); + if (action === "move") return withNumberOptionHint(issueBoardRowMove(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); + return withNumberOptionHint(issueBoardRowDelete(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); } if (options.dryRun) { if (sub === "create") return issueCreate(options.repo, "", options); @@ -6654,29 +6809,29 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult if (isGitHubCommandResult(issueEditRef)) return issueEditRef; const issueNumber = issueEditRef.number; const { token } = resolveToken(false); - return issueEdit(options.repo, token ?? "", issueNumber, options); + return withNumberOptionHint(issueEdit(issueEditRef.repo, token ?? "", issueNumber, { ...options, repo: issueEditRef.repo }), issueEditRef); } if (sub === "update") { const issueUpdateRef = resolvePositionalIssueReference(args, 2, "issue update", options); if (isGitHubCommandResult(issueUpdateRef)) return issueUpdateRef; const issueNumber = issueUpdateRef.number; const { token } = resolveToken(false); - return issueEdit(options.repo, token ?? "", issueNumber, options, "issue update"); + return withNumberOptionHint(issueEdit(issueUpdateRef.repo, token ?? "", issueNumber, { ...options, repo: issueUpdateRef.repo }, "issue update"), issueUpdateRef); } if (sub === "comment") { const r = resolvePositionalIssueReference(args, 2, "issue comment", options); if (isGitHubCommandResult(r)) return r; - return issueComment(r.repo, "", r.number, { ...options, repo: r.repo }); + return withNumberOptionHint(issueComment(r.repo, "", r.number, { ...options, repo: r.repo }), r); } if (sub === "close") { const r = resolvePositionalIssueReference(args, 2, "issue close", options); if (isGitHubCommandResult(r)) return r; - return issueState(r.repo, "", r.number, "closed", true, { ...options, repo: r.repo }); + return withNumberOptionHint(issueState(r.repo, "", r.number, "closed", true, { ...options, repo: r.repo }), r); } if (sub === "reopen") { const r = resolvePositionalIssueReference(args, 2, "issue reopen", options); if (isGitHubCommandResult(r)) return r; - return issueState(r.repo, "", r.number, "open", true, { ...options, repo: r.repo }); + return withNumberOptionHint(issueState(r.repo, "", r.number, "open", true, { ...options, repo: r.repo }), r); } } if (sub === "read" || sub === "view") { @@ -6686,8 +6841,8 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult const missing = authRequired(resolved.repo, `issue ${sub}`, probe); if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `issue ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); const disclosure = readDisclosureOptions(options, resolved.shorthand); - if (sub === "read") return issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure); - return issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure); + if (sub === "read") return withNumberOptionHint(issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure), resolved); + return withNumberOptionHint(issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure), resolved); } const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe); @@ -6699,27 +6854,27 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult if (sub === "edit") { const r = resolvePositionalIssueReference(args, 2, "issue edit", options); if (isGitHubCommandResult(r)) return r; - return issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }); + return withNumberOptionHint(issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }), r); } if (sub === "update") { const r = resolvePositionalIssueReference(args, 2, "issue update", options); if (isGitHubCommandResult(r)) return r; - return issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }, "issue update"); + return withNumberOptionHint(issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }, "issue update"), r); } if (sub === "comment") { const r = resolvePositionalIssueReference(args, 2, "issue comment", options); if (isGitHubCommandResult(r)) return r; - return issueComment(r.repo, token, r.number, { ...options, repo: r.repo }); + return withNumberOptionHint(issueComment(r.repo, token, r.number, { ...options, repo: r.repo }), r); } if (sub === "close") { const r = resolvePositionalIssueReference(args, 2, "issue close", options); if (isGitHubCommandResult(r)) return r; - return issueState(r.repo, token, r.number, "closed", options.dryRun, { ...options, repo: r.repo }); + return withNumberOptionHint(issueState(r.repo, token, r.number, "closed", options.dryRun, { ...options, repo: r.repo }), r); } if (sub === "reopen") { const r = resolvePositionalIssueReference(args, 2, "issue reopen", options); if (isGitHubCommandResult(r)) return r; - return issueState(r.repo, token, r.number, "open", options.dryRun, { ...options, repo: r.repo }); + return withNumberOptionHint(issueState(r.repo, token, r.number, "open", options.dryRun, { ...options, repo: r.repo }), r); } } @@ -6739,7 +6894,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult const { token, probe } = resolveToken(true); const missing = authRequired(resolved.repo, "pr diff --stat", probe); if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr diff --stat", { present: false, source: null, ghFallbackAttempted: true }); - return prFiles(resolved.repo, token, resolved.number, options.limit, "pr diff --stat"); + return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr diff --stat"), resolved); } if (sub === "files") { const resolved = resolvePositionalPrReference(args, 2, "pr files", options); @@ -6747,31 +6902,31 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult const { token, probe } = resolveToken(true); const missing = authRequired(resolved.repo, "pr files", probe); if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr files", { present: false, source: null, ghFallbackAttempted: true }); - return prFiles(resolved.repo, token, resolved.number, options.limit, "pr files"); + return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr files"), resolved); } 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 === "preflight" || sub === "closeout") { const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options); if (isGitHubCommandResult(resolved)) return resolved; - return prPreflight(resolved.repo, resolved.number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw); + return withNumberOptionHint(prPreflight(resolved.repo, resolved.number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw), resolved); } 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 resolved = resolvePositionalNumberReference("pr", args, 3, "pr comment delete", options); + if (isGitHubCommandResult(resolved)) return resolved; + if (options.dryRun) return withNumberOptionHint(commentDelete(resolved.repo, "", "pr", resolved.number, true), resolved); 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); + const missing = authRequired(resolved.repo, "pr comment delete", probe); + 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 === "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 resolved = resolvePositionalPrReference(args, 3, "pr comment create", options); + if (isGitHubCommandResult(resolved)) return resolved; + if (options.dryRun) return withNumberOptionHint(prComment(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }), resolved); const { token, probe } = resolveToken(true); - const missing = authRequired(options.repo, "pr comment create", probe); + const missing = authRequired(resolved.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); + return withNumberOptionHint(prComment(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved); } if (sub === "create") { if (options.dryRun) return prCreate(options.repo, "", options); @@ -6782,35 +6937,35 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult } if (sub === "comment") { if (options.dryRun) { - const number = parseNumberForCommand(options.repo, third, "pr comment"); - if (typeof number !== "number") return number; - return prComment(options.repo, "", number, options); + const resolved = resolvePositionalPrReference(args, 2, "pr comment", options); + if (isGitHubCommandResult(resolved)) return resolved; + return withNumberOptionHint(prComment(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }), resolved); } const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, "pr comment", probe); if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment", { present: false, source: null, ghFallbackAttempted: true }); - const number = parseNumberForCommand(options.repo, third, "pr comment"); - if (typeof number !== "number") return number; - return prComment(options.repo, token, number, options); + const resolved = resolvePositionalPrReference(args, 2, "pr comment", options); + if (isGitHubCommandResult(resolved)) return resolved; + return withNumberOptionHint(prComment(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved); } if (sub === "update" || sub === "edit") { const commandName = `pr ${sub}`; - const number = parseNumberForCommand(options.repo, third, commandName); - if (typeof number !== "number") return number; - if (options.dryRun && options.mode === "replace") return prUpdate(options.repo, "", number, options, commandName); + const resolved = resolvePositionalPrReference(args, 2, commandName, options); + if (isGitHubCommandResult(resolved)) return resolved; + if (options.dryRun && options.mode === "replace") return withNumberOptionHint(prUpdate(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }, commandName), resolved); const { token, probe } = resolveToken(true); - const missing = authRequired(options.repo, commandName, probe); + const missing = authRequired(resolved.repo, commandName, probe); if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); - return prUpdate(options.repo, token, number, options, commandName); + return withNumberOptionHint(prUpdate(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }, commandName), resolved); } 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 resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options); + if (isGitHubCommandResult(resolved)) return resolved; + if (options.dryRun) return withNumberOptionHint(prState(resolved.repo, "", resolved.number, sub === "close" ? "closed" : "open", true), resolved); const { token, probe } = resolveToken(true); - const missing = authRequired(options.repo, `pr ${sub}`, probe); + const missing = authRequired(resolved.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); + return withNumberOptionHint(prState(resolved.repo, token, resolved.number, sub === "close" ? "closed" : "open", false), resolved); } if (sub === "merge") { const resolved = resolvePositionalPrReference(args, 2, "pr merge", options); @@ -6818,7 +6973,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, "pr merge", probe); if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr merge", { present: false, source: null, ghFallbackAttempted: true }); - return prMerge(resolved.repo, token, resolved.number, options); + 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."); @@ -6830,8 +6985,8 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult const missing = authRequired(resolved.repo, `pr ${sub}`, probe); if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); const disclosure = readDisclosureOptions(options, resolved.shorthand); - if (sub === "read") return prRead(resolved.repo, token, resolved.number, prReadJsonFields(options), "pr read", disclosure); - return prView(resolved.repo, token, resolved.number, prReadJsonFields(options), disclosure); + if (sub === "read") return withNumberOptionHint(prRead(resolved.repo, token, resolved.number, prReadJsonFields(options), "pr read", disclosure), resolved); + return withNumberOptionHint(prView(resolved.repo, token, resolved.number, prReadJsonFields(options), disclosure), resolved); } const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, `pr ${sub}`, probe);