diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 245f3920..48173d3b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -30,8 +30,9 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `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`、`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 [--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 edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是指挥简报 #24 的通知入口。正式执行会先读取 GitHub 上 #24 旧正文,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文。带通知 flag 的 `--dry-run` 不 PATCH、不发送,也不要求 GitHub 凭证;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。dry-run 输出包含将要 patch 的 issue、body 字符数、是否检测到新增 brief diff、待发送文本预览、字符数、目标类型和脱敏目标信息。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`,可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID` 和 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 覆盖。 +- `gh issue view <number> [--repo owner/name] [--json body,title,state,comments]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`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。 +- `gh issue edit <number> --body-file <file>` 默认拒绝字面量 `null`、空白正文和过短正文;只有真实需要写短正文时才允许显式加 `--allow-short-body`,返回 JSON 会报告该风险。#20 总看板和 #24 指挥简报是长期 body-only issue,`--body-profile auto` 会按 issue number 自动启用结构 guard:#20 必须包含 `## 看板(OPEN)`,#24 必须包含 `## 常驻观察与长期建议`;也可显式使用 `--body-profile code-queue-board|commander-brief`。`--dry-run` 不 PATCH GitHub,输出新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格和 shell 污染信号;若环境里有 `GH_TOKEN` 或 `GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入可带 `--expect-updated-at <updated_at>` 或 `--expect-body-sha <sha256>`,CLI 会先读当前 issue,匹配后才 PATCH,防止旧缓存覆盖新正文。 +- `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是指挥简报 #24 的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`,可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID` 和 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 覆盖。 - `gh issue 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`。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 818804b5..92f8743c 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -57,11 +57,11 @@ 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`、`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 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 应用它区分基础设施阻塞和业务/参数失败。`gh issue view <number> --json body` 是兼容入口,正文仍应从 `.data.issue.body` 读取;未知 `--json` 字段必须失败,不得让调用方把空正文误判为读取成功。issue 创建、编辑、评论和关闭以及 PR 创建和评论使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。`gh pr merge` 仍然不开放。 CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不好用、兼容性不足、安全 guard 不够或输出不利于 runner/指挥官使用时,应默认创建 GitHub issue 并用 Code Queue 推进;这类 CLI 问题走 `master`、remote commit、轻量 contract test 和文档更新,不套用 backend-core、Code Queue runtime 这类运行态服务的重部署门禁。若 CLI 缺陷已经阻塞当前指挥,可以先做最小安全绕行,同时把长期修复写入 issue 并派单。 -所有 GitHub Markdown 正文写入必须来自 `--body-file <file>`。不要使用 `gh issue comment --body`、`gh api -f body=...` 或把多行正文直接拼进 shell 参数;这些路径容易把真实换行、反引号和 Markdown 表格污染成字面量 `\n` 或 shell escape。`gh issue` 写命令第一阶段不接受 stdin 正文;需要更新 #20 总看板或创建新 issue/comment 时,先把正文写入 Markdown 文件,再运行 `bun scripts/cli.ts gh issue edit|comment|create ... --body-file <file>`。更新 #24 指挥简报主体时使用 `bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff`,命令会先读取旧正文并只把本次新增的 `## 更新 ... 北京时间` 时间线段落推送给 ClaudeQQ;头部“常驻观察与长期建议”等非时间线修改不会单独通知。发送失败只体现在返回 JSON 的 `claudeqq.ok=false`,不回滚已经写入的 GitHub issue。提交前或巡检时可用 `gh issue scan-escape --limit N` 只读扫描污染,不自动修复。 +所有 GitHub Markdown 正文写入必须来自 `--body-file <file>`。不要使用 `gh issue comment --body`、`gh api -f body=...` 或把多行正文直接拼进 shell 参数;这些路径容易把真实换行、反引号和 Markdown 表格污染成字面量 `\n` 或 shell escape。`gh issue` 写命令第一阶段不接受 stdin 正文;需要更新 #20 总看板或创建新 issue/comment 时,先把正文写入 Markdown 文件,再运行 `bun scripts/cli.ts gh issue edit|comment|create ... --body-file <file>`。`gh issue edit --body-file` 默认拒绝 `null`、空白和过短正文;#20 自动要求 `## 看板(OPEN)`,#24 自动要求 `## 常驻观察与长期建议`。更新 body-only issue 前优先跑 `--dry-run`,查看旧/新正文长度、body SHA、关键标题、字面量 `\n` 和 shell 污染信号;正式写入长期正文时优先带上 `--expect-updated-at` 或 `--expect-body-sha`,避免旧缓存覆盖新正文。更新 #24 指挥简报主体时使用 `bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff`,命令会先读取旧正文并只把本次新增的 `## 更新 ... 北京时间` 时间线段落推送给 ClaudeQQ;头部“常驻观察与长期建议”等非时间线修改不会单独通知。发送失败只体现在返回 JSON 的 `claudeqq.ok=false`,不回滚已经写入的 GitHub issue。提交前或巡检时可用 `gh issue scan-escape --limit N` 只读扫描污染,不自动修复。 PR 是审查型交付入口,不是所有 Code Queue 任务的默认出口。默认 master-only 交付仍按项目 Git 规则执行;当变更风险高、跨模块、需要人工审查、或任务目标明确要求 PR 交付时,worker 可以创建 PR。PR 型任务必须报告源分支、目标分支、PR URL、关联 issue、测试证据和未完成风险。禁止把 PR 当成隐藏分支仓库;PR 分支必须来自最新目标线,保持小而可审查,并在合并后确认目标分支远端 commit 可 fetch。 diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts new file mode 100644 index 00000000..3de4005d --- /dev/null +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -0,0 +1,234 @@ +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; + +type JsonRecord = Record<string, unknown>; + +interface MockRequest { + method: string; + url: string; + body: string; +} + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function runCli(args: string[], env: Record<string, string> = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> { + return new Promise((resolve, reject) => { + const child = spawn("bun", ["scripts/cli.ts", ...args], { + cwd: process.cwd(), + env: { ...process.env, ...env }, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on("error", reject); + child.on("close", (status) => { + const stdout = Buffer.concat(stdoutChunks).toString("utf8"); + let json: JsonRecord | null = null; + try { + json = JSON.parse(stdout) as JsonRecord; + } catch { + json = null; + } + resolve({ + status, + stdout, + stderr: Buffer.concat(stderrChunks).toString("utf8"), + 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; +} + +function failedDataOf(response: JsonRecord): JsonRecord { + assertCondition(response.ok === false, "CLI command should fail", response); + assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "failure data should be object", response); + return response.data as JsonRecord; +} + +function collectBody(req: IncomingMessage): Promise<string> { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); +} + +function sendJson(res: ServerResponse, status: number, payload: unknown): void { + res.statusCode = status; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(payload)); +} + +async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise<void> }> { + const requests: MockRequest[] = []; + const issue = { + id: 2000, + number: 20, + title: "长期总看板", + body: "# Code Queue\n\n## 看板(OPEN)\n\n- old item\n", + state: "open", + html_url: "https://github.com/pikasTech/unidesk/issues/20", + comments: 1, + user: { login: "tester" }, + created_at: "2026-05-20T00:00:00Z", + updated_at: "2026-05-20T01:00:00Z", + }; + const comments = [ + { + id: 1, + body: "comment body", + html_url: "https://github.com/pikasTech/unidesk/issues/20#issuecomment-1", + user: { login: "tester" }, + created_at: "2026-05-20T00:30:00Z", + updated_at: "2026-05-20T00:30:00Z", + }, + ]; + const server = createServer(async (req, res) => { + const body = await collectBody(req); + requests.push({ method: req.method ?? "", url: req.url ?? "", body }); + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/20") { + sendJson(res, 200, issue); + return; + } + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/20/comments?per_page=100") { + sendJson(res, 200, comments); + return; + } + if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/20") { + const parsed = JSON.parse(body) as JsonRecord; + sendJson(res, 200, { ...issue, body: String(parsed.body ?? issue.body), updated_at: "2026-05-20T01:05:00Z" }); + return; + } + sendJson(res, 404, { message: "not found" }); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assertCondition(typeof address === "object" && address !== null, "mock server should expose address"); + const port = (address as AddressInfo).port; + assertCondition(typeof port === "number", "mock server should expose port"); + return { + baseUrl: `http://127.0.0.1:${port}`, + requests, + close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), + }; +} + +export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { + const mock = await startMockGitHub(); + const tmp = mkdtempSync(join(tmpdir(), "unidesk-gh-issue-guard-")); + const env = { + GH_TOKEN: "contract-token", + UNIDESK_GITHUB_API_URL: mock.baseUrl, + }; + try { + const viewBody = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env); + assertCondition(viewBody.status === 0, "issue view --json body should succeed", viewBody.json ?? { stdout: viewBody.stdout }); + const viewBodyData = dataOf(viewBody.json ?? {}); + const issue = viewBodyData.issue as JsonRecord; + assertCondition(typeof issue.body === "string" && issue.body.includes("## 看板(OPEN)"), ".data.issue.body should remain readable", viewBodyData); + const selectedJson = viewBodyData.json as JsonRecord; + assertCondition(typeof selectedJson.body === "string" && selectedJson.body === issue.body, "selected json body should match issue body", viewBodyData); + assertCondition(!("comments" in selectedJson), "--json body should not imply comments field", selectedJson); + + const viewFields = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body,title,state,comments"], env); + assertCondition(viewFields.status === 0, "common --json field selection should succeed", viewFields.json ?? { stdout: viewFields.stdout }); + const viewFieldsData = dataOf(viewFields.json ?? {}); + const fieldsJson = viewFieldsData.json as JsonRecord; + assertCondition(fieldsJson.title === "长期总看板", "selected json title should be exposed", fieldsJson); + assertCondition(Array.isArray(fieldsJson.comments) && fieldsJson.comments.length === 1, "selected json comments should be exposed", fieldsJson); + + const unsupported = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body,unknown"], env); + assertCondition(unsupported.status !== 0, "unsupported --json field should fail", unsupported.json ?? { stdout: unsupported.stdout }); + const unsupportedData = failedDataOf(unsupported.json ?? {}); + assertCondition(unsupportedData.degradedReason === "validation-failed", "unsupported --json should be validation-failed", unsupportedData); + assertCondition(unsupportedData.runnerDisposition === "business-failed", "unsupported --json should be business-failed", unsupportedData); + + const nullFile = join(tmp, "null.md"); + writeFileSync(nullFile, "null\n", "utf8"); + const nullEdit = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", nullFile, "--dry-run"], env); + assertCondition(nullEdit.status !== 0, "issue edit should reject literal null body", nullEdit.json ?? { stdout: nullEdit.stdout }); + const nullData = failedDataOf(nullEdit.json ?? {}); + assertCondition(nullData.degradedReason === "validation-failed", "null body should be validation-failed", nullData); + const nullGuard = nullData.guard as JsonRecord; + assertCondition(Array.isArray(nullGuard.failures) && nullGuard.failures.includes("literal-null-body"), "null guard should report literal-null-body", nullGuard); + + const missingHeadingFile = join(tmp, "missing-heading.md"); + writeFileSync(missingHeadingFile, "# Board\n\n## Closed\n\nThis is long enough to pass length only.\n", "utf8"); + const profileBlocked = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", missingHeadingFile, "--dry-run"], env); + assertCondition(profileBlocked.status !== 0, "#20 missing heading should fail", profileBlocked.json ?? { stdout: profileBlocked.stdout }); + const profileData = failedDataOf(profileBlocked.json ?? {}); + const profileGuard = profileData.guard as JsonRecord; + assertCondition(Array.isArray(profileGuard.failures) && profileGuard.failures.includes("profile-heading-missing"), "#20 guard should report missing heading", profileGuard); + + const commanderBriefBlocked = await runCli(["gh", "issue", "edit", "24", "--repo", "pikasTech/unidesk", "--body-file", missingHeadingFile, "--dry-run"], env); + assertCondition(commanderBriefBlocked.status !== 0, "#24 missing heading should fail", commanderBriefBlocked.json ?? { stdout: commanderBriefBlocked.stdout }); + const commanderBriefData = failedDataOf(commanderBriefBlocked.json ?? {}); + const commanderBriefGuard = commanderBriefData.guard as JsonRecord; + assertCondition(Array.isArray(commanderBriefGuard.failures) && commanderBriefGuard.failures.includes("profile-heading-missing"), "#24 guard should report missing heading", commanderBriefGuard); + + const briefWrongProfile = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", missingHeadingFile, "--body-profile", "commander-brief", "--dry-run"], env); + assertCondition(briefWrongProfile.status !== 0, "wrong explicit body profile should fail", briefWrongProfile.json ?? { stdout: briefWrongProfile.stdout }); + const briefWrongData = failedDataOf(briefWrongProfile.json ?? {}); + const briefWrongGuard = briefWrongData.guard as JsonRecord; + assertCondition(Array.isArray(briefWrongGuard.failures) && briefWrongGuard.failures.includes("profile-issue-mismatch"), "explicit profile should check issue number", briefWrongGuard); + + const safeFile = join(tmp, "safe.md"); + writeFileSync(safeFile, "# Code Queue\n\n## 看板(OPEN)\n\n- multiline Markdown keeps `code` intact.\n- real newline follows.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n", "utf8"); + const requestCountBeforeDryRun = mock.requests.length; + const safeDryRun = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", safeFile, "--dry-run"], env); + assertCondition(safeDryRun.status === 0, "safe issue edit dry-run should succeed", safeDryRun.json ?? { stdout: safeDryRun.stdout }); + const dryRunPatchCount = mock.requests.slice(requestCountBeforeDryRun).filter((request) => request.method === "PATCH").length; + assertCondition(dryRunPatchCount === 0, "dry-run must not PATCH GitHub", { requests: mock.requests.slice(requestCountBeforeDryRun) }); + const safeDryRunData = dataOf(safeDryRun.json ?? {}); + assertCondition(safeDryRunData.dryRun === true, "dry-run should set dryRun=true", safeDryRunData); + assertCondition(safeDryRunData.containsBackticks === true, "dry-run should preserve backtick signal", safeDryRunData); + assertCondition(safeDryRunData.containsLiteralBackslashN === false, "real newlines must not become literal backslash-n", safeDryRunData); + assertCondition(safeDryRunData.containsMarkdownTable === true, "dry-run should detect markdown table", safeDryRunData); + const bodyOnlySafety = safeDryRunData.bodyOnlySafety as JsonRecord; + const oldBody = bodyOnlySafety.oldBody as JsonRecord; + assertCondition(oldBody.fetched === true && Number(oldBody.bodyChars ?? 0) > 0, "dry-run should report old body length when token is available", bodyOnlySafety); + + const requestCountBeforePatch = mock.requests.length; + const staleEdit = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", safeFile, "--expect-updated-at", "2026-05-20T00:59:00Z"], env); + assertCondition(staleEdit.status !== 0, "stale expect-updated-at should fail", staleEdit.json ?? { stdout: staleEdit.stdout }); + const stalePatchCount = mock.requests.slice(requestCountBeforePatch).filter((request) => request.method === "PATCH").length; + assertCondition(stalePatchCount === 0, "stale concurrency guard must not PATCH", { requests: mock.requests.slice(requestCountBeforePatch) }); + const staleData = failedDataOf(staleEdit.json ?? {}); + assertCondition(staleData.degradedReason === "validation-failed", "stale guard should be validation-failed", staleData); + + return { + ok: true, + checks: [ + "issue view --json body preserves .data.issue.body", + "issue view supports body,title,state,comments selection", + "unsupported --json fields fail structurally", + "issue edit --body-file rejects literal null", + "#20/#24 body profile guards reject missing headings or wrong profile", + "dry-run reports old/new body safety and does not PATCH", + "multiline Markdown and backticks are not polluted", + "expect-updated-at stale write protection blocks PATCH", + ], + }; + } finally { + rmSync(tmp, { recursive: true, force: true }); + await mock.close(); + } +} + +if (import.meta.main) { + const result = await runGhCliIssueGuardContract(); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index f8154e9f..b0204440 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -1,15 +1,35 @@ +import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { coreInternalFetch } from "./microservices"; const DEFAULT_REPO = "pikasTech/unidesk"; -const GITHUB_API = "https://api.github.com"; +const GITHUB_API = (process.env.UNIDESK_GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/u, ""); const USER_AGENT = "unidesk-cli-gh"; const PREVIEW_CHARS = 240; const REQUEST_TIMEOUT_MS = 20_000; +const MIN_SAFE_ISSUE_BODY_CHARS = 20; const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL = "http://backend-core:8080/api/microservices/claudeqq/proxy"; const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID = "645275593"; +const CODE_QUEUE_BOARD_TARGET_ISSUE = 20; const COMMANDER_BRIEF_TARGET_ISSUE = 24; +const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const; +const ISSUE_BODY_PROFILES = { + "code-queue-board": { + label: "Code Queue long board issue #20", + issueNumber: CODE_QUEUE_BOARD_TARGET_ISSUE, + requiredHeadings: ["## 看板(OPEN)"], + }, + "commander-brief": { + label: "Commander brief body-only issue #24", + issueNumber: COMMANDER_BRIEF_TARGET_ISSUE, + requiredHeadings: ["## 常驻观察与长期建议"], + }, +} as const; + +type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number]; +type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES; +type IssueBodyProfileOption = "auto" | IssueBodyProfileName; type GitHubDegradedReason = | "missing-binary" @@ -102,11 +122,16 @@ interface GitHubOptions { limit: number; draft: boolean; notifyClaudeQqBriefDiff: boolean; + allowShortBody: boolean; title?: string; body?: string; bodyFile?: string; base?: string; head?: string; + jsonFields?: IssueViewJsonField[]; + expectUpdatedAt?: string; + expectBodySha?: string; + bodyProfile: IssueBodyProfileOption; } interface GitHubErrorPayload { @@ -198,6 +223,18 @@ function hasFlag(args: string[], name: string): boolean { return args.includes(name); } +function optionWasProvided(args: string[], name: string): boolean { + return args.includes(name); +} + +function commaListOption(args: string[], name: string): string[] | undefined { + const raw = optionValue(args, name); + if (raw === undefined) return undefined; + const values = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0); + if (values.length === 0) throw new Error(`${name} requires at least one comma-separated field`); + return values; +} + function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number { const raw = optionValue(args, name); if (raw === undefined) return defaultValue; @@ -206,9 +243,26 @@ function positiveIntegerOption(args: string[], name: string, defaultValue: numbe return Math.min(value, maxValue); } +function parseIssueViewJsonFields(args: string[]): IssueViewJsonField[] | undefined { + const requested = commaListOption(args, "--json"); + if (requested === undefined) return undefined; + const allowed = new Set<string>(ISSUE_VIEW_JSON_FIELDS); + const unsupported = requested.filter((field) => !allowed.has(field)); + if (unsupported.length > 0) { + throw new Error(`unsupported gh issue view --json field(s): ${unsupported.join(", ")}; supported fields: ${ISSUE_VIEW_JSON_FIELDS.join(",")}`); + } + return requested as IssueViewJsonField[]; +} + +function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption { + const raw = optionValue(args, "--body-profile") ?? "auto"; + if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw; + throw new Error(`unsupported --body-profile ${raw}; supported profiles: auto, code-queue-board, commander-brief`); +} + function validateKnownOptions(args: string[]): void { - const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head"]); - const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff"]); + const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head", "--json", "--expect-updated-at", "--expect-body-sha", "--body-profile"]); + const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (!arg.startsWith("--")) continue; @@ -229,11 +283,16 @@ function parseOptions(args: string[]): GitHubOptions { limit: positiveIntegerOption(args, "--limit", 30, 100), draft: hasFlag(args, "--draft"), notifyClaudeQqBriefDiff: hasFlag(args, "--notify-claudeqq-brief-diff"), + allowShortBody: hasFlag(args, "--allow-short-body"), title: optionValue(args, "--title"), body: optionValue(args, "--body"), bodyFile: optionValue(args, "--body-file"), base: optionValue(args, "--base"), head: optionValue(args, "--head"), + jsonFields: parseIssueViewJsonFields(args), + expectUpdatedAt: optionValue(args, "--expect-updated-at"), + expectBodySha: optionValue(args, "--expect-body-sha"), + bodyProfile: parseIssueBodyProfile(args), }; } @@ -332,17 +391,51 @@ function previewLines(text: string, maxLines = 12): string[] { return text.split(/\r?\n/).slice(0, maxLines); } -function dryRunBody(repo: string, title: string | undefined, body: string): Record<string, unknown> { +function bodySha(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +function normalizeExpectedSha(raw: string): string { + const value = raw.replace(/^sha256:/iu, "").toLowerCase(); + if (!/^[a-f0-9]{64}$/u.test(value)) throw new Error("--expect-body-sha must be a 64-character SHA-256 hex value, optionally prefixed with sha256:"); + return value; +} + +function shellPollutionEvidence(body: string): string[] { + const evidence: string[] = []; + if (body.includes("\\n")) evidence.push("literal-backslash-n"); + if (body.includes("\\t")) evidence.push("literal-backslash-t"); + if (body.includes("\\`")) evidence.push("escaped-backtick"); + if (/\\x1b|\\u001b|\u001b\[/u.test(body)) evidence.push("ansi-escape"); + if (/\r(?!\n)/u.test(body)) evidence.push("bare-carriage-return"); + return evidence; +} + +function bodySafetySignals(body: string): Record<string, unknown> { + const evidence = shellPollutionEvidence(body); return { - repo, - ...(title === undefined ? {} : { title }), bodyChars: body.length, - bodyPreview: preview(body), - bodyPreviewLines: previewLines(body), + bodyTrimmedChars: body.trim().length, + bodyLines: body.length === 0 ? 0 : body.split(/\r?\n/).length, + bodySha: bodySha(body), preservesRawNewlines: body.includes("\n"), containsLiteralBackslashN: body.includes("\\n"), containsBackticks: body.includes("`"), containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(body), + shellPollution: { + suspected: evidence.length > 0, + evidence, + }, + }; +} + +function dryRunBody(repo: string, title: string | undefined, body: string): Record<string, unknown> { + return { + repo, + ...(title === undefined ? {} : { title }), + bodyPreview: preview(body), + bodyPreviewLines: previewLines(body), + ...bodySafetySignals(body), }; } @@ -807,6 +900,138 @@ function issueSummary(issue: GitHubIssue): Record<string, unknown> { }; } +function issueProfileFor(issueNumber: number, requested: IssueBodyProfileOption): IssueBodyProfileName | null { + if (requested !== "auto") return requested; + if (issueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE) return "code-queue-board"; + if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return "commander-brief"; + return null; +} + +function issueProfileValidation(issueNumber: number, body: string, requested: IssueBodyProfileOption): Record<string, unknown> { + const profileName = issueProfileFor(issueNumber, requested); + if (profileName === null) return { profile: null, applied: false, requiredHeadings: [], missingHeadings: [], ok: true }; + const profile = ISSUE_BODY_PROFILES[profileName]; + const issueMatchesProfile = issueNumber === profile.issueNumber; + const missingHeadings = profile.requiredHeadings.filter((heading) => !body.includes(heading)); + return { + profile: profileName, + label: profile.label, + applied: true, + expectedIssueNumber: profile.issueNumber, + issueMatchesProfile, + requiredHeadings: profile.requiredHeadings, + missingHeadings, + ok: issueMatchesProfile && missingHeadings.length === 0, + }; +} + +function validationBoolean(record: Record<string, unknown>, key: string): boolean { + return record[key] === true; +} + +function validationStringArray(record: Record<string, unknown>, key: string): string[] { + const value = record[key]; + return Array.isArray(value) ? value.map((item) => String(item)) : []; +} + +function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, options: GitHubOptions): GitHubCommandResult | null { + const trimmed = body.trim(); + const isLiteralNull = trimmed.toLowerCase() === "null"; + const isBlank = trimmed.length === 0; + const isShort = trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS; + const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile); + const profileOk = validationBoolean(profileValidation, "ok"); + const failures: string[] = []; + if (isLiteralNull) failures.push("literal-null-body"); + if (isBlank) failures.push("blank-body"); + if (isShort && !options.allowShortBody) failures.push("short-body"); + if (!profileOk) { + if (profileValidation.issueMatchesProfile === false) failures.push("profile-issue-mismatch"); + const missingHeadings = validationStringArray(profileValidation, "missingHeadings"); + if (missingHeadings.length > 0) failures.push("profile-heading-missing"); + } + if (failures.length === 0) return null; + const overrideAvailable = failures.every((failure) => failure === "short-body"); + const message = overrideAvailable + ? "issue edit body is shorter than the safe default; pass --allow-short-body only for intentional short writes" + : "issue edit body failed safety guard; refusing to write possible body-only issue corruption"; + return validationError("issue edit", repo, message, { + issueNumber, + guard: { + ok: false, + failures, + minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS, + allowShortBody: options.allowShortBody, + shortBodyOverrideAvailable: overrideAvailable, + bodySource: { kind: "body-file", path: options.bodyFile ?? null }, + bodyPreview: preview(body), + bodyPreviewLines: previewLines(body), + ...bodySafetySignals(body), + isLiteralNull, + isBlank, + isShort, + profile: profileValidation, + }, + }); +} + +function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions): Record<string, unknown> { + const trimmed = body.trim(); + const profile = issueProfileValidation(issueNumber, body, options.bodyProfile); + const warnings: string[] = []; + if (trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS) { + warnings.push(options.allowShortBody ? "short-body-allowed" : "short-body-would-fail"); + } + if (options.allowShortBody) warnings.push("allow-short-body enabled; caller accepted short-body corruption risk"); + if (profile.ok === false) warnings.push("profile guard would fail"); + const signals = bodySafetySignals(body); + const shellPollution = signals.shellPollution as Record<string, unknown>; + if (shellPollution.suspected === true) warnings.push("shell-pollution-suspected"); + return { + ok: trimmed.length > 0 + && trimmed.toLowerCase() !== "null" + && (trimmed.length >= MIN_SAFE_ISSUE_BODY_CHARS || options.allowShortBody) + && profile.ok === true, + minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS, + allowShortBody: options.allowShortBody, + warnings, + profile, + ...signals, + }; +} + +function assertConcurrencyOptions(options: GitHubOptions): GitHubCommandResult | null { + if (options.expectBodySha === undefined) return null; + try { + normalizeExpectedSha(options.expectBodySha); + return null; + } catch (error) { + return validationError("issue edit", options.repo, error instanceof Error ? error.message : String(error), { + expectBodySha: options.expectBodySha, + }); + } +} + +function validateIssueConcurrency(repo: string, issueNumber: number, oldIssue: GitHubIssue, options: GitHubOptions): GitHubCommandResult | null { + const checks: Record<string, unknown> = { + issueNumber, + expectUpdatedAt: options.expectUpdatedAt ?? null, + actualUpdatedAt: oldIssue.updated_at ?? null, + expectBodySha: options.expectBodySha ?? null, + actualBodySha: bodySha(oldIssue.body ?? ""), + }; + if (options.expectUpdatedAt !== undefined && oldIssue.updated_at !== options.expectUpdatedAt) { + return validationError("issue edit", repo, "--expect-updated-at did not match current GitHub issue updated_at; refusing stale body overwrite", checks); + } + if (options.expectBodySha !== undefined) { + const expected = normalizeExpectedSha(options.expectBodySha); + if (bodySha(oldIssue.body ?? "") !== expected) { + return validationError("issue edit", repo, "--expect-body-sha did not match current GitHub issue body; refusing stale body overwrite", checks); + } + } + return null; +} + function commentSummary(comment: GitHubComment): Record<string, unknown> { return { id: comment.id, @@ -1077,17 +1302,39 @@ async function getIssue(token: string, repo: string, issueNumber: number): Promi return githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`); } -async function issueView(repo: string, token: string, issueNumber: number): Promise<GitHubCommandResult> { +function selectedIssueJson(issue: GitHubIssue, comments: GitHubComment[] | null, fields: IssueViewJsonField[] | undefined): Record<string, unknown> | null { + if (fields === undefined) return null; + const summary = issueSummary(issue); + const selected: Record<string, unknown> = {}; + for (const field of fields) { + if (field === "comments") { + selected.comments = (comments ?? []).map(commentSummary); + } else { + selected[field] = summary[field]; + } + } + return selected; +} + +async function issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined): Promise<GitHubCommandResult> { const issue = await getIssue(token, repo, issueNumber); if (isGitHubError(issue)) return commandError("issue view", repo, issue, { issueNumber }); - const comments = await listIssueComments(token, repo, issueNumber); + const needsComments = jsonFields === undefined || jsonFields.includes("comments"); + const comments = needsComments ? await listIssueComments(token, repo, issueNumber) : null; if (isGitHubError(comments)) return commandError("issue view", repo, comments, { issueNumber, issue: issueSummary(issue) }); return { ok: true, command: "issue view", repo, issue: issueSummary(issue), - comments: comments.map(commentSummary), + ...(comments === null ? {} : { comments: comments.map(commentSummary) }), + ...(jsonFields === undefined ? {} : { + jsonFields, + json: selectedIssueJson(issue, comments, jsonFields), + compatibility: { + legacyJsonBodyPath: ".data.issue.body", + }, + }), }; } @@ -1103,20 +1350,60 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions): async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { const body = readBodyFile(options.bodyFile, "issue edit"); + const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options); + if (bodyGuard !== null) return bodyGuard; + const concurrencyOptionError = assertConcurrencyOptions(options); + if (concurrencyOptionError !== null) return concurrencyOptionError; let oldIssue: GitHubIssue | null = null; let briefDiff: CommanderBriefDiff | null = null; const claudeQqConfig = commanderBriefClaudeQqConfig(); if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) { return validationError("issue edit", repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber }); } - if (options.notifyClaudeQqBriefDiff && !options.dryRun) { + const needsReadBeforeEdit = !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined); + if (needsReadBeforeEdit) { const issue = await getIssue(token, repo, issueNumber); if (isGitHubError(issue)) return commandError("issue edit", repo, issue, { issueNumber, phase: "read-before-edit" }); oldIssue = issue; - briefDiff = commanderBriefDiff(issue.body ?? "", body); + const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options); + if (concurrencyError !== null) return concurrencyError; + if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", body); } if (options.dryRun) { const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", body) : null; + const guard = issueEditGuardSummary(issueNumber, body, options); + let dryRunOldBody: Record<string, unknown> = { + fetched: false, + bodyChars: null, + bodySha: null, + updatedAt: null, + reason: token.length > 0 ? "not-requested" : "no token supplied to dry-run", + }; + if (token.length > 0) { + const issue = await getIssue(token, repo, issueNumber); + if (isGitHubError(issue)) { + dryRunOldBody = { + fetched: false, + bodyChars: null, + bodySha: null, + updatedAt: null, + readError: { + degradedReason: issue.degradedReason, + runnerDisposition: issue.runnerDisposition, + message: issue.message, + status: issue.status ?? null, + }, + }; + } else { + const oldBody = issue.body ?? ""; + dryRunOldBody = { + fetched: true, + bodyChars: oldBody.length, + bodySha: bodySha(oldBody), + updatedAt: issue.updated_at ?? null, + }; + } + } return { ok: true, command: "issue edit", @@ -1124,6 +1411,20 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio dryRun: true, issueNumber, ...dryRunBody(repo, options.title, body), + guard, + bodyOnlySafety: { + oldBody: dryRunOldBody, + newBody: { + bodyChars: body.length, + bodySha: bodySha(body), + }, + }, + concurrency: { + dryRunNoWrite: true, + expectUpdatedAt: options.expectUpdatedAt ?? null, + expectBodySha: options.expectBodySha ?? null, + note: "non-dry-run checks --expect-updated-at and --expect-body-sha against the current GitHub issue before PATCH", + }, wouldPatch: { title: options.title ?? null, bodyFromFile: options.bodyFile }, ...(options.notifyClaudeQqBriefDiff ? { @@ -1141,7 +1442,11 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio 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 commandError("issue edit", repo, issue, { issueNumber }); - if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: "issue edit", repo, issue: issueSummary(issue), rest: true }; + const guard = issueEditGuardSummary(issueNumber, body, options); + const concurrency = oldIssue === null + ? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null } + : { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }; + if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: "issue edit", repo, issue: issueSummary(issue), guard, concurrency, rest: true }; const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", body); const claudeqq = diff.ok @@ -1158,6 +1463,8 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio command: "issue edit", repo, issue: issueSummary(issue), + guard, + concurrency, rest: true, commanderBriefNotification: { issueNumber, @@ -1368,9 +1675,9 @@ export function ghHelp(): unknown { 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 view <number> [--repo owner/name] [--json body,title,state,comments]", "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 edit <number> --body-file <file> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body]", "bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--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]", @@ -1384,7 +1691,10 @@ export function ghHelp(): unknown { 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.", + "issue view supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.", "--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 edit --body-file refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 and #24 body-only profiles require their stable headings.", + "issue edit dry-run reports old/new body length slots, body SHA, required heading checks, literal \\n detection, and shell-pollution signals. Non-dry-run can use --expect-updated-at or --expect-body-sha for stale-cache protection.", "Issue body stdin is intentionally unsupported in this CLI; write generated Markdown to a file and pass --body-file.", "issue edit 24 --notify-claudeqq-brief-diff reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.", "Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.", @@ -1411,6 +1721,14 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; return validationError(command, options.repo, "--notify-claudeqq-brief-diff is only supported by gh issue edit 24"); } + if (options.jsonFields !== undefined && !(top === "issue" && sub === "view")) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--json field selection is only supported by gh issue view"); + } + if ((options.allowShortBody || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined || optionWasProvided(args, "--body-profile")) && !(top === "issue" && sub === "edit")) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--allow-short-body, --expect-updated-at, --expect-body-sha, and --body-profile are only supported by gh issue edit"); + } if (top === "auth" && sub === "status") return authStatus(options.repo); @@ -1419,7 +1737,8 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult if (sub === "create") return issueCreate(options.repo, "", options); if (sub === "edit") { const issueNumber = parseNumber(third, "issue edit"); - return issueEdit(options.repo, "", issueNumber, options); + const { token } = resolveToken(false); + return issueEdit(options.repo, token ?? "", issueNumber, 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); @@ -1429,7 +1748,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult 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 === "view") return issueView(options.repo, token, parseNumber(third, "issue view"), options.jsonFields); 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);