Files
pikasTech-unidesk/scripts/gh-cli-pr-contract-test.ts
T
2026-05-23 07:36:48 +00:00

542 lines
39 KiB
TypeScript

import { spawn } from "node:child_process";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
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: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function runBun(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", 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 runCli(args: string[], env: Record<string, string> = {}, stdin?: string): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> {
return runBun(["scripts/cli.ts", ...args], env, stdin);
}
interface MockRequest {
method: string;
url: string;
body: string;
}
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 pullRequest = {
id: 4200,
number: 42,
title: "contract PR",
body: "PR body",
state: "open",
html_url: "https://github.com/pikasTech/unidesk/pull/42",
draft: false,
user: { login: "runner" },
head: { ref: "feature/pr-contract", sha: "head-sha" },
base: { ref: "master", sha: "base-sha" },
closed_at: null,
merged: false,
merged_at: null,
merge_commit_sha: null,
created_at: "2026-05-20T04:00:00Z",
updated_at: "2026-05-20T05:00:00Z",
};
const shorthandPullRequest = {
...pullRequest,
id: 7000,
number: 7,
title: "generic shorthand PR fixture",
body: "PR shorthand body",
html_url: "https://github.com/pikasTech/HWLAB/pull/7",
head: { ref: "feature/hwlab-shorthand", sha: "hwlab-head-sha" },
base: { ref: "master", sha: "hwlab-base-sha" },
};
const mergedPullRequest = {
...pullRequest,
id: 4300,
number: 43,
title: "merged contract PR",
state: "closed",
html_url: "https://github.com/pikasTech/unidesk/pull/43",
closed_at: "2026-05-21T08:00:00Z",
merged: true,
merged_at: "2026-05-21T08:00:00Z",
merge_commit_sha: "merge-commit-sha",
updated_at: "2026-05-21T08:00:00Z",
};
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 === "/rate_limit") {
sendJson(res, 200, { resources: { core: { limit: 5000, remaining: 4999 } } });
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk") {
sendJson(res, 200, { id: 1, full_name: "pikasTech/unidesk", private: true, default_branch: "master", permissions: { pull: true, push: true } });
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?per_page=1&state=all") {
sendJson(res, 200, []);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=all&per_page=4") {
sendJson(res, 200, [pullRequest]);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=open&per_page=4") {
sendJson(res, 200, [pullRequest]);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=closed&per_page=4") {
sendJson(res, 200, [mergedPullRequest]);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/42") {
sendJson(res, 200, pullRequest);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/pulls/7") {
sendJson(res, 200, shorthandPullRequest);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/43") {
sendJson(res, 200, mergedPullRequest);
return;
}
if (req.method === "POST" && req.url === "/graphql") {
sendJson(res, 200, {
data: {
repository: {
pullRequest: {
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
headRefName: "feature/pr-contract",
baseRefName: "master",
statusCheckRollup: {
state: "SUCCESS",
contexts: {
nodes: [
{ __typename: "CheckRun", name: "contract", status: "COMPLETED", conclusion: "SUCCESS" },
{ __typename: "StatusContext", context: "legacy-ci", state: "SUCCESS", targetUrl: "https://ci.example.test/42", description: "ok" },
],
},
},
},
},
},
});
return;
}
if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/pulls/42") {
const parsed = JSON.parse(body) as JsonRecord;
sendJson(res, 200, { ...pullRequest, title: String(parsed.title ?? pullRequest.title), body: String(parsed.body ?? pullRequest.body), state: String(parsed.state ?? pullRequest.state), updated_at: "2026-05-20T06:00:00Z" });
return;
}
if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues/42/comments") {
const parsed = JSON.parse(body) as JsonRecord;
sendJson(res, 201, { id: 9101, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/pull/42#issuecomment-9101", user: { login: "runner" }, created_at: "2026-05-20T06:10:00Z", updated_at: "2026-05-20T06:10:00Z" });
return;
}
if (req.method === "DELETE" && req.url === "/repos/pikasTech/unidesk/issues/comments/9101") {
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())),
};
}
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;
}
export async function runGhCliPrContract(): 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 pr list")), "gh help should list pr list", { usage });
assertCondition(usage.some((line) => line.includes("gh pr read")), "gh help should list pr read", { usage });
assertCondition(usage.some((line) => line.includes("gh pr view")), "gh help should list pr view", { usage });
assertCondition(usage.some((line) => line.includes("gh pr read") && line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document pr shorthand and raw/full disclosure", { usage });
assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage });
assertCondition(usage.some((line) => line.includes("gh pr edit")), "gh help should list pr edit", { usage });
assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage });
assertCondition(usage.some((line) => line.includes("gh pr list") && line.includes("--state open|closed|all")), "gh help should document pr list state filtering", { usage });
assertCondition(usage.some((line) => line.includes("mergedAt") && line.includes("mergeCommit")), "gh help should document merged PR closeout fields", { usage });
assertCondition(notes.some((line) => line.includes("canonical read path")), "gh help should state pr read is canonical", { notes });
assertCondition(notes.some((line) => line.includes("compatibility alias")), "gh help should state pr view is alias", { notes });
assertCondition(notes.some((line) => line.includes("PR read/view accept owner/repo#number shorthand")), "gh help should explain pr read/view shorthand", { 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("PR list defaults to --state all")), "gh help should document pr list default state", { notes });
assertCondition(notes.some((line) => line.includes("stateDetail") && line.includes("mergedAt")), "gh help should describe closeout field normalization", { notes });
const mock = await startMockGitHub();
const env = {
GH_TOKEN: "contract-token",
UNIDESK_GITHUB_API_URL: mock.baseUrl,
};
try {
const list = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--limit", "4"], env);
assertCondition(list.status === 0, "pr list should succeed through REST", list.json ?? { stdout: list.stdout });
const listData = dataOf(list.json ?? {});
assertCondition(listData.state === "all", "pr list should keep default state=all compatibility", listData);
const pullRequests = listData.pullRequests as JsonRecord[];
assertCondition(Array.isArray(pullRequests) && pullRequests.length === 1, "pr list should return pullRequests", listData);
assertCondition(pullRequests[0]?.number === 42 && pullRequests[0]?.base && pullRequests[0]?.head, "pr list should expose PR summary", pullRequests[0]);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls?state=all&per_page=4"), "default pr list should query state=all", mock.requests);
const listOpen = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "4", "--json", "number,title,state,url"], env);
assertCondition(listOpen.status === 0, "pr list should support --state open", listOpen.json ?? { stdout: listOpen.stdout });
const listOpenData = dataOf(listOpen.json ?? {});
assertCondition(listOpenData.state === "open", "pr list should preserve requested state", listOpenData);
const listOpenPrs = listOpenData.pullRequests as JsonRecord[];
assertCondition(Array.isArray(listOpenPrs) && listOpenPrs[0]?.state === "open", "pr list --state open should return selected PR fields", listOpenData);
assertCondition(!("body" in listOpenPrs[0]), "pr list --json should keep progressive disclosure", listOpenPrs[0]);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls?state=open&per_page=4"), "pr list --state open should query REST state=open", mock.requests);
const listClosed = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--state", "closed", "--limit", "4", "--json", "number,state,url"], env);
assertCondition(listClosed.status === 0, "pr list should support --state closed", listClosed.json ?? { stdout: listClosed.stdout });
const listClosedData = dataOf(listClosed.json ?? {});
assertCondition(listClosedData.state === "closed", "pr list should preserve requested closed state", listClosedData);
const listClosedPrs = listClosedData.pullRequests as JsonRecord[];
assertCondition(Array.isArray(listClosedPrs) && listClosedPrs[0]?.number === 43 && listClosedPrs[0]?.state === "closed", "pr list --state closed should return closed PR summaries", listClosedData);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls?state=closed&per_page=4"), "pr list --state closed should query REST state=closed", mock.requests);
const badState = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--state", "merged"], env);
assertCondition(badState.status !== 0, "pr list unsupported state should fail", badState.json ?? { stdout: badState.stdout });
const badStateData = failedDataOf(badState.json ?? {});
assertCondition(badStateData.degradedReason === "validation-failed", "pr list unsupported state should be validation-failed", badStateData);
assertCondition(badStateData.runnerDisposition === "business-failed", "pr list unsupported state should be business-failed", badStateData);
const read = await runCli(["gh", "pr", "read", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env);
assertCondition(read.status === 0, "pr read should succeed through REST", read.json ?? { stdout: read.stdout });
const readData = dataOf(read.json ?? {});
const pullRequest = readData.pullRequest as JsonRecord;
assertCondition(pullRequest.number === 42 && pullRequest.url === "https://github.com/pikasTech/unidesk/pull/42", "pr read should expose PR details", readData);
const selected = readData.json as JsonRecord;
assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr read --json should select fields", readData);
const openLifecycle = await runCli(["gh", "pr", "read", "42", "--repo", "pikasTech/unidesk", "--json", "state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName"], env);
assertCondition(openLifecycle.status === 0, "open pr lifecycle fields should succeed through REST", openLifecycle.json ?? { stdout: openLifecycle.stdout });
const openLifecycleData = dataOf(openLifecycle.json ?? {});
const openLifecycleJson = openLifecycleData.json as JsonRecord;
assertCondition(openLifecycleJson.state === "open" && openLifecycleJson.stateDetail === "open", "open pr should distinguish stateDetail=open", openLifecycleData);
assertCondition(openLifecycleJson.closed === false && openLifecycleJson.closedAt === null, "open pr should expose closed=false", openLifecycleData);
assertCondition(openLifecycleJson.merged === false && openLifecycleJson.mergedAt === null && openLifecycleJson.mergeCommit === null, "open pr should expose merged=false", openLifecycleData);
const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env);
assertCondition(view.status === 0, "pr view alias should succeed through REST", view.json ?? { stdout: view.stdout });
const viewData = dataOf(view.json ?? {});
assertCondition((viewData.pullRequest as JsonRecord).number === 42, "pr view alias should expose PR details", viewData);
const viewSelected = viewData.json as JsonRecord;
assertCondition(viewSelected.body === "PR body" && viewSelected.title === "contract PR", "pr view alias should preserve selected fields", viewData);
const shorthandRaw = await runCli(["gh", "pr", "view", "pikasTech/HWLAB#7", "--raw"], env);
assertCondition(shorthandRaw.status === 0, "pr view should accept owner/repo#number shorthand with --raw", shorthandRaw.json ?? { stdout: shorthandRaw.stdout });
const shorthandRawData = dataOf(shorthandRaw.json ?? {});
assertCondition(shorthandRawData.repo === "pikasTech/HWLAB", "pr shorthand should derive repo from owner/repo#number", shorthandRawData);
const shorthandPr = shorthandRawData.pullRequest as JsonRecord;
assertCondition(shorthandPr.number === 7 && shorthandPr.url === "https://github.com/pikasTech/HWLAB/pull/7", "pr shorthand should read the requested PR", shorthandRawData);
const shorthandDisclosure = shorthandRawData.disclosure as JsonRecord;
assertCondition(shorthandDisclosure.raw === true && shorthandDisclosure.fullDisclosure === true, "--raw should mark explicit full disclosure for PR read/view", shorthandDisclosure);
const shorthandJson = shorthandRawData.json as JsonRecord;
assertCondition(shorthandJson.body === "PR shorthand body" && shorthandJson.mergeStateStatus === "CLEAN", "--raw should include full PR read fields including closeout metadata", shorthandRawData);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/pulls/7"), "pr shorthand should call the derived repo REST path", mock.requests);
const shorthandConflict = await runCli(["gh", "pr", "read", "pikasTech/HWLAB#7", "--repo", "pikasTech/unidesk", "--raw"], env);
assertCondition(shorthandConflict.status !== 0, "pr shorthand with conflicting --repo should fail", shorthandConflict.json ?? { stdout: shorthandConflict.stdout });
const shorthandConflictData = failedDataOf(shorthandConflict.json ?? {});
assertCondition(shorthandConflictData.degradedReason === "validation-failed", "pr conflicting --repo should be validation-failed", shorthandConflictData);
assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "pr conflict message should name the derived repo", shorthandConflictData);
const prConflictCommands = shorthandConflictData.supportedCommands as string[];
assertCondition(Array.isArray(prConflictCommands) && prConflictCommands.some((command) => command === "bun scripts/cli.ts gh pr read 7 --repo pikasTech/HWLAB --json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"), "pr conflict should include exact supported read command", shorthandConflictData);
const closeout = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "mergeable,mergeStateStatus,statusCheckRollup,headRefName,baseRefName"], env);
assertCondition(closeout.status === 0, "pr view closeout metadata fields should not be rejected", closeout.json ?? { stdout: closeout.stdout });
const closeoutData = dataOf(closeout.json ?? {});
const closeoutJson = closeoutData.json as JsonRecord;
assertCondition(closeoutJson.mergeable === "MERGEABLE", "pr view should expose mergeable", closeoutData);
assertCondition(closeoutJson.mergeStateStatus === "CLEAN", "pr view should expose mergeStateStatus", closeoutData);
assertCondition(closeoutJson.headRefName === "feature/pr-contract" && closeoutJson.baseRefName === "master", "pr view should expose PR branch names", closeoutData);
const rollup = closeoutJson.statusCheckRollup as JsonRecord;
assertCondition(rollup.state === "SUCCESS", "pr view should expose statusCheckRollup", closeoutData);
assertCondition(mock.requests.some((request) => request.method === "POST" && request.url === "/graphql"), "closeout metadata should use GitHub GraphQL when requested", mock.requests);
const requestsBeforeMergedRead = mock.requests.length;
const mergedRead = await runCli(["gh", "pr", "read", "43", "--repo", "pikasTech/unidesk", "--json", "state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit"], env);
assertCondition(mergedRead.status === 0, "merged pr closeout fields should succeed through REST", mergedRead.json ?? { stdout: mergedRead.stdout });
const mergedReadData = dataOf(mergedRead.json ?? {});
const mergedSummary = mergedReadData.pullRequest as JsonRecord;
assertCondition(mergedSummary.state === "closed" && mergedSummary.stateDetail === "merged", "pullRequest summary should distinguish merged from closed", mergedReadData);
const mergedReadJson = mergedReadData.json as JsonRecord;
assertCondition(mergedReadJson.state === "closed" && mergedReadJson.stateDetail === "merged", "merged pr json should distinguish merged from closed", mergedReadData);
assertCondition(mergedReadJson.closed === true && mergedReadJson.closedAt === "2026-05-21T08:00:00Z", "merged pr should expose closed fields", mergedReadData);
assertCondition(mergedReadJson.merged === true && mergedReadJson.mergedAt === "2026-05-21T08:00:00Z", "merged pr should expose merged fields", mergedReadData);
const mergeCommit = mergedReadJson.mergeCommit as JsonRecord;
assertCondition(mergeCommit.oid === "merge-commit-sha", "merged pr should expose merge commit oid", mergedReadData);
const mergedReadRequests = mock.requests.slice(requestsBeforeMergedRead);
assertCondition(!mergedReadRequests.some((request) => request.method === "POST" && request.url === "/graphql"), "REST closeout fields should not require GraphQL", mergedReadRequests);
const unsupportedReadField = await runCli(["gh", "pr", "read", "42", "--repo", "pikasTech/unidesk", "--json", "mergedAt,mergeCommit,projectCards"], env);
assertCondition(unsupportedReadField.status !== 0, "unsupported pr read field should fail before network work", unsupportedReadField.json ?? { stdout: unsupportedReadField.stdout });
const unsupportedReadFieldData = failedDataOf(unsupportedReadField.json ?? {});
assertCondition(unsupportedReadFieldData.degradedReason === "validation-failed", "unsupported pr read field should be validation-failed", unsupportedReadFieldData);
const unsupportedReadDetails = unsupportedReadFieldData.details as JsonRecord;
const unsupportedReadMessage = String(unsupportedReadDetails.message ?? "");
assertCondition(unsupportedReadMessage.includes("projectCards"), "unsupported pr read field message should name the bad field", unsupportedReadFieldData);
assertCondition(unsupportedReadMessage.includes("mergedAt") && unsupportedReadMessage.includes("mergeCommit"), "unsupported pr read field message should include supported closeout fields", unsupportedReadFieldData);
const preflight = await runBun([
"scripts/code-queue-pr-preflight-example.ts",
"--repo",
"pikasTech/unidesk",
"--base",
"master",
"--head",
"feature/pr-contract",
"--comment-pr",
"42",
], env);
assertCondition(preflight.status === 0, "PR preflight example should succeed against mock GitHub", preflight.json ?? { stdout: preflight.stdout });
assertCondition(preflight.json?.ok === true, "PR preflight example should report ok=true", preflight.json ?? {});
assertCondition(!preflight.stdout.includes("contract-token"), "PR preflight example must not print token values", { stdout: preflight.stdout });
assertCondition(typeof preflight.json?.checks === "object" && preflight.json.checks !== null && !Array.isArray(preflight.json.checks), "PR preflight should expose checks", preflight.json ?? {});
const preflightChecks = preflight.json?.checks as JsonRecord;
const envToken = preflightChecks.envToken as JsonRecord;
assertCondition(envToken.present === true && envToken.source === "GH_TOKEN", "PR preflight should require env token source", envToken);
const authStatus = preflightChecks.githubAuthStatus as JsonRecord;
assertCondition(authStatus.ok === true, "PR preflight should prove GitHub REST egress and repo visibility", authStatus);
const preflightCreate = preflightChecks.prCreateDryRun as JsonRecord;
const preflightComment = preflightChecks.prCommentDryRun as JsonRecord;
assertCondition(preflightCreate.ok === true && preflightCreate.dryRun === true && preflightCreate.planned === true, "PR preflight create must stay dry-run", preflightCreate);
assertCondition(preflightComment.ok === true && preflightComment.dryRun === true && preflightComment.planned === true, "PR preflight comment must stay dry-run", preflightComment);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/rate_limit"), "PR preflight should probe REST egress", mock.requests);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk"), "PR preflight should probe repo visibility", mock.requests);
assertCondition(mock.requests.every((request) => request.method === "GET" || request.method === "POST" && request.url === "/graphql"), "initial mock phase should remain read-only except GraphQL metadata reads", mock.requests);
} finally {
await mock.close();
}
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 = await 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 = await 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 mock2 = await startMockGitHub();
const env2 = {
GH_TOKEN: "contract-token",
UNIDESK_GITHUB_API_URL: mock2.baseUrl,
};
try {
const beforeUpdateRequests = mock2.requests.length;
const updateReplace = await runCli(["gh", "pr", "update", "42", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", bodyFile, "--title", "updated"], env2);
assertCondition(updateReplace.status === 0, "pr update replace should succeed", updateReplace.json ?? { stdout: updateReplace.stdout });
const updateReplaceData = dataOf(updateReplace.json ?? {});
assertCondition(updateReplaceData.command === "pr update", "pr update replace should report command", updateReplaceData);
assertCondition(updateReplaceData.repo === "pikasTech/unidesk" && updateReplaceData.pr === 42 && updateReplaceData.number === 42, "pr update should return low-noise repo and PR number", updateReplaceData);
assertCondition(updateReplaceData.url === "https://github.com/pikasTech/unidesk/pull/42", "pr update should return PR URL", updateReplaceData);
assertCondition(JSON.stringify(updateReplaceData.changedFields) === JSON.stringify(["title", "body"]), "pr update should report changed fields only", updateReplaceData);
assertCondition(!("pullRequest" in updateReplaceData), "pr update should not echo full pullRequest/body by default", updateReplaceData);
assertCondition(updateReplaceData.rest === true && updateReplaceData.graphQl === false && updateReplaceData.projectsClassic === false, "pr update should be REST-only and avoid GraphQL projectCards", updateReplaceData);
const updatePatch = mock2.requests.slice(beforeUpdateRequests).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/pulls/42");
assertCondition(updatePatch !== undefined, "pr update should PATCH the pulls REST endpoint", mock2.requests);
const updatePayload = JSON.parse(updatePatch?.body ?? "{}") as JsonRecord;
assertCondition(updatePayload.title === "updated" && updatePayload.body === "Line 1\n`code`\n| a | b |\n", "pr update should preserve body-file bytes in REST payload", updatePayload);
assertCondition(!mock2.requests.slice(beforeUpdateRequests).some((request) => request.method === "POST" && request.url === "/graphql"), "pr update should not call GraphQL", mock2.requests.slice(beforeUpdateRequests));
const updateAppend = await runCli(["gh", "pr", "update", "42", "--repo", "pikasTech/unidesk", "--mode", "append", "--body-file", bodyFile, "--dry-run"], env2);
assertCondition(updateAppend.status === 0, "pr update append dry-run should succeed", updateAppend.json ?? { stdout: updateAppend.stdout });
const updateAppendData = dataOf(updateAppend.json ?? {});
const appendBody = updateAppendData.body as JsonRecord;
const finalBody = appendBody.finalBody as JsonRecord;
assertCondition(appendBody.mode === "append", "pr append mode should be explicit", updateAppendData);
assertCondition(finalBody.containsBackticks === true && finalBody.containsMarkdownTable === true, "pr append should preserve markdown signals", updateAppendData);
const editStdinBody = "stdin line\n`stdin code`\n| c | d |\n";
const beforeEditRequests = mock2.requests.length;
const editStdin = await runCli(["gh", "pr", "edit", "42", "--repo", "pikasTech/unidesk", "--title", "stdin title", "--body-file", "-"], env2, editStdinBody);
assertCondition(editStdin.status === 0, "pr edit stdin should succeed", editStdin.json ?? { stdout: editStdin.stdout });
const editStdinData = dataOf(editStdin.json ?? {});
assertCondition(editStdinData.command === "pr edit" && editStdinData.pr === 42 && editStdinData.url === "https://github.com/pikasTech/unidesk/pull/42", "pr edit stdin should return low-noise summary", editStdinData);
assertCondition(JSON.stringify(editStdinData.changedFields) === JSON.stringify(["title", "body"]), "pr edit stdin should report changed fields", editStdinData);
assertCondition(!JSON.stringify(editStdinData).includes(editStdinBody), "pr edit stdin should not echo full body", editStdinData);
const editBody = editStdinData.body as JsonRecord;
const editBodySource = editBody.bodySource as JsonRecord;
assertCondition(editBodySource.kind === "stdin" && editBodySource.path === "-", "pr edit should mark stdin body source", editBodySource);
const editPatch = mock2.requests.slice(beforeEditRequests).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/pulls/42");
assertCondition(editPatch !== undefined, "pr edit stdin should PATCH the pulls REST endpoint", mock2.requests);
const editPayload = JSON.parse(editPatch?.body ?? "{}") as JsonRecord;
assertCondition(editPayload.title === "stdin title" && editPayload.body === editStdinBody, "pr edit should send stdin body through REST JSON payload", editPayload);
assertCondition(!mock2.requests.slice(beforeEditRequests).some((request) => request.method === "POST" && request.url === "/graphql"), "pr edit should not call GraphQL", mock2.requests.slice(beforeEditRequests));
const titleOnly = await runCli(["gh", "pr", "edit", "42", "--repo", "pikasTech/unidesk", "--title", "title only", "--dry-run"], env2);
assertCondition(titleOnly.status === 0, "pr edit title-only dry-run should succeed", titleOnly.json ?? { stdout: titleOnly.stdout });
const titleOnlyData = dataOf(titleOnly.json ?? {});
assertCondition(JSON.stringify(titleOnlyData.changedFields) === JSON.stringify(["title"]), "title-only edit should report only title changed", titleOnlyData);
assertCondition(!("body" in titleOnlyData), "title-only edit should not include body details", titleOnlyData);
const closePr = await runCli(["gh", "pr", "close", "42", "--repo", "pikasTech/unidesk"], env2);
assertCondition(closePr.status === 0, "pr close should succeed", closePr.json ?? { stdout: closePr.stdout });
const closeData = dataOf(closePr.json ?? {});
assertCondition(closeData.command === "pr close", "pr close command should be explicit", closeData);
const reopenPr = await runCli(["gh", "pr", "reopen", "42", "--repo", "pikasTech/unidesk", "--dry-run"], env2);
assertCondition(reopenPr.status === 0, "pr reopen dry-run should succeed", reopenPr.json ?? { stdout: reopenPr.stdout });
const reopenData = dataOf(reopenPr.json ?? {});
assertCondition(reopenData.command === "pr reopen" && reopenData.dryRun === true, "pr reopen dry-run should be explicit", reopenData);
const commentCreate = await runCli(["gh", "pr", "comment", "create", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile], env2);
assertCondition(commentCreate.status === 0, "pr comment create should succeed", commentCreate.json ?? { stdout: commentCreate.stdout });
const commentCreateData = dataOf(commentCreate.json ?? {});
assertCondition(commentCreateData.command === "pr comment create", "pr comment create should use CRUD command name", commentCreateData);
const commentDelete = await runCli(["gh", "pr", "comment", "delete", "9101", "--repo", "pikasTech/unidesk"], env2);
assertCondition(commentDelete.status === 0, "pr comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout });
const commentDeleteData = dataOf(commentDelete.json ?? {});
assertCondition(commentDeleteData.deleted === true, "pr comment delete should report deleted", commentDeleteData);
} finally {
await mock2.close();
}
const mergeBlocked = await 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 deleteBlocked = await runCli(["gh", "pr", "delete", "42", "--repo", "pikasTech/unidesk"]);
assertCondition(deleteBlocked.status !== 0, "pr hard delete should fail", deleteBlocked.json ?? { stdout: deleteBlocked.stdout });
const deleteData = deleteBlocked.json?.data as JsonRecord | undefined;
assertCondition(deleteData?.degradedReason === "unsupported-command", "pr delete should be unsupported-command", deleteData ?? {});
const createMissingBody = await 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 = await 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 list/read/view work through REST with token and no gh binary dependency",
"pr read/view accept owner/repo#number shorthand and reject conflicting --repo",
"pr read/view --raw is explicit full disclosure",
"pr read normalizes open and merged lifecycle fields from REST",
"pr view closeout metadata fields are accepted and hydrated through GraphQL",
"pr read unsupported fields fail structurally with supported closeout fields listed",
"pr create dry-run exposes planned operation",
"pr comment dry-run preserves markdown text",
"pr update/edit use low-noise REST PATCH without GraphQL projectCards",
"pr edit supports --body-file - stdin without echoing full body",
"pr update append and close/reopen are available",
"pr comment create/delete follows CRUD shape",
"pr merge is blocked",
"pr hard delete is blocked",
"pr create validation failures are structured",
"unknown gh options are structured",
],
};
}
if (import.meta.main) {
const result = await runGhCliPrContract();
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}