From 2a8e9a5cd353b4d526671f80a67fb5ec5a1031b5 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sat, 23 May 2026 00:35:06 +0800 Subject: [PATCH] fix(cli): expose PR closeout metadata Host commander merge after read-only audit. PR #72 is CLEAN/MERGEABLE and limited to UniDesk CLI PR closeout metadata: mergeability/status fields for gh pr read/view plus contract test/docs. Runner validation reported gh-cli-pr-contract-test and live metadata query; no HWLAB business code touched. --- AGENTS.md | 2 +- docs/reference/cli.md | 4 +- docs/reference/code-queue-supervision.md | 4 +- scripts/gh-cli-pr-contract-test.ts | 38 ++++- scripts/src/gh.ts | 192 ++++++++++++++++++++--- 5 files changed, 216 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 066c5c26..13b84cda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 contract,runner 无 `GH_TOKEN`/`GITHUB_TOKEN` 时返回结构化 `auth-missing`/`broker-needed`,不读取或打印 token 值,规则见 `docs/reference/auth-broker.md`。 -- `bun scripts/cli.ts gh auth status|issue ...|pr list|view|create|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 创建/评论 dry-run 与 runner PR preflight;`gh pr merge` 当前仍结构化拒绝,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 +- `bun scripts/cli.ts gh auth status|issue ...|pr list|view|create|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 创建/评论 dry-run、PR 收口元数据观察与 runner PR preflight;`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 e2e;catalog/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 `:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6bbd999f..cf219331 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -39,8 +39,8 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `gh issue edit 24 --body-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 --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]` 是 #20 长期总看板只读覆盖审计入口,默认 repo 为 `pikasTech/unidesk`、board issue 为 `20`、输出 JSON 且不 PATCH/POST/DELETE GitHub。它读取 board body、GitHub open issue 列表和 closed issue 列表,对比 OPEN/CLOSED Markdown 表格并输出 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`openInClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions`。当表格里存在 Issue 列时,row.issueNumber 优先取 Issue cell 中第一个指向 `/issues/` 的 Markdown link,找不到时取开头的 `#N`;同一 Issue cell 里主引用后面的标题说明引用(例如 `#20 总看板`、`基于 #4`)不触发 `multiple-issue-references`。没有 Issue 列的旧表格仍回退到整行 issue 提取,并保留多 issue 引用告警。`相关 Code Queue 任务`/`relatedTask` 列允许 `—`、`-`、`n/a`、`无任务` 等无关联任务占位表示 closed 历史/治理项没有 Code Queue task;这个放宽不适用于 branch、acceptance 或 progress。默认把 #20 和 #24 作为 `known-meta` 治理/简报 issue 忽略;标题匹配 `YYYY-MM-DD 指挥简报(北京时间)` 的每日滚动简报由 #20 顶部指挥简报索引管理,不进入 OPEN/CLOSED 覆盖审计,并在 `ignoredIssues` 中标记 `reason=brief-index-managed`。需要扩展治理项用 `--known-meta-issue`,临时排除业务 issue 用 `--ignore-issue`。指挥官发现总看板可能漏行时,应先跑 board audit 获取结构化结果,再决定是否人工编辑 #20,而不是只靠 grep。 - `gh issue board-row list --board-issue 20 [--state open|closed|all] [--dry-run]`、`gh issue board-row get --board-issue 20` 和 `gh issue board-row update --board-issue 20 --field progress|status|validation|branch|tasks|focus --value [--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 --board-issue 20 --section open|closed [--category text] --branch --tasks --summary --focus --validation --progress [--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 --board-issue 20 --section open|closed --row-file [--dry-run] [--expect-body-sha|--expect-updated-at]`、`move --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]` 和 `delete --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|read|view [--json ...]` 提供 REST 列表和详情,PR 字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt`。`gh pr create --title --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]`、`gh pr update <number> --mode replace|append --body-file <file>|--body <text> [--title ...] [--dry-run]`、`gh pr comment create <number> --body-file <file>|--body <text> [--dry-run]`、`gh pr comment delete <commentId> [--dry-run]`、`gh pr close|reopen <number> [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr update --mode append` 会先读取当前 PR body 再追加正文。`gh pr delete <number>` 和 `gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`;PR 生命周期删除语义请使用 `close`。 -- 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 --limit 5 --json number,title,state,url,head,base` 和 `bun scripts/cli.ts gh pr read <number> --repo pikasTech/unidesk --json body,title,state,head,base` 做只读 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 [--json ...]` 提供 REST 列表,字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt`;`gh pr read|view <number> [--json ...]` 继续稳定返回这些字段,并额外支持 `headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`headRefName` 与 `baseRefName` 来自 REST `head.ref`/`base.ref`;`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 只在 read/view 明确请求这些字段时通过 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 update <number> --mode replace|append --body-file <file>|--body <text> [--title ...] [--dry-run]`、`gh pr comment create <number> --body-file <file>|--body <text> [--dry-run]`、`gh pr comment delete <commentId> [--dry-run]`、`gh pr close|reopen <number> [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr update --mode append` 会先读取当前 PR body 再追加正文。`gh pr delete <number>` 和 `gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`;PR 生命周期删除语义请使用 `close`。 +- 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 --limit 5 --json number,title,state,url,head,base` 和 `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 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`。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index f0a8eaf6..0af9801a 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -110,7 +110,7 @@ issue 内容必须自包含,至少写清楚背景、外部收益、当前观 如果某个 worker 任务需要依赖 GitHub issue 内容,但 runner 的 issue 可达性尚未被单独验证,指挥官不能默认 worker 已能读取该 issue。此时 worker prompt 必须直接内嵌完整需求、约束和验收点,issue URL 只能作为辅助引用。若要把 issue 作为任务输入源,先单独做可达性探测,再决定是否把 issue 作为常规前置条件。 -GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/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`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response`、`unsupported-command` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。`gh issue list --state open --limit N --json number,title,state,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue read <number> --json body` 是 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` 只读对比 GitHub open/closed issue 列表与 #20 的 OPEN/CLOSED Markdown 表格,输出 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions`。存在 Issue 列时,row key 优先来自 Issue cell 的主 Markdown issue link `/issues/<N>`,其次是开头 `#N`;主引用后的标题说明引用不触发多 issue 告警。没有 Issue 列的旧表格仍使用整行 fallback 并保留 `multiple-issue-references`。`相关 Code Queue 任务` 可用 `—`、`-` 或等价无任务占位表达无关联 task,但 branch、acceptance 和 progress 仍必须填写真实值;默认把 #20/#24 作为 known-meta 忽略,标题匹配 `YYYY-MM-DD 指挥简报(北京时间)` 的每日滚动简报由 #20 顶部指挥简报索引管理,并以 `reason=brief-index-managed` 进入 `ignoredIssues`,不进入 OPEN/CLOSED 覆盖审计。指挥官发现 #20 可能漏跟 open issue 或分表错误时,应先跑 board audit 取得结构化缺口,再人工审阅和编辑 #20,不要只靠 grep。显式 `gh issue update <number> --body-profile commander-brief` 可用于 #24 legacy 简报和每日滚动简报 issue;每日简报 issue 应用标题 `YYYY-MM-DD 指挥简报(北京时间)` 或在既有正文首行/关键 heading 中标明简报身份,且新正文必须包含 `## 常驻观察与长期建议`。对非简报 issue 使用该 profile 应失败为 `profile-issue-mismatch`。`gh issue board-row list --board-issue 20 --state open|closed|all` 和 `gh issue board-row get <issueNumber> --board-issue 20` 复用同一个 parser 读取表格行;`gh issue board-row update <issueNumber> --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text>` 只替换一行一个单元格,输出 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|read|view --json ...` 支持 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt` 字段白名单。issue/PR 创建、更新、评论、评论删除、关闭和重开使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。GitHub 不支持 issue/PR 硬删除,`gh issue delete` 和 `gh pr delete` 必须结构化返回 `unsupported-command`;生命周期删除语义使用 `close`。`gh pr merge` 仍然不开放。 +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`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response`、`unsupported-command` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。`gh issue list --state open --limit N --json number,title,state,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue read <number> --json body` 是 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` 只读对比 GitHub open/closed issue 列表与 #20 的 OPEN/CLOSED Markdown 表格,输出 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions`。存在 Issue 列时,row key 优先来自 Issue cell 的主 Markdown issue link `/issues/<N>`,其次是开头 `#N`;主引用后的标题说明引用不触发多 issue 告警。没有 Issue 列的旧表格仍使用整行 fallback 并保留 `multiple-issue-references`。`相关 Code Queue 任务` 可用 `—`、`-` 或等价无任务占位表达无关联 task,但 branch、acceptance 和 progress 仍必须填写真实值;默认把 #20/#24 作为 known-meta 忽略,标题匹配 `YYYY-MM-DD 指挥简报(北京时间)` 的每日滚动简报由 #20 顶部指挥简报索引管理,并以 `reason=brief-index-managed` 进入 `ignoredIssues`,不进入 OPEN/CLOSED 覆盖审计。指挥官发现 #20 可能漏跟 open issue 或分表错误时,应先跑 board audit 取得结构化缺口,再人工审阅和编辑 #20,不要只靠 grep。显式 `gh issue update <number> --body-profile commander-brief` 可用于 #24 legacy 简报和每日滚动简报 issue;每日简报 issue 应用标题 `YYYY-MM-DD 指挥简报(北京时间)` 或在既有正文首行/关键 heading 中标明简报身份,且新正文必须包含 `## 常驻观察与长期建议`。对非简报 issue 使用该 profile 应失败为 `profile-issue-mismatch`。`gh issue board-row list --board-issue 20 --state open|closed|all` 和 `gh issue board-row get <issueNumber> --board-issue 20` 复用同一个 parser 读取表格行;`gh issue board-row update <issueNumber> --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text>` 只替换一行一个单元格,输出 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 ...` 还支持 `headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`,其中 mergeability/check rollup 只在请求时通过 GitHub 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` 仍然不开放。 CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不好用、兼容性不足、安全 guard 不够或输出不利于 runner/指挥官使用时,应默认创建 GitHub issue 并用 Code Queue 推进;这类 CLI 问题走 `master`、remote commit、轻量 contract test 和文档更新,不套用 backend-core、Code Queue runtime 这类运行态服务的重部署门禁。若 CLI 缺陷已经阻塞当前指挥,可以先做最小安全绕行,同时把长期修复写入 issue 并派单。 @@ -118,7 +118,7 @@ CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不 PR 是审查型交付入口,不是所有 Code Queue 任务的默认出口。默认 master-only 交付仍按项目 Git 规则执行;当变更风险高、跨模块、需要人工审查、或任务目标明确要求 PR 交付时,worker 可以创建 PR。PR 型任务必须报告源分支、目标分支、PR URL、关联 issue、测试证据和未完成风险。禁止把 PR 当成隐藏分支仓库;PR 分支必须来自最新目标线,保持小而可审查,并在合并后确认目标分支远端 commit 可 fetch。 -PR 支持本身是 Code Queue 能力的一部分。当前 UniDesk CLI 支持 `gh pr list|view|create|update|comment create|comment delete|close|reopen`,其中 create 需要显式 `--title`、`--base`、`--head` 和正文来源,update 需要显式 PR number、正文来源和 `--mode replace|append`,comment create 需要显式 PR number 和正文来源,且推荐使用 `--body-file`。`pr create --dry-run`、`pr update --dry-run` 与 `pr comment create --dry-run` 只返回 planned operation,不创建 PR、不更新正文、不写评论;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,append 更新会先读取当前 PR body。`gh pr delete` 和 `gh pr merge` 仍是后续范围,本阶段没有 `--confirm` 可以启用真实 merge,也不能伪造硬删除;需要移除活跃 PR 时使用 `gh pr close`。普通 worker 不应隐式依赖未实现的 PR 合并能力;需要 PR 交付时,prompt 必须明确允许的人工或后续工具路径,并报告未覆盖范围。 +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 headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup` 获取目标分支、源分支、mergeability 和检查汇总;该路径仍是只读元数据,不执行 merge。`pr create --dry-run`、`pr update --dry-run` 与 `pr comment create --dry-run` 只返回 planned operation,不创建 PR、不更新正文、不写评论;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,append 更新会先读取当前 PR body。`gh pr delete` 和 `gh pr merge` 仍是后续范围,本阶段没有 `--confirm` 可以启用真实 merge,也不能伪造硬删除;需要移除活跃 PR 时使用 `gh pr close`。普通 worker 不应隐式依赖未实现的 PR 合并能力;需要 PR 交付时,prompt 必须明确允许的人工或后续工具路径,并报告未覆盖范围。 ### PR 驱动派单模板 diff --git a/scripts/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts index dab346c6..01c5f5c5 100644 --- a/scripts/gh-cli-pr-contract-test.ts +++ b/scripts/gh-cli-pr-contract-test.ts @@ -103,6 +103,30 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque sendJson(res, 200, pullRequest); return; } + if (req.method === "POST" && req.url === "/graphql") { + sendJson(res, 200, { + data: { + repository: { + pullRequest: { + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + headRefName: "feature/pr-contract", + baseRefName: "master", + statusCheckRollup: { + state: "SUCCESS", + contexts: { + nodes: [ + { __typename: "CheckRun", name: "contract", status: "COMPLETED", conclusion: "SUCCESS" }, + { __typename: "StatusContext", context: "legacy-ci", state: "SUCCESS", targetUrl: "https://ci.example.test/42", description: "ok" }, + ], + }, + }, + }, + }, + }, + }); + return; + } if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/pulls/42") { const parsed = JSON.parse(body) as JsonRecord; sendJson(res, 200, { ...pullRequest, title: String(parsed.title ?? pullRequest.title), body: String(parsed.body ?? pullRequest.body), state: String(parsed.state ?? pullRequest.state), updated_at: "2026-05-20T06:00:00Z" }); @@ -180,6 +204,17 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { const viewSelected = viewData.json as JsonRecord; assertCondition(viewSelected.body === "PR body" && viewSelected.title === "contract PR", "pr view alias should preserve selected fields", viewData); + 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 }); + const closeoutData = dataOf(closeout.json ?? {}); + const closeoutJson = closeoutData.json as JsonRecord; + assertCondition(closeoutJson.mergeable === "MERGEABLE", "pr view should expose mergeable", closeoutData); + assertCondition(closeoutJson.mergeStateStatus === "CLEAN", "pr view should expose mergeStateStatus", closeoutData); + assertCondition(closeoutJson.headRefName === "feature/pr-contract" && closeoutJson.baseRefName === "master", "pr view should expose PR branch names", closeoutData); + const rollup = closeoutJson.statusCheckRollup as 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 preflight = await runBun([ "scripts/code-queue-pr-preflight-example.ts", "--repo", @@ -206,7 +241,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(preflightComment.ok === true && preflightComment.dryRun === true && preflightComment.planned === true, "PR preflight comment must stay dry-run", preflightComment); assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/rate_limit"), "PR preflight should probe REST egress", mock.requests); assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk"), "PR preflight should probe repo visibility", mock.requests); - assertCondition(mock.requests.every((request) => request.method === "GET"), "initial mock phase should remain read-only", mock.requests); + assertCondition(mock.requests.every((request) => request.method === "GET" || request.method === "POST" && request.url === "/graphql"), "initial mock phase should remain read-only except GraphQL metadata reads", mock.requests); } finally { await mock.close(); } @@ -309,6 +344,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { checks: [ "gh help lists pr create/comment", "pr list/read/view work through REST with token and no gh binary dependency", + "pr view closeout metadata fields are accepted and hydrated through GraphQL", "pr create dry-run exposes planned operation", "pr comment dry-run preserves markdown text", "pr update replace/append and close/reopen are available", diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 7dd516a6..c950c1f2 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -19,7 +19,8 @@ const BOARD_ROW_FIELDS = ["progress", "status", "validation", "branch", "tasks", const BOARD_ROW_UPSERT_TEXT_FIELDS = ["category", "branch", "tasks", "summary", "focus", "validation", "progress"] as const; const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const; const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const; -const PR_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const; +const 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 ISSUE_LIST_STATES = ["open", "closed", "all"] as const; const BODY_UPDATE_MODES = ["replace", "append"] as const; const BOARD_MUTATION_SECTIONS = ["open", "closed"] as const; @@ -49,7 +50,8 @@ const COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS = [ type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number]; type IssueListJsonField = typeof ISSUE_LIST_JSON_FIELDS[number]; -type PrJsonField = typeof PR_JSON_FIELDS[number]; +type PrListJsonField = typeof PR_LIST_JSON_FIELDS[number]; +type PrReadJsonField = typeof PR_READ_JSON_FIELDS[number]; type IssueListState = typeof ISSUE_LIST_STATES[number]; type BodyUpdateMode = typeof BODY_UPDATE_MODES[number]; type BoardMutationSection = typeof BOARD_MUTATION_SECTIONS[number]; @@ -312,7 +314,8 @@ interface GitHubOptions { head?: string; jsonFields?: IssueViewJsonField[]; issueListJsonFields?: IssueListJsonField[]; - prJsonFields?: PrJsonField[]; + prListJsonFields?: PrListJsonField[]; + prJsonFields?: PrReadJsonField[]; listState: IssueListState; mode: BodyUpdateMode; expectUpdatedAt?: string; @@ -385,10 +388,38 @@ interface GitHubPullRequest { user?: { login?: string }; head?: { ref?: string; sha?: string }; base?: { ref?: string; sha?: string }; + mergeable?: string | null; + merge_state_status?: string | null; created_at?: string; updated_at?: string; } +interface GitHubPullRequestGraphqlStatusContext { + __typename?: string; + name?: string | null; + status?: string | null; + conclusion?: string | null; + context?: string | null; + state?: string | null; + targetUrl?: string | null; + description?: string | null; +} + +interface GitHubPullRequestGraphqlStatusCheckRollup { + state?: string | null; + contexts?: { + nodes?: GitHubPullRequestGraphqlStatusContext[] | null; + } | null; +} + +interface GitHubPullRequestGraphqlMetadata { + mergeable?: string | null; + mergeStateStatus?: string | null; + headRefName?: string | null; + baseRefName?: string | null; + statusCheckRollup?: GitHubPullRequestGraphqlStatusCheckRollup | null; +} + interface GitHubRepository { id?: number; full_name?: string; @@ -520,8 +551,12 @@ function parseIssueListJsonFields(requested: string[] | undefined): IssueListJso return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS); } -function parsePrJsonFields(command: string, requested: string[] | undefined): PrJsonField[] | undefined { - return validateJsonFields(command, requested, PR_JSON_FIELDS); +function parsePrListJsonFields(requested: string[] | undefined): PrListJsonField[] | undefined { + return validateJsonFields("gh pr list", requested, PR_LIST_JSON_FIELDS); +} + +function parsePrReadJsonFields(requested: string[] | undefined): PrReadJsonField[] | undefined { + return validateJsonFields("gh pr read/view", requested, PR_READ_JSON_FIELDS); } function isIssueReadCommand(sub: string | undefined): boolean { @@ -618,7 +653,8 @@ function parseOptions(args: string[]): GitHubOptions { head: optionValue(args, "--head"), jsonFields: top === "issue" && isIssueReadCommand(sub) ? parseIssueViewJsonFields(requestedJsonFields) : undefined, issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined, - prJsonFields: top === "pr" && (isPrReadCommand(sub) || sub === "list") ? parsePrJsonFields(`gh pr ${sub}`, requestedJsonFields) : undefined, + prListJsonFields: top === "pr" && sub === "list" ? parsePrListJsonFields(requestedJsonFields) : undefined, + prJsonFields: top === "pr" && isPrReadCommand(sub) ? parsePrReadJsonFields(requestedJsonFields) : undefined, listState: parseIssueListState(args), mode: parseBodyUpdateMode(args), expectUpdatedAt: optionValue(args, "--expect-updated-at"), @@ -1258,6 +1294,63 @@ async function githubRequest<T>( return parsed as T; } +async function githubGraphqlRequest<T>( + token: string, + query: string, + variables: Record<string, unknown>, +): Promise<T | GitHubErrorPayload> { + let response: Response; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + response = await fetch(`${GITHUB_API}/graphql`, { + method: "POST", + signal: controller.signal, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ query, variables }), + }); + } catch (error) { + return errorPayload("network-proxy-failed", error instanceof Error ? error.message : String(error), { request: { method: "POST", path: "/graphql" } }); + } finally { + clearTimeout(timeout); + } + const parsed = await parseGitHubResponse(response); + if (!response.ok) { + const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText; + return errorPayload(classifyHttpStatus(response.status, message, "/graphql", response), message, { + status: response.status, + details: sanitizedErrorDetails(parsed), + scopes: { + accepted: response.headers.get("x-accepted-oauth-scopes"), + token: response.headers.get("x-oauth-scopes"), + }, + request: { method: "POST", path: "/graphql" }, + }); + } + if (!isRecord(parsed)) { + return errorPayload("invalid-response", "GitHub GraphQL response was not an object", { details: parsed }); + } + if (Array.isArray(parsed.errors) && parsed.errors.length > 0) { + const firstError = parsed.errors[0]; + const message = isRecord(firstError) && typeof firstError.message === "string" ? firstError.message : "GitHub GraphQL query failed"; + return errorPayload("validation-failed", message, { + details: sanitizedErrorDetails(parsed), + request: { method: "POST", path: "/graphql" }, + }); + } + const data = parsed.data; + if (!isRecord(data)) { + return errorPayload("invalid-response", "GitHub GraphQL response did not include an object data payload", { details: sanitizedErrorDetails(parsed) }); + } + return data as T; +} + function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null { if (!tokenProbe.present) { if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) { @@ -3327,18 +3420,76 @@ function prSummary(pr: GitHubPullRequest): Record<string, unknown> { author: pr.user?.login ?? null, head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null }, base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null }, + headRefName: pr.head?.ref ?? null, + baseRefName: pr.base?.ref ?? null, createdAt: pr.created_at ?? null, updatedAt: pr.updated_at ?? null, }; } -function selectedPrJson(pr: GitHubPullRequest, fields: PrJsonField[]): Record<string, unknown> { - const summary = prSummary(pr) as Record<PrJsonField, unknown>; +function selectedPrJson(summary: Record<string, unknown>, fields: readonly string[]): Record<string, unknown> { const selected: Record<string, unknown> = {}; for (const field of fields) selected[field] = summary[field]; return selected; } +function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record<string, unknown> { + return { + mergeable: metadata.mergeable ?? null, + mergeStateStatus: metadata.mergeStateStatus ?? null, + statusCheckRollup: metadata.statusCheckRollup ?? null, + headRefName: metadata.headRefName ?? null, + baseRefName: metadata.baseRefName ?? null, + }; +} + +function needsPrGraphqlMetadata(fields: readonly string[] | undefined): boolean { + if (fields === undefined) return false; + return fields.some((field) => field === "mergeable" || field === "mergeStateStatus" || field === "statusCheckRollup"); +} + +async function prGraphqlMetadata(repo: string, token: string, number: number): Promise<GitHubPullRequestGraphqlMetadata | GitHubErrorPayload> { + const { owner, name } = repoParts(repo); + const query = ` + query PullRequestCloseoutMetadata($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + mergeable + mergeStateStatus + headRefName + baseRefName + statusCheckRollup { + state + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { + name + status + conclusion + } + ... on StatusContext { + context + state + targetUrl + description + } + } + } + } + } + } + } + `; + const response = await githubGraphqlRequest<{ repository?: { pullRequest?: GitHubPullRequestGraphqlMetadata | null } }>(token, query, { owner, name, number }); + if (isGitHubError(response)) return response; + const pullRequest = response.repository?.pullRequest; + if (pullRequest === null || pullRequest === undefined) { + return errorPayload("invalid-response", "GitHub GraphQL PR metadata response was missing repository.pullRequest", { details: response }); + } + return pullRequest; +} + function repoSummary(repo: GitHubRepository): Record<string, unknown> { return { id: repo.id ?? null, @@ -4578,11 +4729,11 @@ async function authStatus(repo: string): Promise<GitHubCommandResult> { }; } -async function prList(repo: string, token: string, limit: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> { +async function prList(repo: string, token: string, limit: number, jsonFields: PrListJsonField[] | undefined): Promise<GitHubCommandResult> { const { owner, name } = repoParts(repo); const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=all&per_page=${limit}`); if (isGitHubError(prs)) return commandError("pr list", repo, prs); - const fields = jsonFields ?? PR_JSON_FIELDS.slice(); + const fields = jsonFields ?? PR_LIST_JSON_FIELDS.slice(); return { ok: true, command: "pr list", @@ -4590,24 +4741,28 @@ async function prList(repo: string, token: string, limit: number, jsonFields: Pr limit, count: prs.length, jsonFields: fields, - pullRequests: prs.map((pr) => selectedPrJson(pr, fields)), + pullRequests: prs.map((pr) => selectedPrJson(prSummary(pr), fields)), }; } -async function prRead(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined, commandName = "pr read"): Promise<GitHubCommandResult> { +async function prRead(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, commandName = "pr read"): Promise<GitHubCommandResult> { const { owner, name } = repoParts(repo); const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number }); + const summary = prSummary(pr); + const metadata = needsPrGraphqlMetadata(jsonFields) ? await prGraphqlMetadata(repo, token, number) : null; + if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, requestedJsonFields: jsonFields ?? [] }); + const selectionSummary = metadata === null ? summary : { ...summary, ...prMetadataSummary(metadata) }; return { ok: true, command: commandName, repo, - pullRequest: prSummary(pr), - ...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(pr, jsonFields) }), + pullRequest: summary, + ...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(selectionSummary, jsonFields) }), }; } -async function prView(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> { +async function prView(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined): Promise<GitHubCommandResult> { return prRead(repo, token, number, jsonFields, "pr view"); } @@ -4639,7 +4794,7 @@ export function ghHelp(): unknown { "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 pr list [--repo owner/name] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]", - "bun scripts/cli.ts gh pr read <number> [--repo owner/name] [--json body,title,state,head,base,draft]", + "bun scripts/cli.ts gh pr read <number> [--repo owner/name] [--json body,title,state,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup]", "bun scripts/cli.ts gh pr view <number> [--repo owner/name] [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 update <number> --mode replace|append --body-file <file>|--body <text> [--title title] [--repo owner/name] [--dry-run]", @@ -4671,7 +4826,8 @@ export function ghHelp(): unknown { "issue edit 24 --notify-claudeqq-brief-diff remains the legacy #24 notification helper: it reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.", "Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.", "comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.", - "PR read is the canonical read path; view remains a compatibility alias. 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 supports closeout fields headRefName, baseRefName, mergeable, mergeStateStatus, and statusCheckRollup; mergeability and status rollup are fetched through GitHub GraphQL only when requested.", + "PR create/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.", ], }; } @@ -4901,7 +5057,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, `pr ${sub}`, probe); if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); - if (sub === "list") return prList(options.repo, token, options.limit, options.prJsonFields); + if (sub === "list") return prList(options.repo, token, options.limit, options.prListJsonFields); if (sub === "read") return prRead(options.repo, token, parseNumber(third, "pr read"), options.prJsonFields); return prView(options.repo, token, parseNumber(third, "pr view"), options.prJsonFields); }