fix(cli): expose merged PR closeout fields

This commit is contained in:
Codex
2026-05-23 05:15:09 +00:00
parent df5f5be16b
commit 6c1282bb99
5 changed files with 97 additions and 18 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏和 dev workload manifests,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md``docs/reference/microservices.md`
- `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod`met-nonlinear``k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`
- `bun scripts/cli.ts auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run`:查看 Auth Broker P0 Rust skeleton 与 CLI adapter contractrunner 无 `GH_TOKEN`/`GITHUB_TOKEN` 时返回结构化 `auth-missing`/`broker-needed`,不读取或打印 token 值,规则见 `docs/reference/auth-broker.md`
- `bun scripts/cli.ts gh auth status|issue ...|pr list|files|diff --stat|read|view|create|edit|update|comment` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察与 runner PR preflight`gh issue/pr read|view` 支持 `owner/repo#number` shorthand`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON`gh pr merge` 当前仍结构化拒绝,规则见 `docs/reference/cli.md``docs/reference/code-queue-supervision.md`
- `bun scripts/cli.ts gh auth status|issue ...|pr list|files|diff --stat|read|view|create|edit|update|comment` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit与 runner PR preflight`gh issue/pr read|view` 支持 `owner/repo#number` shorthand`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON`gh pr merge` 当前仍结构化拒绝,规则见 `docs/reference/cli.md``docs/reference/code-queue-supervision.md`
- `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合和 ClaudeQQ 高风险请示草案;当前只返回 dry-run 计划,不接 live bridge、不接管人工指挥官,不发送消息,规则见 `docs/reference/host-codex-commander.md`
- `bun scripts/cli.ts ci install/status/run/publish-backend-core/publish-user-service/run-dev-e2e/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,支持每 commit 检查、Code Queue 只读性能门禁、`CI.json` catalog 驱动的 backend-core 与 user-service commit-pinned 镜像发布和手动触发的 `origin/master:deploy.json#environments.dev` 临时 namespace e2ecatalog/producer/consumer 分工见 `docs/reference/cicd-standardization.md``run-dev-e2e` 的 Git 控制 runner、短 launcher 和 no-CD 边界见 `docs/reference/dev-ci-runner.md`Tekton 规则见 `docs/reference/ci.md`
- `bun scripts/cli.ts codex deploy <commitId>`:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`
+2 -2
View File
@@ -39,8 +39,8 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
- `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 北京时间` 段落发送给 ClaudeQQClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update <number> --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`,可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 覆盖。
- `gh issue 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 ...]` 提供 REST 列表,默认 `state=all` 以保持既有 UniDesk CLI 行为,字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt`;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed``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]` 继续稳定返回这些字段,并额外支持 `headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup``owner/repo#number` shorthand 和冲突 `--repo` 规则与 issue read/view 相同。`headRefName``baseRefName` 来自 REST `head.ref`/`base.ref``mergeable``mergeStateStatus``statusCheckRollup` 只在 read/view 明确请求这些字段或用 `--raw|--full` 显式完整披露时通过 GitHub GraphQL 查询,GraphQL 权限不足、网络失败或 GitHub 暂未计算完成时会结构化失败或返回 GitHub 原始 `UNKNOWN`/null 状态。此时收口人员应优先重试一次;若仍缺失、需要完整 `gh pr view --json` 等 GitHub 官方字段、或需要执行 merge/review 这类 UniDesk CLI 尚未开放的操作,回退到系统 `gh` 只读观察或人工 GitHub UI,不要把空字段当作可合并证据。`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]` 是 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 再追加正文。`gh pr delete <number>``gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`PR 生命周期删除语义请使用 `close`
- 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,head,base,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup` 做只读 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` 必须失败并返回结构化 `unsupported-command`。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。
- `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 ...]` 提供 REST 列表,默认 `state=all` 以保持既有 UniDesk CLI 行为,字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt`;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed``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 暂未计算完成时会结构化失败或返回 GitHub 原始 `UNKNOWN`/null 状态。此时收口人员应优先重试一次;若仍缺失、需要完整 `gh pr view --json` 等 GitHub 官方字段、或需要执行 merge/review 这类 UniDesk CLI 尚未开放的操作,回退到系统 `gh` 只读观察或人工 GitHub UI,不要把空字段当作可合并证据。`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]` 是 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 再追加正文。`gh pr delete <number>``gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`PR 生命周期删除语义请使用 `close`
- 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` 做只读 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` 必须失败并返回结构化 `unsupported-command`。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` 当成成功的空历史。
- `codex deploy <commitId>` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`
File diff suppressed because one or more lines are too long
+59 -2
View File
@@ -80,6 +80,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
user: { login: "runner" },
head: { ref: "feature/pr-contract", sha: "head-sha" },
base: { ref: "master", sha: "base-sha" },
closed_at: null,
merged: false,
merged_at: null,
merge_commit_sha: null,
created_at: "2026-05-20T04:00:00Z",
updated_at: "2026-05-20T05:00:00Z",
};
@@ -93,6 +97,19 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
head: { ref: "feature/hwlab-shorthand", sha: "hwlab-head-sha" },
base: { ref: "master", sha: "hwlab-base-sha" },
};
const mergedPullRequest = {
...pullRequest,
id: 4300,
number: 43,
title: "merged contract PR",
state: "closed",
html_url: "https://github.com/pikasTech/unidesk/pull/43",
closed_at: "2026-05-21T08:00:00Z",
merged: true,
merged_at: "2026-05-21T08:00:00Z",
merge_commit_sha: "merge-commit-sha",
updated_at: "2026-05-21T08:00:00Z",
};
const server = createServer(async (req, res) => {
const body = await collectBody(req);
requests.push({ method: req.method ?? "", url: req.url ?? "", body });
@@ -117,7 +134,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=closed&per_page=4") {
sendJson(res, 200, [{ ...pullRequest, number: 43, state: "closed", html_url: "https://github.com/pikasTech/unidesk/pull/43" }]);
sendJson(res, 200, [mergedPullRequest]);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/42") {
@@ -128,6 +145,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 200, shorthandPullRequest);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/43") {
sendJson(res, 200, mergedPullRequest);
return;
}
if (req.method === "POST" && req.url === "/graphql") {
sendJson(res, 200, {
data: {
@@ -207,11 +228,13 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(usage.some((line) => line.includes("gh pr edit")), "gh help should list pr edit", { usage });
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("--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 });
const mock = await startMockGitHub();
const env = {
@@ -259,6 +282,14 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
const selected = readData.json as JsonRecord;
assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr read --json should select fields", readData);
const openLifecycle = await runCli(["gh", "pr", "read", "42", "--repo", "pikasTech/unidesk", "--json", "state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName"], env);
assertCondition(openLifecycle.status === 0, "open pr lifecycle fields should succeed through REST", openLifecycle.json ?? { stdout: openLifecycle.stdout });
const openLifecycleData = dataOf(openLifecycle.json ?? {});
const openLifecycleJson = openLifecycleData.json as JsonRecord;
assertCondition(openLifecycleJson.state === "open" && openLifecycleJson.stateDetail === "open", "open pr should distinguish stateDetail=open", openLifecycleData);
assertCondition(openLifecycleJson.closed === false && openLifecycleJson.closedAt === null, "open pr should expose closed=false", openLifecycleData);
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 });
const viewData = dataOf(view.json ?? {});
@@ -284,7 +315,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,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 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);
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 });
@@ -297,6 +328,30 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(rollup.state === "SUCCESS", "pr view should expose statusCheckRollup", closeoutData);
assertCondition(mock.requests.some((request) => request.method === "POST" && request.url === "/graphql"), "closeout metadata should use GitHub GraphQL when requested", mock.requests);
const requestsBeforeMergedRead = mock.requests.length;
const mergedRead = await runCli(["gh", "pr", "read", "43", "--repo", "pikasTech/unidesk", "--json", "state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit"], env);
assertCondition(mergedRead.status === 0, "merged pr closeout fields should succeed through REST", mergedRead.json ?? { stdout: mergedRead.stdout });
const mergedReadData = dataOf(mergedRead.json ?? {});
const mergedSummary = mergedReadData.pullRequest as JsonRecord;
assertCondition(mergedSummary.state === "closed" && mergedSummary.stateDetail === "merged", "pullRequest summary should distinguish merged from closed", mergedReadData);
const mergedReadJson = mergedReadData.json as JsonRecord;
assertCondition(mergedReadJson.state === "closed" && mergedReadJson.stateDetail === "merged", "merged pr json should distinguish merged from closed", mergedReadData);
assertCondition(mergedReadJson.closed === true && mergedReadJson.closedAt === "2026-05-21T08:00:00Z", "merged pr should expose closed fields", mergedReadData);
assertCondition(mergedReadJson.merged === true && mergedReadJson.mergedAt === "2026-05-21T08:00:00Z", "merged pr should expose merged fields", mergedReadData);
const mergeCommit = mergedReadJson.mergeCommit as JsonRecord;
assertCondition(mergeCommit.oid === "merge-commit-sha", "merged pr should expose merge commit oid", mergedReadData);
const mergedReadRequests = mock.requests.slice(requestsBeforeMergedRead);
assertCondition(!mergedReadRequests.some((request) => request.method === "POST" && request.url === "/graphql"), "REST closeout fields should not require GraphQL", mergedReadRequests);
const unsupportedReadField = await runCli(["gh", "pr", "read", "42", "--repo", "pikasTech/unidesk", "--json", "mergedAt,mergeCommit,projectCards"], env);
assertCondition(unsupportedReadField.status !== 0, "unsupported pr read field should fail before network work", unsupportedReadField.json ?? { stdout: unsupportedReadField.stdout });
const unsupportedReadFieldData = failedDataOf(unsupportedReadField.json ?? {});
assertCondition(unsupportedReadFieldData.degradedReason === "validation-failed", "unsupported pr read field should be validation-failed", unsupportedReadFieldData);
const unsupportedReadDetails = unsupportedReadFieldData.details as JsonRecord;
const unsupportedReadMessage = String(unsupportedReadDetails.message ?? "");
assertCondition(unsupportedReadMessage.includes("projectCards"), "unsupported pr read field message should name the bad field", unsupportedReadFieldData);
assertCondition(unsupportedReadMessage.includes("mergedAt") && unsupportedReadMessage.includes("mergeCommit"), "unsupported pr read field message should include supported closeout fields", unsupportedReadFieldData);
const preflight = await runBun([
"scripts/code-queue-pr-preflight-example.ts",
"--repo",
@@ -463,7 +518,9 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
"pr list/read/view work through REST with token and no gh binary dependency",
"pr read/view accept owner/repo#number shorthand and reject conflicting --repo",
"pr read/view --raw is explicit full disclosure",
"pr read normalizes open and merged lifecycle fields from REST",
"pr view closeout metadata fields are accepted and hydrated through GraphQL",
"pr read unsupported fields fail structurally with supported closeout fields listed",
"pr create dry-run exposes planned operation",
"pr comment dry-run preserves markdown text",
"pr update/edit use low-noise REST PATCH without GraphQL projectCards",
+33 -11
View File
@@ -21,7 +21,7 @@ const BOARD_ROW_UPSERT_TEXT_FIELDS = ["category", "branch", "tasks", "summary",
const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const;
const PR_LIST_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const;
const PR_READ_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "headRefName", "baseRefName", "mergeable", "mergeStateStatus", "statusCheckRollup"] as const;
const PR_READ_JSON_FIELDS = ["body", "title", "state", "stateDetail", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "closed", "closedAt", "merged", "mergedAt", "mergeCommit", "headRefName", "baseRefName", "mergeable", "mergeStateStatus", "statusCheckRollup"] as const;
const ISSUE_LIST_STATES = ["open", "closed", "all"] as const;
const BODY_UPDATE_MODES = ["replace", "append"] as const;
const BOARD_MUTATION_SECTIONS = ["open", "closed"] as const;
@@ -409,6 +409,10 @@ interface GitHubPullRequest {
deletions?: number;
changed_files?: number;
commits?: number;
closed_at?: string | null;
merged?: boolean | null;
merged_at?: string | null;
merge_commit_sha?: string | null;
mergeable?: string | null;
merge_state_status?: string | null;
created_at?: string;
@@ -742,15 +746,18 @@ function parseOwnerRepoNumberShorthand(raw: string | undefined): GitHubShorthand
}
function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: number): string[] {
const jsonFields = kind === "issue"
? "body,title,state,comments"
: "body,title,state,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup";
return [
`bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${jsonFields}`,
`bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)}`,
`bun scripts/cli.ts gh ${kind} view ${repo}#${number} --raw`,
];
}
function readViewSupportedJsonFields(kind: "issue" | "pr"): string {
return kind === "issue"
? "body,title,state,comments"
: "body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup";
}
function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "view", raw: string | undefined, options: GitHubOptions, args: string[]): GitHubResolvedNumberReference | GitHubCommandResult {
const command = `${kind} ${sub}`;
const shorthand = parseOwnerRepoNumberShorthand(raw);
@@ -772,7 +779,7 @@ function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "vie
return {
...parsed,
supportedCommands: [
`bun scripts/cli.ts gh ${kind} read <number> --repo owner/name --json ${kind === "issue" ? "body,title,state,comments" : "body,title,state,head,base"}`,
`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`,
],
};
@@ -815,7 +822,7 @@ function unknownGhOptionDetails(args: string[], option: string): Record<string,
details.supportedCommands = number > 0
? readViewSupportedCommands(top, repo, number)
: [
`bun scripts/cli.ts gh ${top} read <number> --repo owner/name --json ${top === "issue" ? "body,title,state,comments" : "body,title,state,head,base"}`,
`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`,
];
}
@@ -3558,13 +3565,23 @@ function commentSummary(comment: GitHubComment): Record<string, unknown> {
};
}
function prStateDetail(pr: GitHubPullRequest): "open" | "closed" | "merged" {
if (pr.merged === true || pr.merged_at !== null && pr.merged_at !== undefined) return "merged";
return pr.state === "closed" ? "closed" : "open";
}
function prSummary(pr: GitHubPullRequest): Record<string, unknown> {
const stateDetail = prStateDetail(pr);
const closedAt = pr.closed_at ?? pr.merged_at ?? null;
const mergedAt = pr.merged_at ?? null;
const mergeCommitSha = pr.merge_commit_sha ?? null;
return {
id: pr.id,
number: pr.number,
title: pr.title,
body: pr.body ?? "",
state: pr.state,
stateDetail,
draft: pr.draft ?? false,
url: pr.html_url,
author: pr.user?.login ?? null,
@@ -3574,6 +3591,11 @@ function prSummary(pr: GitHubPullRequest): Record<string, unknown> {
baseRefName: pr.base?.ref ?? null,
createdAt: pr.created_at ?? null,
updatedAt: pr.updated_at ?? null,
closed: stateDetail !== "open",
closedAt,
merged: stateDetail === "merged",
mergedAt,
mergeCommit: stateDetail === "merged" && mergeCommitSha !== null ? { oid: mergeCommitSha } : null,
};
}
@@ -5023,7 +5045,7 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh pr list [--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]",
"bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--limit N] [compatibility alias for pr files; no raw diff]",
"bun scripts/cli.ts gh pr read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]",
"bun scripts/cli.ts gh pr read <number|owner/repo#number> [--repo owner/name] [--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 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]",
@@ -5059,9 +5081,9 @@ export function ghHelp(): unknown {
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
"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 read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. PR read/view supports closeout fields headRefName, baseRefName, mergeable, mergeStateStatus, and statusCheckRollup; mergeability and status rollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure.",
"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 create/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
"PR read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and derive --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.",
"PR create/edit/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
],
};
}
@@ -5102,7 +5124,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
"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 pr read owner/name#<number> --raw",
"bun scripts/cli.ts gh pr read <number> --repo owner/name --json body,title,state,head,base",
`bun scripts/cli.ts gh pr read <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`,
],
});
}