Files
pikasTech-unidesk/scripts/gh-cli-pr-contract-test.ts
T
2026-05-29 09:36:50 +00:00

765 lines
59 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 unknownMetadataPullRequest = {
...pullRequest,
id: 4400,
number: 44,
title: "unknown metadata PR",
html_url: "https://github.com/pikasTech/unidesk/pull/44",
head: { ref: "feature/pr-unknown", sha: "unknown-head-sha" },
};
const graphqlErrorPullRequest = {
...pullRequest,
id: 4500,
number: 45,
title: "graphql error PR",
html_url: "https://github.com/pikasTech/unidesk/pull/45",
head: { ref: "feature/pr-graphql-error", sha: "graphql-error-head-sha" },
};
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/HWLAB/pulls?state=open&per_page=4") {
sendJson(res, 200, [shorthandPullRequest]);
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 === "PUT" && req.url === "/repos/pikasTech/unidesk/pulls/42/merge") {
const parsed = JSON.parse(body) as JsonRecord;
sendJson(res, 200, { sha: "merged-by-rest-sha", merged: true, message: `merged via ${String(parsed.merge_method ?? "merge")}` });
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 === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/44") {
sendJson(res, 200, unknownMetadataPullRequest);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/45") {
sendJson(res, 200, graphqlErrorPullRequest);
return;
}
if (req.method === "POST" && req.url === "/graphql") {
const parsed = JSON.parse(body) as { variables?: { number?: unknown } };
const number = Number(parsed.variables?.number ?? 0);
if (number === 44) {
sendJson(res, 200, {
data: {
repository: {
pullRequest: {
mergeable: "UNKNOWN",
mergeStateStatus: null,
headRefName: "feature/pr-unknown",
baseRefName: "master",
statusCheckRollup: null,
},
},
},
});
return;
}
if (number === 45) {
sendJson(res, 200, {
errors: [
{ type: "FORBIDDEN", message: "Resource not accessible by integration" },
],
});
return;
}
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())),
};
}
async function startResetGitHub(): Promise<{ baseUrl: string; close: () => Promise<void> }> {
const server = createServer((req) => {
req.socket.destroy();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assertCondition(typeof address === "object" && address !== null, "reset mock server should expose address");
const port = (address as AddressInfo).port;
assertCondition(typeof port === "number", "reset mock server should expose port");
return {
baseUrl: `http://127.0.0.1:${port}`,
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 preflight")), "gh help should list top-level preflight alias", { usage });
assertCondition(usage.some((line) => line.includes("gh pr preflight")), "gh help should list pr preflight", { 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 });
assertCondition(notes.some((line) => line.includes("low-noise read-only closeout helper")), "gh help should document PR preflight closeout helper", { notes });
assertCondition(notes.some((line) => line.includes("closeoutMetadata") && line.includes("UNKNOWN/null")), "gh help should document explicit closeout metadata unknowns", { notes });
assertCondition(notes.some((line) => line.includes("PR list does not fetch mergeability")), "gh help should direct closeout metadata to pr view", { 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 positionalRepoList = await runCli(["gh", "pr", "list", "pikasTech/HWLAB", "--state", "open", "--limit", "4", "--json", "number,title,state,url"], env);
assertCondition(positionalRepoList.status === 0, "pr list positional owner/repo should succeed", positionalRepoList.json ?? { stdout: positionalRepoList.stdout });
const positionalRepoListData = dataOf(positionalRepoList.json ?? {});
assertCondition(positionalRepoListData.repo === "pikasTech/HWLAB", "pr list positional repo should become the actual request repo", positionalRepoListData);
const positionalRepoPrs = positionalRepoListData.pullRequests as JsonRecord[];
assertCondition(Array.isArray(positionalRepoPrs) && positionalRepoPrs[0]?.number === 7, "pr list positional repo should return HWLAB fixture PR", positionalRepoListData);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/pulls?state=open&per_page=4"), "pr list positional repo should query derived repo REST path", mock.requests);
const positionalRepoConflict = await runCli(["gh", "pr", "list", "pikasTech/HWLAB", "--repo", "pikasTech/unidesk", "--state", "open"], env);
assertCondition(positionalRepoConflict.status !== 0, "pr list conflicting positional repo and --repo should fail", positionalRepoConflict.json ?? { stdout: positionalRepoConflict.stdout });
const positionalRepoConflictData = failedDataOf(positionalRepoConflict.json ?? {});
assertCondition(positionalRepoConflictData.degradedReason === "validation-failed", "pr list repo conflict should be validation-failed", positionalRepoConflictData);
assertCondition(String((positionalRepoConflictData.details as JsonRecord)?.message ?? "").includes("positional repo pikasTech/HWLAB"), "pr list repo conflict should name positional repo", positionalRepoConflictData);
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 badListCloseout = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--json", "number,mergeable,statusCheckRollup"], env);
assertCondition(badListCloseout.status !== 0, "pr list closeout metadata fields should fail explicitly", badListCloseout.json ?? { stdout: badListCloseout.stdout });
const badListCloseoutData = failedDataOf(badListCloseout.json ?? {});
const badListCloseoutMessage = String((badListCloseoutData.details as JsonRecord)?.message ?? badListCloseoutData.message ?? "");
assertCondition(badListCloseoutData.degradedReason === "validation-failed", "pr list closeout fields should be validation-failed", badListCloseoutData);
assertCondition(badListCloseoutMessage.includes("use gh pr view <number>") && badListCloseoutMessage.includes("statusCheckRollup"), "pr list closeout failure should point to pr view", badListCloseoutData);
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 readNumberAlias = await runCli(["gh", "pr", "read", "--repo", "pikasTech/HWLAB", "--number", "7", "--json", "body,title,state,head,base"], env);
assertCondition(readNumberAlias.status === 0, "pr read should accept --number alias", readNumberAlias.json ?? { stdout: readNumberAlias.stdout });
const readNumberAliasData = dataOf(readNumberAlias.json ?? {});
assertCondition(readNumberAliasData.repo === "pikasTech/HWLAB", "pr read --number should preserve explicit repo", readNumberAliasData);
const readNumberAliasPr = readNumberAliasData.pullRequest as JsonRecord;
assertCondition(readNumberAliasPr.number === 7 && readNumberAliasPr.url === "https://github.com/pikasTech/HWLAB/pull/7", "pr read --number should read the requested PR", readNumberAliasData);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/pulls/7"), "pr read --number should call explicit repo REST path", mock.requests);
const numberAliasUnsupported = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--number", "7"], env);
assertCondition(numberAliasUnsupported.status !== 0, "--number should not be silently ignored outside pr read/view", numberAliasUnsupported.json ?? { stdout: numberAliasUnsupported.stdout });
const numberAliasUnsupportedData = failedDataOf(numberAliasUnsupported.json ?? {});
assertCondition(numberAliasUnsupportedData.degradedReason === "validation-failed", "unsupported --number should be validation-failed", numberAliasUnsupportedData);
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);
const closeoutMetadata = closeoutData.closeoutMetadata as JsonRecord;
const closeoutMergeBoundary = closeoutMetadata.mergeBoundary as JsonRecord;
assertCondition(closeoutMetadata.ok === true && closeoutMetadata.source === "github-graphql", "pr view closeout metadata should report GraphQL source", closeoutMetadata);
assertCondition(Array.isArray(closeoutMetadata.missingOrUnknownFields) && closeoutMetadata.missingOrUnknownFields.length === 0, "known closeout metadata should have no missing/unknown fields", closeoutMetadata);
assertCondition(closeoutMergeBoundary.unideskCliMergeSupported === true, "closeout metadata should expose guarded UniDesk CLI merge support", closeoutMetadata);
assertCondition(mock.requests.some((request) => request.method === "POST" && request.url === "/graphql"), "closeout metadata should use GitHub GraphQL when requested", mock.requests);
const unknownCloseout = await runCli(["gh", "pr", "view", "44", "--repo", "pikasTech/unidesk", "--json", "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"], env);
assertCondition(unknownCloseout.status === 0, "pr view unknown closeout metadata should remain a structured read success", unknownCloseout.json ?? { stdout: unknownCloseout.stdout });
const unknownCloseoutData = dataOf(unknownCloseout.json ?? {});
const unknownCloseoutJson = unknownCloseoutData.json as JsonRecord;
const unknownCloseoutMetadata = unknownCloseoutData.closeoutMetadata as JsonRecord;
const unknownFields = unknownCloseoutMetadata.missingOrUnknownFields as unknown[];
assertCondition(unknownCloseoutJson.mergeable === "UNKNOWN" && unknownCloseoutJson.statusCheckRollup === null, "unknown closeout JSON should preserve GitHub values", unknownCloseoutData);
assertCondition(unknownCloseoutMetadata.ok === false, "unknown closeout metadata should be explicit", unknownCloseoutMetadata);
assertCondition(Array.isArray(unknownFields) && unknownFields.includes("mergeable") && unknownFields.includes("mergeStateStatus") && unknownFields.includes("statusCheckRollup"), "unknown closeout metadata should name missing/unknown fields", unknownCloseoutMetadata);
assertCondition(String(unknownCloseoutMetadata.advice ?? "").includes("missing or unknown"), "unknown closeout metadata should include operator advice", unknownCloseoutMetadata);
const graphqlErrorCloseout = await runCli(["gh", "pr", "view", "45", "--repo", "pikasTech/unidesk", "--json", "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"], env);
assertCondition(graphqlErrorCloseout.status !== 0, "pr view GraphQL closeout failure should fail structurally", graphqlErrorCloseout.json ?? { stdout: graphqlErrorCloseout.stdout });
const graphqlErrorData = failedDataOf(graphqlErrorCloseout.json ?? {});
const graphqlErrorMetadata = graphqlErrorData.closeoutMetadata as JsonRecord;
assertCondition(graphqlErrorData.phase === "fetch-pr-closeout-metadata", "GraphQL closeout failure should report phase", graphqlErrorData);
assertCondition(graphqlErrorMetadata.ok === false && graphqlErrorMetadata.source === "github-graphql", "GraphQL closeout failure should include explicit metadata error", graphqlErrorData);
assertCondition(String(graphqlErrorMetadata.message ?? "").includes("Resource not accessible"), "GraphQL closeout failure should preserve sanitized error message", graphqlErrorMetadata);
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 closeoutPreflight = await runCli(["gh", "pr", "preflight", "42", "--repo", "pikasTech/unidesk"], env);
assertCondition(closeoutPreflight.status === 0, "pr preflight should succeed through REST and GraphQL", closeoutPreflight.json ?? { stdout: closeoutPreflight.stdout });
assertCondition(!closeoutPreflight.stdout.includes("contract-token"), "pr preflight must not print token values", { stdout: closeoutPreflight.stdout });
const closeoutPreflightData = dataOf(closeoutPreflight.json ?? {});
assertCondition(closeoutPreflightData.command === "pr preflight", "pr preflight should report command", closeoutPreflightData);
assertCondition(closeoutPreflightData.readOnly === true && closeoutPreflightData.writesRemote === false, "pr preflight must stay read-only", closeoutPreflightData);
assertCondition(!("raw" in closeoutPreflightData), "pr preflight default output should omit raw payloads", closeoutPreflightData);
const authCapability = closeoutPreflightData.authCapability as JsonRecord;
assertCondition(authCapability.ok === true && authCapability.tokenPresent === true && authCapability.tokenSource === "GH_TOKEN", "pr preflight should expose redacted auth capability", authCapability);
assertCondition(authCapability.valuesPrinted === false, "pr preflight should explicitly avoid secret values", authCapability);
const preflightPr = closeoutPreflightData.pullRequest as JsonRecord;
assertCondition(preflightPr.number === 42 && preflightPr.bodyOmitted === true, "pr preflight should return bounded PR metadata", preflightPr);
const mergeability = closeoutPreflightData.mergeability as JsonRecord;
assertCondition(mergeability.mergeable === "MERGEABLE" && mergeability.mergeStateStatus === "CLEAN", "pr preflight should expose mergeability", mergeability);
assertCondition(mergeability.readyForCommanderMerge === true && mergeability.conclusion === "ready", "pr preflight should summarize closeout readiness", mergeability);
const preflightStatus = closeoutPreflightData.statusChecks as JsonRecord;
const preflightCounts = preflightStatus.counts as JsonRecord;
assertCondition(preflightStatus.state === "SUCCESS" && preflightStatus.rawOmitted === true, "pr preflight default status rollup should be compact", preflightStatus);
assertCondition(preflightCounts.success === 2, "pr preflight should count successful contexts", preflightStatus);
const policy = closeoutPreflightData.policy as JsonRecord;
assertCondition(policy.mergesPr === false && policy.mergeCommandSupported === true && policy.unideskCliMergeSupported === true, "pr preflight policy should expose guarded UniDesk CLI merge execution", policy);
const aliasPreflight = await runCli(["gh", "preflight", "42", "--repo", "pikasTech/unidesk"], env);
assertCondition(aliasPreflight.status === 0, "top-level gh preflight alias should succeed", aliasPreflight.json ?? { stdout: aliasPreflight.stdout });
const aliasPreflightData = dataOf(aliasPreflight.json ?? {});
assertCondition(aliasPreflightData.command === "preflight", "top-level gh preflight should report alias command", aliasPreflightData);
assertCondition((aliasPreflightData.policy as JsonRecord).mergesPr === false, "top-level gh preflight alias must not merge", aliasPreflightData);
const optionsFirstPreflight = await runCli(["gh", "pr", "preflight", "--repo", "pikasTech/unidesk", "42"], env);
assertCondition(optionsFirstPreflight.status === 0, "pr preflight should accept PR number after --repo", optionsFirstPreflight.json ?? { stdout: optionsFirstPreflight.stdout });
const optionsFirstPreflightData = dataOf(optionsFirstPreflight.json ?? {});
assertCondition(optionsFirstPreflightData.command === "pr preflight", "options-first pr preflight should report command", optionsFirstPreflightData);
assertCondition((optionsFirstPreflightData.pullRequest as JsonRecord).number === 42, "options-first pr preflight should read the requested PR", optionsFirstPreflightData);
const fullPreflight = await runCli(["gh", "pr", "preflight", "42", "--repo", "pikasTech/unidesk", "--full"], env);
assertCondition(fullPreflight.status === 0, "pr preflight --full should succeed", fullPreflight.json ?? { stdout: fullPreflight.stdout });
const fullPreflightData = dataOf(fullPreflight.json ?? {});
const fullStatus = fullPreflightData.statusChecks as JsonRecord;
assertCondition(fullStatus.rawOmitted === false && Array.isArray(fullStatus.contexts), "pr preflight --full should include status contexts", fullStatus);
assertCondition(typeof fullPreflightData.raw === "object" && fullPreflightData.raw !== null, "pr preflight --full should include raw read payload summary", fullPreflightData);
const mergeDryRun = await runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk", "--dry-run"], env);
assertCondition(mergeDryRun.status === 0, "pr merge dry-run should expose a guarded merge plan", mergeDryRun.json ?? { stdout: mergeDryRun.stdout });
const mergeDryRunData = dataOf(mergeDryRun.json ?? {});
assertCondition(mergeDryRunData.wouldMerge === true && mergeDryRunData.method === "merge", "merge dry-run should not write but should plan merge", mergeDryRunData);
const mergeActual = await runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk", "--squash"], env);
assertCondition(mergeActual.status === 0, "pr merge should use guarded REST merge when preflight is ready", mergeActual.json ?? { stdout: mergeActual.stdout });
const mergeData = dataOf(mergeActual.json ?? {});
assertCondition(mergeData.method === "squash" && mergeData.rest === true, "merge result should report REST merge method", mergeData);
const mergeRequest = mock.requests.find((request) => request.method === "PUT" && request.url === "/repos/pikasTech/unidesk/pulls/42/merge");
assertCondition(mergeRequest !== undefined, "pr merge should call GitHub REST merge endpoint", mock.requests);
const mergePayload = JSON.parse(mergeRequest?.body ?? "{}") as JsonRecord;
assertCondition(mergePayload.merge_method === "squash", "pr merge should pass selected merge method", mergePayload);
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.some((request) => request.method === "PUT" && request.url === "/repos/pikasTech/unidesk/pulls/42/merge"), "initial mock phase should include the guarded REST merge write", mock.requests);
} finally {
await mock.close();
}
const resetMock = await startResetGitHub();
try {
const transient = await runCli(["gh", "auth", "status", "--repo", "pikasTech/unidesk"], {
GH_TOKEN: "contract-token",
UNIDESK_GITHUB_API_URL: resetMock.baseUrl,
});
assertCondition(transient.status !== 0, "GitHub DNS/API transient should fail structurally", transient.json ?? { stdout: transient.stdout });
const transientData = failedDataOf(transient.json ?? {});
assertCondition(transientData.degradedReason === "github-transient", "GitHub DNS/API transient should not be auth-missing or semantic failure", transientData);
assertCondition(transientData.runnerDisposition === "infra-blocked", "GitHub transient should remain infra-blocked", transientData);
const transientDetails = transientData.details as JsonRecord;
assertCondition(transientDetails.retryable === true, "GitHub transient should be retryable", transientDetails);
assertCondition(transientDetails.commanderAction === "retry-backoff-or-keep-running-if-heartbeat-fresh", "GitHub transient should expose bounded commander action", transientDetails);
assertCondition(!transient.stdout.includes("contract-token"), "GitHub transient output must not print token values", { stdout: transient.stdout });
} finally {
await resetMock.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 prInlineCommentBody = "short PR inline comment remains supported";
const prInlineComment = await runCli(["gh", "pr", "comment", "create", "42", "--repo", "pikasTech/unidesk", "--body", prInlineCommentBody], env2);
assertCondition(prInlineComment.status === 0, "pr comment create --body should remain supported", prInlineComment.json ?? { stdout: prInlineComment.stdout });
assertCondition(prInlineComment.json?.command === "gh pr comment create 42 --repo pikasTech/unidesk --body <body:redacted>", "outer gh command should redact PR inline comment body", prInlineComment.json ?? {});
const prInlineCommentData = dataOf(prInlineComment.json ?? {});
assertCondition(prInlineCommentData.command === "pr comment create", "pr inline comment should use CRUD command name", prInlineCommentData);
const prInlineCommentRequest = mock2.requests.filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues/42/comments").at(-1);
assertCondition(prInlineCommentRequest !== undefined, "pr inline comment should POST to issue comments endpoint", mock2.requests);
const prInlinePayload = JSON.parse(prInlineCommentRequest?.body ?? "{}") as JsonRecord;
assertCondition(prInlinePayload.body === prInlineCommentBody, "pr inline comment payload should preserve --body text", prInlinePayload);
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 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 list positional owner/repo targets the requested repo and conflicting --repo fails",
"pr read supports --number alias without silently ignoring it elsewhere",
"pr read/view accept owner/repo#number shorthand and reject conflicting --repo",
"pr read/view --raw is explicit full disclosure",
"pr list rejects closeout fields and points to pr view",
"pr read normalizes open and merged lifecycle fields from REST",
"GitHub DNS/API transients are retryable and distinct from auth or PR semantic failures",
"pr view closeout metadata fields are accepted and hydrated through GraphQL",
"pr view closeout metadata makes GraphQL errors and UNKNOWN/null explicit",
"pr read unsupported fields fail structurally with supported closeout fields listed",
"pr preflight exposes redacted auth plus compact merge/status closeout metadata",
"top-level gh preflight alias works for commander closeout",
"pr preflight accepts the PR number after options",
"pr preflight --full is the explicit status-context disclosure path",
"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 and --body remains supported",
"pr merge is guarded by preflight and uses REST",
"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`);
}