feat: add safe gh pr create/comment
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void {
|
||||
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
||||
}
|
||||
|
||||
function runCli(args: string[]): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } {
|
||||
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
const stdout = String(result.stdout || "");
|
||||
let json: JsonRecord | null = null;
|
||||
try {
|
||||
json = JSON.parse(stdout) as JsonRecord;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return {
|
||||
status: result.status,
|
||||
stdout,
|
||||
stderr: String(result.stderr || ""),
|
||||
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;
|
||||
}
|
||||
|
||||
export function runGhCliPrContract(): JsonRecord {
|
||||
const help = 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)) : [];
|
||||
assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage });
|
||||
assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage });
|
||||
|
||||
const title = "contract pr create";
|
||||
const bodyFile = join(tmpdir(), `unidesk-gh-pr-contract-${process.pid}.md`);
|
||||
writeFileSync(bodyFile, "Line 1\n`code`\n| a | b |\n", "utf8");
|
||||
try {
|
||||
const createDryRun = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--draft", "--dry-run"]);
|
||||
assertCondition(createDryRun.status === 0, "pr create dry-run should succeed", createDryRun.json ?? { stdout: createDryRun.stdout });
|
||||
const createData = dataOf(createDryRun.json ?? {});
|
||||
assertCondition(createData.dryRun === true, "dry-run create must set dryRun=true", createData);
|
||||
assertCondition(createData.planned === true, "dry-run create must set planned=true", createData);
|
||||
assertCondition(createData.repo === "pikasTech/unidesk", "dry-run create should preserve repo", createData);
|
||||
assertCondition(createData.base === "master", "dry-run create should preserve base", createData);
|
||||
assertCondition(createData.head === "feature/pr-contract", "dry-run create should preserve head", createData);
|
||||
assertCondition(createData.draft === true, "dry-run create should preserve draft", createData);
|
||||
assertCondition(Number(createData.bodyChars ?? 0) > 0, "dry-run create should expose bodyChars", createData);
|
||||
assertCondition(Array.isArray(createData.bodyPreviewLines), "dry-run create should expose bodyPreviewLines", createData);
|
||||
assertCondition(String(createData.bodyPreview ?? "").includes("`code`"), "dry-run create should preserve backticks in preview", createData);
|
||||
assertCondition(createData.request && typeof createData.request === "object", "dry-run create should include request plan", createData);
|
||||
|
||||
const commentDryRun = runCli(["gh", "pr", "comment", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile, "--dry-run"]);
|
||||
assertCondition(commentDryRun.status === 0, "pr comment dry-run should succeed", commentDryRun.json ?? { stdout: commentDryRun.stdout });
|
||||
const commentData = dataOf(commentDryRun.json ?? {});
|
||||
assertCondition(commentData.dryRun === true, "dry-run comment must set dryRun=true", commentData);
|
||||
assertCondition(commentData.planned === true, "dry-run comment must set planned=true", commentData);
|
||||
assertCondition(commentData.issueNumber === 42, "dry-run comment should preserve PR number", commentData);
|
||||
assertCondition(Number(commentData.bodyChars ?? 0) > 0, "dry-run comment should expose bodyChars", commentData);
|
||||
|
||||
const mergeBlocked = runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk"]);
|
||||
assertCondition(mergeBlocked.status !== 0, "pr merge should fail", mergeBlocked.json ?? { stdout: mergeBlocked.stdout });
|
||||
const mergeData = mergeBlocked.json?.data as JsonRecord | undefined;
|
||||
assertCondition(String(mergeData?.message ?? "").includes("intentionally unsupported"), "merge block message should be explicit", mergeData ?? {});
|
||||
assertCondition(mergeData?.runnerDisposition === "business-failed", "merge block should classify as business-failed", mergeData ?? {});
|
||||
|
||||
const createMissingBody = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--base", "master", "--head", "feature/pr-contract", "--dry-run"]);
|
||||
assertCondition(createMissingBody.status !== 0, "pr create without body source should fail", createMissingBody.json ?? { stdout: createMissingBody.stdout });
|
||||
const createMissingBodyData = createMissingBody.json?.data as JsonRecord | undefined;
|
||||
assertCondition(createMissingBodyData?.degradedReason === "validation-failed", "missing body source should be validation-failed", createMissingBodyData ?? {});
|
||||
assertCondition(createMissingBodyData?.runnerDisposition === "business-failed", "validation should classify as business-failed", createMissingBodyData ?? {});
|
||||
|
||||
const unknownOption = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--dry-run", "--bad-option"]);
|
||||
assertCondition(unknownOption.status !== 0, "unknown gh option should fail", unknownOption.json ?? { stdout: unknownOption.stdout });
|
||||
const unknownOptionData = unknownOption.json?.data as JsonRecord | undefined;
|
||||
assertCondition(unknownOptionData?.degradedReason === "unsupported-command", "unknown option should be unsupported-command", unknownOptionData ?? {});
|
||||
assertCondition(unknownOptionData?.runnerDisposition === "business-failed", "unknown option should classify as business-failed", unknownOptionData ?? {});
|
||||
} finally {
|
||||
unlinkSync(bodyFile);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
checks: [
|
||||
"gh help lists pr create/comment",
|
||||
"pr create dry-run exposes planned operation",
|
||||
"pr comment dry-run preserves markdown text",
|
||||
"pr merge is blocked",
|
||||
"pr create validation failures are structured",
|
||||
"unknown gh options are structured",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const result = runGhCliPrContract();
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
Reference in New Issue
Block a user