From 2bd1b0a1d6c3e0f806e1b348d9abc2e6871bc52d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 09:56:21 +0000 Subject: [PATCH] feat: add safe gh pr create/comment --- AGENTS.md | 2 +- TEST.md | 4 + docs/reference/cli.md | 6 +- docs/reference/code-queue-supervision.md | 4 +- scripts/gh-cli-pr-contract-test.ts | 109 +++++ scripts/src/gh.ts | 576 ++++++++++++++++++++--- scripts/src/help.ts | 2 +- 7 files changed, 619 insertions(+), 84 deletions(-) create mode 100644 scripts/gh-cli-pr-contract-test.ts diff --git a/AGENTS.md b/AGENTS.md index fdab2c58..a7c5d9ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json|--env dev|prod] [--service ]`:按根目录 `deploy.json` 或 `origin/master:deploy.json#environments.` 的服务 repo 和 commit 期望状态校验或更新用户服务;`--env dev` 开放 D601 `backend-core` rollout、reviewed registry artifact consumers 和 D601 direct consumer validation,`findjob`/`pipeline` 是 D601 direct pull-only 样板,`met-nonlinear` dry-run blocked,`k3sctl-adapter` supervisor-only,`code-queue` prod unsupported,规则见 `docs/reference/deploy.md` 与 `docs/reference/dev-environment.md`。 - `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 gh auth status|issue ...|pr list|view`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入和 escape 扫描;PR 第一阶段只支持只读 list/view,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 +- `bun scripts/cli.ts gh auth status|issue ...|pr list|view|create|comment`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、escape 扫描和 PR 创建/评论;`gh pr merge` 当前仍结构化拒绝,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.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`。 - `bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue ]`:通过 backend-core 私有代理提交 Code Queue 任务;控制面默认走主 server `code-queue-mgr` 写入 PostgreSQL,`--dry-run` 可只检查请求体不入队,规则见 `docs/reference/cli.md`。 diff --git a/TEST.md b/TEST.md index 61e89745..39f2824d 100644 --- a/TEST.md +++ b/TEST.md @@ -126,3 +126,7 @@ ## T25 ClaudeQQ D601 QQ Gateway User Service 阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 D601 `/home/ubuntu/.agents/skills/claudeqq` 中存在 `napcat/config/onebot11.json`,确认 UniDesk 仓库中存在 `src/components/microservices/claudeqq/Dockerfile` 和 `src/components/microservices/claudeqq/adapter.js`;运行 `bun scripts/cli.ts microservice list`,确认 `claudeqq` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://gitee.com/lyon1998/agent_skills`、k3s/k8s `k3s://unidesk/claudeqq:3290` 逻辑服务映射、`deployment.mode=k3sctl-managed`、`runtime.orchestrator=k3sctl` 且无业务直连容器摘要;使用 `bun scripts/cli.ts deploy apply --service claudeqq` 按 `deploy.json` 期望状态部署,确认 job 能在 D601 target-side fetch/export 外部 repo 或验证过的 provider worktree、注入 UniDesk 管理的 ClaudeQQ Dockerfile/API adapter、构建 `unidesk-claudeqq:d601`、导入原生 k3s/containerd、apply `src/components/microservices/k3sctl-adapter/k3s/claudeqq.k8s.yaml`、stamp deployment commit、rollout 并通过 UniDesk microservice proxy 验证 live commit。运行 `bun scripts/cli.ts microservice health claudeqq`、`bun scripts/cli.ts microservice proxy claudeqq /api/napcat/login`、`bun scripts/cli.ts microservice proxy claudeqq /api/events/recent` 和 `bun scripts/cli.ts microservice proxy claudeqq /api/events/subscriptions`,确认链路通过 backend-core、k3sctl-adapter、Kubernetes API service proxy 和 D601 ClaudeQQ Service,health 返回 `service=claudeqq`、`pureBackend=true`、`napcat.containerized=true`、NapCat HTTP/WS 状态、登录状态/二维码、`/api/push/text` 与 `/api/events/subscriptions` 端点;通过 `POST /api/events/subscriptions` 创建临时 webhook 订阅再删除,确认 main server 和其他用户服务可订阅 QQ 消息事件;通过 `POST /api/push/text` 发送消息时若 NapCat 未登录必须返回可解释错误,NapCat 在线时必须返回消息 ID,人工推送验收只允许发给主用户私聊账号 `645275593`。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / ClaudeQQ`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、NapCat 容器登录二维码、NapCat 状态、QQ 事件订阅、消息推送、主用户私聊账号 `645275593`、最近 QQ 事件和已发送记录,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据;不得把 D601 `3290/3000/3001/6099` 暴露到公网,也不得 iframe ClaudeQQ 旧 WebUI。 + +## T26 GitHub CLI PR 安全写入口 + +阅读 `AGENTS.md` 和 `docs/reference/cli.md`,然后用 cli 手动测试以下内容:准备一份包含真实换行、反引号和 Markdown 表格的临时正文文件,运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh pr create` 和 `gh pr comment`。运行 `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 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。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 46f2308e..c3ba46ce 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -29,9 +29,9 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `dev-env validate [--manifest path] [--kubectl-dry-run]` 离线校验 D601 `unidesk-dev` namespace、dev PostgreSQL 底座和 dev workload manifest。默认检查 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`;也可显式校验 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml` 或 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml`。所有 namespaced 对象必须只落到 `unidesk-dev`,foundation manifest 必须包含 `postgres-dev` StatefulSet/Service、dev secret/config、迁移 Job 和 DB URL guard,core manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/Service,Code Queue dev manifest 必须包含 `code-queue-scheduler-dev`、`code-queue-read-dev`、`code-queue-write-dev`、dev provider egress proxy,以及只读挂载宿主 `/home/ubuntu/.agents/skills` 到容器 `/root/.agents/skills` 的 `skills-dir` volume。加 `--kubectl-dry-run` 时额外执行 `kubectl apply --dry-run=client --validate=false -f <manifest>`,仍不 apply 资源。 - `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine` 和 `rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。 - `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。`deploy-backend-core` 是 deprecated 兼容名,标准 backend-core prod CD 入口是 `deploy apply --env prod --service backend-core`。长期规则见 `docs/reference/artifact-registry.md`。 -- `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary`、`missing-token`、`egress-failed`、`permission-denied`、`repo-not-found`、`issue-not-found`、`invalid-response` 或 `unsupported-command`,不得打印 token。 +- `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary`、`missing-token`、`auth-failed`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response` 或 `unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应优先用该字段分流。 - `gh issue view <number> [--repo owner/name]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON。`gh issue create --title <title> --body-file <file> [--dry-run]`、`gh issue edit <number> --body-file <file> [--title ...] [--dry-run]`、`gh issue comment <number> --body-file <file> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary;`--dry-run` 不调用 GitHub,只返回 repo/title/bodyChars/bodyPreview/bodyPreviewLines 和 newline/Markdown 检测结果。 -- `gh issue scan-escape [--repo owner/name] [--limit N]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape 和 ANSI escape 字符串,输出 issue/comment id、url、kind、snippet,不自动修复。`gh pr list|view` 第一阶段只提供 REST 只读列表和详情;PR create/edit/comment/merge 仍是 planned 状态,未知 PR 写命令必须结构化返回 `unsupported-command`,不能假装支持。 +- `gh issue scan-escape [--repo owner/name] [--limit N]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape 和 ANSI escape 字符串,输出 issue/comment id、url、kind、snippet,不自动修复。`gh pr list|view` 继续提供 REST 只读列表和详情;`gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]` 与 `gh pr comment <number> --body-file <file>|--body <text> [--dry-run]` 是安全写入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr comment --dry-run` 只输出计划并保留 Markdown 原始换行和反引号;非 dry-run 会先确认 PR 存在再写入 issue comment。`gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`。 - `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`。 - `codex deploy <commitId>` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`。 - `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向稳定 `code-queue` 用户服务路径提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求且不实际入队。提交确认和 dry-run 必须返回完整 prompt、字符数和 `truncated=false`,不能套用任务详情的预览截断策略,否则长任务 prompt 无法被人工验收。真实提交会经过本机本地串行化保护和短节流,避免同一指挥端并发 submit 把低内存主机或 `code-queue-mgr` 控制面打抖;返回值会附带 `submitConcurrencyGuard` 说明本次提交的锁与等待信息。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQL;D601 scheduler 只轮询并执行已入库任务。 @@ -66,7 +66,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI `microservice proxy` 是面向人工验证和受控调试的私有后端入口。默认 method 为 GET;使用 `--body-json JSON`、`--body-file path` 或 `--body-stdin` 时默认 method 切换为 POST,也可显式加 `--method POST|PUT|PATCH|DELETE`,但 GET/HEAD 不允许携带请求体。所有请求仍受 config 中的 `allowedMethods` 和 `allowedPathPrefixes` 限制。为了避免 Pipeline snapshot 这类超大业务 JSON 造成 CLI 输出爆炸,响应 body 超过默认阈值时会返回 `bodyOmitted=true`、`bodyPreview`、`bodyBytes` 和 `rawHint`;`--raw` 仍受默认硬限额保护,需要完整 body 时显式添加 `--raw --full`,或用 `--max-body-bytes <N>` 调整预览阈值。正式 frontend 展示仍应优先使用业务控件和 `__unideskArrayLimit` 这类展示级裁剪参数,而不是默认倾倒完整 JSON。 -GitHub issue 写操作必须优先使用 `bun scripts/cli.ts gh issue ... --body-file <file>`。不要把 Markdown 正文拼进 shell 参数、`gh issue comment --body` 或 `gh api -f body=...`;这些路径容易把真实换行污染成字面量 `\n`。`gh issue` 写命令第一阶段不接受 stdin 正文;需要从生成内容写入 issue 时,先落到临时 Markdown 文件或已审阅的工作文件,再把该文件路径传给 `--body-file`。CLI 会按 UTF-8 原样读取文件内容并用 JSON body 调用 REST API。 +GitHub issue 写操作必须优先使用 `bun scripts/cli.ts gh issue ... --body-file <file>`。不要把 Markdown 正文拼进 shell 参数、`gh issue comment --body` 或 `gh api -f body=...`;这些路径容易把真实换行污染成字面量 `\n`。`gh issue` 写命令第一阶段不接受 stdin 正文;需要从生成内容写入 issue 时,先落到临时 Markdown 文件或已审阅的工作文件,再把该文件路径传给 `--body-file`。PR 安全写入口同样优先 `--body-file`,`--body` 只适合短单行内容;`gh pr merge` 暂不开放,不存在 `--confirm` 可绕过的真实 merge 路径。CLI 会按 UTF-8 原样读取文件内容并用 JSON body 调用 REST API。 `network perf` 用于生成组网性能前后对比数据。标准 Code Queue overview 读路径基准命令是 `bun scripts/cli.ts network perf --service code-queue --path /api/tasks/overview?limit=30 --count 30 --concurrency 1 --label before`,远程主 server 可用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 network perf ...`。输出包含成功/失败数、状态码分布、`x-unidesk-cache`、`x-unidesk-proxy-mode`、`x-unidesk-upstream-proxy-mode` 分布和 min/p50/p90/p95/max;provider-gateway 长连接数据面验收应看到 `proxyModeCounts.provider-ws-http-tunnel`,adapter native Service 数据面验收应看到 upstream proxy mode 为 `kubernetes-native-service`,若出现 `kubernetes-api-service-proxy` 必须结合 `/api/control-plane.nativeServiceProxy.failedServices` 解释 fallback 原因。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 73d403f2..4d8e10a5 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -57,13 +57,13 @@ 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 view/create/edit/comment/close/reopen/scan-escape` 和只读 `gh pr list|view`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `missing-binary`、`missing-token`、`egress-failed`、`permission-denied`、`repo-not-found`、`issue-not-found`、`invalid-response`、`unsupported-command` 等失败原因结构化。issue 创建、编辑、评论和关闭使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是 issue 写操作的主路径。 +GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue view/create/edit/comment/close/reopen/scan-escape`、`gh pr list|view`、`gh pr create` 和 `gh pr comment`。该入口默认 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 应用它区分基础设施阻塞和业务/参数失败。issue 创建、编辑、评论和关闭以及 PR 创建和评论使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。`gh pr merge` 仍然不开放。 所有 GitHub Markdown 正文写入必须来自 `--body-file <file>`。不要使用 `gh issue comment --body`、`gh api -f body=...` 或把多行正文直接拼进 shell 参数;这些路径容易把真实换行、反引号和 Markdown 表格污染成字面量 `\n` 或 shell escape。`gh issue` 写命令第一阶段不接受 stdin 正文;需要更新 #20 总看板、#24 指挥简报或创建新 issue/comment 时,先把正文写入 Markdown 文件,再运行 `bun scripts/cli.ts gh issue edit|comment|create ... --body-file <file>`;更新看板和简报应使用 `issue edit` 更新主体,除非明确需要追加评论。提交前或巡检时可用 `gh issue scan-escape --limit N` 只读扫描污染,不自动修复。 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` 只读能力;PR 创建、编辑、评论、合并和状态流转仍是后续范围。普通 worker 不应隐式依赖未实现的 PR 写能力;需要 PR 交付时,prompt 必须明确允许的人工或后续工具路径,并报告未覆盖范围。 +PR 支持本身是 Code Queue 能力的一部分。当前 UniDesk CLI 支持 `gh pr list|view|create|comment`,其中 create 需要显式 `--title`、`--base`、`--head` 和正文来源,comment 需要显式 PR number 和正文来源,且推荐使用 `--body-file`。`pr create --dry-run` 与 `pr comment --dry-run` 只返回 planned operation,不创建 PR、不写评论;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态。`gh pr merge` 仍是后续范围,本阶段没有 `--confirm` 可以启用真实 merge。普通 worker 不应隐式依赖未实现的 PR 合并能力;需要 PR 交付时,prompt 必须明确允许的人工或后续工具路径,并报告未覆盖范围。 ## 监控 diff --git a/scripts/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts new file mode 100644 index 00000000..36090a40 --- /dev/null +++ b/scripts/gh-cli-pr-contract-test.ts @@ -0,0 +1,109 @@ +import { spawnSync } from "node:child_process"; +import { writeFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +type JsonRecord = Record<string, unknown>; + +function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function runCli(args: string[]): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } { + const result = spawnSync("bun", ["scripts/cli.ts", ...args], { + cwd: process.cwd(), + encoding: "utf8", + }); + const stdout = String(result.stdout || ""); + let json: JsonRecord | null = null; + try { + json = JSON.parse(stdout) as JsonRecord; + } catch { + json = null; + } + return { + status: result.status, + stdout, + stderr: String(result.stderr || ""), + json, + }; +} + +function dataOf(response: JsonRecord): JsonRecord { + assertCondition(response.ok === true, "CLI command should succeed", response); + assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "response data should be object", response); + return response.data as JsonRecord; +} + +export function runGhCliPrContract(): JsonRecord { + const help = runCli(["gh", "help"]); + assertCondition(help.status === 0, "gh help should succeed", help.json ?? { stdout: help.stdout }); + const helpData = dataOf(help.json ?? {}); + const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : []; + assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage }); + assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage }); + + const title = "contract pr create"; + const bodyFile = join(tmpdir(), `unidesk-gh-pr-contract-${process.pid}.md`); + writeFileSync(bodyFile, "Line 1\n`code`\n| a | b |\n", "utf8"); + try { + const createDryRun = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--draft", "--dry-run"]); + assertCondition(createDryRun.status === 0, "pr create dry-run should succeed", createDryRun.json ?? { stdout: createDryRun.stdout }); + const createData = dataOf(createDryRun.json ?? {}); + assertCondition(createData.dryRun === true, "dry-run create must set dryRun=true", createData); + assertCondition(createData.planned === true, "dry-run create must set planned=true", createData); + assertCondition(createData.repo === "pikasTech/unidesk", "dry-run create should preserve repo", createData); + assertCondition(createData.base === "master", "dry-run create should preserve base", createData); + assertCondition(createData.head === "feature/pr-contract", "dry-run create should preserve head", createData); + assertCondition(createData.draft === true, "dry-run create should preserve draft", createData); + assertCondition(Number(createData.bodyChars ?? 0) > 0, "dry-run create should expose bodyChars", createData); + assertCondition(Array.isArray(createData.bodyPreviewLines), "dry-run create should expose bodyPreviewLines", createData); + assertCondition(String(createData.bodyPreview ?? "").includes("`code`"), "dry-run create should preserve backticks in preview", createData); + assertCondition(createData.request && typeof createData.request === "object", "dry-run create should include request plan", createData); + + const commentDryRun = runCli(["gh", "pr", "comment", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile, "--dry-run"]); + assertCondition(commentDryRun.status === 0, "pr comment dry-run should succeed", commentDryRun.json ?? { stdout: commentDryRun.stdout }); + const commentData = dataOf(commentDryRun.json ?? {}); + assertCondition(commentData.dryRun === true, "dry-run comment must set dryRun=true", commentData); + assertCondition(commentData.planned === true, "dry-run comment must set planned=true", commentData); + assertCondition(commentData.issueNumber === 42, "dry-run comment should preserve PR number", commentData); + assertCondition(Number(commentData.bodyChars ?? 0) > 0, "dry-run comment should expose bodyChars", commentData); + + const mergeBlocked = runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk"]); + assertCondition(mergeBlocked.status !== 0, "pr merge should fail", mergeBlocked.json ?? { stdout: mergeBlocked.stdout }); + const mergeData = mergeBlocked.json?.data as JsonRecord | undefined; + assertCondition(String(mergeData?.message ?? "").includes("intentionally unsupported"), "merge block message should be explicit", mergeData ?? {}); + assertCondition(mergeData?.runnerDisposition === "business-failed", "merge block should classify as business-failed", mergeData ?? {}); + + const createMissingBody = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--base", "master", "--head", "feature/pr-contract", "--dry-run"]); + assertCondition(createMissingBody.status !== 0, "pr create without body source should fail", createMissingBody.json ?? { stdout: createMissingBody.stdout }); + const createMissingBodyData = createMissingBody.json?.data as JsonRecord | undefined; + assertCondition(createMissingBodyData?.degradedReason === "validation-failed", "missing body source should be validation-failed", createMissingBodyData ?? {}); + assertCondition(createMissingBodyData?.runnerDisposition === "business-failed", "validation should classify as business-failed", createMissingBodyData ?? {}); + + const unknownOption = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--dry-run", "--bad-option"]); + assertCondition(unknownOption.status !== 0, "unknown gh option should fail", unknownOption.json ?? { stdout: unknownOption.stdout }); + const unknownOptionData = unknownOption.json?.data as JsonRecord | undefined; + assertCondition(unknownOptionData?.degradedReason === "unsupported-command", "unknown option should be unsupported-command", unknownOptionData ?? {}); + assertCondition(unknownOptionData?.runnerDisposition === "business-failed", "unknown option should classify as business-failed", unknownOptionData ?? {}); + } finally { + unlinkSync(bodyFile); + } + + return { + ok: true, + checks: [ + "gh help lists pr create/comment", + "pr create dry-run exposes planned operation", + "pr comment dry-run preserves markdown text", + "pr merge is blocked", + "pr create validation failures are structured", + "unknown gh options are structured", + ], + }; +} + +if (import.meta.main) { + const result = runGhCliPrContract(); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index a400673d..424198cb 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -5,23 +5,33 @@ const DEFAULT_REPO = "pikasTech/unidesk"; const GITHUB_API = "https://api.github.com"; const USER_AGENT = "unidesk-cli-gh"; const PREVIEW_CHARS = 240; +const REQUEST_TIMEOUT_MS = 20_000; type GitHubDegradedReason = | "missing-binary" | "missing-token" + | "auth-failed" | "egress-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"; +type RunnerDisposition = "infra-blocked" | "business-failed"; + interface GitHubCommandResult { ok: boolean; repo: string; command: string; degradedReason?: GitHubDegradedReason; degraded?: GitHubDegradedReason[]; + runnerDisposition?: RunnerDisposition; [key: string]: unknown; } @@ -29,22 +39,37 @@ interface GitHubTokenProbe { present: boolean; source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null; ghFallbackAttempted: boolean; + ghBinaryFound?: boolean; + ghAuthTokenAvailable?: boolean | null; } interface GitHubOptions { repo: string; dryRun: boolean; limit: number; + draft: boolean; title?: string; + body?: string; bodyFile?: string; + base?: string; + head?: string; } interface GitHubErrorPayload { ok: false; degradedReason: GitHubDegradedReason; + runnerDisposition: RunnerDisposition; status?: number; message: string; details?: unknown; + scopes?: { + accepted: string | null; + token: string | null; + }; + request?: { + method: string; + path: string; + }; } interface GitHubIssue { @@ -84,6 +109,29 @@ interface GitHubPullRequest { updated_at?: string; } +interface GitHubRepository { + id?: number; + full_name?: string; + private?: boolean; + default_branch?: string; + permissions?: Record<string, boolean>; +} + +interface GitHubBranch { + name?: string; + commit?: { sha?: string }; +} + +interface GitHubCompare { + status?: string; + ahead_by?: number; + behind_by?: number; + total_commits?: number; + html_url?: string; + base_commit?: { sha?: string }; + merge_base_commit?: { sha?: string }; +} + function optionValue(args: string[], name: string): string | undefined { const index = args.indexOf(name); if (index === -1) return undefined; @@ -104,13 +152,33 @@ function positiveIntegerOption(args: string[], name: string, defaultValue: numbe return Math.min(value, maxValue); } +function validateKnownOptions(args: string[]): void { + const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head"]); + const flagOptions = new Set(["--dry-run", "--draft"]); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg.startsWith("--")) continue; + if (flagOptions.has(arg)) continue; + if (valueOptions.has(arg)) { + index += 1; + continue; + } + throw new Error(`unknown gh option: ${arg}`); + } +} + function parseOptions(args: string[]): GitHubOptions { + validateKnownOptions(args); return { repo: optionValue(args, "--repo") ?? DEFAULT_REPO, dryRun: hasFlag(args, "--dry-run"), limit: positiveIntegerOption(args, "--limit", 30, 100), + draft: hasFlag(args, "--draft"), title: optionValue(args, "--title"), + body: optionValue(args, "--body"), bodyFile: optionValue(args, "--body-file"), + base: optionValue(args, "--base"), + head: optionValue(args, "--head"), }; } @@ -121,12 +189,38 @@ function parseNumber(raw: string | undefined, label: string): number { return value; } +function parseNumberForCommand(repo: string, raw: string | undefined, label: string): number | GitHubCommandResult { + try { + return parseNumber(raw, label); + } catch (error) { + return validationError(label, repo, error instanceof Error ? error.message : String(error)); + } +} + function readBodyFile(path: string | undefined, command: string): string { if (path === undefined) throw new Error(`${command} requires --body-file <file>`); if (!existsSync(path)) throw new Error(`body file not found: ${path}`); return readFileSync(path, "utf8"); } +function readMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } { + if (options.bodyFile !== undefined && options.body !== undefined) throw new Error(`${command} accepts only one body source: --body-file or --body`); + if (options.bodyFile !== undefined) { + const body = readBodyFile(options.bodyFile, command); + return { body, bodySource: { kind: "body-file", path: options.bodyFile } }; + } + if (options.body !== undefined) { + return { + body: options.body, + bodySource: { + kind: "inline", + warning: options.body.includes("\n") ? "inline body contains real newlines; --body-file is safer for generated Markdown" : "inline body is intended only for short single-line text", + }, + }; + } + throw new Error(`${command} requires --body-file <file> or --body <text>`); +} + function tokenFromEnvironment(): GitHubTokenProbe { if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) { return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false }; @@ -162,9 +256,11 @@ function resolveToken(allowGhFallback: boolean): { token: string | null; probe: return { token, probe: envProbe }; } if (!allowGhFallback) return { token: null, probe: envProbe }; + const ghPath = ghBinaryPath(); + if (ghPath === null) return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } }; const token = ghAuthToken(); - if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true } }; - return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true } }; + if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: true } }; + return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } }; } function repoParts(repo: string): { owner: string; name: string } { @@ -191,6 +287,55 @@ function dryRunBody(repo: string, title: string | undefined, body: string): Reco }; } +function runnerDisposition(reason: GitHubDegradedReason): RunnerDisposition { + if (reason === "unsupported-command" || reason === "validation-failed" || reason === "issue-not-found" || reason === "pr-not-found") return "business-failed"; + return "infra-blocked"; +} + +function sanitizedErrorDetails(parsed: unknown): unknown { + if (typeof parsed !== "object" || parsed === null) return parsed; + const source = parsed as Record<string, unknown>; + const details: Record<string, unknown> = {}; + if ("message" in source) details.message = source.message; + if ("documentation_url" in source) details.documentationUrl = source.documentation_url; + if ("status" in source) details.status = source.status; + if (Array.isArray(source.errors)) details.errors = source.errors.slice(0, 5); + if ("raw" in source) details.raw = source.raw; + return details; +} + +function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit<GitHubErrorPayload, "ok" | "degradedReason" | "runnerDisposition" | "message"> = {}): GitHubErrorPayload { + return { + ok: false, + degradedReason: reason, + runnerDisposition: runnerDisposition(reason), + message, + ...extra, + }; +} + +function commandError(command: string, repo: string, error: GitHubErrorPayload, extra: Record<string, unknown> = {}): GitHubCommandResult { + return { + ok: false, + command, + repo, + degradedReason: error.degradedReason, + runnerDisposition: error.runnerDisposition, + details: error, + ...extra, + }; +} + +function validationError(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult { + const error = errorPayload("validation-failed", message, { details: extra }); + return commandError(command, repo, error, extra); +} + +function unsupportedCommand(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult { + const error = errorPayload("unsupported-command", message, { details: extra }); + return commandError(command, repo, error, { message, ...extra }); +} + async function parseGitHubResponse(response: Response): Promise<unknown> { const text = await response.text(); if (text.length === 0) return null; @@ -201,9 +346,22 @@ async function parseGitHubResponse(response: Response): Promise<unknown> { } } -function classifyHttpStatus(status: number, message: string, path: string): GitHubDegradedReason { - if (status === 401 || status === 403) return "permission-denied"; - if (status === 404) return path.includes("/issues/") || message.toLowerCase().includes("issue") ? "issue-not-found" : "repo-not-found"; +function classifyHttpStatus(status: number, message: string, path: string, response: Response): GitHubDegradedReason { + const lower = message.toLowerCase(); + const acceptedScopes = response.headers.get("x-accepted-oauth-scopes"); + if (status === 401) return "auth-failed"; + if (status === 403) { + if (lower.includes("resource not accessible") || lower.includes("insufficient") || lower.includes("scope") || (acceptedScopes !== null && acceptedScopes.length > 0)) return "scope-insufficient"; + if (path.startsWith("/repos/")) return "repo-forbidden"; + return "permission-denied"; + } + if (status === 404) { + if (/\/pulls\/\d+/u.test(path)) return "pr-not-found"; + if (/\/issues\/\d+/u.test(path)) return "issue-not-found"; + if (path.includes("/branches/") || path.includes("/compare/")) return "validation-failed"; + return "repo-not-found"; + } + if (status === 422) return "validation-failed"; return "invalid-response"; } @@ -214,9 +372,12 @@ async function githubRequest<T>( body?: 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}${path}`, { method, + signal: controller.signal, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, @@ -227,35 +388,38 @@ async function githubRequest<T>( body: body === undefined ? undefined : JSON.stringify(body), }); } catch (error) { - return { - ok: false, - degradedReason: "egress-failed", - message: error instanceof Error ? error.message : String(error), - }; + return errorPayload("network-proxy-failed", error instanceof Error ? error.message : String(error), { request: { method, path } }); + } 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 { - ok: false, - degradedReason: classifyHttpStatus(response.status, message, path), + return errorPayload(classifyHttpStatus(response.status, message, path, response), message, { status: response.status, - message, - details: parsed, - }; + details: sanitizedErrorDetails(parsed), + scopes: { + accepted: response.headers.get("x-accepted-oauth-scopes"), + token: response.headers.get("x-oauth-scopes"), + }, + request: { method, path }, + }); } return parsed as T; } function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null { if (!tokenProbe.present) { - return { - ok: false, - command, - repo, - degradedReason: "missing-token", + if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) { + return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe })); + } + if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === true && tokenProbe.ghAuthTokenAvailable === false) { + return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe })); + } + return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: tokenProbe }), { + degraded: ["missing-token"], token: tokenProbe, - }; + }); } return null; } @@ -306,6 +470,238 @@ function prSummary(pr: GitHubPullRequest): Record<string, unknown> { }; } +function repoSummary(repo: GitHubRepository): Record<string, unknown> { + return { + id: repo.id ?? null, + fullName: repo.full_name ?? null, + private: repo.private ?? null, + defaultBranch: repo.default_branch ?? null, + permissions: repo.permissions ?? null, + }; +} + +function branchSummary(branch: GitHubBranch): Record<string, unknown> { + return { + name: branch.name ?? null, + sha: branch.commit?.sha ?? null, + }; +} + +function compareSummary(compare: GitHubCompare): Record<string, unknown> { + return { + status: compare.status ?? null, + aheadBy: compare.ahead_by ?? null, + behindBy: compare.behind_by ?? null, + totalCommits: compare.total_commits ?? null, + htmlUrl: compare.html_url ?? null, + baseSha: compare.base_commit?.sha ?? null, + mergeBaseSha: compare.merge_base_commit?.sha ?? null, + }; +} + +function encodePathPart(value: string): string { + return encodeURIComponent(value); +} + +async function repoInfo(token: string, repo: string): Promise<GitHubRepository | GitHubErrorPayload> { + const { owner, name } = repoParts(repo); + return githubRequest<GitHubRepository>(token, "GET", `/repos/${owner}/${name}`); +} + +async function branchInfo(token: string, repo: string, branch: string): Promise<GitHubBranch | GitHubErrorPayload> { + const { owner, name } = repoParts(repo); + return githubRequest<GitHubBranch>(token, "GET", `/repos/${owner}/${name}/branches/${encodePathPart(branch)}`); +} + +async function compareBranches(token: string, repo: string, base: string, head: string): Promise<GitHubCompare | GitHubErrorPayload> { + const { owner, name } = repoParts(repo); + return githubRequest<GitHubCompare>(token, "GET", `/repos/${owner}/${name}/compare/${encodePathPart(base)}...${encodePathPart(head)}`); +} + +function prCreatePlannedOperation(repo: string, options: GitHubOptions, body: string, bodySource: Record<string, unknown>): Record<string, unknown> { + return { + repo, + title: options.title, + base: options.base, + head: options.head, + draft: options.draft, + bodyChars: body.length, + bodyPreview: preview(body), + bodyPreviewLines: body.split(/\r?\n/).slice(0, 12), + bodySource, + preservesRawNewlines: body.includes("\n"), + containsLiteralBackslashN: body.includes("\\n"), + containsBackticks: body.includes("`"), + request: { + method: "POST", + path: "/repos/{owner}/{repo}/pulls", + body: { + title: options.title, + base: options.base, + head: options.head, + draft: options.draft, + bodyChars: body.length, + }, + }, + validation: { + repo: "required", + base: "required", + head: "required", + title: "required", + body: body.length > 0 ? "optional" : "empty", + }, + }; +} + +function prCommentPlannedOperation(repo: string, issueNumber: number, body: string, bodySource: Record<string, unknown>): Record<string, unknown> { + return { + repo, + issueNumber, + bodyChars: body.length, + bodyPreview: preview(body), + bodyPreviewLines: body.split(/\r?\n/).slice(0, 12), + bodySource, + preservesRawNewlines: body.includes("\n"), + containsLiteralBackslashN: body.includes("\\n"), + containsBackticks: body.includes("`"), + request: { + method: "POST", + path: `/repos/{owner}/{repo}/issues/${issueNumber}/comments`, + body: { bodyChars: body.length }, + }, + validation: { + repo: "required", + issueNumber: "required", + body: "required", + }, + }; +} + +async function prCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> { + if (options.title === undefined) return validationError("pr create", repo, "pr create requires --title <title>"); + if (options.base === undefined) return validationError("pr create", repo, "pr create requires --base <branch>"); + if (options.head === undefined) return validationError("pr create", repo, "pr create requires --head <branch>"); + let bodySource: { body: string; bodySource: Record<string, unknown> }; + try { + bodySource = readMarkdownBody(options, "pr create"); + } catch (error) { + return validationError("pr create", repo, error instanceof Error ? error.message : String(error)); + } + const body = bodySource.body; + const planned = prCreatePlannedOperation(repo, options, body, bodySource.bodySource); + if (options.dryRun) { + return { + ok: true, + command: "pr create", + repo, + dryRun: true, + planned: true, + draft: options.draft, + ...planned, + }; + } + + const repoResult = await repoInfo(token, repo); + if (isGitHubError(repoResult)) return commandError("pr create", repo, repoResult, { planned }); + const baseBranch = await branchInfo(token, repo, options.base); + if (isGitHubError(baseBranch)) return commandError("pr create", repo, baseBranch, { base: options.base, planned }); + const headBranch = await branchInfo(token, repo, options.head); + if (isGitHubError(headBranch)) return commandError("pr create", repo, headBranch, { head: options.head, planned }); + const compare = await compareBranches(token, repo, options.base, options.head); + if (isGitHubError(compare)) return commandError("pr create", repo, compare, { base: options.base, head: options.head, planned }); + const aheadBy = typeof compare.ahead_by === "number" ? compare.ahead_by : null; + if (aheadBy === null || aheadBy <= 0) { + return validationError("pr create", repo, "head branch must be ahead of base branch before creating a PR", { + base: options.base, + head: options.head, + compare: compareSummary(compare), + }); + } + + const { owner, name } = repoParts(repo); + const payload: Record<string, unknown> = { + title: options.title, + base: options.base, + head: options.head, + draft: options.draft, + body, + }; + const pr = await githubRequest<GitHubPullRequest>(token, "POST", `/repos/${owner}/${name}/pulls`, payload); + if (isGitHubError(pr)) return commandError("pr create", repo, pr, { base: options.base, head: options.head, planned }); + return { + ok: true, + command: "pr create", + repo, + pr: prSummary(pr), + validation: { + repo: repoSummary(repoResult), + base: branchSummary(baseBranch), + head: branchSummary(headBranch), + compare: compareSummary(compare), + bodySource: bodySource.bodySource, + }, + request: { + method: "POST", + path: `/repos/${owner}/${name}/pulls`, + title: options.title, + base: options.base, + head: options.head, + draft: options.draft, + bodyChars: body.length, + }, + rest: true, + }; +} + +async function prComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + let bodySource: { body: string; bodySource: Record<string, unknown> }; + try { + bodySource = readMarkdownBody(options, "pr comment"); + } catch (error) { + return validationError("pr comment", repo, error instanceof Error ? error.message : String(error), { issueNumber }); + } + const body = bodySource.body; + const planned = prCommentPlannedOperation(repo, issueNumber, body, bodySource.bodySource); + if (options.dryRun) { + return { + ok: true, + command: "pr comment", + repo, + dryRun: true, + planned: true, + issueNumber, + ...planned, + }; + } + + const repoResult = await repoInfo(token, repo); + if (isGitHubError(repoResult)) return commandError("pr comment", repo, repoResult, { issueNumber, planned }); + const { owner, name } = repoParts(repo); + const prResult = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${issueNumber}`); + if (isGitHubError(prResult)) return commandError("pr comment", repo, prResult, { issueNumber, planned }); + const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body }); + if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned }); + return { + ok: true, + command: "pr comment", + repo, + issueNumber, + pr: prSummary(prResult), + comment: commentSummary(comment), + request: { + method: "POST", + path: `/repos/${owner}/${name}/issues/${issueNumber}/comments`, + bodyChars: body.length, + }, + validation: { + repo: repoSummary(repoResult), + pr: prSummary(prResult), + bodySource: bodySource.bodySource, + }, + rest: true, + }; +} + async function listIssueComments(token: string, repo: string, issueNumber: number): Promise<GitHubComment[] | GitHubErrorPayload> { const { owner, name } = repoParts(repo); return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`); @@ -314,9 +710,9 @@ async function listIssueComments(token: string, repo: string, issueNumber: numbe async function issueView(repo: string, token: string, issueNumber: number): Promise<GitHubCommandResult> { const { owner, name } = repoParts(repo); const issue = await githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`); - if (isGitHubError(issue)) return { ok: false, command: "issue view", repo, degradedReason: issue.degradedReason, details: issue }; + if (isGitHubError(issue)) return commandError("issue view", repo, issue, { issueNumber }); const comments = await listIssueComments(token, repo, issueNumber); - if (isGitHubError(comments)) return { ok: false, command: "issue view", repo, degradedReason: comments.degradedReason, issue: issueSummary(issue), details: comments }; + if (isGitHubError(comments)) return commandError("issue view", repo, comments, { issueNumber, issue: issueSummary(issue) }); return { ok: true, command: "issue view", @@ -332,7 +728,7 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions): if (options.dryRun) return { ok: true, command: "issue create", repo, dryRun: true, ...dryRunBody(repo, options.title, body) }; const { owner, name } = repoParts(repo); const issue = await githubRequest<GitHubIssue>(token, "POST", `/repos/${owner}/${name}/issues`, { title: options.title, body }); - if (isGitHubError(issue)) return { ok: false, command: "issue create", repo, degradedReason: issue.degradedReason, details: issue }; + if (isGitHubError(issue)) return commandError("issue create", repo, issue); return { ok: true, command: "issue create", repo, issue: issueSummary(issue), rest: true }; } @@ -353,7 +749,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio const payload: Record<string, unknown> = { body }; if (options.title !== undefined) payload.title = options.title; const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload); - if (isGitHubError(issue)) return { ok: false, command: "issue edit", repo, degradedReason: issue.degradedReason, details: issue }; + if (isGitHubError(issue)) return commandError("issue edit", repo, issue, { issueNumber }); return { ok: true, command: "issue edit", repo, issue: issueSummary(issue), rest: true }; } @@ -362,7 +758,7 @@ async function issueComment(repo: string, token: string, issueNumber: number, op if (options.dryRun) return { ok: true, command: "issue comment", repo, dryRun: true, issueNumber, ...dryRunBody(repo, undefined, body) }; const { owner, name } = repoParts(repo); const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body }); - if (isGitHubError(comment)) return { ok: false, command: "issue comment", repo, degradedReason: comment.degradedReason, details: comment }; + if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber }); return { ok: true, command: "issue comment", repo, comment: commentSummary(comment), rest: true }; } @@ -370,7 +766,7 @@ async function issueState(repo: string, token: string, issueNumber: number, stat if (dryRun) return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", dryRun: true, repo, issueNumber, wouldPatch: { state } }; const { owner, name } = repoParts(repo); const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state }); - if (isGitHubError(issue)) return { ok: false, command: state === "closed" ? "issue close" : "issue reopen", repo, degradedReason: issue.degradedReason, details: issue }; + if (isGitHubError(issue)) return commandError(state === "closed" ? "issue close" : "issue reopen", repo, issue, { issueNumber }); return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", repo, issue: issueSummary(issue), rest: true }; } @@ -397,7 +793,7 @@ function scanText(text: string, patterns: Array<{ kind: string; pattern: RegExp async function issueScanEscape(repo: string, token: string, limit: number): Promise<GitHubCommandResult> { const { owner, name } = repoParts(repo); const issues = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=all&per_page=${limit}`); - if (isGitHubError(issues)) return { ok: false, command: "issue scan-escape", repo, degradedReason: issues.degradedReason, details: issues }; + if (isGitHubError(issues)) return commandError("issue scan-escape", repo, issues); const patterns = [ { kind: "literal-backslash-n", pattern: /\\n/g }, @@ -413,7 +809,14 @@ async function issueScanEscape(repo: string, token: string, limit: number): Prom } const comments = await listIssueComments(token, repo, issue.number); if (isGitHubError(comments)) { - findings.push({ type: "comment-scan-error", issueNumber: issue.number, url: issue.html_url, degradedReason: comments.degradedReason, details: comments }); + findings.push({ + type: "comment-scan-error", + issueNumber: issue.number, + url: issue.html_url, + degradedReason: comments.degradedReason, + runnerDisposition: comments.runnerDisposition ?? runnerDisposition(comments.degradedReason), + details: comments, + }); continue; } for (const comment of comments) { @@ -438,60 +841,57 @@ async function authStatus(repo: string): Promise<GitHubCommandResult> { const degraded: GitHubDegradedReason[] = []; if (ghPath === null) degraded.push("missing-binary"); if (!probe.present || token === null) { + if (ghPath === null) { + return { + ok: false, + command: "auth status", + repo, + degradedReason: "missing-binary", + degraded, + runnerDisposition: runnerDisposition("missing-binary"), + gh: { binaryFound: false, path: null }, + token: probe, + probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" }, + }; + } degraded.push("missing-token"); - return { - ok: false, - command: "auth status", - repo, - degradedReason: "missing-token", + return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: probe }), { degraded, gh: { binaryFound: ghPath !== null, path: ghPath }, token: probe, probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" }, - }; + }); } const { owner, name } = repoParts(repo); const api = await githubRequest<Record<string, unknown>>(token, "GET", "/rate_limit"); if (isGitHubError(api)) { - return { - ok: false, - command: "auth status", - repo, - degradedReason: api.degradedReason, + return commandError("auth status", repo, api, { degraded: [...degraded, api.degradedReason], gh: { binaryFound: ghPath !== null, path: ghPath }, token: probe, probes: { restApi: api, repo: "skipped", issueRead: "skipped" }, - }; + }); } const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`); if (isGitHubError(repoProbe)) { - return { - ok: false, - command: "auth status", - repo, - degradedReason: repoProbe.degradedReason, + return commandError("auth status", repo, repoProbe, { degraded: [...degraded, repoProbe.degradedReason], gh: { binaryFound: ghPath !== null, path: ghPath }, token: probe, probes: { restApi: "ok", repo: repoProbe, issueRead: "skipped" }, - }; + }); } const issueProbe = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?per_page=1&state=all`); if (isGitHubError(issueProbe)) { - return { - ok: false, - command: "auth status", - repo, - degradedReason: issueProbe.degradedReason, + return commandError("auth status", repo, issueProbe, { degraded: [...degraded, issueProbe.degradedReason], gh: { binaryFound: ghPath !== null, path: ghPath }, token: probe, probes: { restApi: "ok", repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: issueProbe }, - }; + }); } return { @@ -513,12 +913,12 @@ async function authStatus(repo: string): Promise<GitHubCommandResult> { async function prList(repo: string, token: string, limit: number): 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 { ok: false, command: "pr list", repo, degradedReason: prs.degradedReason, details: prs }; + if (isGitHubError(prs)) return commandError("pr list", repo, prs); return { ok: true, command: "pr list", repo, - plannedScope: "first-stage read-only REST support", + plannedScope: "read-only REST support", pullRequests: prs.map(prSummary), }; } @@ -526,12 +926,12 @@ async function prList(repo: string, token: string, limit: number): Promise<GitHu async function prView(repo: string, token: string, number: number): Promise<GitHubCommandResult> { const { owner, name } = repoParts(repo); const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); - if (isGitHubError(pr)) return { ok: false, command: "pr view", repo, degradedReason: pr.degradedReason, details: pr }; + if (isGitHubError(pr)) return commandError("pr view", repo, pr, { number }); return { ok: true, command: "pr view", repo, - plannedScope: "first-stage read-only REST support", + plannedScope: "read-only REST support", pullRequest: prSummary(pr), }; } @@ -550,14 +950,16 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N]", "bun scripts/cli.ts gh pr list [--repo owner/name] [--limit N]", "bun scripts/cli.ts gh pr view <number> [--repo owner/name]", + "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 comment <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]", ], defaults: { repo: DEFAULT_REPO }, notes: [ "Issue create/edit/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.", "Token values are never printed; auth status reports only token source and presence.", - "--body-file is required for mutating Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.", - "Issue body stdin is intentionally unsupported in the first phase; write generated Markdown to a file and pass --body-file.", - "PR support is first-stage read-only list/view; create/edit/merge are planned and intentionally unsupported.", + "--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.", + "Issue body stdin is intentionally unsupported in this CLI; write generated Markdown to a file and pass --body-file.", + "PR create/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.", ], }; } @@ -565,7 +967,17 @@ export function ghHelp(): unknown { export async function runGhCommand(args: string[]): Promise<GitHubCommandResult | unknown> { const [top, sub, third] = args; if (top === undefined || top === "help" || top === "--help" || top === "-h") return ghHelp(); - const options = parseOptions(args); + let options: GitHubOptions; + try { + options = parseOptions(args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + const repo = optionValue(args, "--repo") ?? DEFAULT_REPO; + return message.startsWith("unknown gh option:") + ? unsupportedCommand(command, repo, message) + : validationError(command, repo, message); + } if (top === "auth" && sub === "status") return authStatus(options.repo); @@ -591,15 +1003,31 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult } if (top === "pr") { + if (sub === "create") { + if (options.dryRun) return prCreate(options.repo, "", options); + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, "pr create", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr create", { present: false, source: null, ghFallbackAttempted: true }); + return prCreate(options.repo, token, options); + } + if (sub === "comment") { + if (options.dryRun) { + const number = parseNumberForCommand(options.repo, third, "pr comment"); + if (typeof number !== "number") return number; + return prComment(options.repo, "", number, options); + } + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, "pr comment", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment", { present: false, source: null, ghFallbackAttempted: true }); + const number = parseNumberForCommand(options.repo, third, "pr comment"); + if (typeof number !== "number") return number; + return prComment(options.repo, token, number, options); + } + if (sub === "merge") { + return unsupportedCommand("pr merge", options.repo, "PR merge is intentionally unsupported in this phase; use create/comment/read only."); + } if (sub !== "list" && sub !== "view") { - return { - ok: false, - command: `pr ${sub ?? ""}`.trim(), - repo: options.repo, - degradedReason: "unsupported-command", - planned: true, - message: "PR create/edit/comment/merge are planned for a later phase; first-stage support is read-only pr list/view.", - }; + return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, view, create, comment, and unsupported merge."); } const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, `pr ${sub}`, probe); @@ -608,11 +1036,5 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult return prView(options.repo, token, parseNumber(third, "pr view")); } - return { - ok: false, - command: args.join(" ") || "gh", - repo: options.repo, - degradedReason: "unsupported-command", - help: ghHelp(), - }; + return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() }); } diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 5f021cf5..1df432f4 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -43,7 +43,7 @@ export function rootHelp(): unknown { { command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest; --env reads origin/master:deploy.json environments and applies supported dev target-side rollouts or reviewed D601 registry artifact consumers. code-queue artifact consumption is dev-only." }, { 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: "gh auth|issue|pr", description: "Run safe GitHub issue operations through REST with body-file support, token diagnostics, escape scanning, and first-stage read-only PR list/view." }, + { command: "gh auth|issue|pr", description: "Run safe GitHub issue and PR list/view/create/comment operations through REST with body-file support, token diagnostics, escape scanning, and merge blocked." }, { command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." }, { command: "schedule list|get|runs|run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N." }, { command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },