From ca746777036bdc4b01595b2c14ae62be6c222417 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 08:32:29 +0000 Subject: [PATCH] feat: add safe GitHub CLI wrapper --- AGENTS.md | 1 + docs/reference/cli.md | 5 + docs/reference/code-queue-supervision.md | 6 +- scripts/cli.ts | 9 + scripts/src/gh.ts | 618 +++++++++++++++++++++++ scripts/src/help.ts | 4 + 6 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 scripts/src/gh.ts diff --git a/AGENTS.md b/AGENTS.md index 0f67781d..ab0ad1fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +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 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/docs/reference/cli.md b/docs/reference/cli.md index 13dfed3a..a52067ec 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -29,6 +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 `,仍不 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 issue view [--repo owner/name]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON。`gh issue create --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`,不能假装支持。 - `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 只轮询并执行已入库任务。 @@ -62,6 +65,8 @@ 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。 + `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 原因。 ## Debug Contract diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 72504b7b..73d403f2 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -57,11 +57,13 @@ issue 内容必须自包含,至少写清楚背景、外部收益、当前观 如果某个 worker 任务需要依赖 GitHub issue 内容,但 runner 的 issue 可达性尚未被单独验证,指挥官不能默认 worker 已能读取该 issue。此时 worker prompt 必须直接内嵌完整需求、约束和验收点,issue URL 只能作为辅助引用。若要把 issue 作为任务输入源,先单独做可达性探测,再决定是否把 issue 作为常规前置条件。 -如果 `gh issue create` 的 GraphQL 路径因为 token scope 或 repository metadata 查询失败,允许使用 GitHub REST API 创建 issue。CLI 或脚本应避免在日志中打印 token;失败原因应归类为 GitHub 凭证/CLI 兼容问题,而不是业务任务失败。 +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 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 能力的一部分。实现前,指挥官可以先把“支持 PR 交付”的需求拆成独立 infra issue 和 task;在能力未完成前,不应让普通 worker 隐式依赖 PR 机制。 +PR 支持本身是 Code Queue 能力的一部分。当前 UniDesk CLI 第一阶段只承诺 `gh pr list|view` 只读能力;PR 创建、编辑、评论、合并和状态流转仍是后续范围。普通 worker 不应隐式依赖未实现的 PR 写能力;需要 PR 交付时,prompt 必须明确允许的人工或后续工具路径,并报告未覆盖范围。 ## 监控 diff --git a/scripts/cli.ts b/scripts/cli.ts index c9d10f46..2388c3c4 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -18,6 +18,7 @@ import { ciHelp, runCiCommand } from "./src/ci"; import { runSwapCommand } from "./src/swap"; import { runDevEnvCommand } from "./src/dev-env"; import { runArtifactRegistryCommand } from "./src/artifact-registry"; +import { runGhCommand } from "./src/gh"; import { isHelpToken, rootHelp, serverHelp, sshHelp, staticNamespaceHelp } from "./src/help"; const remoteOptions = extractRemoteCliOptions(process.argv.slice(2)); @@ -143,6 +144,14 @@ async function main(): Promise<void> { return; } + if (top === "gh") { + const result = await runGhCommand(args.slice(1)); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; + return; + } + const config = readConfig(); if (top === "ssh") { diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts new file mode 100644 index 00000000..a400673d --- /dev/null +++ b/scripts/src/gh.ts @@ -0,0 +1,618 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; + +const DEFAULT_REPO = "pikasTech/unidesk"; +const GITHUB_API = "https://api.github.com"; +const USER_AGENT = "unidesk-cli-gh"; +const PREVIEW_CHARS = 240; + +type GitHubDegradedReason = + | "missing-binary" + | "missing-token" + | "egress-failed" + | "permission-denied" + | "repo-not-found" + | "issue-not-found" + | "invalid-response" + | "unsupported-command"; + +interface GitHubCommandResult { + ok: boolean; + repo: string; + command: string; + degradedReason?: GitHubDegradedReason; + degraded?: GitHubDegradedReason[]; + [key: string]: unknown; +} + +interface GitHubTokenProbe { + present: boolean; + source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null; + ghFallbackAttempted: boolean; +} + +interface GitHubOptions { + repo: string; + dryRun: boolean; + limit: number; + title?: string; + bodyFile?: string; +} + +interface GitHubErrorPayload { + ok: false; + degradedReason: GitHubDegradedReason; + status?: number; + message: string; + details?: unknown; +} + +interface GitHubIssue { + id: number; + number: number; + title: string; + body: string | null; + state: string; + html_url: string; + comments: number; + user?: { login?: string }; + created_at?: string; + updated_at?: string; +} + +interface GitHubComment { + id: number; + body: string | null; + html_url: string; + user?: { login?: string }; + created_at?: string; + updated_at?: string; +} + +interface GitHubPullRequest { + id: number; + number: number; + title: string; + body: string | null; + state: string; + html_url: string; + draft?: boolean; + user?: { login?: string }; + head?: { ref?: string; sha?: string }; + base?: { ref?: string; sha?: string }; + created_at?: string; + updated_at?: string; +} + +function optionValue(args: string[], name: string): string | undefined { + const index = args.indexOf(name); + if (index === -1) return undefined; + const value = args[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function hasFlag(args: string[], name: string): boolean { + return args.includes(name); +} + +function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number { + const raw = optionValue(args, name); + if (raw === undefined) return defaultValue; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); + return Math.min(value, maxValue); +} + +function parseOptions(args: string[]): GitHubOptions { + return { + repo: optionValue(args, "--repo") ?? DEFAULT_REPO, + dryRun: hasFlag(args, "--dry-run"), + limit: positiveIntegerOption(args, "--limit", 30, 100), + title: optionValue(args, "--title"), + bodyFile: optionValue(args, "--body-file"), + }; +} + +function parseNumber(raw: string | undefined, label: string): number { + if (raw === undefined) throw new Error(`${label} requires a number`); + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`); + return value; +} + +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 tokenFromEnvironment(): GitHubTokenProbe { + if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) { + return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false }; + } + if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN.length > 0) { + return { present: true, source: "GITHUB_TOKEN", ghFallbackAttempted: false }; + } + return { present: false, source: null, ghFallbackAttempted: false }; +} + +function ghBinaryPath(): string | null { + try { + const output = execFileSync("sh", ["-lc", "command -v gh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + return output.length > 0 ? output : null; + } catch { + return null; + } +} + +function ghAuthToken(): string | null { + try { + const output = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim(); + return output.length > 0 ? output : null; + } catch { + return null; + } +} + +function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } { + const envProbe = tokenFromEnvironment(); + if (envProbe.present) { + const token = envProbe.source === "GH_TOKEN" ? process.env.GH_TOKEN ?? null : process.env.GITHUB_TOKEN ?? null; + return { token, probe: envProbe }; + } + if (!allowGhFallback) return { token: null, probe: envProbe }; + 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 } }; +} + +function repoParts(repo: string): { owner: string; name: string } { + const [owner, name, extra] = repo.split("/"); + if (!owner || !name || extra !== undefined) throw new Error("--repo must be in owner/name format"); + return { owner, name }; +} + +function preview(text: string): string { + return text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}...` : text; +} + +function dryRunBody(repo: string, title: string | undefined, body: string): Record<string, unknown> { + return { + repo, + ...(title === undefined ? {} : { title }), + bodyChars: body.length, + bodyPreview: preview(body), + bodyPreviewLines: body.split(/\r?\n/).slice(0, 12), + preservesRawNewlines: body.includes("\n"), + containsLiteralBackslashN: body.includes("\\n"), + containsBackticks: body.includes("`"), + containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(body), + }; +} + +async function parseGitHubResponse(response: Response): Promise<unknown> { + const text = await response.text(); + if (text.length === 0) return null; + try { + return JSON.parse(text) as unknown; + } catch { + return { raw: preview(text) }; + } +} + +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"; + return "invalid-response"; +} + +async function githubRequest<T>( + token: string, + method: string, + path: string, + body?: Record<string, unknown>, +): Promise<T | GitHubErrorPayload> { + let response: Response; + try { + response = await fetch(`${GITHUB_API}${path}`, { + method, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + "X-GitHub-Api-Version": "2022-11-28", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (error) { + return { + ok: false, + degradedReason: "egress-failed", + message: error instanceof Error ? error.message : String(error), + }; + } + 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), + status: response.status, + message, + details: parsed, + }; + } + 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", + token: tokenProbe, + }; + } + return null; +} + +function isGitHubError(value: unknown): value is GitHubErrorPayload { + return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === false && "degradedReason" in value; +} + +function issueSummary(issue: GitHubIssue): Record<string, unknown> { + return { + id: issue.id, + number: issue.number, + title: issue.title, + body: issue.body ?? "", + state: issue.state, + url: issue.html_url, + author: issue.user?.login ?? null, + createdAt: issue.created_at ?? null, + updatedAt: issue.updated_at ?? null, + }; +} + +function commentSummary(comment: GitHubComment): Record<string, unknown> { + return { + id: comment.id, + body: comment.body ?? "", + url: comment.html_url, + author: comment.user?.login ?? null, + createdAt: comment.created_at ?? null, + updatedAt: comment.updated_at ?? null, + }; +} + +function prSummary(pr: GitHubPullRequest): Record<string, unknown> { + return { + id: pr.id, + number: pr.number, + title: pr.title, + body: pr.body ?? "", + state: pr.state, + draft: pr.draft ?? false, + url: pr.html_url, + author: pr.user?.login ?? null, + head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null }, + base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null }, + createdAt: pr.created_at ?? null, + updatedAt: pr.updated_at ?? null, + }; +} + +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`); +} + +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 }; + 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 }; + return { + ok: true, + command: "issue view", + repo, + issue: issueSummary(issue), + comments: comments.map(commentSummary), + }; +} + +async function issueCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> { + if (options.title === undefined) throw new Error("issue create requires --title <title>"); + const body = readBodyFile(options.bodyFile, "issue create"); + 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 }; + return { ok: true, command: "issue create", repo, issue: issueSummary(issue), rest: true }; +} + +async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + const body = readBodyFile(options.bodyFile, "issue edit"); + if (options.dryRun) { + return { + ok: true, + command: "issue edit", + repo, + dryRun: true, + issueNumber, + ...dryRunBody(repo, options.title, body), + wouldPatch: { title: options.title ?? null, bodyFromFile: options.bodyFile }, + }; + } + const { owner, name } = repoParts(repo); + 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 }; + return { ok: true, command: "issue edit", repo, issue: issueSummary(issue), rest: true }; +} + +async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + const body = readBodyFile(options.bodyFile, "issue comment"); + 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 }; + return { ok: true, command: "issue comment", repo, comment: commentSummary(comment), rest: true }; +} + +async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> { + 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 }; + return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", repo, issue: issueSummary(issue), rest: true }; +} + +function escapeSnippet(text: string, index: number): string { + const start = Math.max(0, index - 80); + const end = Math.min(text.length, index + 120); + return text.slice(start, end).replace(/\n/g, "\\n"); +} + +function scanText(text: string, patterns: Array<{ kind: string; pattern: RegExp }>): Array<{ kind: string; snippet: string }> { + const findings: Array<{ kind: string; snippet: string }> = []; + for (const item of patterns) { + item.pattern.lastIndex = 0; + let match = item.pattern.exec(text); + while (match !== null) { + findings.push({ kind: item.kind, snippet: escapeSnippet(text, match.index) }); + if (findings.length >= 5) return findings; + match = item.pattern.exec(text); + } + } + return findings; +} + +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 }; + + const patterns = [ + { kind: "literal-backslash-n", pattern: /\\n/g }, + { kind: "literal-backslash-t", pattern: /\\t/g }, + { kind: "shell-escaped-newline", pattern: /\\r\\n|\\012|\\x0a/g }, + { kind: "ansi-escape-literal", pattern: /\\u001b|\\033/g }, + ]; + + const findings: Array<Record<string, unknown>> = []; + for (const issue of issues) { + for (const finding of scanText(issue.body ?? "", patterns)) { + findings.push({ type: "issue", issueNumber: issue.number, id: issue.id, url: issue.html_url, ...finding }); + } + 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 }); + continue; + } + for (const comment of comments) { + for (const finding of scanText(comment.body ?? "", patterns)) { + findings.push({ type: "comment", issueNumber: issue.number, id: comment.id, url: comment.html_url, ...finding }); + } + } + } + return { + ok: true, + command: "issue scan-escape", + repo, + scannedIssues: issues.length, + findings, + note: "Read-only scan; no issue or comment content was modified.", + }; +} + +async function authStatus(repo: string): Promise<GitHubCommandResult> { + const ghPath = ghBinaryPath(); + const { token, probe } = resolveToken(ghPath !== null); + const degraded: GitHubDegradedReason[] = []; + if (ghPath === null) degraded.push("missing-binary"); + if (!probe.present || token === null) { + degraded.push("missing-token"); + return { + ok: false, + command: "auth status", + repo, + degradedReason: "missing-token", + 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, + 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, + 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, + 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 { + ok: true, + command: "auth status", + repo, + degraded, + gh: { binaryFound: ghPath !== null, path: ghPath }, + token: probe, + probes: { + restApi: "ok", + repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, + issueRead: { ok: true, readable: true, sampleCount: issueProbe.length }, + }, + restFallback: true, + }; +} + +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 }; + return { + ok: true, + command: "pr list", + repo, + plannedScope: "first-stage read-only REST support", + pullRequests: prs.map(prSummary), + }; +} + +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 }; + return { + ok: true, + command: "pr view", + repo, + plannedScope: "first-stage read-only REST support", + pullRequest: prSummary(pr), + }; +} + +export function ghHelp(): unknown { + return { + command: "gh", + output: "json", + usage: [ + "bun scripts/cli.ts gh auth status [--repo owner/name]", + "bun scripts/cli.ts gh issue view <number> [--repo owner/name]", + "bun scripts/cli.ts gh issue create --title <title> --body-file <file> [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh issue edit <number> --body-file <file> [--title title] [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh issue comment <number> --body-file <file> [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--dry-run]", + "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]", + ], + 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.", + ], + }; +} + +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); + + if (top === "auth" && sub === "status") return authStatus(options.repo); + + if (top === "issue") { + if (options.dryRun) { + if (sub === "create") return issueCreate(options.repo, "", options); + if (sub === "edit") return issueEdit(options.repo, "", parseNumber(third, "issue edit"), options); + if (sub === "comment") return issueComment(options.repo, "", parseNumber(third, "issue comment"), options); + if (sub === "close") return issueState(options.repo, "", parseNumber(third, "issue close"), "closed", true); + if (sub === "reopen") return issueState(options.repo, "", parseNumber(third, "issue reopen"), "open", true); + } + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, `issue ${sub ?? ""}`.trim(), { present: false, source: null, ghFallbackAttempted: true }); + + if (sub === "view") return issueView(options.repo, token, parseNumber(third, "issue view")); + if (sub === "create") return issueCreate(options.repo, token, options); + if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options); + if (sub === "comment") return issueComment(options.repo, token, parseNumber(third, "issue comment"), options); + if (sub === "close") return issueState(options.repo, token, parseNumber(third, "issue close"), "closed", options.dryRun); + if (sub === "reopen") return issueState(options.repo, token, parseNumber(third, "issue reopen"), "open", options.dryRun); + if (sub === "scan-escape") return issueScanEscape(options.repo, token, options.limit); + } + + if (top === "pr") { + 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.", + }; + } + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, `pr ${sub}`, probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); + if (sub === "list") return prList(options.repo, token, options.limit); + return prView(options.repo, token, parseNumber(third, "pr view")); + } + + return { + ok: false, + command: args.join(" ") || "gh", + repo: options.repo, + degradedReason: "unsupported-command", + help: ghHelp(), + }; +} diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 135083f4..cc700c19 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -1,3 +1,5 @@ +import { ghHelp } from "./gh"; + export function rootHelp(): unknown { return { entry: "bun scripts/cli.ts", @@ -41,6 +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: "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." }, @@ -332,5 +335,6 @@ export function staticNamespaceHelp(args: string[]): unknown | null { if (top === "e2e") return e2eHelp(); if (top === "dev-env") return devEnvHelp(); if (top === "artifact-registry") return artifactRegistryHelp(); + if (top === "gh") return ghHelp(); return null; }