diff --git a/AGENTS.md b/AGENTS.md index 87b0748d..c2384471 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|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` 当前仍结构化拒绝但普通 PR 可按任务边界用 repo-owned GitHub 路径收口,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 +- `bun scripts/cli.ts gh preflight|auth status|issue ...|pr list|files|diff --stat|read|view|preflight|closeout|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)、低噪声 PR 收口 preflight 与 runner PR preflight;`gh issue/pr read|view` 支持 `owner/repo#number` shorthand,`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON,`gh pr merge` 当前仍结构化拒绝但普通 PR 可按任务边界用 repo-owned GitHub 路径收口,规则见 `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/TEST.md b/TEST.md index c4d982d6..95417c42 100644 --- a/TEST.md +++ b/TEST.md @@ -137,7 +137,7 @@ ## T26 GitHub CLI PR 安全写入口 -阅读 `AGENTS.md` 和 `docs/reference/cli.md`,然后用 cli 手动测试以下内容:准备一份包含真实换行、反引号和 Markdown 表格的临时正文文件,运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh pr create`、`gh pr edit`、`gh pr comment`、`gh pr read `、`--raw|--full`、`gh pr files ` 和 `gh pr diff --stat`。运行 `bun scripts/gh-cli-pr-contract-test.ts`,确认 mock GitHub 覆盖 PR read/view 的 `owner/repo#number` shorthand、`--raw` 完整披露、冲突 `--repo` 结构化失败、PR closeout GraphQL 字段、PR edit/update REST PATCH payload、stdin `--body-file -` 和不回显完整正文。运行 `bun scripts/gh-cli-pr-files-contract-test.ts`,确认 mock GitHub 覆盖 `gh pr files` 的 REST changed-file/stat JSON、bounded file list、truncation metadata、next command、无 raw patch,以及 `gh pr diff --stat` 兼容别名和无 `--stat` raw diff 的结构化拒绝。对真实仓库只读观察可运行 `bun scripts/cli.ts gh pr files --repo pikasTech/unidesk --limit 30` 或 `bun scripts/cli.ts gh pr diff --repo pikasTech/unidesk --stat --limit 30`,确认输出固定 JSON 且默认不含 raw diff。运行 `bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title --body-file <file> --base master --head <branch> --draft --dry-run`,确认命令不访问 GitHub、不创建 PR,JSON 中包含 `dryRun=true`、`planned=true`、repo、title、base、head、draft、bodyChars、bodyPreviewLines、request plan,并且正文预览保留真实换行和反引号。运行 `bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --title <title> --body-file <file> --dry-run`,确认命令使用 REST PATCH 计划、不访问 GitHub Projects Classic GraphQL/projectCards,JSON 只包含 repo、PR number、changedFields、url、body 长度/SHA/source 和 request plan,不默认回显完整正文;再运行 `cat <file> | bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --body-file - --dry-run`,确认 stdin source 标记为 `kind=stdin` 且同样低噪声。运行 `bun scripts/cli.ts gh pr comment <number> --repo pikasTech/unidesk --body-file <file> --dry-run`,确认命令不写评论,JSON 中包含 PR number、bodyChars、bodySource 和 request plan,且没有把换行污染成字面量 `\n`。运行 `bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk`,确认返回非零状态和结构化 JSON,`degradedReason=unsupported-command`、`runnerDisposition=business-failed`,且不会真实 merge。需要测试真实创建、编辑或评论时,只允许使用明确的 throwaway 源分支和 PR,并在记录中写明 PR URL、number、源/目标分支和清理动作;默认验收只做 dry-run,不创建或修改真实 PR。 +阅读 `AGENTS.md` 和 `docs/reference/cli.md`,然后用 cli 手动测试以下内容:准备一份包含真实换行、反引号和 Markdown 表格的临时正文文件,运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh pr create`、`gh pr edit`、`gh pr comment`、`gh pr read <number|owner/repo#number>`、`gh pr preflight <number>`、`gh preflight <prNumber>`、`--raw|--full`、`gh pr files <number>` 和 `gh pr diff <number> --stat`。运行 `bun scripts/gh-cli-pr-contract-test.ts`,确认 mock GitHub 覆盖 PR read/view 的 `owner/repo#number` shorthand、`--raw` 完整披露、冲突 `--repo` 结构化失败、PR closeout GraphQL 字段、低噪声 `gh pr preflight`/`gh preflight`、PR edit/update REST PATCH payload、stdin `--body-file -` 和不回显完整正文。运行 `bun scripts/gh-cli-pr-files-contract-test.ts`,确认 mock GitHub 覆盖 `gh pr files` 的 REST changed-file/stat JSON、bounded file list、truncation metadata、next command、无 raw patch,以及 `gh pr diff --stat` 兼容别名和无 `--stat` raw diff 的结构化拒绝。对真实仓库只读观察可运行 `bun scripts/cli.ts gh pr files <number> --repo pikasTech/unidesk --limit 30`、`bun scripts/cli.ts gh pr diff <number> --repo pikasTech/unidesk --stat --limit 30` 或 `bun scripts/cli.ts gh pr preflight <number> --repo pikasTech/unidesk`,确认输出固定 JSON 且默认不含 raw diff 或完整 status contexts;需要完整 status contexts 时显式加 `--full`。运行 `bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title <title> --body-file <file> --base master --head <branch> --draft --dry-run`,确认命令不访问 GitHub、不创建 PR,JSON 中包含 `dryRun=true`、`planned=true`、repo、title、base、head、draft、bodyChars、bodyPreviewLines、request plan,并且正文预览保留真实换行和反引号。运行 `bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --title <title> --body-file <file> --dry-run`,确认命令使用 REST PATCH 计划、不访问 GitHub Projects Classic GraphQL/projectCards,JSON 只包含 repo、PR number、changedFields、url、body 长度/SHA/source 和 request plan,不默认回显完整正文;再运行 `cat <file> | bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --body-file - --dry-run`,确认 stdin source 标记为 `kind=stdin` 且同样低噪声。运行 `bun scripts/cli.ts gh pr comment <number> --repo pikasTech/unidesk --body-file <file> --dry-run`,确认命令不写评论,JSON 中包含 PR number、bodyChars、bodySource 和 request plan,且没有把换行污染成字面量 `\n`。运行 `bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk`,确认返回非零状态和结构化 JSON,`degradedReason=unsupported-command`、`runnerDisposition=business-failed`,且不会真实 merge。需要测试真实创建、编辑或评论时,只允许使用明确的 throwaway 源分支和 PR,并在记录中写明 PR URL、number、源/目标分支和清理动作;默认验收只做 dry-run,不创建或修改真实 PR。 ## T27 GitHub Issue/Comment 换行转义卫生扫描 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 45985a7f..36a8c973 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -40,8 +40,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 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update <number> --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`,可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_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]` 继续稳定返回这些字段,并额外支持 `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。 +- `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 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` 当成成功的空历史。 - `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/scripts/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts index d9fdbb0e..be859040 100644 --- a/scripts/gh-cli-pr-contract-test.ts +++ b/scripts/gh-cli-pr-contract-test.ts @@ -239,6 +239,8 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(usage.some((line) => line.includes("gh pr read")), "gh help should list pr read", { usage }); assertCondition(usage.some((line) => line.includes("gh pr view")), "gh help should list pr view", { usage }); assertCondition(usage.some((line) => line.includes("gh pr read") && line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document pr shorthand and raw/full disclosure", { usage }); + assertCondition(usage.some((line) => line.includes("gh preflight")), "gh help should list top-level preflight alias", { usage }); + assertCondition(usage.some((line) => line.includes("gh pr preflight")), "gh help should list pr preflight", { usage }); assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage }); 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 }); @@ -250,6 +252,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { 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 }); + assertCondition(notes.some((line) => line.includes("low-noise read-only closeout helper")), "gh help should document PR preflight closeout helper", { notes }); const mock = await startMockGitHub(); const env = { @@ -367,6 +370,41 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { 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 closeoutPreflight = await runCli(["gh", "pr", "preflight", "42", "--repo", "pikasTech/unidesk"], env); + assertCondition(closeoutPreflight.status === 0, "pr preflight should succeed through REST and GraphQL", closeoutPreflight.json ?? { stdout: closeoutPreflight.stdout }); + assertCondition(!closeoutPreflight.stdout.includes("contract-token"), "pr preflight must not print token values", { stdout: closeoutPreflight.stdout }); + const closeoutPreflightData = dataOf(closeoutPreflight.json ?? {}); + assertCondition(closeoutPreflightData.command === "pr preflight", "pr preflight should report command", closeoutPreflightData); + assertCondition(closeoutPreflightData.readOnly === true && closeoutPreflightData.writesRemote === false, "pr preflight must stay read-only", closeoutPreflightData); + assertCondition(!("raw" in closeoutPreflightData), "pr preflight default output should omit raw payloads", closeoutPreflightData); + const authCapability = closeoutPreflightData.authCapability as JsonRecord; + assertCondition(authCapability.ok === true && authCapability.tokenPresent === true && authCapability.tokenSource === "GH_TOKEN", "pr preflight should expose redacted auth capability", authCapability); + assertCondition(authCapability.valuesPrinted === false, "pr preflight should explicitly avoid secret values", authCapability); + const preflightPr = closeoutPreflightData.pullRequest as JsonRecord; + assertCondition(preflightPr.number === 42 && preflightPr.bodyOmitted === true, "pr preflight should return bounded PR metadata", preflightPr); + const mergeability = closeoutPreflightData.mergeability as JsonRecord; + assertCondition(mergeability.mergeable === "MERGEABLE" && mergeability.mergeStateStatus === "CLEAN", "pr preflight should expose mergeability", mergeability); + assertCondition(mergeability.readyForCommanderMerge === true && mergeability.conclusion === "ready", "pr preflight should summarize closeout readiness", mergeability); + const preflightStatus = closeoutPreflightData.statusChecks as JsonRecord; + const preflightCounts = preflightStatus.counts as JsonRecord; + assertCondition(preflightStatus.state === "SUCCESS" && preflightStatus.rawOmitted === true, "pr preflight default status rollup should be compact", preflightStatus); + assertCondition(preflightCounts.success === 2, "pr preflight should count successful contexts", preflightStatus); + const policy = closeoutPreflightData.policy as JsonRecord; + assertCondition(policy.mergesPr === false && policy.mergeCommandSupported === false && policy.unideskCliMergeSupported === false, "pr preflight policy should block UniDesk CLI merge execution", policy); + + const aliasPreflight = await runCli(["gh", "preflight", "42", "--repo", "pikasTech/unidesk"], env); + assertCondition(aliasPreflight.status === 0, "top-level gh preflight alias should succeed", aliasPreflight.json ?? { stdout: aliasPreflight.stdout }); + const aliasPreflightData = dataOf(aliasPreflight.json ?? {}); + assertCondition(aliasPreflightData.command === "preflight", "top-level gh preflight should report alias command", aliasPreflightData); + assertCondition((aliasPreflightData.policy as JsonRecord).mergesPr === false, "top-level gh preflight alias must not merge", aliasPreflightData); + + const fullPreflight = await runCli(["gh", "pr", "preflight", "42", "--repo", "pikasTech/unidesk", "--full"], env); + assertCondition(fullPreflight.status === 0, "pr preflight --full should succeed", fullPreflight.json ?? { stdout: fullPreflight.stdout }); + const fullPreflightData = dataOf(fullPreflight.json ?? {}); + const fullStatus = fullPreflightData.statusChecks as JsonRecord; + assertCondition(fullStatus.rawOmitted === false && Array.isArray(fullStatus.contexts), "pr preflight --full should include status contexts", fullStatus); + assertCondition(typeof fullPreflightData.raw === "object" && fullPreflightData.raw !== null, "pr preflight --full should include raw read payload summary", fullPreflightData); + const preflight = await runBun([ "scripts/code-queue-pr-preflight-example.ts", "--repo", @@ -558,6 +596,9 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { "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 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", + "pr preflight --full is the explicit status-context disclosure path", "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", diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index fd597054..61695cec 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -3901,6 +3901,264 @@ async function prGraphqlMetadata(repo: string, token: string, number: number): P return pullRequest; } +function statusContextName(context: GitHubPullRequestGraphqlStatusContext): string { + return context.name ?? context.context ?? "(unnamed status check)"; +} + +function statusContextBucket(context: GitHubPullRequestGraphqlStatusContext): string { + const type = context.__typename ?? "StatusContext"; + const status = (context.status ?? "").toUpperCase(); + const conclusion = (context.conclusion ?? "").toUpperCase(); + const state = (context.state ?? "").toUpperCase(); + if (type === "CheckRun") { + if (status.length > 0 && status !== "COMPLETED") return "pending"; + if (conclusion === "SUCCESS") return "success"; + if (conclusion === "NEUTRAL") return "neutral"; + if (conclusion === "SKIPPED") return "skipped"; + if (conclusion === "CANCELLED") return "cancelled"; + if (conclusion === "TIMED_OUT") return "timed-out"; + if (["FAILURE", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(conclusion)) return "failure"; + return "unknown"; + } + if (state === "SUCCESS") return "success"; + if (state === "PENDING" || state === "EXPECTED") return "pending"; + if (state === "FAILURE" || state === "ERROR") return "failure"; + return "unknown"; +} + +function compactStatusContext(context: GitHubPullRequestGraphqlStatusContext): Record<string, unknown> { + const bucket = statusContextBucket(context); + return { + name: statusContextName(context), + type: context.__typename ?? "StatusContext", + bucket, + status: context.status ?? null, + conclusion: context.conclusion ?? null, + state: context.state ?? null, + targetUrl: context.targetUrl ?? null, + description: context.description === undefined || context.description === null ? null : preview(context.description), + }; +} + +function statusRollupSummary( + repo: string, + number: number, + rollup: GitHubPullRequestGraphqlStatusCheckRollup | null | undefined, + includeRaw: boolean, +): Record<string, unknown> { + const contexts = rollup?.contexts?.nodes ?? []; + const compactContexts = contexts.map(compactStatusContext); + const counts: Record<string, number> = { + success: 0, + failure: 0, + pending: 0, + neutral: 0, + skipped: 0, + cancelled: 0, + "timed-out": 0, + unknown: 0, + }; + for (const context of compactContexts) { + const bucket = String(context.bucket ?? "unknown"); + counts[bucket] = (counts[bucket] ?? 0) + 1; + } + const nonSuccess = compactContexts.filter((context) => context.bucket !== "success"); + const failing = compactContexts.filter((context) => context.bucket === "failure" || context.bucket === "cancelled" || context.bucket === "timed-out"); + const pending = compactContexts.filter((context) => context.bucket === "pending" || context.bucket === "unknown"); + const neutral = compactContexts.filter((context) => context.bucket === "neutral" || context.bucket === "skipped"); + return { + state: rollup?.state ?? null, + totalContexts: compactContexts.length, + counts, + failingContexts: failing.slice(0, 10), + pendingContexts: pending.slice(0, 10), + neutralContexts: neutral.slice(0, 10), + nonSuccessPreview: nonSuccess.slice(0, 12), + omittedNonSuccessContexts: Math.max(0, nonSuccess.length - 12), + rawOmitted: !includeRaw, + ...(includeRaw + ? { contexts: compactContexts, raw: rollup ?? null } + : { fullHint: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo} --full` }), + }; +} + +function compactProbeOk(value: unknown): boolean | null { + if (value === "ok") return true; + if (value === "skipped") return null; + if (isRecord(value) && value.ok === true) return true; + if (isRecord(value) && value.ok === false) return false; + return null; +} + +function compactAuthCapability(auth: GitHubCommandResult): Record<string, unknown> { + const token = isRecord(auth.token) ? auth.token : {}; + const gh = isRecord(auth.gh) ? auth.gh : {}; + const probes = isRecord(auth.probes) ? auth.probes : {}; + return { + ok: auth.ok === true, + degradedReason: auth.ok === false ? auth.degradedReason ?? null : null, + runnerDisposition: auth.ok === false ? auth.runnerDisposition ?? null : null, + tokenPresent: token.present === true, + tokenSource: typeof token.source === "string" ? token.source : null, + ghFallbackAttempted: token.ghFallbackAttempted === true, + ghBinaryFound: gh.binaryFound === true, + restApi: compactProbeOk(probes.restApi), + repoVisible: compactProbeOk(probes.repo), + issueRead: compactProbeOk(probes.issueRead), + valuesPrinted: false, + }; +} + +function prPreflightPolicy(repo: string, number: number): Record<string, unknown> { + return { + readOnly: true, + writesRemote: false, + createsPr: false, + comments: false, + mergesPr: false, + mergeCommandSupported: false, + unsupportedMergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo}`, + unideskCliMergeSupported: false, + ordinaryRunnerFinalActionAllowed: true, + note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks; the UniDesk REST CLI still never merges PRs.", + }; +} + +function preflightPullRequestSummary(summary: Record<string, unknown>): Record<string, unknown> { + return { + number: summary.number ?? null, + title: summary.title ?? null, + state: summary.state ?? null, + stateDetail: summary.stateDetail ?? null, + draft: summary.draft ?? null, + url: summary.url ?? null, + author: summary.author ?? null, + head: summary.head ?? null, + base: summary.base ?? null, + headRefName: summary.headRefName ?? null, + baseRefName: summary.baseRefName ?? null, + closed: summary.closed ?? null, + merged: summary.merged ?? null, + mergedAt: summary.mergedAt ?? null, + updatedAt: summary.updatedAt ?? null, + bodyOmitted: true, + }; +} + +function prCloseoutSummary( + pr: Record<string, unknown>, + metadata: Record<string, unknown>, + statusChecks: Record<string, unknown>, +): Record<string, unknown> { + const blockers: string[] = []; + const pending: string[] = []; + const prState = typeof pr.state === "string" ? pr.state.toLowerCase() : ""; + if (prState !== "open") blockers.push(`state:${pr.state ?? "unknown"}`); + if (pr.draft === true) blockers.push("draft:true"); + const mergeable = typeof metadata.mergeable === "string" ? metadata.mergeable.toUpperCase() : null; + if (mergeable === "MERGEABLE") { + // Ready signal, combined with mergeStateStatus and checks below. + } else if (mergeable === "CONFLICTING" || mergeable === "NOT_MERGEABLE") { + blockers.push(`mergeable:${mergeable}`); + } else { + pending.push(`mergeable:${mergeable ?? "unknown"}`); + } + const mergeStateStatus = typeof metadata.mergeStateStatus === "string" ? metadata.mergeStateStatus.toUpperCase() : null; + if (mergeStateStatus === "CLEAN") { + // Ready signal. + } else if (mergeStateStatus === "UNKNOWN" || mergeStateStatus === null) { + pending.push(`mergeStateStatus:${mergeStateStatus ?? "unknown"}`); + } else { + blockers.push(`mergeStateStatus:${mergeStateStatus}`); + } + const rollupState = typeof statusChecks.state === "string" ? statusChecks.state.toUpperCase() : null; + const totalContexts = typeof statusChecks.totalContexts === "number" ? statusChecks.totalContexts : 0; + const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {}; + const failureCount = Number(counts.failure ?? 0) + Number(counts.cancelled ?? 0) + Number(counts["timed-out"] ?? 0); + const pendingCount = Number(counts.pending ?? 0) + Number(counts.unknown ?? 0); + if (failureCount > 0) blockers.push(`statusChecks:failure:${failureCount}`); + if (pendingCount > 0) pending.push(`statusChecks:pending:${pendingCount}`); + if (rollupState === "SUCCESS" || rollupState === null && totalContexts === 0) { + // Ready signal. + } else if (rollupState === "PENDING" || rollupState === "EXPECTED" || rollupState === null) { + pending.push(`statusCheckRollup:${rollupState ?? "unknown"}`); + } else if (rollupState !== null) { + blockers.push(`statusCheckRollup:${rollupState}`); + } + const readyForCommanderMerge = blockers.length === 0 && pending.length === 0; + return { + readyForCommanderMerge, + conclusion: readyForCommanderMerge ? "ready" : blockers.length > 0 ? "blocked" : "pending", + blockers, + pending, + commanderAction: readyForCommanderMerge + ? "review and merge through a repo-owned GitHub path when task boundaries allow; UniDesk REST gh pr merge remains unsupported" + : "resolve blockers or rerun after GitHub finishes computing mergeability/status checks", + }; +} + +async function prPreflight(repo: string, number: number, commandName: "preflight" | "pr preflight" | "pr closeout", includeRaw: boolean): Promise<GitHubCommandResult> { + const auth = await authStatus(repo); + const authCapability = compactAuthCapability(auth); + const policy = prPreflightPolicy(repo, number); + if (auth.ok === false) { + return { + ok: false, + command: commandName, + repo, + number, + degradedReason: auth.degradedReason, + runnerDisposition: auth.runnerDisposition, + authCapability, + policy, + details: auth, + }; + } + const { token, probe } = resolveToken(true); + const missing = authRequired(repo, commandName, probe); + if (missing !== null || token === null) { + const missingResult = missing ?? authRequired(repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); + return { + ...(missingResult ?? commandError(commandName, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required"))), + command: commandName, + number, + authCapability, + policy, + } as 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, authCapability, policy }); + const metadata = await prGraphqlMetadata(repo, token, number); + const summary = prSummary(pr); + const pullRequest = preflightPullRequestSummary(summary); + if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest, authCapability, policy }); + const metadataSummary = prMetadataSummary(metadata); + const statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, includeRaw); + const closeout = prCloseoutSummary(summary, metadataSummary, statusChecks); + return { + ok: true, + command: commandName, + canonicalCommand: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo}`, + aliases: ["bun scripts/cli.ts gh preflight <number>", "bun scripts/cli.ts gh pr closeout <number>"], + repo, + number, + readOnly: true, + writesRemote: false, + valuesPrinted: false, + authCapability, + pullRequest, + mergeability: { + mergeable: metadataSummary.mergeable, + mergeStateStatus: metadataSummary.mergeStateStatus, + ...closeout, + }, + statusChecks, + policy, + ...(includeRaw ? { raw: { authStatus: auth, pullRequest, closeoutMetadata: metadataSummary } } : {}), + }; +} + function repoSummary(repo: GitHubRepository): Record<string, unknown> { return { id: repo.id ?? null, @@ -5238,11 +5496,14 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh issue board-row upsert <issueNumber> [--repo owner/name] --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", "bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]", "bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] --board-issue 20 [--dry-run] [--expect-body-sha sha256]", + "bun scripts/cli.ts gh preflight <prNumber> [--repo owner/name] [--full|--raw] [compatibility alias for gh pr preflight]", "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,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]", "bun scripts/cli.ts gh pr view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for pr read]", + "bun scripts/cli.ts gh pr preflight <number> [--repo owner/name] [--full|--raw]", + "bun scripts/cli.ts gh pr closeout <number> [--repo owner/name] [--full|--raw] [compatibility alias for pr preflight]", "bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]", "bun scripts/cli.ts gh pr edit <number> [--title title] [--body-file <file>|--body-file -|--body <text>] [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title title] [--repo owner/name] [--dry-run]", @@ -5279,6 +5540,7 @@ export function ghHelp(): unknown { "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 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.", ], }; @@ -5313,14 +5575,15 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult return validationError(command, options.repo, "--json field selection is only supported by gh issue read/view/list and gh pr read/view/list"); } } - if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && isIssueReadCommand(sub)) || (top === "pr" && isPrReadCommand(sub)))) { + if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && isIssueReadCommand(sub)) || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "preflight" || sub === "closeout")))) { const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue read/view and gh pr read/view.", { + return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue read/view, gh pr read/view, and gh pr preflight/closeout.", { supportedCommands: [ "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 ${readViewSupportedJsonFields("pr")}`, + "bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full", ], }); } @@ -5380,6 +5643,12 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult if (top === "auth" && sub === "status") return authStatus(options.repo); + if (top === "preflight") { + const number = parseNumberForCommand(options.repo, sub, "preflight"); + if (typeof number !== "number") return number; + return prPreflight(options.repo, number, "preflight", options.full || options.raw); + } + if (top === "issue") { if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics."); if (sub === "comment" && third === "delete") { @@ -5498,6 +5767,11 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult return prFiles(options.repo, token, number, options.limit, "pr files"); } if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics."); + if (sub === "preflight" || sub === "closeout") { + const number = parseNumberForCommand(options.repo, third, `pr ${sub}`); + if (typeof number !== "number") return number; + return prPreflight(options.repo, number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw); + } if (sub === "comment" && third === "delete") { const commentId = parseNumberForCommand(options.repo, args[3], "pr comment delete"); if (typeof commentId !== "number") return commentId; @@ -5573,7 +5847,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult ); } if (sub !== "list" && !isPrReadCommand(sub)) { - return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, create, update/edit, close, reopen, comment create/delete, and unsupported merge/delete."); + return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, comment create/delete, and unsupported merge/delete."); } if (sub === "read" || sub === "view") { const resolved = resolveReadViewNumberReference("pr", sub, third, options, args); diff --git a/scripts/src/help.ts b/scripts/src/help.ts index c4ddaae1..89ff4d22 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -46,7 +46,7 @@ export function rootHelp(): unknown { { command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." }, { command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." }, { command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." }, - { command: "gh auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, hard delete unsupported, and merge blocked." }, + { command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and merge blocked." }, { command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run preview; exposes local health, state, trace summary, and approval draft helpers without live bridges or message sends." }, { command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." }, { command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." },