fix: harden gh issue body updates
This commit is contained in:
@@ -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 <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 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`。
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
+337
-18
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user