feat: add safe gh pr create/comment

This commit is contained in:
Codex
2026-05-20 09:56:21 +00:00
parent 4fdf31a67e
commit 2bd1b0a1d6
7 changed files with 619 additions and 84 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json|--env dev|prod] [--service <id>]`:按根目录 `deploy.json``origin/master:deploy.json#environments.<env>` 的服务 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 e2ecatalog/producer/consumer 分工见 `docs/reference/cicd-standardization.md``run-dev-e2e` 的 Git 控制 runner、短 launcher 和 no-CD 边界见 `docs/reference/dev-ci-runner.md`Tekton 规则见 `docs/reference/ci.md`
- `bun scripts/cli.ts codex deploy <commitId>`:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`
- `bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue <id>]`:通过 backend-core 私有代理提交 Code Queue 任务;控制面默认走主 server `code-queue-mgr` 写入 PostgreSQL`--dry-run` 可只检查请求体不入队,规则见 `docs/reference/cli.md`
+4
View File
@@ -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 Servicehealth 返回 `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 <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。
+3 -3
View File
@@ -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 guardcore manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/ServiceCode 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`,由它写入主 PostgreSQLD601 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/maxprovider-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 原因。
+2 -2
View File
@@ -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 必须明确允许的人工或后续工具路径,并报告未覆盖范围。
## 监控
+109
View File
@@ -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`);
}
+499 -77
View File
@@ -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() });
}
+1 -1
View File
@@ -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." },