import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, 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; 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 = {}, stdin?: 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 }, stdio: ["pipe", "pipe", "pipe"], }); 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, }); }); if (stdin !== undefined) child.stdin.end(stdin); else child.stdin.end(); }); } 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 failureMessageOf(data: JsonRecord): string { return String((data.details as JsonRecord | undefined)?.message ?? data.message ?? ""); } function collectBody(req: IncomingMessage): Promise { 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)); } function mockUrl(rawUrl: string | undefined): URL { return new URL(rawUrl ?? "/", "http://mock.local"); } async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise }> { const requests: MockRequest[] = []; let shorthandIssueBody = "HWLAB-style shorthand body fixture\n\nThis is generic CLI coverage, not product data."; let shorthandIssueUpdatedAt = "2026-05-20T03:00:00Z"; 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", closed_at: null, }; const shorthandIssue = { id: 7000, number: 7, title: "generic shorthand fixture", body: shorthandIssueBody, state: "open", html_url: "https://github.com/pikasTech/HWLAB/issues/7", comments: 1, user: { login: "tester" }, created_at: "2026-05-20T02:00:00Z", updated_at: shorthandIssueUpdatedAt, closed_at: null, }; const boardIssueBodyInitial = [ "# Code Queue", "", "## 看板(OPEN)", "", "| Issue | GitHub 状态 | Branch | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", "| --- | --- | --- | --- | --- | --- | --- |", "| #20 | OPEN | master | meta | governance | 关注:#19 / #26 / #30 | active |", "| #35 | OPEN | master | pass | cq-35 | 当前关注点:#19 / #26 / #30 | doing |", "| [#45](https://github.com/pikasTech/unidesk/issues/45) #20 总看板缺少自动覆盖审计 | OPEN | master | pass | cq-45 | 复核:#20 / #24 | doing |", "| #40 | OPEN | — | — | — | 复核:#19 / #26 / #30 | — |", "", "## 看板(CLOSED)", "", "| Issue | GitHub 状态 | Branch | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", "| --- | --- | --- | --- | --- | --- | --- |", "| #24 | CLOSED | master | meta | brief | 常驻:#24 / #20 | active |", "| [#18](https://github.com/pikasTech/unidesk/issues/18) 基于 #4 评审结论修复 | CLOSED | master | pass | — | 历史治理归档 | done |", "| #36 | CLOSED | master | pending | cq-36 | 复核:#35 / #40 | done |", "", ].join("\n"); let boardIssueBody = boardIssueBodyInitial; let boardIssueUpdatedAt = "2026-05-20T01:00:00Z"; const upsertBoardIssueBodyInitial = [ "# Upsert Board", "", "## 看板(OPEN)", "", "| Issue | GitHub 状态 | Category | Branch | Summary | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", "| #70 | OPEN | cli | master | existing summary | pass | cq-70 | existing focus | doing |", "| #72 | OPEN | cli | master | duplicate open | pass | cq-72 | duplicate open | doing |", "| #79 | OPEN | defect | master | bad row | pass | cq-79 | missing progress |", "", "## 看板(CLOSED)", "", "| Issue | GitHub 状态 | Category | Branch | Summary | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", "| #71 | CLOSED | ops | master | closed summary | pass | — | archived focus | done |", "| #72 | CLOSED | ops | master | duplicate closed | pass | — | duplicate closed | done |", "", "## 更新 2026-05-21 10:00 北京时间", "", "- 表后的更新段落必须保留。", "", ].join("\n"); let upsertBoardIssueBody = upsertBoardIssueBodyInitial; let upsertBoardIssueUpdatedAt = "2026-05-20T01:30:00Z"; const upsertBoardIssue = { ...issue, id: 2062, number: 62, title: "Upsert board fixture", body: upsertBoardIssueBody, html_url: "https://github.com/pikasTech/unidesk/issues/62", updated_at: upsertBoardIssueUpdatedAt, }; const legacyBoardIssue = { ...issue, id: 2600, number: 60, title: "遗留总看板", body: [ "# Code Queue", "", "## 看板(OPEN)", "", "| 当前项 | Branch | 验收状态 | 相关任务 | 进度 |", "| --- | --- | --- | --- | --- |", "| #101 / #102 说明:#101 / #102 | master | pass | cq-legacy | doing |", "", ].join("\n"), html_url: "https://github.com/pikasTech/unidesk/issues/60", }; const legacyCommanderBriefIssue = { ...issue, id: 2024, number: 24, title: "指挥简报", body: [ "# 指挥简报", "", "## 常驻观察与长期建议", "", "- 维持 Code Queue 指挥态势。", "", "## 更新 2026-05-20 17:28 北京时间", "", "- 已完成历史简报入口维护。", "", ].join("\n"), html_url: "https://github.com/pikasTech/unidesk/issues/24", }; const dailyCommanderBriefIssue = { ...issue, id: 2046, number: 46, title: "2026-05-21 指挥简报(北京时间)", body: [ "# 2026-05-21 指挥简报(北京时间)", "", "## 常驻观察与长期建议", "", "- 今日滚动简报使用每日 issue 主体维护。", "", "## 更新 2026-05-21 09:00 北京时间", "", "- 启动当日队列监督。", "", ].join("\n"), html_url: "https://github.com/pikasTech/unidesk/issues/46", }; const nonBriefIssue = { ...issue, id: 2047, number: 47, title: "普通任务 issue", body: "# 普通任务\n\n## 背景\n\n- 不是指挥简报。\n", html_url: "https://github.com/pikasTech/unidesk/issues/47", }; const largeIssueBody = Array.from({ length: 900 }, (_, index) => `large-output-line-${String(index + 1).padStart(4, "0")} ${"x".repeat(60)}`).join("\n"); const largeIssue = { ...issue, id: 2090, number: 90, title: "large issue output fixture", body: largeIssueBody, html_url: "https://github.com/pikasTech/unidesk/issues/90", updated_at: "2026-05-20T09:00:00Z", }; const issueList = [ { id: 2001, number: 35, title: "master:补齐 UniDesk CLI gh issue list 与 PR 驱动最小闭环前置能力", body: "issue list body", state: "open", html_url: "https://github.com/pikasTech/unidesk/issues/35", comments: 0, user: { login: "commander" }, labels: [{ name: "cli", color: "1d76db", description: "CLI work" }], created_at: "2026-05-20T02:00:00Z", updated_at: "2026-05-20T03:00:00Z", closed_at: null, }, { id: 2002, number: 36, title: "second issue", body: "second body", state: "open", html_url: "https://github.com/pikasTech/unidesk/issues/36", comments: 0, user: { login: "runner" }, labels: [], created_at: "2026-05-20T02:05:00Z", updated_at: "2026-05-20T03:05:00Z", closed_at: null, }, { id: 3001, number: 37, title: "pull request should be filtered", body: "pr body", state: "open", html_url: "https://github.com/pikasTech/unidesk/pull/37", comments: 0, user: { login: "runner" }, labels: [], pull_request: { html_url: "https://github.com/pikasTech/unidesk/pull/37" }, created_at: "2026-05-20T02:10:00Z", updated_at: "2026-05-20T03:10:00Z", }, ]; const hwlabIssueList = [ { id: 7001, number: 7, title: "HWLAB generic issue fixture", body: "HWLAB issue list body fixture", state: "open", html_url: "https://github.com/pikasTech/HWLAB/issues/7", comments: 0, user: { login: "tester" }, labels: [], created_at: "2026-05-20T02:30:00Z", updated_at: "2026-05-20T03:30:00Z", closed_at: null, }, ]; const scanIssues = [ { id: 2501, number: 51, title: "polluted issue", body: "## Update\\n- item with `code`\\n| a | b |\\n", state: "open", html_url: "https://github.com/pikasTech/unidesk/issues/51", comments: 1, user: { login: "runner" }, labels: [], created_at: "2026-05-20T04:00:00Z", updated_at: "2026-05-20T04:30:00Z", }, { id: 2502, number: 52, title: "explanatory issue", body: "文档说明:字面量 `\\n` 只是在示例中提到,不代表正文污染。\n", state: "open", html_url: "https://github.com/pikasTech/unidesk/issues/52", comments: 1, user: { login: "runner" }, labels: [], created_at: "2026-05-20T04:05:00Z", updated_at: "2026-05-20T04:35:00Z", }, { id: 2503, number: 53, title: "null body issue", body: null, state: "open", html_url: "https://github.com/pikasTech/unidesk/issues/53", comments: 0, user: { login: "runner" }, labels: [], created_at: "2026-05-20T04:10:00Z", updated_at: "2026-05-20T04:40:00Z", }, ]; const boardOpenIssues = (): JsonRecord[] => [ { id: 2000, number: 20, title: "长期总看板", body: boardIssueBody, state: "open", html_url: "https://github.com/pikasTech/unidesk/issues/20", comments: 1, user: { login: "tester" }, labels: [], created_at: "2026-05-20T00:00:00Z", updated_at: boardIssueUpdatedAt, }, issueList[0], issueList[1], { id: 2004, number: 24, title: "指挥简报", body: "brief", state: "open", html_url: "https://github.com/pikasTech/unidesk/issues/24", comments: 0, user: { login: "tester" }, labels: [], created_at: "2026-05-20T01:00:00Z", updated_at: "2026-05-20T02:00:00Z", }, { id: 2045, number: 45, title: "#20 总看板缺少自动覆盖审计", body: "audit body", state: "open", html_url: "https://github.com/pikasTech/unidesk/issues/45", comments: 0, user: { login: "tester" }, labels: [], created_at: "2026-05-20T01:30:00Z", updated_at: "2026-05-20T02:30:00Z", }, dailyCommanderBriefIssue, ]; const legacyBoardOpenIssues = [ { id: 2600, number: 60, title: "遗留总看板", body: legacyBoardIssue.body, state: "open", html_url: "https://github.com/pikasTech/unidesk/issues/60", comments: 0, user: { login: "tester" }, labels: [], created_at: "2026-05-20T01:00:00Z", updated_at: "2026-05-20T02:00:00Z", }, issueList[0], issueList[1], ]; const boardClosedIssues = [ { id: 2018, number: 18, title: "基于 #4 评审结论修复", body: "closed body", state: "closed", html_url: "https://github.com/pikasTech/unidesk/issues/18", comments: 0, user: { login: "runner" }, labels: [], created_at: "2026-05-18T02:00:00Z", updated_at: "2026-05-20T03:00:00Z", closed_at: "2026-05-20T03:15:00Z", }, { id: 2040, number: 40, title: "closed issue still in open", body: "closed body", state: "closed", html_url: "https://github.com/pikasTech/unidesk/issues/40", comments: 0, user: { login: "runner" }, labels: [], created_at: "2026-05-19T02:00:00Z", updated_at: "2026-05-20T03:00:00Z", closed_at: "2026-05-20T03:20:00Z", }, { id: 2041, number: 41, title: "closed issue missing from closed table", body: "closed body", state: "closed", html_url: "https://github.com/pikasTech/unidesk/issues/41", comments: 0, user: { login: "runner" }, labels: [], created_at: "2026-05-19T02:05:00Z", updated_at: "2026-05-20T03:05:00Z", closed_at: "2026-05-20T03:25: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 scanComments: Record = { 51: [ { id: 5101, body: "comment line 1\\ncomment line 2\\twith tab", html_url: "https://github.com/pikasTech/unidesk/issues/51#issuecomment-5101", user: { login: "runner" }, created_at: "2026-05-20T04:40:00Z", updated_at: "2026-05-20T04:40:00Z", }, ], 52: [ { id: 5201, body: "说明性提到字面量 `\\n`,用于描述问题本身。", html_url: "https://github.com/pikasTech/unidesk/issues/52#issuecomment-5201", user: { login: "runner" }, created_at: "2026-05-20T04:45:00Z", updated_at: "2026-05-20T04:45:00Z", }, ], 53: [], }; let unideskAllIssuesRequestCount = 0; const server = createServer(async (req, res) => { const body = await collectBody(req); requests.push({ method: req.method ?? "", url: req.url ?? "", body }); const url = mockUrl(req.url); const state = url.searchParams.get("state") ?? ""; const perPage = url.searchParams.get("per_page") ?? ""; const page = url.searchParams.get("page") ?? "1"; if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/20") { sendJson(res, 200, { ...issue, body: boardIssueBody, updated_at: boardIssueUpdatedAt }); return; } if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/issues/7") { sendJson(res, 200, { ...shorthandIssue, body: shorthandIssueBody, updated_at: shorthandIssueUpdatedAt }); return; } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/24") { sendJson(res, 200, legacyCommanderBriefIssue); return; } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/46") { sendJson(res, 200, dailyCommanderBriefIssue); return; } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/47") { sendJson(res, 200, nonBriefIssue); return; } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/90") { sendJson(res, 200, largeIssue); return; } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/60") { sendJson(res, 200, legacyBoardIssue); return; } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/62") { sendJson(res, 200, { ...upsertBoardIssue, body: upsertBoardIssueBody, updated_at: upsertBoardIssueUpdatedAt }); return; } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/20/comments?per_page=100") { sendJson(res, 200, comments); return; } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/90/comments?per_page=100") { sendJson(res, 200, []); return; } if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/issues/7/comments?per_page=100") { sendJson(res, 200, [{ id: 7001, body: "shorthand comment", html_url: "https://github.com/pikasTech/HWLAB/issues/7#issuecomment-7001", user: { login: "tester" }, created_at: "2026-05-20T03:10:00Z", updated_at: "2026-05-20T03:10:00Z" }]); return; } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/60/comments?per_page=100") { sendJson(res, 200, []); return; } if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/issues" && state === "open" && perPage === "100" && page === "1") { sendJson(res, 200, issueList); return; } if (req.method === "GET" && url.pathname === "/search/issues" && url.searchParams.get("q") === "AgentRun final response repo:pikasTech/unidesk type:issue" && perPage === "100" && page === "1") { sendJson(res, 200, { total_count: 1, incomplete_results: false, items: issueList.slice(0, 1) }); return; } if (req.method === "GET" && url.pathname === "/repos/pikasTech/HWLAB/issues" && state === "open" && perPage === "100" && page === "1") { sendJson(res, 200, hwlabIssueList); return; } if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/issues" && state === "all" && perPage === "100" && page === "1") { unideskAllIssuesRequestCount += 1; sendJson(res, 200, unideskAllIssuesRequestCount === 1 ? issueList : scanIssues); return; } if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/issues" && state === "closed" && perPage === "100" && page === "1") { sendJson(res, 200, boardClosedIssues); return; } for (const [issueNumber, issueComments] of Object.entries(scanComments)) { if (req.method === "GET" && req.url === `/repos/pikasTech/unidesk/issues/${issueNumber}/comments?per_page=100`) { sendJson(res, 200, issueComments); return; } } if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/20") { const parsed = JSON.parse(body) as JsonRecord; boardIssueBody = String(parsed.body ?? boardIssueBody); const patchedState = typeof parsed.state === "string" ? parsed.state : issue.state; boardIssueUpdatedAt = "2026-05-20T01:05:00Z"; sendJson(res, 200, { ...issue, body: boardIssueBody, state: patchedState, updated_at: boardIssueUpdatedAt }); return; } if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/62") { const parsed = JSON.parse(body) as JsonRecord; upsertBoardIssueBody = String(parsed.body ?? upsertBoardIssueBody); upsertBoardIssueUpdatedAt = "2026-05-20T01:35:00Z"; sendJson(res, 200, { ...upsertBoardIssue, body: upsertBoardIssueBody, updated_at: upsertBoardIssueUpdatedAt }); return; } if (req.method === "PATCH" && req.url === "/repos/pikasTech/HWLAB/issues/7") { const parsed = JSON.parse(body) as JsonRecord; shorthandIssueBody = String(parsed.body ?? shorthandIssueBody); shorthandIssueUpdatedAt = "2026-05-20T03:05:00Z"; sendJson(res, 200, { ...shorthandIssue, body: shorthandIssueBody, updated_at: shorthandIssueUpdatedAt }); return; } if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues/20/comments") { const parsed = JSON.parse(body) as JsonRecord; sendJson(res, 201, { id: 9001, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/20#issuecomment-9001", user: { login: "tester" }, created_at: "2026-05-20T06:00:00Z", updated_at: "2026-05-20T06:00:00Z" }); return; } if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues/36/comments") { const parsed = JSON.parse(body) as JsonRecord; sendJson(res, 201, { id: 9002, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/36#issuecomment-9002", user: { login: "tester" }, created_at: "2026-05-20T06:02:00Z", updated_at: "2026-05-20T06:02:00Z" }); return; } if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/comments/9002") { const parsed = JSON.parse(body) as JsonRecord; sendJson(res, 200, { id: 9002, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/36#issuecomment-9002", user: { login: "tester" }, created_at: "2026-05-20T06:02:00Z", updated_at: "2026-05-20T06:04:00Z" }); return; } if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues") { const parsed = JSON.parse(body) as JsonRecord; const labels = Array.isArray(parsed.labels) ? parsed.labels.map(String) : []; if (labels.includes("missing-label")) { sendJson(res, 422, { message: "Validation Failed", errors: [{ resource: "Issue", field: "labels", code: "invalid", value: "missing-label" }], documentation_url: "https://docs.github.com/rest/issues/issues#create-an-issue", }); return; } sendJson(res, 201, { id: 9100, number: 91, title: String(parsed.title ?? ""), body: String(parsed.body ?? ""), state: "open", html_url: "https://github.com/pikasTech/unidesk/issues/91", comments: 0, user: { login: "tester" }, labels: labels.map((name) => ({ name })), created_at: "2026-05-20T06:05:00Z", updated_at: "2026-05-20T06:05:00Z", }); return; } if (req.method === "DELETE" && req.url === "/repos/pikasTech/unidesk/issues/comments/9001") { res.statusCode = 204; res.end(); return; } sendJson(res, 404, { message: "not found" }); }); await new Promise((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 { const help = await runCli(["gh", "help"]); assertCondition(help.status === 0, "gh help should succeed", help.json ?? { stdout: help.stdout }); const helpData = dataOf(help.json ?? {}); const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : []; const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : []; assertCondition(usage.some((line) => line.includes("gh issue list")), "gh help should list issue list", { usage }); assertCondition(usage.some((line) => line.includes("gh issue view") && line.includes("number|url|owner/repo#number")), "gh help should list standard issue view target forms", { usage }); assertCondition(usage.some((line) => line.includes("gh issue read") && line.includes("compatibility alias for issue view")), "gh help should list issue read compatibility alias", { usage }); assertCondition(usage.some((line) => line.includes("gh issue comment create") && line.includes("--body ")), "gh help should list short inline issue comment body", { usage }); assertCondition(usage.some((line) => line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document issue shorthand and raw/full disclosure", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row list")), "gh help should list board-row list", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row update")), "gh help should list board-row update", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row add")), "gh help should list board-row add", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row upsert")), "gh help should list board-row upsert", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row move")), "gh help should list board-row move", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row delete")), "gh help should list board-row delete", { usage }); assertCondition(usage.some((line) => line.includes("gh issue list") && line.includes("--search text")), "gh help should list issue list search", { usage }); assertCondition(notes.some((line) => line.includes("issue view is the canonical")), "gh help should state issue view is canonical", { notes }); assertCondition(notes.some((line) => line.includes("read remains") && line.includes("compatibility alias")), "gh help should state issue read is alias", { notes }); assertCondition(notes.some((line) => line.includes("GitHub issue URLs") && line.includes("owner/repo#number shorthand")), "gh help should explain issue view/read URL and shorthand targets", { notes }); assertCondition(notes.some((line) => line.includes("--number is accepted on single issue/comment numeric target commands") && line.includes("Comment delete treats --number as commentId")), "gh help should document issue --number compatibility scope", { notes }); assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes }); assertCondition(notes.some((line) => line.includes("issue comment create/update/edit accept --body-stdin") && line.includes("--body only for short single-line text")), "gh help should document issue comment heredoc stdin and inline safety limits", { notes }); assertCondition(notes.some((line) => line.includes("board-row update changes one table cell")), "gh help should describe board-row update safety", { notes }); assertCondition(notes.some((line) => line.includes("board-row upsert updates an existing row")), "gh help should describe board-row upsert safety", { notes }); assertCondition(notes.some((line) => line.includes("board-row add/move/delete are row-scoped")), "gh help should describe board-row row mutation safety", { notes }); const mock = await startMockGitHub(); const tmp = mkdtempSync(join(tmpdir(), "unidesk-gh-issue-guard-")); const startedAt = Date.now(); const heartbeat = setInterval(() => { const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); process.stderr.write(`[gh-issue-contract] elapsed=${elapsedSeconds}s requests=${mock.requests.length}\n`); }, 10_000); const env = { GH_TOKEN: "contract-token-should-not-print", UNIDESK_GITHUB_API_URL: mock.baseUrl, }; try { const listOpen = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "2", "--json", "number,title,state,url"], env); assertCondition(listOpen.status === 0, "issue list should support state/limit/json", listOpen.json ?? { stdout: listOpen.stdout }); const listOpenData = dataOf(listOpen.json ?? {}); assertCondition(listOpenData.state === "open", "issue list should preserve state", listOpenData); assertCondition(listOpenData.limit === 2, "issue list should preserve limit", listOpenData); assertCondition(listOpenData.count === 2, "issue list should return bounded issues", listOpenData); const listOpenIssues = listOpenData.issues as JsonRecord[]; assertCondition(Array.isArray(listOpenIssues), "issue list should expose issues array", listOpenData); assertCondition(listOpenIssues[0]?.number === 35, "issue list should expose number field", listOpenData); assertCondition(listOpenIssues[0]?.title === "master:补齐 UniDesk CLI gh issue list 与 PR 驱动最小闭环前置能力", "issue list should expose title field", listOpenData); assertCondition(!("labels" in listOpenIssues[0]), "issue list --json should select only requested fields", listOpenIssues[0]); const listOpenLifecycle = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "2", "--json", "number,state,closed,closedAt"], env); assertCondition(listOpenLifecycle.status === 0, "issue list lifecycle fields should succeed", listOpenLifecycle.json ?? { stdout: listOpenLifecycle.stdout }); const listOpenLifecycleData = dataOf(listOpenLifecycle.json ?? {}); const listOpenLifecycleIssues = listOpenLifecycleData.issues as JsonRecord[]; assertCondition(listOpenLifecycleIssues[0]?.closed === false && listOpenLifecycleIssues[0]?.closedAt === null, "open issue list rows should expose closed=false and closedAt=null", listOpenLifecycleData); const listClosedLifecycle = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "closed", "--limit", "2", "--json", "number,state,closed,closedAt"], env); assertCondition(listClosedLifecycle.status === 0, "closed issue list lifecycle fields should succeed", listClosedLifecycle.json ?? { stdout: listClosedLifecycle.stdout }); const listClosedLifecycleData = dataOf(listClosedLifecycle.json ?? {}); const listClosedLifecycleIssues = listClosedLifecycleData.issues as JsonRecord[]; assertCondition(listClosedLifecycleIssues[0]?.state === "closed" && listClosedLifecycleIssues[0]?.closed === true && listClosedLifecycleIssues[0]?.closedAt === "2026-05-20T03:15:00Z", "closed issue list rows should expose closed=true and closedAt", listClosedLifecycleData); const acceptanceList = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "5", "--json", "number,title,state,url"], env); assertCondition(acceptanceList.status === 0, "acceptance issue list command should succeed under mock GitHub", acceptanceList.json ?? { stdout: acceptanceList.stdout }); const acceptanceListData = dataOf(acceptanceList.json ?? {}); assertCondition(acceptanceListData.limit === 5, "acceptance issue list command should preserve limit=5", acceptanceListData); assertCondition(acceptanceListData.count === 2, "acceptance issue list command should filter PRs and keep issues", acceptanceListData); const listDefaultState = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--limit", "2", "--json", "number,title,state,url"], env); assertCondition(listDefaultState.status === 0, "issue list default state should still succeed", listDefaultState.json ?? { stdout: listDefaultState.stdout }); const listDefaultStateData = dataOf(listDefaultState.json ?? {}); assertCondition(listDefaultStateData.state === "open", "issue list should keep default state=open", listDefaultStateData); assertCondition(mock.requests.some((request) => { const requestedUrl = mockUrl(request.url); return request.method === "GET" && requestedUrl.pathname === "/repos/pikasTech/unidesk/issues" && requestedUrl.searchParams.get("state") === "open"; }), "issue list default should query state=open", mock.requests); const searchList = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "all", "--limit", "4", "--search", "AgentRun final response", "--json", "number,title,state,url"], env); assertCondition(searchList.status === 0, "issue list should support search query", searchList.json ?? { stdout: searchList.stdout }); const searchListData = dataOf(searchList.json ?? {}); assertCondition(searchListData.search === "AgentRun final response", "issue list should expose search query", searchListData); assertCondition(mock.requests.some((request) => { const requestedUrl = mockUrl(request.url); return request.method === "GET" && requestedUrl.pathname === "/search/issues" && requestedUrl.searchParams.get("q") === "AgentRun final response repo:pikasTech/unidesk type:issue"; }), "issue list search should use GitHub Search Issues API with repo/type qualifiers", mock.requests); const positionalRepoList = await runCli(["gh", "issue", "list", "pikasTech/HWLAB", "--state", "open", "--limit", "2", "--json", "number,title,state,url"], env); assertCondition(positionalRepoList.status === 0, "issue list positional owner/repo should succeed", positionalRepoList.json ?? { stdout: positionalRepoList.stdout }); const positionalRepoListData = dataOf(positionalRepoList.json ?? {}); assertCondition(positionalRepoListData.repo === "pikasTech/HWLAB", "issue list positional repo should become the actual request repo", positionalRepoListData); const positionalRepoIssues = positionalRepoListData.issues as JsonRecord[]; assertCondition(Array.isArray(positionalRepoIssues) && positionalRepoIssues[0]?.number === 7, "issue list positional repo should return HWLAB fixture issue", positionalRepoListData); assertCondition(mock.requests.some((request) => { const requestedUrl = mockUrl(request.url); return request.method === "GET" && requestedUrl.pathname === "/repos/pikasTech/HWLAB/issues" && requestedUrl.searchParams.get("state") === "open"; }), "issue list positional repo should query derived repo REST path", mock.requests); const positionalRepoConflict = await runCli(["gh", "issue", "list", "pikasTech/HWLAB", "--repo", "pikasTech/unidesk", "--state", "open"], env); assertCondition(positionalRepoConflict.status !== 0, "issue list conflicting positional repo and --repo should fail", positionalRepoConflict.json ?? { stdout: positionalRepoConflict.stdout }); const positionalRepoConflictData = failedDataOf(positionalRepoConflict.json ?? {}); assertCondition(positionalRepoConflictData.degradedReason === "validation-failed", "issue list repo conflict should be validation-failed", positionalRepoConflictData); assertCondition(String((positionalRepoConflictData.details as JsonRecord)?.message ?? "").includes("positional repo pikasTech/HWLAB"), "issue list repo conflict should name positional repo", positionalRepoConflictData); const listDefaultFields = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "all", "--limit", "3"], env); assertCondition(listDefaultFields.status === 0, "issue list should support default fields", listDefaultFields.json ?? { stdout: listDefaultFields.stdout }); const listDefaultData = dataOf(listDefaultFields.json ?? {}); assertCondition(listDefaultData.count === 2, "issue list should filter pull requests from GitHub issues endpoint", listDefaultData); const defaultIssues = listDefaultData.issues as JsonRecord[]; const firstLabels = defaultIssues[0]?.labels as JsonRecord[]; assertCondition(Array.isArray(firstLabels) && firstLabels[0]?.name === "cli", "issue list default fields should include labels", listDefaultData); assertCondition(defaultIssues.every((item) => typeof item.number === "number" && typeof item.url === "string"), "issue list default fields should expose stable JSON", listDefaultData); const largeRead = await runCli(["gh", "issue", "read", "90", "--repo", "pikasTech/unidesk", "--full"], env); assertCondition(largeRead.status === 0, "large issue read should succeed", largeRead.json ?? { stdout: largeRead.stdout }); assertCondition(largeRead.stdout.length < 20_000, "large issue read stdout should stay bounded", { bytes: largeRead.stdout.length }); const largeReadData = dataOf(largeRead.json ?? {}); assertCondition(largeReadData.outputTruncated === true, "large issue read should be dumped instead of printed fully", largeReadData); const dump = largeReadData.dump as JsonRecord; assertCondition(typeof dump.path === "string" && existsSync(String(dump.path)), "large issue dump file should exist", dump); assertCondition(Number(dump.bytes ?? 0) > 20_000, "dump should record full output size", dump); assertCondition(Number(dump.lines ?? 0) > 20, "dump should record total line count", dump); assertCondition(String(dump.head ?? "").length > 0 && String(dump.tail ?? "").length > 0, "dump should include head and tail previews", dump); const dumpText = readFileSync(String(dump.path), "utf8"); assertCondition(dumpText.includes("large-output-line-0900"), "dump file should contain full original JSON", { path: dump.path, tail: dumpText.slice(-500) }); const scanEscape = await runCli(["gh", "issue", "scan-escape", "--repo", "pikasTech/unidesk", "--limit", "4", "--dry-run"], env); assertCondition(scanEscape.status === 0, "issue scan-escape dry-run should succeed", scanEscape.json ?? { stdout: scanEscape.stdout }); const scanData = dataOf(scanEscape.json ?? {}); assertCondition(scanData.dryRun === true && scanData.planned === true, "scan-escape dry-run should be explicit", scanData); const scanSummary = scanData.summary as JsonRecord; assertCondition(Number(scanSummary.suspectedPollution ?? 0) >= 2, "scan should find suspected pollution in body/comment", scanSummary); assertCondition(Number(scanSummary.explanatoryMention ?? 0) >= 1, "scan should classify explanatory literal backslash-n separately", scanSummary); assertCondition(Number(scanSummary.bodyRisks ?? 0) >= 1, "scan should report null/short body risks", scanSummary); const scanFindings = scanData.findings as JsonRecord[]; assertCondition(Array.isArray(scanFindings), "scan should expose findings array", scanData); assertCondition(scanFindings.some((finding) => finding.issueNumber === 51 && finding.classification === "suspected-pollution" && finding.bodyKind === "issue-body" && typeof finding.bodyId === "string"), "polluted issue body should be suspected with body id", scanFindings); assertCondition(scanFindings.some((finding) => finding.commentId === 5101 && finding.classification === "suspected-pollution" && finding.bodyKind === "comment-body" && String(finding.bodyId ?? "").includes("comment:5101")), "polluted comment should include comment id and body id", scanFindings); assertCondition(scanFindings.some((finding) => finding.issueNumber === 52 && finding.classification === "explanatory-mention"), "explanatory literal backslash-n should not be pollution", scanFindings); assertCondition(scanFindings.some((finding) => finding.issueNumber === 53 && finding.kind === "null-body" && finding.classification === "risk"), "null body should be guarded as risk", scanFindings); const cleanupSuggestions = scanData.cleanupSuggestions as JsonRecord[]; assertCondition(Array.isArray(cleanupSuggestions), "scan should expose cleanupSuggestions", scanData); assertCondition(cleanupSuggestions.some((suggestion) => suggestion.issueNumber === 51 && suggestion.type === "issue-body" && typeof suggestion.bodyId === "string" && suggestion.action === "rewrite-issue-body-with-body-stdin"), "issue body cleanup suggestion should use heredoc/stdin rewrite with body id", cleanupSuggestions); assertCondition(cleanupSuggestions.some((suggestion) => suggestion.commentId === 5101 && suggestion.type === "comment-body" && String(suggestion.bodyId ?? "").includes("comment:5101") && suggestion.action === "review-comment-manually"), "comment cleanup suggestion should be manual review with body id", cleanupSuggestions); assertCondition(cleanupSuggestions.every((suggestion) => suggestion.issueNumber !== 52), "explanatory mention should not create cleanup suggestion", cleanupSuggestions); const scanPatchCount = mock.requests.filter((request) => request.method === "PATCH" || request.method === "DELETE" || request.method === "POST").length; assertCondition(scanPatchCount === 0, "scan-escape must not write GitHub", { requests: mock.requests }); const cleanupPlan = await runCli(["gh", "issue", "cleanup-plan", "--repo", "pikasTech/unidesk", "--limit", "4"], env); assertCondition(cleanupPlan.status === 0, "issue cleanup-plan should succeed as read-only alias", cleanupPlan.json ?? { stdout: cleanupPlan.stdout }); const cleanupPlanData = dataOf(cleanupPlan.json ?? {}); assertCondition(cleanupPlanData.command === "issue cleanup-plan" && cleanupPlanData.dryRun === true, "cleanup-plan should remain dry-run", cleanupPlanData); const boardAuditRequestCountBefore = mock.requests.length; const boardAudit = await runCli(["gh", "issue", "board-audit", "--repo", "pikasTech/unidesk", "--limit", "100", "--dry-run"], env); assertCondition(boardAudit.status === 0, "issue board-audit should succeed as a read-only audit", boardAudit.json ?? { stdout: boardAudit.stdout, stderr: boardAudit.stderr }); const boardAuditData = dataOf(boardAudit.json ?? {}); assertCondition(boardAuditData.command === "issue board-audit" && boardAuditData.dryRun === true && boardAuditData.readOnly === true, "board-audit should be explicit read-only dry-run", boardAuditData); const boardAuditIssue = boardAuditData.boardIssue as JsonRecord; assertCondition(typeof boardAuditIssue.bodySha === "string" && String(boardAuditIssue.bodySha).length === 64, "board-audit should expose board body sha", boardAuditIssue); const boardAuditSummary = boardAuditData.summary as JsonRecord; assertCondition(boardAuditSummary.openIssues === null && boardAuditSummary.closedIssues === null, "board-audit should not fetch GitHub open/closed issue lists", boardAuditSummary); assertCondition(boardAuditSummary.openRows === 4 && boardAuditSummary.closedRows === 3 && boardAuditSummary.parsedSections === 2, "board-audit should report parsed board structure", boardAuditSummary); const boardAuditValidation = boardAuditData.validation as JsonRecord; const openClosedCoverage = boardAuditValidation.openClosedCoverage as JsonRecord; assertCondition(openClosedCoverage.enabled === false, "board-audit should disable OPEN/CLOSED coverage validation", boardAuditValidation); const missingOpenIssues = boardAuditData.missingOpenIssues as JsonRecord[]; assertCondition(Array.isArray(missingOpenIssues) && missingOpenIssues.length === 0, "board-audit should not report missing OPEN rows", missingOpenIssues); const closedInOpenRows = boardAuditData.closedInOpenRows as JsonRecord[]; assertCondition(Array.isArray(closedInOpenRows) && closedInOpenRows.length === 0, "board-audit should not report closed issues in OPEN rows", closedInOpenRows); const missingClosedRows = boardAuditData.missingClosedRows as JsonRecord[]; assertCondition(Array.isArray(missingClosedRows) && missingClosedRows.length === 0, "board-audit should not report missing CLOSED rows", missingClosedRows); const openInClosedRows = boardAuditData.openInClosedRows as JsonRecord[]; assertCondition(Array.isArray(openInClosedRows) && openInClosedRows.length === 0, "board-audit should not report open issues in CLOSED rows", openInClosedRows); const rowValidationWarnings = boardAuditData.rowValidationWarnings as JsonRecord[]; assertCondition(Array.isArray(rowValidationWarnings) && rowValidationWarnings.length === 0, "board-audit should not report required-column row warnings", rowValidationWarnings); const parserWarnings = boardAuditData.parserWarnings as JsonRecord[]; assertCondition(Array.isArray(parserWarnings), "board-audit should expose parser warnings separately", boardAuditData); const ignoredIssues = boardAuditData.ignoredIssues as JsonRecord[]; assertCondition(Array.isArray(ignoredIssues) && ignoredIssues.length === 0, "board-audit should not produce ignored issue coverage output", ignoredIssues); const recommendedActions = boardAuditData.recommendedActions as JsonRecord[]; assertCondition(Array.isArray(recommendedActions) && recommendedActions.length === 0, "board-audit should not emit OPEN/CLOSED coverage actions", recommendedActions); const boardAuditGetRequests = mock.requests.slice(boardAuditRequestCountBefore).filter((request) => request.method === "GET"); assertCondition(boardAuditGetRequests.length === 1 && boardAuditGetRequests[0]?.url === "/repos/pikasTech/unidesk/issues/20", "board-audit should only fetch the board issue body", boardAuditGetRequests); const boardAuditWriteCount = mock.requests.slice(boardAuditRequestCountBefore).filter((request) => request.method === "PATCH" || request.method === "DELETE" || request.method === "POST").length; assertCondition(boardAuditWriteCount === 0, "board-audit must not write GitHub", { requests: mock.requests.slice(boardAuditRequestCountBefore) }); const legacyBoardAuditRequestCountBefore = mock.requests.length; const legacyBoardAudit = await runCli(["gh", "issue", "board-audit", "--repo", "pikasTech/unidesk", "--board-issue", "60", "--limit", "60", "--dry-run"], env); assertCondition(legacyBoardAudit.status === 0, "legacy board-audit fixture should succeed", legacyBoardAudit.json ?? { stdout: legacyBoardAudit.stdout, stderr: legacyBoardAudit.stderr }); const legacyBoardAuditData = dataOf(legacyBoardAudit.json ?? {}); const legacyWarnings = legacyBoardAuditData.parserWarnings as JsonRecord[]; assertCondition(Array.isArray(legacyWarnings) && legacyWarnings.some((warning) => warning.kind === "multiple-issue-references" && warning.issueNumber === 101), "legacy board-audit fixture should keep parser warnings when no Issue column exists", legacyWarnings); const legacyBoardWriteCount = mock.requests.slice(legacyBoardAuditRequestCountBefore).filter((request) => request.method === "PATCH" || request.method === "DELETE" || request.method === "POST").length; assertCondition(legacyBoardWriteCount === 0, "legacy board-audit must not write GitHub", { requests: mock.requests.slice(legacyBoardAuditRequestCountBefore) }); const boardRowListRequestCountBefore = mock.requests.length; const boardRowList = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "open", "--dry-run"], env); assertCondition(boardRowList.status === 0, "board-row list should succeed", boardRowList.json ?? { stdout: boardRowList.stdout, stderr: boardRowList.stderr }); const boardRowListData = dataOf(boardRowList.json ?? {}); assertCondition(boardRowListData.command === "issue board-row list" && boardRowListData.readOnly === true && boardRowListData.dryRun === true, "board-row list should be read-only", boardRowListData); assertCondition(boardRowListData.state === "open" && boardRowListData.count === 4, "board-row list should filter OPEN rows", boardRowListData); const boardRowListBoardIssue = boardRowListData.boardIssue as JsonRecord; assertCondition(typeof boardRowListBoardIssue.bodySha === "string" && String(boardRowListBoardIssue.bodySha).length === 64, "board-row list should expose board body sha", boardRowListBoardIssue); const boardRowListRows = boardRowListData.rows as JsonRecord[]; assertCondition(Array.isArray(boardRowListRows) && boardRowListRows.some((row) => row.issueNumber === 45), "board-row list should use primary markdown issue link row keys", boardRowListRows); const listWriteCount = mock.requests.slice(boardRowListRequestCountBefore).filter((request) => request.method === "PATCH" || request.method === "DELETE" || request.method === "POST").length; assertCondition(listWriteCount === 0, "board-row list must not write GitHub", { requests: mock.requests.slice(boardRowListRequestCountBefore) }); const boardRowGet = await runCli(["gh", "issue", "board-row", "get", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20"], env); assertCondition(boardRowGet.status === 0, "board-row get should succeed", boardRowGet.json ?? { stdout: boardRowGet.stdout, stderr: boardRowGet.stderr }); const boardRowGetData = dataOf(boardRowGet.json ?? {}); const boardRowGetRow = boardRowGetData.row as JsonRecord; const boardRowGetFields = boardRowGetRow.fields as JsonRecord; assertCondition(boardRowGetRow.issueNumber === 35 && boardRowGetRow.section === "open", "board-row get should return the target row", boardRowGetData); assertCondition(Array.isArray(boardRowGetRow.cells) && boardRowGetRow.cells[1] === "OPEN", "board-row get should expose the GitHub status column", boardRowGetRow); assertCondition(boardRowGetFields.branch === "master" && boardRowGetFields.status === "pass" && boardRowGetFields.validation === "pass" && boardRowGetFields.tasks === "cq-35" && String(boardRowGetFields.focus ?? "").includes("当前关注点"), "board-row get should expose canonical field aliases", boardRowGetFields); const boardRowGetHint = boardRowGetData.codeQueueBoardHint as JsonRecord; assertCondition(boardRowGetHint.detected === false && String(boardRowGetHint.warning ?? "").includes("governance board only"), "board-row get should remind callers that #20 is governance-only", boardRowGetHint); const boardRowUpsertUpdateRequestCountBefore = mock.requests.length; const boardRowUpsertUpdate = await runCli([ "gh", "issue", "board-row", "upsert", "70", "--repo", "pikasTech/unidesk", "--board-issue", "62", "--section", "open", "--summary", "中文 `code` [#20](https://github.com/pikasTech/unidesk/issues/20) A | B\nsecond line", "--focus", "焦点 A | B\n下一行", "--validation", "manual pass", "--progress", "reviewing", "--dry-run", ], env); assertCondition(boardRowUpsertUpdate.status === 0, "board-row upsert existing row should dry-run update", boardRowUpsertUpdate.json ?? { stdout: boardRowUpsertUpdate.stdout, stderr: boardRowUpsertUpdate.stderr }); const boardRowUpsertUpdateData = dataOf(boardRowUpsertUpdate.json ?? {}); assertCondition(boardRowUpsertUpdateData.command === "issue board-row upsert" && boardRowUpsertUpdateData.operation === "update" && boardRowUpsertUpdateData.dryRun === true && boardRowUpsertUpdateData.planned === true, "upsert existing row should report update operation", boardRowUpsertUpdateData); const boardRowUpsertUpdatePlan = boardRowUpsertUpdateData.upsert as JsonRecord; assertCondition(boardRowUpsertUpdatePlan.operation === "update" && boardRowUpsertUpdatePlan.section === "open", "upsert update plan should stay in existing section", boardRowUpsertUpdatePlan); assertCondition(boardRowUpsertUpdatePlan.oldRow === "| #70 | OPEN | cli | master | existing summary | pass | cq-70 | existing focus | doing |", "upsert update should expose old row", boardRowUpsertUpdatePlan); assertCondition(boardRowUpsertUpdatePlan.newRow === "| #70 | OPEN | cli | master | 中文 `code` [#20](https://github.com/pikasTech/unidesk/issues/20) A \\| B second line | manual pass | cq-70 | 焦点 A \\| B 下一行 | reviewing |", "upsert update should escape pipes, preserve backticks/link text, and fold real newlines", boardRowUpsertUpdatePlan); const boardRowUpsertUpdateSafety = boardRowUpsertUpdateData.bodyOnlySafety as JsonRecord; const boardRowUpsertUpdateNewBody = boardRowUpsertUpdateSafety.newBody as JsonRecord; assertCondition(boardRowUpsertUpdateNewBody.containsLiteralBackslashN === false && boardRowUpsertUpdateNewBody.containsBackticks === true && boardRowUpsertUpdateNewBody.containsMarkdownTable === true, "upsert update should preserve markdown safety signals", boardRowUpsertUpdateNewBody); const boardRowUpsertUpdateWriteCount = mock.requests.slice(boardRowUpsertUpdateRequestCountBefore).filter((request) => request.method === "PATCH").length; assertCondition(boardRowUpsertUpdateWriteCount === 0, "board-row upsert update dry-run must not PATCH GitHub", { requests: mock.requests.slice(boardRowUpsertUpdateRequestCountBefore) }); const boardRowUpsertAddRequestCountBefore = mock.requests.length; const boardRowUpsertAdd = await runCli([ "gh", "issue", "board-row", "upsert", "73", "--repo", "pikasTech/unidesk", "--board-issue", "62", "--section", "open", "--category", "cli", "--branch", "master", "--tasks", "cq-73", "--summary", "新增中文 `code` [#20](https://github.com/pikasTech/unidesk/issues/20) A | B\n真实换行", "--focus", "关注 | 重点\nnext", "--validation", "pending", "--progress", "queued", "--dry-run", ], env); assertCondition(boardRowUpsertAdd.status === 0, "board-row upsert missing row should dry-run add", boardRowUpsertAdd.json ?? { stdout: boardRowUpsertAdd.stdout, stderr: boardRowUpsertAdd.stderr }); const boardRowUpsertAddData = dataOf(boardRowUpsertAdd.json ?? {}); assertCondition(boardRowUpsertAddData.command === "issue board-row upsert" && boardRowUpsertAddData.operation === "add" && boardRowUpsertAddData.dryRun === true && boardRowUpsertAddData.planned === true, "upsert missing row should report add operation", boardRowUpsertAddData); const boardRowUpsertAddPlan = boardRowUpsertAddData.upsert as JsonRecord; assertCondition(boardRowUpsertAddPlan.operation === "add" && boardRowUpsertAddPlan.section === "open" && Number(boardRowUpsertAddPlan.insertAfterLine ?? 0) > 0, "upsert add should expose insertion plan", boardRowUpsertAddPlan); assertCondition(boardRowUpsertAddPlan.newRow === "| #73 | OPEN | cli | master | 新增中文 `code` [#20](https://github.com/pikasTech/unidesk/issues/20) A \\| B 真实换行 | pending | cq-73 | 关注 \\| 重点 next | queued |", "upsert add should generate a full escaped row", boardRowUpsertAddPlan); const boardRowUpsertAddNewBody = ((boardRowUpsertAddData.bodyOnlySafety as JsonRecord).newBody as JsonRecord); assertCondition(boardRowUpsertAddNewBody.containsLiteralBackslashN === false && boardRowUpsertAddNewBody.containsBackticks === true, "upsert add should not introduce literal backslash-n", boardRowUpsertAddNewBody); const boardRowUpsertAddWriteCount = mock.requests.slice(boardRowUpsertAddRequestCountBefore).filter((request) => request.method === "PATCH").length; assertCondition(boardRowUpsertAddWriteCount === 0, "board-row upsert add dry-run must not PATCH GitHub", { requests: mock.requests.slice(boardRowUpsertAddRequestCountBefore) }); const boardRowUpsertNoGuardRequestCountBefore = mock.requests.length; const boardRowUpsertNoGuard = await runCli([ "gh", "issue", "board-row", "upsert", "74", "--repo", "pikasTech/unidesk", "--board-issue", "62", "--section", "open", "--category", "cli", "--branch", "master", "--tasks", "cq-74", "--summary", "formal write omitted guard", "--focus", "focus", "--validation", "pending", "--progress", "queued", ], env); assertCondition(boardRowUpsertNoGuard.status === 0, "board-row upsert without guard should stay on the dry-run path", boardRowUpsertNoGuard.json ?? { stdout: boardRowUpsertNoGuard.stdout, stderr: boardRowUpsertNoGuard.stderr }); const boardRowUpsertNoGuardData = dataOf(boardRowUpsertNoGuard.json ?? {}); assertCondition(boardRowUpsertNoGuardData.dryRun === true && boardRowUpsertNoGuardData.planned === true && boardRowUpsertNoGuardData.operation === "add", "upsert without guard should not PATCH GitHub", boardRowUpsertNoGuardData); const boardRowUpsertNoGuardWriteCount = mock.requests.slice(boardRowUpsertNoGuardRequestCountBefore).filter((request) => request.method === "PATCH").length; assertCondition(boardRowUpsertNoGuardWriteCount === 0, "board-row upsert without guard must not PATCH GitHub", { requests: mock.requests.slice(boardRowUpsertNoGuardRequestCountBefore) }); const boardRowUpsertListBeforeWrite = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "62", "--state", "all"], env); assertCondition(boardRowUpsertListBeforeWrite.status === 0, "upsert board list before guarded write should succeed", boardRowUpsertListBeforeWrite.json ?? { stdout: boardRowUpsertListBeforeWrite.stdout, stderr: boardRowUpsertListBeforeWrite.stderr }); const boardRowUpsertBoardSha = String((dataOf(boardRowUpsertListBeforeWrite.json ?? {}).boardIssue as JsonRecord).bodySha ?? ""); const boardRowUpsertWriteRequestCountBefore = mock.requests.length; const boardRowUpsertWrite = await runCli([ "gh", "issue", "board-row", "upsert", "73", "--repo", "pikasTech/unidesk", "--board-issue", "62", "--section", "open", "--category", "cli", "--branch", "master", "--tasks", "cq-73", "--summary", "guarded add keeps table trailer", "--focus", "focus", "--validation", "pending", "--progress", "queued", "--expect-body-sha", boardRowUpsertBoardSha, ], env); assertCondition(boardRowUpsertWrite.status === 0, "board-row upsert with expect body sha should PATCH", boardRowUpsertWrite.json ?? { stdout: boardRowUpsertWrite.stdout, stderr: boardRowUpsertWrite.stderr }); const boardRowUpsertWriteData = dataOf(boardRowUpsertWrite.json ?? {}); assertCondition(boardRowUpsertWriteData.dryRun === false && boardRowUpsertWriteData.rest === true && boardRowUpsertWriteData.operation === "add", "upsert guarded write should report real add", boardRowUpsertWriteData); const boardRowUpsertWriteRequests = mock.requests.slice(boardRowUpsertWriteRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/62"); assertCondition(boardRowUpsertWriteRequests.length === 1, "board-row upsert should send exactly one PATCH", { requests: mock.requests.slice(boardRowUpsertWriteRequestCountBefore) }); const boardRowUpsertWritePayload = JSON.parse(boardRowUpsertWriteRequests[0]?.body ?? "{}") as JsonRecord; const boardRowUpsertWrittenBody = String(boardRowUpsertWritePayload.body ?? ""); assertCondition(boardRowUpsertWrittenBody.includes("| #73 | OPEN | cli | master | guarded add keeps table trailer | pending | cq-73 | focus | queued |"), "upsert write payload should include generated row", boardRowUpsertWritePayload); assertCondition(boardRowUpsertWrittenBody.includes("## 看板(CLOSED)") && boardRowUpsertWrittenBody.includes("## 更新 2026-05-21 10:00 北京时间") && boardRowUpsertWrittenBody.includes("- 表后的更新段落必须保留。"), "upsert write should preserve CLOSED table and post-table update section", boardRowUpsertWritePayload); const boardRowUpsertStale = await runCli([ "gh", "issue", "board-row", "upsert", "76", "--repo", "pikasTech/unidesk", "--board-issue", "62", "--section", "open", "--category", "cli", "--branch", "master", "--tasks", "cq-76", "--summary", "stale guard must fail", "--focus", "focus", "--validation", "pending", "--progress", "queued", "--expect-body-sha", boardRowUpsertBoardSha, ], env); assertCondition(boardRowUpsertStale.status !== 0, "stale board-row upsert should fail structurally", boardRowUpsertStale.json ?? { stdout: boardRowUpsertStale.stdout, stderr: boardRowUpsertStale.stderr }); const boardRowUpsertStaleData = failedDataOf(boardRowUpsertStale.json ?? {}); assertCondition(boardRowUpsertStaleData.degradedReason === "validation-failed" && boardRowUpsertStaleData.command === "issue board-row upsert", "stale board-row upsert should be validation-failed", boardRowUpsertStaleData); const boardRowUpsertDuplicate = await runCli([ "gh", "issue", "board-row", "upsert", "72", "--repo", "pikasTech/unidesk", "--board-issue", "62", "--section", "open", "--focus", "duplicate should fail", "--dry-run", ], env); assertCondition(boardRowUpsertDuplicate.status !== 0, "duplicate board-row upsert should fail structurally", boardRowUpsertDuplicate.json ?? { stdout: boardRowUpsertDuplicate.stdout, stderr: boardRowUpsertDuplicate.stderr }); const boardRowUpsertDuplicateData = failedDataOf(boardRowUpsertDuplicate.json ?? {}); assertCondition(String((boardRowUpsertDuplicateData.details as JsonRecord).message ?? "").includes("ambiguous"), "duplicate upsert should report ambiguous row", boardRowUpsertDuplicateData); const boardRowUpsertSectionConflict = await runCli([ "gh", "issue", "board-row", "upsert", "70", "--repo", "pikasTech/unidesk", "--board-issue", "62", "--section", "closed", "--focus", "migration belongs to move", "--dry-run", ], env); assertCondition(boardRowUpsertSectionConflict.status !== 0, "upsert section conflict should fail structurally", boardRowUpsertSectionConflict.json ?? { stdout: boardRowUpsertSectionConflict.stdout, stderr: boardRowUpsertSectionConflict.stderr }); const boardRowUpsertSectionConflictData = failedDataOf(boardRowUpsertSectionConflict.json ?? {}); assertCondition(String((boardRowUpsertSectionConflictData.details as JsonRecord).message ?? "").includes("use gh issue board-row move"), "upsert section conflict should point to move", boardRowUpsertSectionConflictData); const boardRowUpsertMissingField = await runCli([ "gh", "issue", "board-row", "upsert", "75", "--repo", "pikasTech/unidesk", "--board-issue", "62", "--section", "open", "--category", "cli", "--branch", "master", "--tasks", "cq-75", "--summary", "missing progress field", "--focus", "focus", "--validation", "pending", "--dry-run", ], env); assertCondition(boardRowUpsertMissingField.status !== 0, "upsert add missing required generated cell should fail", boardRowUpsertMissingField.json ?? { stdout: boardRowUpsertMissingField.stdout, stderr: boardRowUpsertMissingField.stderr }); const boardRowUpsertMissingFieldData = failedDataOf(boardRowUpsertMissingField.json ?? {}); const boardRowUpsertMissingFieldDetails = boardRowUpsertMissingFieldData.details as JsonRecord; assertCondition(String(boardRowUpsertMissingFieldDetails.message ?? "").includes("requires values") && Array.isArray(boardRowUpsertMissingFieldData.missingFields) && (boardRowUpsertMissingFieldData.missingFields as unknown[]).includes("progress"), "upsert missing field should be structured", boardRowUpsertMissingFieldData); const boardRowUpsertColumnMismatch = await runCli([ "gh", "issue", "board-row", "upsert", "79", "--repo", "pikasTech/unidesk", "--board-issue", "62", "--focus", "bad existing row", "--dry-run", ], env); assertCondition(boardRowUpsertColumnMismatch.status !== 0, "upsert existing row with column mismatch should fail", boardRowUpsertColumnMismatch.json ?? { stdout: boardRowUpsertColumnMismatch.stdout, stderr: boardRowUpsertColumnMismatch.stderr }); const boardRowUpsertColumnMismatchData = failedDataOf(boardRowUpsertColumnMismatch.json ?? {}); assertCondition(String((boardRowUpsertColumnMismatchData.details as JsonRecord).message ?? "").includes("column count"), "upsert column mismatch should be structured", boardRowUpsertColumnMismatchData); const boardRowDryRunRequestCountBefore = mock.requests.length; const boardRowDryRun = await runCli(["gh", "issue", "board-row", "update", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--field", "focus", "--value", "复核 A | B\nsecond line"], env); assertCondition(boardRowDryRun.status === 0, "board-row update should default to dry-run without concurrency expectation", boardRowDryRun.json ?? { stdout: boardRowDryRun.stdout, stderr: boardRowDryRun.stderr }); const boardRowDryRunData = dataOf(boardRowDryRun.json ?? {}); assertCondition(boardRowDryRunData.command === "issue board-row update" && boardRowDryRunData.dryRun === true && boardRowDryRunData.planned === true, "board-row update should default to dry-run", boardRowDryRunData); const dryRunUpdate = boardRowDryRunData.update as JsonRecord; assertCondition(dryRunUpdate.oldRow === "| #35 | OPEN | master | pass | cq-35 | 当前关注点:#19 / #26 / #30 | doing |", "board-row dry-run should expose old row", dryRunUpdate); assertCondition(dryRunUpdate.newRow === "| #35 | OPEN | master | pass | cq-35 | 复核 A \\| B second line | doing |", "board-row dry-run should escape table pipes and fold cell newlines", dryRunUpdate); const dryRunGuard = boardRowDryRunData.guard as JsonRecord; assertCondition(dryRunGuard.ok === true, "board-row dry-run should include body guard result", dryRunGuard); const dryRunSafety = boardRowDryRunData.bodyOnlySafety as JsonRecord; const dryRunOldBody = dryRunSafety.oldBody as JsonRecord; const dryRunNewBody = dryRunSafety.newBody as JsonRecord; assertCondition(typeof dryRunOldBody.bodySha === "string" && String(dryRunOldBody.bodySha).length === 64, "board-row dry-run should expose old body sha", dryRunSafety); assertCondition(dryRunNewBody.containsLiteralBackslashN === false && dryRunNewBody.shellPollution && typeof dryRunNewBody.shellPollution === "object", "board-row dry-run must not introduce literal backslash-n pollution", dryRunNewBody); const defaultDryRunWriteCount = mock.requests.slice(boardRowDryRunRequestCountBefore).filter((request) => request.method === "PATCH").length; assertCondition(defaultDryRunWriteCount === 0, "board-row default dry-run must not PATCH GitHub", { requests: mock.requests.slice(boardRowDryRunRequestCountBefore) }); const boardRowValidationDryRun = await runCli(["gh", "issue", "board-row", "update", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--field", "validation", "--value", "manual pass", "--dry-run"], env); assertCondition(boardRowValidationDryRun.status === 0, "board-row update validation alias should dry-run", boardRowValidationDryRun.json ?? { stdout: boardRowValidationDryRun.stdout, stderr: boardRowValidationDryRun.stderr }); const boardRowValidationData = dataOf(boardRowValidationDryRun.json ?? {}); const validationUpdate = boardRowValidationData.update as JsonRecord; assertCondition(validationUpdate.targetColumn === "acceptance" && validationUpdate.targetColumnIndex === 3, "validation field should map to 验收状态/acceptance column", validationUpdate); const boardRowPollutedValue = await runCli(["gh", "issue", "board-row", "update", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--field", "focus", "--value", "bad\\nvalue", "--dry-run"], env); assertCondition(boardRowPollutedValue.status !== 0, "board-row update should reject literal backslash-n values", boardRowPollutedValue.json ?? { stdout: boardRowPollutedValue.stdout, stderr: boardRowPollutedValue.stderr }); const boardRowPollutedData = failedDataOf(boardRowPollutedValue.json ?? {}); const boardRowPollutedDetails = boardRowPollutedData.details as JsonRecord; assertCondition(boardRowPollutedData.degradedReason === "validation-failed" && String(boardRowPollutedDetails.message ?? "").includes("--value contains literal shell escape"), "board-row polluted value should fail before planning", boardRowPollutedData); const boardRowPatchRequestCountBefore = mock.requests.length; const oldBodySha = String(dryRunOldBody.bodySha); const boardRowPatch = await runCli(["gh", "issue", "board-row", "update", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--field", "focus", "--value", "复核 A | B\nsecond line", "--expect-body-sha", oldBodySha], env); assertCondition(boardRowPatch.status === 0, "board-row update with expect body sha should PATCH", boardRowPatch.json ?? { stdout: boardRowPatch.stdout, stderr: boardRowPatch.stderr }); const boardRowPatchData = dataOf(boardRowPatch.json ?? {}); assertCondition(boardRowPatchData.dryRun === false && boardRowPatchData.rest === true, "board-row patch should report a real REST update", boardRowPatchData); const boardRowPatchRequests = mock.requests.slice(boardRowPatchRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20"); assertCondition(boardRowPatchRequests.length === 1, "board-row patch should send exactly one PATCH", { requests: mock.requests.slice(boardRowPatchRequestCountBefore) }); const boardRowPatchPayload = JSON.parse(boardRowPatchRequests[0]?.body ?? "{}") as JsonRecord; assertCondition(typeof boardRowPatchPayload.body === "string", "board-row patch payload should carry body string", boardRowPatchPayload); const patchedBody = String(boardRowPatchPayload.body ?? ""); assertCondition(patchedBody.includes("| #35 | OPEN | master | pass | cq-35 | 复核 A \\| B second line | doing |"), "board-row patch payload should contain escaped updated row", patchedBody); assertCondition(!patchedBody.includes("\\n"), "board-row patch payload should not add literal backslash-n pollution to markdown body", patchedBody); const addRowFile = join(tmp, "board-add-row.md"); writeFileSync(addRowFile, "| #91 | OPEN | master | pass | cq-91 | 新增 open row | doing |\n", "utf8"); const noGuardRowFile = join(tmp, "board-add-row-no-guard.md"); writeFileSync(noGuardRowFile, "| #94 | OPEN | master | pass | cq-94 | no guard row | doing |\n", "utf8"); const mismatchRowFile = join(tmp, "board-add-row-mismatch.md"); writeFileSync(mismatchRowFile, "| #92 | OPEN | master | pass | cq-92 | too few |\n", "utf8"); const statusMismatchRowFile = join(tmp, "board-add-row-status-mismatch.md"); writeFileSync(statusMismatchRowFile, "| #93 | OPEN | master | pass | cq-93 | status mismatch row | doing |\n", "utf8"); const boardRowAddNoGuardRequestCountBefore = mock.requests.length; const boardRowAddNoGuard = await runCli(["gh", "issue", "board-row", "add", "94", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "open", "--row-file", noGuardRowFile], env); assertCondition(boardRowAddNoGuard.status === 0, "board-row add without guard should stay on the dry-run path", boardRowAddNoGuard.json ?? { stdout: boardRowAddNoGuard.stdout, stderr: boardRowAddNoGuard.stderr }); const boardRowAddNoGuardData = dataOf(boardRowAddNoGuard.json ?? {}); assertCondition(boardRowAddNoGuardData.dryRun === true && boardRowAddNoGuardData.planned === true, "board-row add without guard should not PATCH GitHub", boardRowAddNoGuardData); const boardRowAddNoGuardPlan = boardRowAddNoGuardData.add as JsonRecord; assertCondition(boardRowAddNoGuardPlan.section === "open" && Number(boardRowAddNoGuardPlan.insertAfterLine ?? 0) > 0, "board-row add without guard should still return an insertion plan", boardRowAddNoGuardPlan); const boardRowAddNoGuardWriteCount = mock.requests.slice(boardRowAddNoGuardRequestCountBefore).filter((request) => request.method === "PATCH").length; assertCondition(boardRowAddNoGuardWriteCount === 0, "board-row add without guard must not PATCH GitHub", { requests: mock.requests.slice(boardRowAddNoGuardRequestCountBefore) }); const boardRowAddDryRunRequestCountBefore = mock.requests.length; const boardRowAddDryRun = await runCli(["gh", "issue", "board-row", "add", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "open", "--row-file", addRowFile, "--dry-run"], env); assertCondition(boardRowAddDryRun.status === 0, "board-row add dry-run should succeed", boardRowAddDryRun.json ?? { stdout: boardRowAddDryRun.stdout, stderr: boardRowAddDryRun.stderr }); const boardRowAddDryRunData = dataOf(boardRowAddDryRun.json ?? {}); assertCondition(boardRowAddDryRunData.command === "issue board-row add" && boardRowAddDryRunData.dryRun === true && boardRowAddDryRunData.planned === true, "board-row add should default to dry-run", boardRowAddDryRunData); const boardRowAddDryRunPlan = boardRowAddDryRunData.add as JsonRecord; const boardRowAddDryRunValidation = boardRowAddDryRunPlan.validation as JsonRecord; assertCondition(boardRowAddDryRunPlan.section === "open" && boardRowAddDryRunValidation.actualStatus === "OPEN", "board-row add dry-run should validate the target section and GitHub status", boardRowAddDryRunPlan); const boardRowAddDryRunPatchCount = mock.requests.slice(boardRowAddDryRunRequestCountBefore).filter((request) => request.method === "PATCH").length; assertCondition(boardRowAddDryRunPatchCount === 0, "board-row add dry-run must not PATCH GitHub", { requests: mock.requests.slice(boardRowAddDryRunRequestCountBefore) }); const boardRowListBeforeAdd = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "open"], env); assertCondition(boardRowListBeforeAdd.status === 0, "board-row list before add should succeed", boardRowListBeforeAdd.json ?? { stdout: boardRowListBeforeAdd.stdout }); const boardRowListBeforeAddData = dataOf(boardRowListBeforeAdd.json ?? {}); const boardRowAddBoardSha = String((boardRowListBeforeAddData.boardIssue as JsonRecord).bodySha ?? ""); const boardRowAddRequestCountBefore = mock.requests.length; const boardRowAdd = await runCli(["gh", "issue", "board-row", "add", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "open", "--row-file", addRowFile, "--expect-body-sha", boardRowAddBoardSha], env); assertCondition(boardRowAdd.status === 0, "board-row add with expect body sha should PATCH", boardRowAdd.json ?? { stdout: boardRowAdd.stdout, stderr: boardRowAdd.stderr }); const boardRowAddData = dataOf(boardRowAdd.json ?? {}); assertCondition(boardRowAddData.dryRun === false && boardRowAddData.rest === true, "board-row add should report a real REST update", boardRowAddData); const boardRowAddPlan = boardRowAddData.add as JsonRecord; const boardRowAddValidation = boardRowAddPlan.validation as JsonRecord; assertCondition(boardRowAddPlan.section === "open" && boardRowAddValidation.expectedStatus === "OPEN" && boardRowAddValidation.actualStatus === "OPEN", "board-row add should validate section/status alignment", boardRowAddPlan); const boardRowAddRequests = mock.requests.slice(boardRowAddRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20"); assertCondition(boardRowAddRequests.length === 1, "board-row add should send exactly one PATCH", { requests: mock.requests.slice(boardRowAddRequestCountBefore) }); const boardRowAddPayload = JSON.parse(boardRowAddRequests[0]?.body ?? "{}") as JsonRecord; assertCondition(String(boardRowAddPayload.body ?? "").includes("| #91 | OPEN | master | pass | cq-91 | 新增 open row | doing |"), "board-row add payload should contain the inserted row", boardRowAddPayload); const boardRowGetAdded = await runCli(["gh", "issue", "board-row", "get", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20"], env); assertCondition(boardRowGetAdded.status === 0, "board-row get should find the added row", boardRowGetAdded.json ?? { stdout: boardRowGetAdded.stdout, stderr: boardRowGetAdded.stderr }); const boardRowGetAddedData = dataOf(boardRowGetAdded.json ?? {}); const boardRowGetAddedRow = boardRowGetAddedData.row as JsonRecord; assertCondition(boardRowGetAddedRow.issueNumber === 91 && boardRowGetAddedRow.section === "open", "board-row get should see the added issue in OPEN", boardRowGetAddedData); const boardRowListAfterAdd = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "open"], env); assertCondition(boardRowListAfterAdd.status === 0, "board-row list after add should succeed", boardRowListAfterAdd.json ?? { stdout: boardRowListAfterAdd.stdout }); const boardRowListAfterAddData = dataOf(boardRowListAfterAdd.json ?? {}); const boardRowListAfterAddRows = boardRowListAfterAddData.rows as JsonRecord[]; assertCondition(boardRowListAfterAddData.count === 5 && boardRowListAfterAddRows.some((row) => row.issueNumber === 91), "board-row add should make the new row visible to the parser", boardRowListAfterAddData); const boardRowAddDuplicate = await runCli(["gh", "issue", "board-row", "add", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "open", "--row-file", addRowFile], env); assertCondition(boardRowAddDuplicate.status !== 0, "duplicate board-row add should fail structurally", boardRowAddDuplicate.json ?? { stdout: boardRowAddDuplicate.stdout, stderr: boardRowAddDuplicate.stderr }); const boardRowAddDuplicateData = failedDataOf(boardRowAddDuplicate.json ?? {}); assertCondition(String((boardRowAddDuplicateData.details as JsonRecord).message ?? "").includes("already exists"), "duplicate add should report duplicate row", boardRowAddDuplicateData); const boardRowAddMismatch = await runCli(["gh", "issue", "board-row", "add", "92", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "open", "--row-file", mismatchRowFile], env); assertCondition(boardRowAddMismatch.status !== 0, "column count mismatch should fail structurally", boardRowAddMismatch.json ?? { stdout: boardRowAddMismatch.stdout, stderr: boardRowAddMismatch.stderr }); const boardRowAddMismatchData = failedDataOf(boardRowAddMismatch.json ?? {}); assertCondition(String((boardRowAddMismatchData.details as JsonRecord).message ?? "").includes("column count"), "column mismatch should be reported", boardRowAddMismatchData); const boardRowAddStatusConflict = await runCli(["gh", "issue", "board-row", "add", "93", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "closed", "--row-file", statusMismatchRowFile], env); assertCondition(boardRowAddStatusConflict.status !== 0, "section/status mismatch should fail structurally", boardRowAddStatusConflict.json ?? { stdout: boardRowAddStatusConflict.stdout, stderr: boardRowAddStatusConflict.stderr }); const boardRowAddStatusConflictData = failedDataOf(boardRowAddStatusConflict.json ?? {}); assertCondition(String((boardRowAddStatusConflictData.details as JsonRecord).message ?? "").includes("GitHub 状态"), "section/status mismatch should be reported", boardRowAddStatusConflictData); const boardRowDeleteNoGuardRequestCountBefore = mock.requests.length; const boardRowDeleteNoGuard = await runCli(["gh", "issue", "board-row", "delete", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20"], env); assertCondition(boardRowDeleteNoGuard.status === 0, "board-row delete without guard should stay on the dry-run path", boardRowDeleteNoGuard.json ?? { stdout: boardRowDeleteNoGuard.stdout, stderr: boardRowDeleteNoGuard.stderr }); const boardRowDeleteNoGuardData = dataOf(boardRowDeleteNoGuard.json ?? {}); assertCondition(boardRowDeleteNoGuardData.dryRun === true && boardRowDeleteNoGuardData.planned === true, "board-row delete without guard should not PATCH GitHub", boardRowDeleteNoGuardData); const boardRowDeleteNoGuardPlan = boardRowDeleteNoGuardData.delete as JsonRecord; assertCondition(boardRowDeleteNoGuardPlan.section === "open" && Number(boardRowDeleteNoGuardPlan.lineNumber ?? 0) > 0, "board-row delete without guard should return the matched row plan", boardRowDeleteNoGuardPlan); const boardRowDeleteNoGuardLinePlan = boardRowDeleteNoGuardPlan.linePlan as JsonRecord; assertCondition(boardRowDeleteNoGuardLinePlan.action === "remove" && Number(boardRowDeleteNoGuardLinePlan.lineNumber ?? 0) > 0, "board-row delete should expose a line plan", boardRowDeleteNoGuardLinePlan); const boardRowDeleteNoGuardWriteCount = mock.requests.slice(boardRowDeleteNoGuardRequestCountBefore).filter((request) => request.method === "PATCH").length; assertCondition(boardRowDeleteNoGuardWriteCount === 0, "board-row delete without guard must not PATCH GitHub", { requests: mock.requests.slice(boardRowDeleteNoGuardRequestCountBefore) }); const boardRowDeleteStale = await runCli(["gh", "issue", "board-row", "delete", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--expect-body-sha", boardRowAddBoardSha], env); assertCondition(boardRowDeleteStale.status !== 0, "stale board-row delete should fail structurally", boardRowDeleteStale.json ?? { stdout: boardRowDeleteStale.stdout, stderr: boardRowDeleteStale.stderr }); const boardRowDeleteStaleData = failedDataOf(boardRowDeleteStale.json ?? {}); assertCondition(boardRowDeleteStaleData.degradedReason === "validation-failed", "stale board-row delete should be validation-failed", boardRowDeleteStaleData); const boardRowDeleteBoardSha = String((boardRowListAfterAddData.boardIssue as JsonRecord).bodySha ?? ""); const boardRowDeleteRequestCountBefore = mock.requests.length; const boardRowDelete = await runCli(["gh", "issue", "board-row", "delete", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--expect-body-sha", boardRowDeleteBoardSha], env); assertCondition(boardRowDelete.status === 0, "board-row delete with expect body sha should PATCH", boardRowDelete.json ?? { stdout: boardRowDelete.stdout, stderr: boardRowDelete.stderr }); const boardRowDeleteData = dataOf(boardRowDelete.json ?? {}); assertCondition(boardRowDeleteData.dryRun === false && boardRowDeleteData.rest === true, "board-row delete should report a real REST update", boardRowDeleteData); const boardRowDeleteRequests = mock.requests.slice(boardRowDeleteRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20"); assertCondition(boardRowDeleteRequests.length === 1, "board-row delete should send exactly one PATCH", { requests: mock.requests.slice(boardRowDeleteRequestCountBefore) }); const boardRowDeletePayload = JSON.parse(boardRowDeleteRequests[0]?.body ?? "{}") as JsonRecord; assertCondition(!String(boardRowDeletePayload.body ?? "").includes("#91"), "board-row delete payload should remove the row", boardRowDeletePayload); const boardRowGetDeleted = await runCli(["gh", "issue", "board-row", "get", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20"], env); assertCondition(boardRowGetDeleted.status !== 0, "deleted board-row should no longer be discoverable", boardRowGetDeleted.json ?? { stdout: boardRowGetDeleted.stdout, stderr: boardRowGetDeleted.stderr }); const boardRowGetDeletedData = failedDataOf(boardRowGetDeleted.json ?? {}); assertCondition(String((boardRowGetDeletedData.details as JsonRecord).message ?? "").includes("was not found"), "deleted board-row should report missing row", boardRowGetDeletedData); const boardRowListAfterDelete = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "open"], env); assertCondition(boardRowListAfterDelete.status === 0, "board-row list after delete should succeed", boardRowListAfterDelete.json ?? { stdout: boardRowListAfterDelete.stdout }); const boardRowListAfterDeleteData = dataOf(boardRowListAfterDelete.json ?? {}); const boardRowListAfterDeleteNumbers = (boardRowListAfterDeleteData.rows as JsonRecord[]).map((row) => row.issueNumber); assertCondition(boardRowListAfterDeleteData.count === 4 && JSON.stringify(boardRowListAfterDeleteNumbers) === JSON.stringify([20, 35, 45, 40]), "board-row delete should preserve other row order", boardRowListAfterDeleteData); const boardRowMoveDryRun = await runCli(["gh", "issue", "board-row", "move", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--to", "closed", "--status", "CLOSED", "--dry-run"], env); assertCondition(boardRowMoveDryRun.status === 0, "board-row move dry-run should succeed", boardRowMoveDryRun.json ?? { stdout: boardRowMoveDryRun.stdout, stderr: boardRowMoveDryRun.stderr }); const boardRowMoveDryRunData = dataOf(boardRowMoveDryRun.json ?? {}); assertCondition(boardRowMoveDryRunData.command === "issue board-row move" && boardRowMoveDryRunData.dryRun === true && boardRowMoveDryRunData.planned === true, "board-row move should default to dry-run", boardRowMoveDryRunData); const boardRowMoveDryRunPlan = boardRowMoveDryRunData.move as JsonRecord; const boardRowMoveDryRunStatus = boardRowMoveDryRunPlan.status as JsonRecord; assertCondition(boardRowMoveDryRunPlan.from === "open" && boardRowMoveDryRunPlan.to === "closed" && boardRowMoveDryRunStatus.requested === "CLOSED", "board-row move dry-run should plan the cross-section migration", boardRowMoveDryRunPlan); const boardRowMoveConflict = await runCli(["gh", "issue", "board-row", "move", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--to", "closed", "--status", "OPEN", "--dry-run"], env); assertCondition(boardRowMoveConflict.status !== 0, "board-row move status conflict should fail structurally", boardRowMoveConflict.json ?? { stdout: boardRowMoveConflict.stdout, stderr: boardRowMoveConflict.stderr }); const boardRowMoveConflictData = failedDataOf(boardRowMoveConflict.json ?? {}); assertCondition(String((boardRowMoveConflictData.details as JsonRecord).message ?? "").includes("conflicts with --to"), "board-row move should report status conflict", boardRowMoveConflictData); const boardRowListBeforeMove = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "open"], env); assertCondition(boardRowListBeforeMove.status === 0, "board-row list before move should succeed", boardRowListBeforeMove.json ?? { stdout: boardRowListBeforeMove.stdout }); const boardRowMoveBoardSha = String((dataOf(boardRowListBeforeMove.json ?? {}).boardIssue as JsonRecord).bodySha ?? ""); const boardRowMoveRequestCountBefore = mock.requests.length; const boardRowMove = await runCli(["gh", "issue", "board-row", "move", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--to", "closed", "--status", "CLOSED", "--expect-body-sha", boardRowMoveBoardSha], env); assertCondition(boardRowMove.status === 0, "board-row move with expect body sha should PATCH", boardRowMove.json ?? { stdout: boardRowMove.stdout, stderr: boardRowMove.stderr }); const boardRowMoveData = dataOf(boardRowMove.json ?? {}); assertCondition(boardRowMoveData.dryRun === false && boardRowMoveData.rest === true, "board-row move should report a real REST update", boardRowMoveData); const boardRowMovePlan = boardRowMoveData.move as JsonRecord; const boardRowMoveStatus = boardRowMovePlan.status as JsonRecord; assertCondition(boardRowMovePlan.from === "open" && boardRowMovePlan.to === "closed" && boardRowMoveStatus.new === "CLOSED", "board-row move should update the GitHub status column", boardRowMovePlan); const boardRowMoveLinePlan = boardRowMovePlan.linePlan as JsonRecord; const boardRowMoveSectionPlan = boardRowMovePlan.sectionPlan as JsonRecord; assertCondition(boardRowMoveLinePlan.action === "move" && Number(boardRowMoveLinePlan.sourceLineNumber ?? 0) > 0 && Number(boardRowMoveLinePlan.newLineNumber ?? 0) > 0, "board-row move should expose a line plan", boardRowMoveLinePlan); assertCondition(boardRowMoveSectionPlan.from === "open" && boardRowMoveSectionPlan.to === "closed", "board-row move should expose a section plan", boardRowMoveSectionPlan); const boardRowMoveRequests = mock.requests.slice(boardRowMoveRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20"); assertCondition(boardRowMoveRequests.length === 1, "board-row move should send exactly one PATCH", { requests: mock.requests.slice(boardRowMoveRequestCountBefore) }); const boardRowMovePayload = JSON.parse(boardRowMoveRequests[0]?.body ?? "{}") as JsonRecord; assertCondition(String(boardRowMovePayload.body ?? "").includes("| #35 | CLOSED | master | pass | cq-35 |"), "board-row move payload should move the row into CLOSED", boardRowMovePayload); const boardRowOpenAfterMove = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "open"], env); const boardRowOpenAfterMoveData = dataOf(boardRowOpenAfterMove.json ?? {}); assertCondition(boardRowOpenAfterMoveData.count === 3 && !(boardRowOpenAfterMoveData.rows as JsonRecord[]).some((row) => row.issueNumber === 35), "board-row move should remove the row from OPEN", boardRowOpenAfterMoveData); const boardRowClosedAfterMove = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "closed"], env); const boardRowClosedAfterMoveData = dataOf(boardRowClosedAfterMove.json ?? {}); const boardRowClosedAfterMoveRows = boardRowClosedAfterMoveData.rows as JsonRecord[]; assertCondition(boardRowClosedAfterMoveData.count === 4 && boardRowClosedAfterMoveRows.some((row) => row.issueNumber === 35), "board-row move should add the row to CLOSED", boardRowClosedAfterMoveData); const boardRowMoved = await runCli(["gh", "issue", "board-row", "get", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20"], env); const boardRowMovedData = dataOf(boardRowMoved.json ?? {}); const boardRowMovedRow = boardRowMovedData.row as JsonRecord; assertCondition(boardRowMovedRow.section === "closed" && Array.isArray(boardRowMovedRow.cells) && boardRowMovedRow.cells[1] === "CLOSED", "board-row move should preserve row fields while updating GitHub status", boardRowMovedRow); const boardRowMoveTargetSectionPlan = boardRowMovePlan.sectionPlan as JsonRecord; assertCondition(boardRowMoveTargetSectionPlan.targetHeading === "## 看板(CLOSED)", "board-row move should point to the CLOSED section heading", boardRowMoveTargetSectionPlan); const badListField = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--json", "number,body"], env); assertCondition(badListField.status !== 0, "issue list unsupported --json field should fail", badListField.json ?? { stdout: badListField.stdout }); const badListFieldData = failedDataOf(badListField.json ?? {}); assertCondition(badListFieldData.degradedReason === "validation-failed", "issue list unsupported --json should be validation-failed", badListFieldData); assertCondition(badListFieldData.runnerDisposition === "business-failed", "issue list unsupported --json should be business-failed", badListFieldData); const badState = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "triaged"], env); assertCondition(badState.status !== 0, "issue list unsupported state should fail", badState.json ?? { stdout: badState.stdout }); const badStateData = failedDataOf(badState.json ?? {}); assertCondition(badStateData.degradedReason === "validation-failed", "issue list unsupported state should be validation-failed", badStateData); assertCondition(badStateData.runnerDisposition === "business-failed", "issue list unsupported state should be business-failed", badStateData); const readBody = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env); assertCondition(readBody.status === 0, "issue read --json body should succeed", readBody.json ?? { stdout: readBody.stdout }); const readBodyData = dataOf(readBody.json ?? {}); const readIssue = readBodyData.issue as JsonRecord; assertCondition(typeof readIssue.body === "string" && readIssue.body.includes("## 看板(OPEN)"), ".data.issue.body should remain readable", readBodyData); const readBodyHint = readBodyData.codeQueueBoardHint as JsonRecord; assertCondition(readBodyHint.detected === false && String(readBodyHint.warning ?? "").includes("governance board only"), "issue read #20 should remind callers that #20 is governance-only", readBodyHint); const selectedJson = readBodyData.json as JsonRecord; assertCondition(typeof selectedJson.body === "string" && selectedJson.body === readIssue.body, "selected json body should match issue body", readBodyData); assertCondition(!("comments" in selectedJson), "--json body should not imply comments field", selectedJson); const viewBody = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env); assertCondition(viewBody.status === 0, "issue view should succeed as canonical read path", viewBody.json ?? { stdout: viewBody.stdout }); const viewBodyData = dataOf(viewBody.json ?? {}); const viewIssue = viewBodyData.issue as JsonRecord; assertCondition(typeof viewIssue.body === "string" && viewIssue.body.includes("## 看板(OPEN)"), "issue view should keep .data.issue.body readable", viewBodyData); const viewSelectedJson = viewBodyData.json as JsonRecord; assertCondition(typeof viewSelectedJson.body === "string" && viewSelectedJson.body === readIssue.body, "issue view should preserve selected json body", viewBodyData); const issueUrlView = await runCli(["gh", "issue", "view", "https://github.com/pikasTech/HWLAB/issues/7", "--json", "body,title,state"], env); assertCondition(issueUrlView.status === 0, "issue view should accept GitHub issue URL target", issueUrlView.json ?? { stdout: issueUrlView.stdout }); const issueUrlViewData = dataOf(issueUrlView.json ?? {}); assertCondition(issueUrlViewData.repo === "pikasTech/HWLAB", "issue URL target should derive repo", issueUrlViewData); assertCondition((issueUrlViewData.issue as JsonRecord).number === 7, "issue URL target should derive issue number", issueUrlViewData); const issueUrlDisclosure = issueUrlViewData.disclosure as JsonRecord; assertCondition(issueUrlDisclosure.shorthand && (issueUrlDisclosure.shorthand as JsonRecord).source === "github-url", "issue URL target should be disclosed", issueUrlDisclosure); const issuePrUrlMismatch = await runCli(["gh", "issue", "view", "https://github.com/pikasTech/HWLAB/pull/7", "--json", "body"], env); assertCondition(issuePrUrlMismatch.status !== 0, "issue view should reject PR URLs", issuePrUrlMismatch.json ?? { stdout: issuePrUrlMismatch.stdout }); const issuePrUrlMismatchData = failedDataOf(issuePrUrlMismatch.json ?? {}); assertCondition(failureMessageOf(issuePrUrlMismatchData).includes("GitHub pr URL"), "issue view PR URL mismatch should be explicit", issuePrUrlMismatchData); const issueNumberOption = await runCli(["gh", "issue", "view", "--repo", "pikasTech/HWLAB", "--number", "7", "--json", "body"], env); assertCondition(issueNumberOption.status === 0, "issue view should accept --number compatibility alias", issueNumberOption.json ?? { stdout: issueNumberOption.stdout }); const issueNumberOptionData = dataOf(issueNumberOption.json ?? {}); assertCondition(issueNumberOptionData.repo === "pikasTech/HWLAB", "issue view --number should preserve explicit repo", issueNumberOptionData); assertCondition((issueNumberOptionData.issue as JsonRecord).number === 7, "issue view --number should read the requested issue", issueNumberOptionData); const issueNumberOptionHint = issueNumberOptionData.standardSyntaxHint as JsonRecord; assertCondition(issueNumberOptionHint.compatibility === true && String(issueNumberOptionHint.standardCommand ?? "").includes("gh issue view 7 --repo pikasTech/HWLAB"), "issue view --number should return standard syntax hint", issueNumberOptionHint); const shorthandRaw = await runCli(["gh", "issue", "view", "pikasTech/HWLAB#7", "--raw"], env); assertCondition(shorthandRaw.status === 0, "issue view should accept owner/repo#number shorthand with --raw", shorthandRaw.json ?? { stdout: shorthandRaw.stdout }); const shorthandRawData = dataOf(shorthandRaw.json ?? {}); assertCondition(shorthandRawData.repo === "pikasTech/HWLAB", "issue shorthand should derive repo from owner/repo#number", shorthandRawData); const shorthandIssueData = shorthandRawData.issue as JsonRecord; assertCondition(shorthandIssueData.number === 7 && String(shorthandIssueData.body ?? "").includes("shorthand body fixture"), "issue shorthand should read the requested issue", shorthandRawData); const shorthandDisclosure = shorthandRawData.disclosure as JsonRecord; assertCondition(shorthandDisclosure.raw === true && shorthandDisclosure.fullDisclosure === true, "--raw should mark explicit full disclosure", shorthandDisclosure); const shorthandSelected = shorthandRawData.json as JsonRecord; assertCondition(shorthandSelected.body === shorthandIssueData.body && Array.isArray(shorthandSelected.comments), "--raw should select the supported full issue read field set", shorthandRawData); assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/issues/7"), "issue shorthand should call the derived repo REST path", mock.requests); const shorthandConflict = await runCli(["gh", "issue", "read", "pikasTech/HWLAB#7", "--repo", "pikasTech/unidesk", "--raw"], env); assertCondition(shorthandConflict.status !== 0, "issue shorthand with conflicting --repo should fail", shorthandConflict.json ?? { stdout: shorthandConflict.stdout }); const shorthandConflictData = failedDataOf(shorthandConflict.json ?? {}); assertCondition(shorthandConflictData.degradedReason === "validation-failed", "conflicting --repo should be validation-failed", shorthandConflictData); assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "conflict message should name the derived repo", shorthandConflictData); const issueConflictCommands = shorthandConflictData.supportedCommands as string[]; assertCondition(Array.isArray(issueConflictCommands) && issueConflictCommands.some((command) => command === "bun scripts/cli.ts gh issue view 7 --repo pikasTech/HWLAB --json body,title,state,closed,closedAt,comments,commentCount,number,url,author,createdAt,updatedAt"), "conflict should include the exact supported issue view command", shorthandConflictData); const rawIssueList = await runCli(["gh", "issue", "list", "--raw"], env); assertCondition(rawIssueList.status === 0, "issue list --raw should be a supported explicit list disclosure path", rawIssueList.json ?? { stdout: rawIssueList.stdout }); const rawIssueListData = dataOf(rawIssueList.json ?? {}); assertCondition(rawIssueListData.command === "issue list" && rawIssueListData.rawCount === 3, "issue list --raw should keep compact list semantics with raw pagination metadata", rawIssueListData); const readFields = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body,title,state,closed,closedAt,comments,commentCount"], env); assertCondition(readFields.status === 0, "common --json field selection should succeed", readFields.json ?? { stdout: readFields.stdout }); const readFieldsData = dataOf(readFields.json ?? {}); const fieldsJson = readFieldsData.json as JsonRecord; assertCondition(fieldsJson.title === "长期总看板", "selected json title should be exposed", fieldsJson); assertCondition(fieldsJson.closed === false && fieldsJson.closedAt === null, "open issue read should expose lifecycle fields", fieldsJson); assertCondition(Array.isArray(fieldsJson.comments) && fieldsJson.comments.length === 1, "selected json comments should be exposed", fieldsJson); assertCondition(fieldsJson.commentCount === 1, "selected json commentCount should be exposed", fieldsJson); const unsupported = await runCli(["gh", "issue", "read", "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 pollutedBoardFile = join(tmp, "polluted-board.md"); writeFileSync(pollutedBoardFile, [ "# Code Queue", "", "## 看板(OPEN)", "", "| Issue | GitHub 状态 | Branch | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", "| --- | --- | --- | --- | --- | --- | --- |", "| #20 | OPEN | master | meta | governance | active | active |", "", "## 更新 2026-05-21 15:18 北京时间", "", "- 这类每日简报段落必须写到每日滚动简报 issue,而不是 #20。", "", ].join("\n"), "utf8"); const pollutedBoard = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", pollutedBoardFile, "--dry-run"], env); assertCondition(pollutedBoard.status !== 0, "#20 body guard should reject commander brief update sections", pollutedBoard.json ?? { stdout: pollutedBoard.stdout }); const pollutedBoardData = failedDataOf(pollutedBoard.json ?? {}); const pollutedBoardGuard = pollutedBoardData.guard as JsonRecord; assertCondition(Array.isArray(pollutedBoardGuard.failures) && pollutedBoardGuard.failures.includes("code-queue-board-contains-commander-brief-updates"), "#20 guard should report commander brief pollution", pollutedBoardGuard); const pollutedBoardHint = pollutedBoardGuard.codeQueueBoardHint as JsonRecord; assertCondition(pollutedBoardHint.detected === true && String(pollutedBoardHint.route ?? "").includes("daily rolling commander brief"), "#20 guard should hint to move updates to the daily brief issue", pollutedBoardHint); const hwlabProductBoardFile = join(tmp, "hwlab-product-board.md"); writeFileSync(hwlabProductBoardFile, [ "# Code Queue", "", "## 看板(OPEN)", "", "| Issue | GitHub 状态 | Branch | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", "| --- | --- | --- | --- | --- | --- | --- |", "| [pikasTech/HWLAB#108](https://github.com/pikasTech/HWLAB/issues/108) HWLAB user feedback | OPEN | main | product | cq-hwlab | Cloud Workbench 用户反馈 | doing |", "", ].join("\n"), "utf8"); const hwlabProductBoard = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", hwlabProductBoardFile, "--dry-run"], env); assertCondition(hwlabProductBoard.status === 0, "#20 body guard should warn, not reject, HWLAB product issue rows", hwlabProductBoard.json ?? { stdout: hwlabProductBoard.stdout }); const hwlabProductBoardData = dataOf(hwlabProductBoard.json ?? {}); const hwlabProductBoardGuard = hwlabProductBoardData.guard as JsonRecord; assertCondition(hwlabProductBoardGuard.ok === true, "#20 body guard should keep replacement allowed when only HWLAB product routing is detected", hwlabProductBoardGuard); assertCondition(Array.isArray(hwlabProductBoardGuard.warnings) && hwlabProductBoardGuard.warnings.includes("code-queue-board-contains-hwlab-product-work"), "#20 guard should warn about HWLAB product routing pollution", hwlabProductBoardGuard); const hwlabProductHint = hwlabProductBoardGuard.codeQueueBoardHint as JsonRecord; const hwlabProductRouting = hwlabProductHint.hwlabProductRouting as JsonRecord; assertCondition(hwlabProductRouting.detected === true && String(hwlabProductRouting.route ?? "").includes("pikasTech/HWLAB"), "#20 guard should route HWLAB product issues to the HWLAB repo", hwlabProductRouting); const hwlabProductUpsert = await runCli([ "gh", "issue", "board-row", "upsert", "108", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "open", "--branch", "main", "--tasks", "cq-hwlab", "--summary", "pikasTech/HWLAB#108 HWLAB user feedback", "--focus", "Cloud Workbench 用户反馈", "--validation", "product", "--progress", "doing", "--dry-run", ], env); assertCondition(hwlabProductUpsert.status === 0, "#20 board-row upsert should warn, not reject, HWLAB product issue rows", hwlabProductUpsert.json ?? { stdout: hwlabProductUpsert.stdout }); const hwlabProductUpsertData = dataOf(hwlabProductUpsert.json ?? {}); const hwlabProductUpsertGuard = hwlabProductUpsertData.guard as JsonRecord; assertCondition(Array.isArray(hwlabProductUpsertGuard.warnings) && hwlabProductUpsertGuard.warnings.includes("code-queue-board-contains-hwlab-product-work"), "board-row upsert guard should report HWLAB product routing pollution", hwlabProductUpsertGuard); const hwlabGovernanceGuard = await runCli([ "gh", "issue", "board-row", "upsert", "109", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "open", "--branch", "master", "--tasks", "cq-guard", "--summary", "UniDesk CLI guard for HWLAB#108 routing", "--focus", "commander governance guard prevents HWLAB product misfile", "--validation", "dry-run guard", "--progress", "ready", "--dry-run", ], env); assertCondition(hwlabGovernanceGuard.status === 0, "#20 board-row upsert should allow UniDesk governance rows that mention HWLAB as routing context", hwlabGovernanceGuard.json ?? { stdout: hwlabGovernanceGuard.stdout }); const hwlabGovernanceGuardData = dataOf(hwlabGovernanceGuard.json ?? {}); const hwlabGovernanceGuardSummary = hwlabGovernanceGuardData.guard as JsonRecord; assertCondition(hwlabGovernanceGuardSummary.ok === true && !(hwlabGovernanceGuardSummary.warnings as unknown[]).includes("code-queue-board-contains-hwlab-product-work"), "governance rows mentioning HWLAB should not be classified as HWLAB product work", hwlabGovernanceGuardSummary); 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); assertCondition(!briefWrongProfile.stdout.includes(env.GH_TOKEN) && !briefWrongProfile.stderr.includes(env.GH_TOKEN), "failed profile output must not print GH_TOKEN", { stdout: briefWrongProfile.stdout, stderr: briefWrongProfile.stderr, }); 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 validBriefFile = join(tmp, "valid-commander-brief.md"); writeFileSync(validBriefFile, [ "# 2026-05-21 指挥简报(北京时间)", "", "## 常驻观察与长期建议", "", "- 保持滚动简报正文只通过 heredoc/stdin 更新。", "", "## 更新 2026-05-21 15:18 北京时间", "", "- 今日新增进展包含 `code` 和表格。", "", "| 项 | 状态 |", "| --- | --- |", "| CLI | guarded |", "", ].join("\n"), "utf8"); const legacyBriefDryRun = await runCli(["gh", "issue", "update", "24", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", validBriefFile, "--body-profile", "commander-brief", "--dry-run"], env); assertCondition(legacyBriefDryRun.status === 0, "#24 explicit commander-brief profile should remain compatible", legacyBriefDryRun.json ?? { stdout: legacyBriefDryRun.stdout }); const legacyBriefData = dataOf(legacyBriefDryRun.json ?? {}); const legacyBriefGuard = legacyBriefData.guard as JsonRecord; const legacyBriefProfile = legacyBriefGuard.profile as JsonRecord; assertCondition(legacyBriefGuard.ok === true && legacyBriefProfile.issueMatchesProfile === true && legacyBriefProfile.issueMatchReason === "legacy-issue-number", "#24 commander-brief profile should pass by legacy issue number", legacyBriefGuard); const dailyBriefDryRun = await runCli(["gh", "issue", "update", "46", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", validBriefFile, "--body-profile", "commander-brief", "--dry-run"], env); assertCondition(dailyBriefDryRun.status === 0, "daily commander brief issue should pass commander-brief dry-run guard", dailyBriefDryRun.json ?? { stdout: dailyBriefDryRun.stdout }); const dailyBriefData = dataOf(dailyBriefDryRun.json ?? {}); const dailyBriefGuard = dailyBriefData.guard as JsonRecord; const dailyBriefProfile = dailyBriefGuard.profile as JsonRecord; assertCondition(dailyBriefGuard.ok === true && dailyBriefProfile.issueMatchesProfile === true && dailyBriefProfile.issueMatchReason === "daily-title", "daily commander brief profile should match by title", dailyBriefGuard); assertCondition(dailyBriefData.containsLiteralBackslashN === false && dailyBriefData.containsMarkdownTable === true, "daily brief dry-run should preserve markdown safety signals", dailyBriefData); const nonBriefDryRun = await runCli(["gh", "issue", "update", "47", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", validBriefFile, "--body-profile", "commander-brief", "--dry-run"], env); assertCondition(nonBriefDryRun.status !== 0, "non-brief issue should fail commander-brief profile", nonBriefDryRun.json ?? { stdout: nonBriefDryRun.stdout }); const nonBriefData = failedDataOf(nonBriefDryRun.json ?? {}); const nonBriefGuard = nonBriefData.guard as JsonRecord; assertCondition(Array.isArray(nonBriefGuard.failures) && nonBriefGuard.failures.includes("profile-issue-mismatch"), "non-brief issue should report profile-issue-mismatch", nonBriefGuard); assertCondition(!nonBriefDryRun.stdout.includes(env.GH_TOKEN) && !nonBriefDryRun.stderr.includes(env.GH_TOKEN), "non-brief failure must not print GH_TOKEN", { stdout: nonBriefDryRun.stdout, stderr: nonBriefDryRun.stderr, }); 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); const appendFile = join(tmp, "append.md"); writeFileSync(appendFile, "\n- appended `code`\n| c | d |\n| --- | --- |\n| 3 | 4 |\n", "utf8"); const issueCreateRequestCountBeforeDryRun = mock.requests.length; const issueCreateDryRun = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "body file dry-run", "--body-file", appendFile, "--label", "cli,infra", "--label", "ops", "--dry-run"], env); assertCondition(issueCreateDryRun.status === 0, "issue create dry-run should succeed", issueCreateDryRun.json ?? { stdout: issueCreateDryRun.stdout }); const issueCreateDryRunData = dataOf(issueCreateDryRun.json ?? {}); const issueCreateBodySource = issueCreateDryRunData.bodySource as JsonRecord; assertCondition(issueCreateDryRunData.planned === true && issueCreateBodySource.kind === "body-file" && issueCreateBodySource.path === appendFile, "issue create dry-run should expose body-file source", issueCreateDryRunData); const issueCreateDryRunLabels = issueCreateDryRunData.labels as unknown[]; assertCondition(Array.isArray(issueCreateDryRunLabels) && issueCreateDryRunLabels.join(",") === "cli,infra,ops", "issue create dry-run should parse repeated and comma labels", issueCreateDryRunData); const issueCreateDryRunRequest = issueCreateDryRunData.request as JsonRecord; const issueCreateDryRunRequestBody = issueCreateDryRunRequest.body as JsonRecord; assertCondition(Array.isArray(issueCreateDryRunRequestBody.labels) && (issueCreateDryRunRequestBody.labels as unknown[]).join(",") === "cli,infra,ops", "issue create dry-run request plan should include labels", issueCreateDryRunData); assertCondition(issueCreateDryRunData.request && typeof issueCreateDryRunData.request === "object", "issue create dry-run should expose request plan", issueCreateDryRunData); const issueCreateDryRunWriteCount = mock.requests.slice(issueCreateRequestCountBeforeDryRun).filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues").length; assertCondition(issueCreateDryRunWriteCount === 0, "issue create dry-run must not POST GitHub", { requests: mock.requests.slice(issueCreateRequestCountBeforeDryRun) }); const issueCreateRequestCountBeforeWrite = mock.requests.length; const issueCreate = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "body file write", "--body-file", appendFile, "--label", "cli", "--label", "infra,ops"], env); assertCondition(issueCreate.status === 0, "issue create with labels should succeed", issueCreate.json ?? { stdout: issueCreate.stdout }); const issueCreateData = dataOf(issueCreate.json ?? {}); assertCondition(issueCreateData.command === "issue create", "issue create should report command name", issueCreateData); const issueCreateLabels = issueCreateData.labels as unknown[]; assertCondition(Array.isArray(issueCreateLabels) && issueCreateLabels.join(",") === "cli,infra,ops", "issue create should report labels", issueCreateData); const issueCreateRequest = mock.requests.slice(issueCreateRequestCountBeforeWrite).find((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues"); assertCondition(issueCreateRequest !== undefined, "issue create should POST to GitHub REST issues endpoint", { requests: mock.requests.slice(issueCreateRequestCountBeforeWrite) }); const issueCreatePayload = JSON.parse(issueCreateRequest?.body ?? "{}") as JsonRecord; assertCondition(Array.isArray(issueCreatePayload.labels) && (issueCreatePayload.labels as unknown[]).join(",") === "cli,infra,ops", "issue create REST payload should include labels", issueCreatePayload); const issueCreateStdinBody = "# stdin issue create\n\n- preserves `code`\n"; const issueCreateStdinRequestCountBefore = mock.requests.length; const issueCreateStdin = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "stdin issue create", "--body-file", "-", "--dry-run"], env, issueCreateStdinBody); assertCondition(issueCreateStdin.status === 0, "issue create dry-run should accept --body-file - stdin", issueCreateStdin.json ?? { stdout: issueCreateStdin.stdout }); const issueCreateStdinData = dataOf(issueCreateStdin.json ?? {}); const issueCreateStdinSource = issueCreateStdinData.bodySource as JsonRecord; assertCondition(issueCreateStdinSource.kind === "stdin" && issueCreateStdinSource.path === "-", "issue create stdin dry-run should expose stdin source", issueCreateStdinData); assertCondition(issueCreateStdinData.containsBackticks === true && issueCreateStdinData.containsLiteralBackslashN === false, "issue create stdin should preserve Markdown signals", issueCreateStdinData); const issueCreateStdinWriteCount = mock.requests.slice(issueCreateStdinRequestCountBefore).filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues").length; assertCondition(issueCreateStdinWriteCount === 0, "issue create stdin dry-run must not POST GitHub", { requests: mock.requests.slice(issueCreateStdinRequestCountBefore) }); const issueCreateBodyStdin = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "body-stdin issue create", "--body-stdin", "--dry-run"], env, issueCreateStdinBody); assertCondition(issueCreateBodyStdin.status === 0, "issue create dry-run should accept --body-stdin", issueCreateBodyStdin.json ?? { stdout: issueCreateBodyStdin.stdout }); const issueCreateBodyStdinData = dataOf(issueCreateBodyStdin.json ?? {}); const issueCreateBodyStdinSource = issueCreateBodyStdinData.bodySource as JsonRecord; assertCondition(issueCreateBodyStdinSource.kind === "stdin" && issueCreateBodyStdinSource.path === "-", "issue create --body-stdin should expose stdin source", issueCreateBodyStdinData); const issueCreateInline = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "inline rejected", "--body", "inline body", "--dry-run"], env); assertCondition(issueCreateInline.status !== 0, "issue create inline --body should fail", issueCreateInline.json ?? { stdout: issueCreateInline.stdout }); const issueCreateInlineData = failedDataOf(issueCreateInline.json ?? {}); assertCondition(failureMessageOf(issueCreateInlineData).includes("does not support --body"), "issue create inline --body should point to body-stdin", issueCreateInlineData); const issueCreateMissingLabel = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "bad label", "--body-file", appendFile, "--label", "missing-label"], env); assertCondition(issueCreateMissingLabel.status !== 0, "issue create missing label should fail structurally", issueCreateMissingLabel.json ?? { stdout: issueCreateMissingLabel.stdout }); const missingLabelData = failedDataOf(issueCreateMissingLabel.json ?? {}); assertCondition(missingLabelData.degradedReason === "validation-failed", "missing label should map to validation-failed", missingLabelData); const missingLabelDetails = missingLabelData.details as JsonRecord; const missingLabelNestedDetails = missingLabelDetails.details as JsonRecord; assertCondition(Array.isArray(missingLabelNestedDetails.errors), "missing label error should preserve GitHub validation errors", missingLabelData); const appendDryRun = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "append", "--body-file", appendFile, "--dry-run"], env); assertCondition(appendDryRun.status === 0, "issue update append dry-run should succeed", appendDryRun.json ?? { stdout: appendDryRun.stdout }); const appendData = dataOf(appendDryRun.json ?? {}); assertCondition(appendData.command === "issue update", "update command should be primary", appendData); assertCondition(appendData.mode === "append", "append mode should be explicit", appendData); assertCondition(appendData.containsBackticks === true && appendData.containsMarkdownTable === true, "append should preserve markdown signals", appendData); assertCondition(appendData.containsLiteralBackslashN === false, "append should preserve real newlines", appendData); const replaceDryRun = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", safeFile, "--dry-run"], env); assertCondition(replaceDryRun.status === 0, "issue update replace dry-run should succeed", replaceDryRun.json ?? { stdout: replaceDryRun.stdout }); const replaceData = dataOf(replaceDryRun.json ?? {}); assertCondition(replaceData.command === "issue update" && replaceData.mode === "replace", "replace mode should be explicit", replaceData); const replaceDisclosure = replaceData.disclosure as JsonRecord; const replaceReadCommands = replaceData.readCommands as JsonRecord; assertCondition(replaceDisclosure.bodyOmitted === true && replaceDisclosure.dryRunBoundedPreview === true, "issue update dry-run should disclose compact body policy", replaceDisclosure); assertCondition(typeof replaceReadCommands.full === "string" && String(replaceReadCommands.full).includes("gh issue view 20"), "issue update dry-run should expose full body drill-down", replaceReadCommands); const replaceWouldPatch = replaceData.wouldPatch as JsonRecord; assertCondition(typeof replaceWouldPatch.bodySha === "string" && String(replaceWouldPatch.bodySha).length === 64, "issue update dry-run should include wouldPatch body sha", replaceWouldPatch); assertCondition(Number(replaceWouldPatch.bodyChars ?? 0) === Number(replaceData.bodyChars ?? 0), "issue update dry-run wouldPatch should include final body chars", replaceWouldPatch); const replaceNumberDryRun = await runCli(["gh", "issue", "update", "--number", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", safeFile, "--dry-run"], env); assertCondition(replaceNumberDryRun.status === 0, "issue update should accept --number compatibility alias", replaceNumberDryRun.json ?? { stdout: replaceNumberDryRun.stdout }); const replaceNumberData = dataOf(replaceNumberDryRun.json ?? {}); const replaceNumberHint = replaceNumberData.standardSyntaxHint as JsonRecord; assertCondition(String(replaceNumberHint.standardCommand ?? "").includes("gh issue update 20 --repo pikasTech/unidesk"), "issue update --number should return standard syntax hint", replaceNumberHint); const compactLongBody = Array.from({ length: 260 }, (_, index) => `compact-success-line-${String(index + 1).padStart(4, "0")} ${"x".repeat(80)}`).join("\n"); const compactLongFile = join(tmp, "compact-long-body.md"); writeFileSync(compactLongFile, compactLongBody, "utf8"); const compactUpdateRequestCountBefore = mock.requests.length; const compactUpdate = await runCli(["gh", "issue", "update", "7", "--repo", "pikasTech/HWLAB", "--mode", "replace", "--body-file", compactLongFile], env); assertCondition(compactUpdate.status === 0, "issue update non-dry-run compact success should succeed", compactUpdate.json ?? { stdout: compactUpdate.stdout, stderr: compactUpdate.stderr }); assertCondition(compactUpdate.stdout.length < 20_000, "issue update compact success stdout should stay bounded for long bodies", { bytes: compactUpdate.stdout.length }); assertCondition(!compactUpdate.stdout.includes("compact-success-line-0260"), "default issue update success stdout must not echo the full long body tail", { tail: compactUpdate.stdout.slice(-1000) }); const compactUpdateData = dataOf(compactUpdate.json ?? {}); const compactIssue = compactUpdateData.issue as JsonRecord; assertCondition(compactUpdateData.command === "issue update" && compactUpdateData.rest === true, "compact update should report REST success", compactUpdateData); assertCondition(!("body" in compactIssue), "default issue update success should omit issue.body", compactIssue); assertCondition(compactIssue.bodyOmitted === true && compactIssue.fullBodyIncluded === false, "compact issue summary should mark omitted full body", compactIssue); assertCondition(Number(compactIssue.bodyChars ?? 0) === compactLongBody.length, "compact issue summary should include bodyChars", compactIssue); assertCondition(typeof compactIssue.bodySha === "string" && String(compactIssue.bodySha).length === 64, "compact issue summary should include bodySha", compactIssue); assertCondition(String(compactIssue.bodyPreview ?? "").includes("compact-success-line-0001") && !String(compactIssue.bodyPreview ?? "").includes("compact-success-line-0260"), "compact issue summary should include only bounded preview", compactIssue); const compactConcurrency = compactUpdateData.concurrency as JsonRecord; assertCondition(compactConcurrency.checked === true && typeof compactConcurrency.oldBodySha === "string" && compactConcurrency.expectBodySha === null, "compact update should automatically read old issue metadata before PATCH", compactConcurrency); const compactGuard = compactUpdateData.guard as JsonRecord; assertCondition(compactGuard.ok === true && typeof compactGuard.bodySha === "string", "compact update should keep guard/body sha summary", compactGuard); const compactDisclosure = compactUpdateData.disclosure as JsonRecord; assertCondition(compactDisclosure.bodyOmitted === true && compactDisclosure.fullBodyIncluded === false && compactDisclosure.defaultCompact === true, "compact update disclosure should be explicit", compactDisclosure); const compactCommands = compactUpdateData.readCommands as JsonRecord; assertCondition(String(compactCommands.body ?? "").includes("gh issue view 7 --repo pikasTech/HWLAB --json body"), "compact update should expose body view command", compactCommands); assertCondition(String(compactCommands.full ?? "").includes("--full") && String(compactCommands.raw ?? "").includes("--raw"), "compact update should expose full/raw drill-down", compactCommands); const compactUpdatePatchCount = mock.requests.slice(compactUpdateRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/HWLAB/issues/7").length; assertCondition(compactUpdatePatchCount === 1, "compact update should PATCH GitHub exactly once", { requests: mock.requests.slice(compactUpdateRequestCountBefore) }); const stdinIssueBody = "# Code Queue\n\n## 看板(OPEN)\n\n- stdin issue body keeps `code`.\n\n| s | t |\n| --- | --- |\n| 5 | 6 |\n"; const stdinUpdateRequestCountBefore = mock.requests.length; const stdinUpdate = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", "-"], env, stdinIssueBody); assertCondition(stdinUpdate.status === 0, "issue update should accept --body-file - stdin", stdinUpdate.json ?? { stdout: stdinUpdate.stdout, stderr: stdinUpdate.stderr }); const stdinUpdateData = dataOf(stdinUpdate.json ?? {}); const stdinUpdateBodySource = stdinUpdateData.bodySource as JsonRecord; assertCondition(stdinUpdateBodySource.kind === "stdin" && stdinUpdateBodySource.path === "-", "stdin issue update should report stdin bodySource", stdinUpdateData); const stdinUpdateConcurrency = stdinUpdateData.concurrency as JsonRecord; assertCondition(stdinUpdateConcurrency.checked === true && typeof stdinUpdateConcurrency.oldBodySha === "string", "stdin issue update should automatically read current issue metadata before PATCH", stdinUpdateConcurrency); const stdinUpdatePatch = mock.requests.slice(stdinUpdateRequestCountBefore).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20"); assertCondition(stdinUpdatePatch !== undefined, "stdin issue update should PATCH issue body", { requests: mock.requests.slice(stdinUpdateRequestCountBefore) }); const stdinUpdatePayload = JSON.parse(stdinUpdatePatch?.body ?? "{}") as JsonRecord; assertCondition(stdinUpdatePayload.body === stdinIssueBody, "stdin issue update should preserve exact stdin Markdown", stdinUpdatePayload); const explicitFullBody = "# compact full body\n\nThis body is intentionally short enough to avoid global dump while still proving explicit disclosure.\n"; const explicitFullFile = join(tmp, "explicit-full-body.md"); writeFileSync(explicitFullFile, explicitFullBody, "utf8"); const explicitFullUpdate = await runCli(["gh", "issue", "update", "7", "--repo", "pikasTech/HWLAB", "--mode", "replace", "--body-file", explicitFullFile, "--full"], env); assertCondition(explicitFullUpdate.status === 0, "issue update --full should succeed", explicitFullUpdate.json ?? { stdout: explicitFullUpdate.stdout, stderr: explicitFullUpdate.stderr }); const explicitFullData = dataOf(explicitFullUpdate.json ?? {}); const explicitFullIssue = explicitFullData.issue as JsonRecord; assertCondition(typeof explicitFullIssue.body === "string" && explicitFullIssue.body === explicitFullBody, "issue update --full should explicitly include the full body", explicitFullIssue); const explicitFullDisclosure = explicitFullData.disclosure as JsonRecord; assertCondition(explicitFullDisclosure.fullBodyIncluded === true && explicitFullDisclosure.explicitFullDisclosure === true, "issue update --full disclosure should mark full body inclusion", explicitFullDisclosure); const commentCreate = await runCli(["gh", "issue", "comment", "create", "20", "--repo", "pikasTech/unidesk", "--body-file", appendFile], env); assertCondition(commentCreate.status === 0, "issue comment create should succeed", commentCreate.json ?? { stdout: commentCreate.stdout }); const commentCreateData = dataOf(commentCreate.json ?? {}); assertCondition(commentCreateData.command === "issue comment create", "comment create should use CRUD command name", commentCreateData); assertCondition(commentCreateData.source === "body-file" && typeof commentCreateData.bodySha === "string", "issue comment body-file write should expose low-noise source and bodySha", commentCreateData); const commentCreateSummary = commentCreateData.comment as JsonRecord; assertCondition(commentCreateSummary.bodyOmitted === true && !("body" in commentCreateSummary), "issue comment write should not echo full comment body by default", commentCreateSummary); const inlineBody = "短评:已完成 #76 CLI inline body dry-run"; const inlineDryRunRequestCountBefore = mock.requests.length; const inlineDryRun = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", inlineBody, "--dry-run"], env); assertCondition(inlineDryRun.status === 0, "issue comment inline body dry-run should succeed", inlineDryRun.json ?? { stdout: inlineDryRun.stdout }); assertCondition(inlineDryRun.json?.command === "gh issue comment create 36 --repo pikasTech/unidesk --body --dry-run", "outer gh command should redact inline body", inlineDryRun.json ?? {}); const inlineDryRunData = dataOf(inlineDryRun.json ?? {}); assertCondition(inlineDryRunData.dryRun === true && inlineDryRunData.planned === true, "inline issue comment dry-run should be planned", inlineDryRunData); assertCondition(inlineDryRunData.issueNumber === 36 && inlineDryRunData.source === "inline", "inline issue comment dry-run should preserve issue number and source", inlineDryRunData); const inlineDryRunSource = inlineDryRunData.bodySource as JsonRecord; assertCondition(inlineDryRunSource.kind === "inline" && inlineDryRunSource.maxInlineBodyChars === 1000, "inline issue comment dry-run should expose inline source policy", inlineDryRunSource); assertCondition(Number(inlineDryRunData.bodyChars ?? 0) === inlineBody.length && typeof inlineDryRunData.bodySha === "string", "inline issue comment dry-run should expose bodyChars/bodySha", inlineDryRunData); assertCondition(String(inlineDryRunData.bodyPreview ?? "") === inlineBody, "inline issue comment dry-run should provide bounded preview for short text", inlineDryRunData); const inlineDryRunReadCommands = inlineDryRunData.readCommands as JsonRecord; assertCondition(String(inlineDryRunReadCommands.comments ?? "").includes("gh issue view 36") && String(inlineDryRunReadCommands.comments ?? "").includes("--json comments"), "inline issue comment dry-run should expose comment view command", inlineDryRunReadCommands); const inlineDryRunWriteCount = mock.requests.slice(inlineDryRunRequestCountBefore).filter((request) => request.method === "POST" && request.url.includes("/comments")).length; assertCondition(inlineDryRunWriteCount === 0, "inline issue comment dry-run must not POST GitHub", { requests: mock.requests.slice(inlineDryRunRequestCountBefore) }); const inlineWriteRequestCountBefore = mock.requests.length; const inlineWrite = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", inlineBody], env); assertCondition(inlineWrite.status === 0, "issue comment inline body write should succeed", inlineWrite.json ?? { stdout: inlineWrite.stdout }); assertCondition(inlineWrite.json?.command === "gh issue comment create 36 --repo pikasTech/unidesk --body ", "outer gh command should redact inline body on write", inlineWrite.json ?? {}); const inlineWriteData = dataOf(inlineWrite.json ?? {}); assertCondition(inlineWriteData.command === "issue comment create" && inlineWriteData.source === "inline", "inline issue comment write should report source=inline", inlineWriteData); assertCondition(Number(inlineWriteData.bodyChars ?? 0) === inlineBody.length && typeof inlineWriteData.bodySha === "string", "inline issue comment write should expose bounded body metadata", inlineWriteData); const inlineWriteComment = inlineWriteData.comment as JsonRecord; assertCondition(inlineWriteComment.bodyOmitted === true && inlineWriteComment.bodyPreview === inlineBody && !("body" in inlineWriteComment), "inline issue comment write should summarize without full body field", inlineWriteComment); const inlinePost = mock.requests.slice(inlineWriteRequestCountBefore).find((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues/36/comments"); assertCondition(inlinePost !== undefined, "inline issue comment write should POST comments REST endpoint", { requests: mock.requests.slice(inlineWriteRequestCountBefore) }); const inlinePayload = JSON.parse(inlinePost?.body ?? "{}") as JsonRecord; assertCondition(inlinePayload.body === inlineBody, "inline issue comment REST payload should preserve short text", inlinePayload); const commentUpdateBody = "修正:保留评论 ID 的原地编辑"; const commentUpdateDryRunRequestCountBefore = mock.requests.length; const commentUpdateDryRun = await runCli(["gh", "issue", "comment", "update", "9002", "--repo", "pikasTech/unidesk", "--body", commentUpdateBody, "--dry-run"], env); assertCondition(commentUpdateDryRun.status === 0, "issue comment update dry-run should succeed", commentUpdateDryRun.json ?? { stdout: commentUpdateDryRun.stdout }); assertCondition(commentUpdateDryRun.json?.command === "gh issue comment update 9002 --repo pikasTech/unidesk --body --dry-run", "outer gh command should redact issue comment update inline body", commentUpdateDryRun.json ?? {}); const commentUpdateDryRunData = dataOf(commentUpdateDryRun.json ?? {}); assertCondition(commentUpdateDryRunData.command === "issue comment update" && commentUpdateDryRunData.dryRun === true && commentUpdateDryRunData.commentId === 9002, "issue comment update dry-run should plan by commentId", commentUpdateDryRunData); assertCondition(typeof commentUpdateDryRunData.bodySha === "string" && String(commentUpdateDryRunData.bodySha).length === 64 && Number(commentUpdateDryRunData.bodyChars ?? 0) === commentUpdateBody.length, "issue comment update dry-run should expose body metadata", commentUpdateDryRunData); const commentUpdateRequest = commentUpdateDryRunData.request as JsonRecord; assertCondition(commentUpdateRequest.method === "PATCH" && String(commentUpdateRequest.path ?? "").includes("/issues/comments/{comment_id}"), "issue comment update dry-run should plan PATCH comment endpoint", commentUpdateRequest); const commentUpdateDryRunWriteCount = mock.requests.slice(commentUpdateDryRunRequestCountBefore).filter((request) => request.method === "PATCH" && request.url.includes("/issues/comments/")).length; assertCondition(commentUpdateDryRunWriteCount === 0, "issue comment update dry-run must not PATCH GitHub", { requests: mock.requests.slice(commentUpdateDryRunRequestCountBefore) }); const commentEditStdinBody = "编辑别名:stdin 正文\n\n- 保留 `code`\n"; const commentEditRequestCountBefore = mock.requests.length; const commentEdit = await runCli(["gh", "issue", "comment", "edit", "--number", "9002", "--repo", "pikasTech/unidesk", "--body-stdin"], env, commentEditStdinBody); assertCondition(commentEdit.status === 0, "issue comment edit should accept --number compatibility alias and stdin", commentEdit.json ?? { stdout: commentEdit.stdout }); const commentEditData = dataOf(commentEdit.json ?? {}); assertCondition(commentEditData.command === "issue comment edit" && commentEditData.commentId === 9002, "issue comment edit should report alias command and commentId", commentEditData); const commentEditHint = commentEditData.standardSyntaxHint as JsonRecord; assertCondition(String(commentEditHint.standardCommand ?? "").includes("gh issue comment edit 9002 --repo pikasTech/unidesk"), "issue comment edit --number should point to positional commentId syntax", commentEditHint); const commentEditSummary = commentEditData.comment as JsonRecord; assertCondition(commentEditSummary.id === 9002 && commentEditSummary.bodyOmitted === true && !("body" in commentEditSummary), "issue comment edit should preserve id and omit full body", commentEditSummary); const commentEditPatch = mock.requests.slice(commentEditRequestCountBefore).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/comments/9002"); assertCondition(commentEditPatch !== undefined, "issue comment edit should PATCH issue comments endpoint", { requests: mock.requests.slice(commentEditRequestCountBefore) }); const commentEditPayload = JSON.parse(commentEditPatch?.body ?? "{}") as JsonRecord; assertCondition(commentEditPayload.body === commentEditStdinBody, "issue comment edit payload should preserve stdin Markdown", commentEditPayload); const closeComment = "收口:CLI close --comment contract"; const closeDryRunRequestCountBefore = mock.requests.length; const closeDryRun = await runCli(["gh", "issue", "close", "20", "--repo", "pikasTech/unidesk", "--comment", closeComment, "--dry-run"], env); assertCondition(closeDryRun.status === 0, "issue close --comment dry-run should succeed", closeDryRun.json ?? { stdout: closeDryRun.stdout }); assertCondition(closeDryRun.json?.command === "gh issue close 20 --repo pikasTech/unidesk --comment --dry-run", "outer gh command should redact lifecycle comment", closeDryRun.json ?? {}); const closeDryRunData = dataOf(closeDryRun.json ?? {}); assertCondition(closeDryRunData.command === "issue close" && closeDryRunData.dryRun === true, "issue close dry-run should report lifecycle command", closeDryRunData); const closeDryRunComment = closeDryRunData.comment as JsonRecord; assertCondition(closeDryRunComment.planned === true && closeDryRunComment.source === "inline", "issue close dry-run should plan inline lifecycle comment", closeDryRunComment); assertCondition(String(closeDryRunComment.bodyPreview ?? "") === closeComment && typeof closeDryRunComment.bodySha === "string", "issue close dry-run should expose bounded comment metadata", closeDryRunComment); const closeDryRunWriteCount = mock.requests.slice(closeDryRunRequestCountBefore).filter((request) => request.method === "POST" || request.method === "PATCH").length; assertCondition(closeDryRunWriteCount === 0, "issue close --comment dry-run must not POST or PATCH", { requests: mock.requests.slice(closeDryRunRequestCountBefore) }); const closeWriteRequestCountBefore = mock.requests.length; const closeWrite = await runCli(["gh", "issue", "close", "20", "--repo", "pikasTech/unidesk", "--comment", closeComment], env); assertCondition(closeWrite.status === 0, "issue close --comment should succeed", closeWrite.json ?? { stdout: closeWrite.stdout }); const closeWriteData = dataOf(closeWrite.json ?? {}); assertCondition(closeWriteData.command === "issue close", "issue close write should report lifecycle command", closeWriteData); const closeWriteComment = closeWriteData.comment as JsonRecord; assertCondition(closeWriteComment.bodyOmitted === true && closeWriteComment.bodyPreview === closeComment, "issue close should summarize lifecycle comment without full body", closeWriteComment); const closeWriteIssue = closeWriteData.issue as JsonRecord; assertCondition(closeWriteIssue.state === "closed", "issue close should PATCH state=closed", closeWriteIssue); const closeWriteRequests = mock.requests.slice(closeWriteRequestCountBefore).filter((request) => request.url === "/repos/pikasTech/unidesk/issues/20/comments" || request.url === "/repos/pikasTech/unidesk/issues/20"); assertCondition(closeWriteRequests.length === 2 && closeWriteRequests[0]?.method === "POST" && closeWriteRequests[1]?.method === "PATCH", "issue close --comment should POST comment before PATCH state", closeWriteRequests); const closeCommentPayload = JSON.parse(closeWriteRequests[0]?.body ?? "{}") as JsonRecord; const closePatchPayload = JSON.parse(closeWriteRequests[1]?.body ?? "{}") as JsonRecord; assertCondition(closeCommentPayload.body === closeComment && closePatchPayload.state === "closed", "issue close --comment payloads should preserve comment and state", { closeCommentPayload, closePatchPayload }); const reopenCommentStdinBody = "reopen heredoc comment\n\n- keeps `code`\n"; const reopenStdinRequestCountBefore = mock.requests.length; const reopenStdin = await runCli(["gh", "issue", "reopen", "20", "--repo", "pikasTech/unidesk", "--comment-stdin", "--dry-run"], env, reopenCommentStdinBody); assertCondition(reopenStdin.status === 0, "issue reopen --comment-stdin dry-run should succeed", reopenStdin.json ?? { stdout: reopenStdin.stdout }); const reopenStdinData = dataOf(reopenStdin.json ?? {}); const reopenStdinComment = reopenStdinData.comment as JsonRecord; const reopenStdinCommentSource = (reopenStdinComment.bodySource ?? {}) as JsonRecord; assertCondition(reopenStdinCommentSource.kind === "stdin" && reopenStdinCommentSource.path === "-", "issue reopen --comment-stdin should expose stdin source", reopenStdinComment); const reopenStdinWriteCount = mock.requests.slice(reopenStdinRequestCountBefore).filter((request) => request.method === "POST" || request.method === "PATCH").length; assertCondition(reopenStdinWriteCount === 0, "issue reopen --comment-stdin dry-run must not write GitHub", { requests: mock.requests.slice(reopenStdinRequestCountBefore) }); const closeCommentWrongCommand = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--comment", closeComment, "--dry-run"], env); assertCondition(closeCommentWrongCommand.status !== 0, "--comment outside issue close/reopen should fail structurally", closeCommentWrongCommand.json ?? { stdout: closeCommentWrongCommand.stdout }); const closeCommentWrongData = failedDataOf(closeCommentWrongCommand.json ?? {}); assertCondition(failureMessageOf(closeCommentWrongData).includes("only supported by gh issue close/reopen"), "wrong --comment usage should point to close/reopen", closeCommentWrongData); const missingCommentBody = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--dry-run"], env); assertCondition(missingCommentBody.status !== 0, "issue comment create without body source should fail", missingCommentBody.json ?? { stdout: missingCommentBody.stdout }); const missingCommentBodyData = failedDataOf(missingCommentBody.json ?? {}); assertCondition(missingCommentBodyData.degradedReason === "validation-failed" && failureMessageOf(missingCommentBodyData).includes("requires --body-stdin"), "missing issue comment body should be structured validation failure", missingCommentBodyData); const mutualCommentBody = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "inline", "--body-file", appendFile, "--dry-run"], env); assertCondition(mutualCommentBody.status !== 0, "issue comment create with body and body-file should fail", mutualCommentBody.json ?? { stdout: mutualCommentBody.stdout }); const mutualCommentBodyData = failedDataOf(mutualCommentBody.json ?? {}); assertCondition(mutualCommentBodyData.degradedReason === "validation-failed" && failureMessageOf(mutualCommentBodyData).includes("accepts only one body source"), "mutual issue comment body sources should be rejected", mutualCommentBodyData); const blankInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", " ", "--dry-run"], env); assertCondition(blankInlineComment.status !== 0, "blank inline issue comment body should fail", blankInlineComment.json ?? { stdout: blankInlineComment.stdout }); const blankInlineCommentData = failedDataOf(blankInlineComment.json ?? {}); assertCondition(failureMessageOf(blankInlineCommentData).includes("must not be blank"), "blank inline issue comment body should name blank-body reason", blankInlineCommentData); const multilineInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "line1\nline2", "--dry-run"], env); assertCondition(multilineInlineComment.status !== 0, "multiline inline issue comment body should fail", multilineInlineComment.json ?? { stdout: multilineInlineComment.stdout }); const multilineInlineCommentData = failedDataOf(multilineInlineComment.json ?? {}); assertCondition(failureMessageOf(multilineInlineCommentData).includes("single-line text only"), "multiline inline issue comment body should point to body-stdin", multilineInlineCommentData); const pollutedInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "literal \\n pollution", "--dry-run"], env); assertCondition(pollutedInlineComment.status !== 0, "polluted inline issue comment body should fail", pollutedInlineComment.json ?? { stdout: pollutedInlineComment.stdout }); const pollutedInlineCommentData = failedDataOf(pollutedInlineComment.json ?? {}); assertCondition(failureMessageOf(pollutedInlineCommentData).includes("shell-pollution signals"), "polluted inline issue comment body should report shell pollution", pollutedInlineCommentData); const secretInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "token=ghp_1234567890abcdef", "--dry-run"], env); assertCondition(secretInlineComment.status !== 0, "secret-like inline issue comment body should fail", secretInlineComment.json ?? { stdout: secretInlineComment.stdout }); assertCondition(!secretInlineComment.stdout.includes("ghp_1234567890abcdef") && !secretInlineComment.stderr.includes("ghp_1234567890abcdef"), "secret-like inline issue comment failure must not print token value", { stdout: secretInlineComment.stdout, stderr: secretInlineComment.stderr, }); const stdinCommentBody = "stdin comment line 1\n\n- keeps `code`\n"; const stdinComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body-file", "-", "--dry-run"], env, stdinCommentBody); assertCondition(stdinComment.status === 0, "issue comment body stdin should be supported", stdinComment.json ?? { stdout: stdinComment.stdout }); const stdinCommentData = dataOf(stdinComment.json ?? {}); const stdinCommentSource = stdinCommentData.bodySource as JsonRecord; assertCondition(stdinCommentSource.kind === "stdin" && stdinCommentSource.path === "-" && stdinCommentData.source === "stdin", "stdin issue comment dry-run should expose stdin source", stdinCommentData); assertCondition(stdinCommentData.containsBackticks === true && stdinCommentData.containsLiteralBackslashN === false, "stdin issue comment should preserve Markdown signals", stdinCommentData); const bodyStdinComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body-stdin", "--dry-run"], env, stdinCommentBody); assertCondition(bodyStdinComment.status === 0, "issue comment --body-stdin should be supported", bodyStdinComment.json ?? { stdout: bodyStdinComment.stdout }); const bodyStdinCommentData = dataOf(bodyStdinComment.json ?? {}); const bodyStdinCommentSource = bodyStdinCommentData.bodySource as JsonRecord; assertCondition(bodyStdinCommentSource.kind === "stdin" && bodyStdinCommentSource.path === "-" && bodyStdinCommentData.source === "stdin", "issue comment --body-stdin should expose stdin source", bodyStdinCommentData); const commentDeleteDryRun = await runCli(["gh", "issue", "comment", "delete", "9001", "--repo", "pikasTech/unidesk", "--dry-run"], env); assertCondition(commentDeleteDryRun.status === 0, "issue comment delete dry-run should succeed", commentDeleteDryRun.json ?? { stdout: commentDeleteDryRun.stdout }); const commentDeleteDryRunData = dataOf(commentDeleteDryRun.json ?? {}); assertCondition(commentDeleteDryRunData.command === "issue comment delete" && commentDeleteDryRunData.planned === true, "comment delete dry-run should plan DELETE", commentDeleteDryRunData); const commentDeleteNumberDryRun = await runCli(["gh", "issue", "comment", "delete", "--number", "9001", "--repo", "pikasTech/unidesk", "--dry-run"], env); assertCondition(commentDeleteNumberDryRun.status === 0, "issue comment delete should accept --number commentId compatibility alias", commentDeleteNumberDryRun.json ?? { stdout: commentDeleteNumberDryRun.stdout }); const commentDeleteNumberData = dataOf(commentDeleteNumberDryRun.json ?? {}); assertCondition(commentDeleteNumberData.commentId === 9001 && commentDeleteNumberData.standardSyntaxHint, "issue comment delete --number should return commentId and standard syntax hint", commentDeleteNumberData); const commentDeleteNumberHint = commentDeleteNumberData.standardSyntaxHint as JsonRecord; assertCondition(String(commentDeleteNumberHint.standardCommand ?? "").includes("gh issue comment delete 9001 --repo pikasTech/unidesk"), "issue comment delete --number should point to positional commentId syntax", commentDeleteNumberHint); const commentDelete = await runCli(["gh", "issue", "comment", "delete", "9001", "--repo", "pikasTech/unidesk"], env); assertCondition(commentDelete.status === 0, "issue comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout }); const commentDeleteData = dataOf(commentDelete.json ?? {}); assertCondition(commentDeleteData.deleted === true, "comment delete should report deleted", commentDeleteData); const issueDelete = await runCli(["gh", "issue", "delete", "20", "--repo", "pikasTech/unidesk"], env); assertCondition(issueDelete.status !== 0, "issue hard delete should be unsupported", issueDelete.json ?? { stdout: issueDelete.stdout }); const issueDeleteData = failedDataOf(issueDelete.json ?? {}); assertCondition(issueDeleteData.degradedReason === "unsupported-command", "issue delete should be unsupported-command", issueDeleteData); return { ok: true, checks: [ "issue view --json body preserves .data.issue.body", "issue read remains a compatibility alias", "issue view/read accept GitHub URL and owner/repo#number targets and reject conflicting --repo", "issue single numeric target commands accept --number compatibility with a standard syntax hint", "issue view/read --raw is explicit full disclosure", "issue list supports state/limit/json with stable selected fields", "issue list positional owner/repo targets the requested repo and conflicting --repo fails", "acceptance issue list command succeeds under mock GitHub", "issue list default fields include labels and filter pull requests", "large gh issue view/read output is dumped to a temp file with bounded stdout and head/tail metadata", "issue scan-escape classifies pollution, explanatory mentions, and body risks", "issue cleanup-plan remains dry-run with body/comment cleanup suggestions", "issue board-audit returns read-only board structure, disables OPEN/CLOSED coverage validation, and keeps compatibility fields empty without writes", "issue board-row list/get expose parsed #20 rows without writes", "issue board-row upsert updates existing rows, adds missing rows, reports operation, preserves table trailers, rejects ambiguous rows, blocks stale body SHA writes, and stays dry-run without concurrency guards", "issue board-row add/delete without guard stay on dry-run and do not PATCH", "issue board-row update defaults to dry-run, reports old/new row, body SHA, guard result, and does not introduce literal backslash-n", "issue board-row update rejects literal backslash-n cell values", "issue board-row update escapes markdown table pipes and performs guarded PATCH with --expect-body-sha", "issue board-row move is supported, defaults to dry-run, and can migrate OPEN rows into CLOSED", "issue create dry-run parses repeated/comma labels, supports --body-stdin and compatible --body-file -, rejects inline --body, and exposes request plan", "issue create sends labels through REST and preserves GitHub validation errors for missing labels", "issue list unsupported fields and states fail structurally", "issue read supports body,title,state,closed,closedAt,comments,commentCount selection", "unknown/full disclosure option guidance remains actionable", "unsupported --json fields fail structurally", "issue edit --body-file rejects literal null", "#20/#24 body profile guards reject missing headings or wrong profile", "#20 body and board-row guards reject HWLAB product issue routing and point to pikasTech/HWLAB", "#20 board-row guard allows UniDesk governance rows that mention HWLAB only as routing context", "#24 commander-brief profile remains compatible", "daily commander brief issues match commander-brief profile by title", "non-brief issues fail commander-brief profile without printing token", "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", "issue update replace/append modes preserve Markdown and support stdin with automatic current issue metadata checks", "issue update non-dry-run success defaults to compact output without full issue.body and exposes bodySha plus drill-down commands", "issue update --full explicitly includes full issue.body", "issue close/reopen supports --comment-stdin dry-run without writes", "issue comment create supports short inline --body dry-run and write with bounded output", "issue comment create supports --body-stdin and compatible --body-file -, and still rejects missing, blank, multiline inline, polluted inline, secret-like inline, and mixed body sources", "issue comment create/update/edit/delete follows CRUD shape", "issue hard delete is structurally unsupported", ], }; } finally { clearInterval(heartbeat); 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`); }