diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4dfbf6fa..97f7fb95 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -40,7 +40,7 @@ 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]` 是总看板只读结构审计入口,默认 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 --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 [--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 [--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 --stat` 是兼容别名,返回同一 JSON,未带 `--stat` 的 raw diff 请求会结构化拒绝。`gh pr read|view [--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 preflight [--repo owner/name] [--full|--raw]` 是低噪声 PR 收口入口,`gh preflight ` 和 `gh pr closeout ` 是兼容别名;它先输出脱敏 auth capability,再读取 PR state/draft/head/base、mergeable、mergeStateStatus 和 statusCheckRollup,默认只给 status check 计数与失败/等待预览,完整 contexts 和原始读取摘要必须显式加 `--full` 或 `--raw`。该命令固定 `readOnly=true`、`writesRemote=false`、`policy.mergesPr=false`、`policy.unideskCliMergeSupported=false`,不会创建、评论、更新或 merge PR。`gh pr create --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`。 +- `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`。`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=false`。此时收口人员应优先重试一次;若仍缺失、需要完整 `gh pr view --json` 等 GitHub 官方字段、或需要执行 merge/review 这类 UniDesk CLI 尚未开放的操作,回退到系统 `gh` 只读观察或人工 GitHub UI,不要把空字段当作可合并证据。`gh pr preflight <number> [--repo owner/name] [--full|--raw]` 是低噪声 PR 收口入口,`gh preflight <number>` 和 `gh pr closeout <number>` 是兼容别名;它先输出脱敏 auth capability,再读取 PR state/draft/head/base、mergeable、mergeStateStatus 和 statusCheckRollup,默认只给 status check 计数与失败/等待预览,完整 contexts 和原始读取摘要必须显式加 `--full` 或 `--raw`。该命令固定 `readOnly=true`、`writesRemote=false`、`policy.mergesPr=false`、`policy.unideskCliMergeSupported=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]` 是 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` 和 `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` 必须失败并返回结构化 `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` 当成成功的空历史。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index df5c615b..fe426346 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -181,7 +181,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。`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` 在 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` 和 `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 驱动派单模板 diff --git a/scripts/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts index be859040..7dd42d62 100644 --- a/scripts/gh-cli-pr-contract-test.ts +++ b/scripts/gh-cli-pr-contract-test.ts @@ -110,6 +110,22 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque merge_commit_sha: "merge-commit-sha", updated_at: "2026-05-21T08:00:00Z", }; + const unknownMetadataPullRequest = { + ...pullRequest, + id: 4400, + number: 44, + title: "unknown metadata PR", + html_url: "https://github.com/pikasTech/unidesk/pull/44", + head: { ref: "feature/pr-unknown", sha: "unknown-head-sha" }, + }; + const graphqlErrorPullRequest = { + ...pullRequest, + id: 4500, + number: 45, + title: "graphql error PR", + html_url: "https://github.com/pikasTech/unidesk/pull/45", + head: { ref: "feature/pr-graphql-error", sha: "graphql-error-head-sha" }, + }; const server = createServer(async (req, res) => { const body = await collectBody(req); requests.push({ method: req.method ?? "", url: req.url ?? "", body }); @@ -149,7 +165,41 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque sendJson(res, 200, mergedPullRequest); return; } + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/44") { + sendJson(res, 200, unknownMetadataPullRequest); + return; + } + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/45") { + sendJson(res, 200, graphqlErrorPullRequest); + return; + } if (req.method === "POST" && req.url === "/graphql") { + const parsed = JSON.parse(body) as { variables?: { number?: unknown } }; + const number = Number(parsed.variables?.number ?? 0); + if (number === 44) { + sendJson(res, 200, { + data: { + repository: { + pullRequest: { + mergeable: "UNKNOWN", + mergeStateStatus: null, + headRefName: "feature/pr-unknown", + baseRefName: "master", + statusCheckRollup: null, + }, + }, + }, + }); + return; + } + if (number === 45) { + sendJson(res, 200, { + errors: [ + { type: "FORBIDDEN", message: "Resource not accessible by integration" }, + ], + }); + return; + } sendJson(res, 200, { data: { repository: { @@ -253,6 +303,8 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { 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 }); assertCondition(notes.some((line) => line.includes("low-noise read-only closeout helper")), "gh help should document PR preflight closeout helper", { notes }); + assertCondition(notes.some((line) => line.includes("closeoutMetadata") && line.includes("UNKNOWN/null")), "gh help should document explicit closeout metadata unknowns", { notes }); + assertCondition(notes.some((line) => line.includes("PR list does not fetch mergeability")), "gh help should direct closeout metadata to pr view", { notes }); const mock = await startMockGitHub(); const env = { @@ -292,6 +344,13 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(badStateData.degradedReason === "validation-failed", "pr list unsupported state should be validation-failed", badStateData); assertCondition(badStateData.runnerDisposition === "business-failed", "pr list unsupported state should be business-failed", badStateData); + const badListCloseout = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--json", "number,mergeable,statusCheckRollup"], env); + assertCondition(badListCloseout.status !== 0, "pr list closeout metadata fields should fail explicitly", badListCloseout.json ?? { stdout: badListCloseout.stdout }); + const badListCloseoutData = failedDataOf(badListCloseout.json ?? {}); + const badListCloseoutMessage = String((badListCloseoutData.details as JsonRecord)?.message ?? badListCloseoutData.message ?? ""); + assertCondition(badListCloseoutData.degradedReason === "validation-failed", "pr list closeout fields should be validation-failed", badListCloseoutData); + assertCondition(badListCloseoutMessage.includes("use gh pr view <number>") && badListCloseoutMessage.includes("statusCheckRollup"), "pr list closeout failure should point to pr view", badListCloseoutData); + const read = await runCli(["gh", "pr", "read", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env); assertCondition(read.status === 0, "pr read should succeed through REST", read.json ?? { stdout: read.stdout }); const readData = dataOf(read.json ?? {}); @@ -344,8 +403,32 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { 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); + const closeoutMetadata = closeoutData.closeoutMetadata as JsonRecord; + const closeoutMergeBoundary = closeoutMetadata.mergeBoundary as JsonRecord; + assertCondition(closeoutMetadata.ok === true && closeoutMetadata.source === "github-graphql", "pr view closeout metadata should report GraphQL source", closeoutMetadata); + assertCondition(Array.isArray(closeoutMetadata.missingOrUnknownFields) && closeoutMetadata.missingOrUnknownFields.length === 0, "known closeout metadata should have no missing/unknown fields", closeoutMetadata); + assertCondition(closeoutMergeBoundary.unideskCliMergeSupported === false, "closeout metadata should keep UniDesk CLI merge unsupported", closeoutMetadata); assertCondition(mock.requests.some((request) => request.method === "POST" && request.url === "/graphql"), "closeout metadata should use GitHub GraphQL when requested", mock.requests); + const unknownCloseout = await runCli(["gh", "pr", "view", "44", "--repo", "pikasTech/unidesk", "--json", "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"], env); + assertCondition(unknownCloseout.status === 0, "pr view unknown closeout metadata should remain a structured read success", unknownCloseout.json ?? { stdout: unknownCloseout.stdout }); + const unknownCloseoutData = dataOf(unknownCloseout.json ?? {}); + const unknownCloseoutJson = unknownCloseoutData.json as JsonRecord; + const unknownCloseoutMetadata = unknownCloseoutData.closeoutMetadata as JsonRecord; + const unknownFields = unknownCloseoutMetadata.missingOrUnknownFields as unknown[]; + assertCondition(unknownCloseoutJson.mergeable === "UNKNOWN" && unknownCloseoutJson.statusCheckRollup === null, "unknown closeout JSON should preserve GitHub values", unknownCloseoutData); + assertCondition(unknownCloseoutMetadata.ok === false, "unknown closeout metadata should be explicit", unknownCloseoutMetadata); + assertCondition(Array.isArray(unknownFields) && unknownFields.includes("mergeable") && unknownFields.includes("mergeStateStatus") && unknownFields.includes("statusCheckRollup"), "unknown closeout metadata should name missing/unknown fields", unknownCloseoutMetadata); + assertCondition(String(unknownCloseoutMetadata.advice ?? "").includes("missing or unknown"), "unknown closeout metadata should include operator advice", unknownCloseoutMetadata); + + const graphqlErrorCloseout = await runCli(["gh", "pr", "view", "45", "--repo", "pikasTech/unidesk", "--json", "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"], env); + assertCondition(graphqlErrorCloseout.status !== 0, "pr view GraphQL closeout failure should fail structurally", graphqlErrorCloseout.json ?? { stdout: graphqlErrorCloseout.stdout }); + const graphqlErrorData = failedDataOf(graphqlErrorCloseout.json ?? {}); + const graphqlErrorMetadata = graphqlErrorData.closeoutMetadata as JsonRecord; + assertCondition(graphqlErrorData.phase === "fetch-pr-closeout-metadata", "GraphQL closeout failure should report phase", graphqlErrorData); + assertCondition(graphqlErrorMetadata.ok === false && graphqlErrorMetadata.source === "github-graphql", "GraphQL closeout failure should include explicit metadata error", graphqlErrorData); + assertCondition(String(graphqlErrorMetadata.message ?? "").includes("Resource not accessible"), "GraphQL closeout failure should preserve sanitized error message", graphqlErrorMetadata); + 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 }); @@ -564,6 +647,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { const closeoutBoundary = mergeData?.closeoutBoundary as JsonRecord | undefined; assertCondition(closeoutBoundary?.ordinaryRunnerFinalActionAllowed === true, "merge block should preserve ordinary runner PR closeout policy", closeoutBoundary ?? {}); assertCondition(closeoutBoundary?.unideskCliMergeSupported === false, "merge block should state UniDesk REST CLI merge remains unsupported", closeoutBoundary ?? {}); + assertCondition(String(closeoutBoundary?.readOnlyCloseoutCommand ?? "").includes("gh pr view 42"), "merge block should point to read-only closeout command", closeoutBoundary ?? {}); const deleteBlocked = await runCli(["gh", "pr", "delete", "42", "--repo", "pikasTech/unidesk"]); assertCondition(deleteBlocked.status !== 0, "pr hard delete should fail", deleteBlocked.json ?? { stdout: deleteBlocked.stdout }); @@ -592,9 +676,11 @@ 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 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", "pr view closeout metadata fields are accepted and hydrated through GraphQL", + "pr view closeout metadata makes GraphQL errors and UNKNOWN/null explicit", "pr read unsupported fields fail structurally with supported closeout fields listed", "pr preflight exposes redacted auth plus compact merge/status closeout metadata", "top-level gh preflight alias works for commander closeout", diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 61695cec..d93c24d9 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -22,6 +22,8 @@ const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", 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", "stateDetail", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "closed", "closedAt", "merged", "mergedAt", "mergeCommit", "headRefName", "baseRefName", "mergeable", "mergeStateStatus", "statusCheckRollup"] as const; +const PR_CLOSEOUT_JSON_FIELDS = ["mergeable", "mergeStateStatus", "statusCheckRollup"] as const; +const PR_CLOSEOUT_VIEW_JSON = "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"; 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; @@ -594,6 +596,17 @@ function parseIssueListJsonFields(requested: string[] | undefined): IssueListJso } function parsePrListJsonFields(requested: string[] | undefined): PrListJsonField[] | undefined { + if (requested !== undefined) { + const listFields = new Set<string>(PR_LIST_JSON_FIELDS); + const closeoutFields = requested.filter((field) => (PR_CLOSEOUT_JSON_FIELDS as readonly string[]).includes(field)); + const unsupported = requested.filter((field) => !listFields.has(field)); + if (closeoutFields.length > 0) { + throw new Error(`unsupported gh pr list --json closeout field(s): ${closeoutFields.join(", ")}; use gh pr view <number> --json ${PR_CLOSEOUT_VIEW_JSON} for mergeability/statusCheckRollup metadata; pr list supported fields: ${PR_LIST_JSON_FIELDS.join(",")}`); + } + if (unsupported.length > 0) { + throw new Error(`unsupported gh pr list --json field(s): ${unsupported.join(", ")}; supported fields: ${PR_LIST_JSON_FIELDS.join(",")}; closeout metadata requires gh pr view <number> --json ${PR_CLOSEOUT_VIEW_JSON}`); + } + } return validateJsonFields("gh pr list", requested, PR_LIST_JSON_FIELDS); } @@ -3854,6 +3867,47 @@ function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record<s }; } +function prMergeBoundary(): Record<string, unknown> { + return { + runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close"], + ordinaryRunnerFinalActionAllowed: true, + commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"], + hostAllowedToolsAfterReview: ["system gh pr merge", "GitHub UI merge/close"], + unideskCliMergeSupported: false, + degradedReason: "unsupported-command", + }; +} + +function prCloseoutMetadata(metadata: GitHubPullRequestGraphqlMetadata): Record<string, unknown> { + const summary = prMetadataSummary(metadata); + const missingOrUnknownFields = PR_CLOSEOUT_JSON_FIELDS.filter((field) => { + const value = summary[field]; + if (value === null || value === undefined) return true; + return typeof value === "string" && value.toUpperCase() === "UNKNOWN"; + }); + return { + ok: missingOrUnknownFields.length === 0, + source: "github-graphql", + missingOrUnknownFields, + advice: missingOrUnknownFields.length === 0 + ? "Closeout GraphQL metadata is present; still review checks, branch scope, and task boundary before final action." + : "Some closeout GraphQL metadata is missing or unknown; retry or cross-check with system gh/GitHub UI before treating the PR as merge-ready.", + mergeBoundary: prMergeBoundary(), + }; +} + +function prCloseoutMetadataError(error: GitHubErrorPayload): Record<string, unknown> { + return { + ok: false, + source: "github-graphql", + degradedReason: error.degradedReason, + runnerDisposition: error.runnerDisposition, + message: error.message, + advice: "Closeout GraphQL metadata was not available; retry or use a read-only system gh/GitHub UI cross-check before deciding merge readiness.", + mergeBoundary: prMergeBoundary(), + }; +} + function needsPrGraphqlMetadata(fields: readonly string[] | undefined): boolean { if (fields === undefined) return false; return fields.some((field) => field === "mergeable" || field === "mergeStateStatus" || field === "statusCheckRollup"); @@ -5453,7 +5507,7 @@ async function prRead(repo: string, token: string, number: number, jsonFields: P 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 ?? [] }); + if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, requestedJsonFields: jsonFields ?? [], closeoutMetadata: prCloseoutMetadataError(metadata) }); const selectionSummary = metadata === null ? summary : { ...summary, ...prMetadataSummary(metadata) }; return { ok: true, @@ -5461,6 +5515,7 @@ async function prRead(repo: string, token: string, number: number, jsonFields: P repo, ...(disclosure === null ? {} : { disclosure }), pullRequest: summary, + ...(metadata === null ? {} : { closeoutMetadata: prCloseoutMetadata(metadata) }), ...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(selectionSummary, jsonFields) }), }; } @@ -5539,7 +5594,8 @@ 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 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 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, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.", + "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 create/edit/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.", ], @@ -5836,12 +5892,8 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult "PR merge is intentionally unsupported by the UniDesk REST CLI; PR-bound GPT-5.5 runners may self-close/merge ordinary in-boundary PRs after checks using repo-owned GitHub paths, while high-risk or ambiguous PRs stay commander-reviewed.", { closeoutBoundary: { - runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close"], - ordinaryRunnerFinalActionAllowed: true, - commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"], - hostAllowedToolsAfterReview: ["system gh pr merge", "GitHub UI merge/close"], - unideskCliMergeSupported: false, - degradedReason: "unsupported-command", + ...prMergeBoundary(), + readOnlyCloseoutCommand: `bun scripts/cli.ts gh pr view ${third ?? "<number>"} --repo ${options.repo} --json ${PR_CLOSEOUT_VIEW_JSON}`, }, }, );