546 lines
35 KiB
TypeScript
546 lines
35 KiB
TypeScript
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`);
|
||
}
|