1858 lines
150 KiB
TypeScript
1858 lines
150 KiB
TypeScript
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 === "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 accepts --body-file <file|->") && line.includes("--body only for short single-line text")), "gh help should document issue comment 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-file"), "issue body cleanup suggestion should use body-file 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,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"], 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);
|
||
|
||
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 指挥简报(北京时间)",
|
||
"",
|
||
"## 常驻观察与长期建议",
|
||
"",
|
||
"- 保持滚动简报正文只通过 body-file 更新。",
|
||
"",
|
||
"## 更新 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 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-file", 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 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 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-file <file> or --body <text>"), "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-file", 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 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 stdin, 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 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 comment create supports short inline --body dry-run and write with bounded output",
|
||
"issue comment create supports stdin and still rejects missing, blank, multiline inline, polluted inline, secret-like inline, and mixed body sources",
|
||
"issue comment create/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`);
|
||
}
|