Files
pikasTech-unidesk/scripts/gh-cli-issue-guard-contract-test.ts
T
2026-06-09 10:47:31 +00:00

1916 lines
158 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>;
interface MockRequest {
method: string;
url: string;
body: string;
}
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function runCli(args: string[], env: Record<string, string> = {}, 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<string> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
});
}
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
res.statusCode = status;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(payload));
}
function mockUrl(rawUrl: string | undefined): URL {
return new URL(rawUrl ?? "/", "http://mock.local");
}
async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise<void> }> {
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<number, JsonRecord[]> = {
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<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assertCondition(typeof address === "object" && address !== null, "mock server should expose address");
const port = (address as AddressInfo).port;
assertCondition(typeof port === "number", "mock server should expose port");
return {
baseUrl: `http://127.0.0.1:${port}`,
requests,
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
};
}
export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const 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 <short-text>")), "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 <body:redacted> --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 <body:redacted>", "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 <body:redacted> --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 <body:redacted> --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`);
}