Files
pikasTech-unidesk/scripts/gh-cli-issue-guard-contract-test.ts
T
2026-05-20 20:22:50 +00:00

546 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
type JsonRecord = Record<string, unknown>;
interface MockRequest {
method: string;
url: string;
body: string;
}
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function runCli(args: string[], env: Record<string, string> = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> {
return new Promise((resolve, reject) => {
const child = spawn("bun", ["scripts/cli.ts", ...args], {
cwd: process.cwd(),
env: { ...process.env, ...env },
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on("error", reject);
child.on("close", (status) => {
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
let json: JsonRecord | null = null;
try {
json = JSON.parse(stdout) as JsonRecord;
} catch {
json = null;
}
resolve({
status,
stdout,
stderr: Buffer.concat(stderrChunks).toString("utf8"),
json,
});
});
});
}
function dataOf(response: JsonRecord): JsonRecord {
assertCondition(response.ok === true, "CLI command should succeed", response);
assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "response data should be object", response);
return response.data as JsonRecord;
}
function failedDataOf(response: JsonRecord): JsonRecord {
assertCondition(response.ok === false, "CLI command should fail", response);
assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "failure data should be object", response);
return response.data as JsonRecord;
}
function collectBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
});
}
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
res.statusCode = status;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(payload));
}
async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise<void> }> {
const requests: MockRequest[] = [];
const issue = {
id: 2000,
number: 20,
title: "长期总看板",
body: "# Code Queue\n\n## 看板(OPEN\n\n- old item\n",
state: "open",
html_url: "https://github.com/pikasTech/unidesk/issues/20",
comments: 1,
user: { login: "tester" },
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T01:00:00Z",
};
const 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",
},
{
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",
},
{
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 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 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: [],
};
const server = createServer(async (req, res) => {
const body = await collectBody(req);
requests.push({ method: req.method ?? "", url: req.url ?? "", body });
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/20") {
sendJson(res, 200, issue);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/20/comments?per_page=100") {
sendJson(res, 200, comments);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=open&per_page=2") {
sendJson(res, 200, issueList.slice(0, 2));
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=open&per_page=5") {
sendJson(res, 200, issueList);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=all&per_page=3") {
sendJson(res, 200, issueList);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=all&per_page=4") {
sendJson(res, 200, scanIssues);
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;
sendJson(res, 200, { ...issue, body: String(parsed.body ?? issue.body), updated_at: "2026-05-20T01:05:00Z" });
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") {
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 mock = await startMockGitHub();
const tmp = mkdtempSync(join(tmpdir(), "unidesk-gh-issue-guard-"));
const env = {
GH_TOKEN: "contract-token",
UNIDESK_GITHUB_API_URL: mock.baseUrl,
};
try {
const 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 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 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 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 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 viewBody = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env);
assertCondition(viewBody.status === 0, "issue view --json body should succeed", viewBody.json ?? { stdout: viewBody.stdout });
const viewBodyData = dataOf(viewBody.json ?? {});
const issue = viewBodyData.issue as JsonRecord;
assertCondition(typeof issue.body === "string" && issue.body.includes("## 看板(OPEN"), ".data.issue.body should remain readable", viewBodyData);
const selectedJson = viewBodyData.json as JsonRecord;
assertCondition(typeof selectedJson.body === "string" && selectedJson.body === issue.body, "selected json body should match issue body", viewBodyData);
assertCondition(!("comments" in selectedJson), "--json body should not imply comments field", selectedJson);
const viewFields = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body,title,state,comments"], env);
assertCondition(viewFields.status === 0, "common --json field selection should succeed", viewFields.json ?? { stdout: viewFields.stdout });
const viewFieldsData = dataOf(viewFields.json ?? {});
const fieldsJson = viewFieldsData.json as JsonRecord;
assertCondition(fieldsJson.title === "长期总看板", "selected json title should be exposed", fieldsJson);
assertCondition(Array.isArray(fieldsJson.comments) && fieldsJson.comments.length === 1, "selected json comments should be exposed", fieldsJson);
const unsupported = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body,unknown"], env);
assertCondition(unsupported.status !== 0, "unsupported --json field should fail", unsupported.json ?? { stdout: unsupported.stdout });
const unsupportedData = failedDataOf(unsupported.json ?? {});
assertCondition(unsupportedData.degradedReason === "validation-failed", "unsupported --json should be validation-failed", unsupportedData);
assertCondition(unsupportedData.runnerDisposition === "business-failed", "unsupported --json should be business-failed", unsupportedData);
const nullFile = join(tmp, "null.md");
writeFileSync(nullFile, "null\n", "utf8");
const nullEdit = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", nullFile, "--dry-run"], env);
assertCondition(nullEdit.status !== 0, "issue edit should reject literal null body", nullEdit.json ?? { stdout: nullEdit.stdout });
const nullData = failedDataOf(nullEdit.json ?? {});
assertCondition(nullData.degradedReason === "validation-failed", "null body should be validation-failed", nullData);
const nullGuard = nullData.guard as JsonRecord;
assertCondition(Array.isArray(nullGuard.failures) && nullGuard.failures.includes("literal-null-body"), "null guard should report literal-null-body", nullGuard);
const missingHeadingFile = join(tmp, "missing-heading.md");
writeFileSync(missingHeadingFile, "# Board\n\n## Closed\n\nThis is long enough to pass length only.\n", "utf8");
const profileBlocked = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", missingHeadingFile, "--dry-run"], env);
assertCondition(profileBlocked.status !== 0, "#20 missing heading should fail", profileBlocked.json ?? { stdout: profileBlocked.stdout });
const profileData = failedDataOf(profileBlocked.json ?? {});
const profileGuard = profileData.guard as JsonRecord;
assertCondition(Array.isArray(profileGuard.failures) && profileGuard.failures.includes("profile-heading-missing"), "#20 guard should report missing heading", profileGuard);
const commanderBriefBlocked = await runCli(["gh", "issue", "edit", "24", "--repo", "pikasTech/unidesk", "--body-file", missingHeadingFile, "--dry-run"], env);
assertCondition(commanderBriefBlocked.status !== 0, "#24 missing heading should fail", commanderBriefBlocked.json ?? { stdout: commanderBriefBlocked.stdout });
const commanderBriefData = failedDataOf(commanderBriefBlocked.json ?? {});
const commanderBriefGuard = commanderBriefData.guard as JsonRecord;
assertCondition(Array.isArray(commanderBriefGuard.failures) && commanderBriefGuard.failures.includes("profile-heading-missing"), "#24 guard should report missing heading", commanderBriefGuard);
const briefWrongProfile = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", missingHeadingFile, "--body-profile", "commander-brief", "--dry-run"], env);
assertCondition(briefWrongProfile.status !== 0, "wrong explicit body profile should fail", briefWrongProfile.json ?? { stdout: briefWrongProfile.stdout });
const briefWrongData = failedDataOf(briefWrongProfile.json ?? {});
const briefWrongGuard = briefWrongData.guard as JsonRecord;
assertCondition(Array.isArray(briefWrongGuard.failures) && briefWrongGuard.failures.includes("profile-issue-mismatch"), "explicit profile should check issue number", briefWrongGuard);
const safeFile = join(tmp, "safe.md");
writeFileSync(safeFile, "# Code Queue\n\n## 看板(OPEN\n\n- multiline Markdown keeps `code` intact.\n- real newline follows.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n", "utf8");
const requestCountBeforeDryRun = mock.requests.length;
const safeDryRun = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", safeFile, "--dry-run"], env);
assertCondition(safeDryRun.status === 0, "safe issue edit dry-run should succeed", safeDryRun.json ?? { stdout: safeDryRun.stdout });
const dryRunPatchCount = mock.requests.slice(requestCountBeforeDryRun).filter((request) => request.method === "PATCH").length;
assertCondition(dryRunPatchCount === 0, "dry-run must not PATCH GitHub", { requests: mock.requests.slice(requestCountBeforeDryRun) });
const safeDryRunData = dataOf(safeDryRun.json ?? {});
assertCondition(safeDryRunData.dryRun === true, "dry-run should set dryRun=true", safeDryRunData);
assertCondition(safeDryRunData.containsBackticks === true, "dry-run should preserve backtick signal", safeDryRunData);
assertCondition(safeDryRunData.containsLiteralBackslashN === false, "real newlines must not become literal backslash-n", safeDryRunData);
assertCondition(safeDryRunData.containsMarkdownTable === true, "dry-run should detect markdown table", safeDryRunData);
const bodyOnlySafety = safeDryRunData.bodyOnlySafety as JsonRecord;
const oldBody = bodyOnlySafety.oldBody as JsonRecord;
assertCondition(oldBody.fetched === true && Number(oldBody.bodyChars ?? 0) > 0, "dry-run should report old body length when token is available", bodyOnlySafety);
const requestCountBeforePatch = mock.requests.length;
const staleEdit = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", safeFile, "--expect-updated-at", "2026-05-20T00:59:00Z"], env);
assertCondition(staleEdit.status !== 0, "stale expect-updated-at should fail", staleEdit.json ?? { stdout: staleEdit.stdout });
const stalePatchCount = mock.requests.slice(requestCountBeforePatch).filter((request) => request.method === "PATCH").length;
assertCondition(stalePatchCount === 0, "stale concurrency guard must not PATCH", { requests: mock.requests.slice(requestCountBeforePatch) });
const staleData = failedDataOf(staleEdit.json ?? {});
assertCondition(staleData.degradedReason === "validation-failed", "stale guard should be validation-failed", staleData);
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 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 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);
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 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 list supports state/limit/json with stable selected fields",
"acceptance issue list command succeeds under mock GitHub",
"issue list default fields include labels and filter pull requests",
"issue scan-escape classifies pollution, explanatory mentions, and body risks",
"issue cleanup-plan remains dry-run with body/comment cleanup suggestions",
"issue create dry-run parses repeated/comma labels 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 view supports body,title,state,comments selection",
"unsupported --json fields fail structurally",
"issue edit --body-file rejects literal null",
"#20/#24 body profile guards reject missing headings or wrong profile",
"dry-run reports old/new body safety and does not PATCH",
"multiline Markdown and backticks are not polluted",
"expect-updated-at stale write protection blocks PATCH",
"issue update replace/append modes preserve Markdown",
"issue comment create/delete follows CRUD shape",
"issue hard delete is structurally unsupported",
],
};
} finally {
rmSync(tmp, { recursive: true, force: true });
await mock.close();
}
}
if (import.meta.main) {
const result = await runGhCliIssueGuardContract();
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}