847 lines
69 KiB
TypeScript
847 lines
69 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 === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/comments/9101") {
|
|
const parsed = JSON.parse(body) as JsonRecord;
|
|
sendJson(res, 200, { 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:12: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;
|
|
}
|
|
|
|
function failureMessageOf(data: JsonRecord): string {
|
|
return String((data.details as JsonRecord | undefined)?.message ?? data.message ?? "");
|
|
}
|
|
|
|
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 view") && line.includes("number|url|owner/repo#number") && line.includes("--raw|--full")), "gh help should document standard pr view targets and raw/full disclosure", { usage });
|
|
assertCondition(usage.some((line) => line.includes("gh pr read") && line.includes("compatibility alias for pr view")), "gh help should list pr read compatibility alias", { 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("PR view is the canonical")), "gh help should state pr view is canonical", { notes });
|
|
assertCondition(notes.some((line) => line.includes("read remains") && line.includes("compatibility alias")), "gh help should state pr read is alias", { notes });
|
|
assertCondition(notes.some((line) => line.includes("GitHub PR URLs") && line.includes("owner/repo#number shorthand")), "gh help should explain pr view/read URL and shorthand targets", { notes });
|
|
assertCondition(notes.some((line) => line.includes("--number is accepted on single PR/comment numeric target commands") && line.includes("PR comment update/edit/delete treat --number as commentId")), "gh help should document --number compatibility hint", { 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 compatibility 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);
|
|
const readNumberAliasDisclosure = readNumberAliasData.disclosure as JsonRecord;
|
|
assertCondition(String(readNumberAliasDisclosure.compatibilityHint ?? "").includes("standard gh syntax") && String(readNumberAliasDisclosure.standardCommand ?? "").includes("gh pr view 7 --repo pikasTech/HWLAB"), "pr read --number should return standard syntax hint", readNumberAliasDisclosure);
|
|
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 standard view/read", 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 should succeed as canonical read path", view.json ?? { stdout: view.stdout });
|
|
const viewData = dataOf(view.json ?? {});
|
|
assertCondition((viewData.pullRequest as JsonRecord).number === 42, "pr view should expose PR details", viewData);
|
|
const viewSelected = viewData.json as JsonRecord;
|
|
assertCondition(viewSelected.body === "PR body" && viewSelected.title === "contract PR", "pr view should preserve selected fields", viewData);
|
|
|
|
const prUrlView = await runCli(["gh", "pr", "view", "https://github.com/pikasTech/HWLAB/pull/7", "--json", "body,title,state,head,base"], env);
|
|
assertCondition(prUrlView.status === 0, "pr view should accept GitHub PR URL target", prUrlView.json ?? { stdout: prUrlView.stdout });
|
|
const prUrlViewData = dataOf(prUrlView.json ?? {});
|
|
assertCondition(prUrlViewData.repo === "pikasTech/HWLAB", "PR URL target should derive repo", prUrlViewData);
|
|
assertCondition((prUrlViewData.pullRequest as JsonRecord).number === 7, "PR URL target should derive PR number", prUrlViewData);
|
|
const prUrlDisclosure = prUrlViewData.disclosure as JsonRecord;
|
|
assertCondition(prUrlDisclosure.shorthand && (prUrlDisclosure.shorthand as JsonRecord).source === "github-url", "PR URL target should be disclosed", prUrlDisclosure);
|
|
|
|
const prIssueUrlMismatch = await runCli(["gh", "pr", "view", "https://github.com/pikasTech/HWLAB/issues/7", "--json", "body"], env);
|
|
assertCondition(prIssueUrlMismatch.status !== 0, "pr view should reject issue URLs", prIssueUrlMismatch.json ?? { stdout: prIssueUrlMismatch.stdout });
|
|
const prIssueUrlMismatchData = failedDataOf(prIssueUrlMismatch.json ?? {});
|
|
assertCondition(failureMessageOf(prIssueUrlMismatchData).includes("GitHub issue URL"), "pr view issue URL mismatch should be explicit", prIssueUrlMismatchData);
|
|
|
|
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 view 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 view 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 mergeRequestCount = mock.requests.filter((request) => request.method === "PUT" && request.url === "/repos/pikasTech/unidesk/pulls/42/merge").length;
|
|
const alreadyMerged = await runCli(["gh", "pr", "merge", "43", "--repo", "pikasTech/unidesk", "--squash"], env);
|
|
assertCondition(alreadyMerged.status === 0, "pr merge should treat an already merged PR as idempotent success", alreadyMerged.json ?? { stdout: alreadyMerged.stdout });
|
|
const alreadyMergedData = dataOf(alreadyMerged.json ?? {});
|
|
assertCondition(alreadyMergedData.alreadyMerged === true, "already merged PR response should expose alreadyMerged=true", alreadyMergedData);
|
|
const alreadyMergedPullRequest = alreadyMergedData.pullRequest as JsonRecord | undefined;
|
|
assertCondition(alreadyMergedPullRequest?.merged === true, "already merged PR response should expose merged pullRequest", alreadyMergedData);
|
|
const mergeRequestCountAfterAlreadyMerged = mock.requests.filter((request) => request.method === "PUT" && request.url === "/repos/pikasTech/unidesk/pulls/42/merge").length;
|
|
assertCondition(mergeRequestCountAfterAlreadyMerged === mergeRequestCount, "already merged PR should not call REST merge endpoint again", mock.requests);
|
|
|
|
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 updateNumberDryRun = await runCli(["gh", "pr", "update", "--number", "42", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", bodyFile, "--dry-run"], env2);
|
|
assertCondition(updateNumberDryRun.status === 0, "pr update should accept --number compatibility alias", updateNumberDryRun.json ?? { stdout: updateNumberDryRun.stdout });
|
|
const updateNumberData = dataOf(updateNumberDryRun.json ?? {});
|
|
const updateNumberHint = updateNumberData.standardSyntaxHint as JsonRecord;
|
|
assertCondition(String(updateNumberHint.standardCommand ?? "").includes("gh pr update 42 --repo pikasTech/unidesk"), "pr update --number should return standard syntax hint", updateNumberHint);
|
|
|
|
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-stdin"], 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 prBodyStdinCommentBody = "PR heredoc comment line\n\n- keeps `code`\n";
|
|
const prBodyStdinComment = await runCli(["gh", "pr", "comment", "create", "42", "--repo", "pikasTech/unidesk", "--body-stdin"], env2, prBodyStdinCommentBody);
|
|
assertCondition(prBodyStdinComment.status === 0, "pr comment create --body-stdin should succeed", prBodyStdinComment.json ?? { stdout: prBodyStdinComment.stdout });
|
|
const prBodyStdinCommentData = dataOf(prBodyStdinComment.json ?? {});
|
|
const prBodyStdinSource = prBodyStdinCommentData.bodySource as JsonRecord;
|
|
assertCondition(prBodyStdinSource.kind === "stdin" && prBodyStdinSource.path === "-", "pr comment create --body-stdin should expose stdin source", prBodyStdinCommentData);
|
|
const prBodyStdinRequest = mock2.requests.filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues/42/comments").at(-1);
|
|
const prBodyStdinPayload = JSON.parse(prBodyStdinRequest?.body ?? "{}") as JsonRecord;
|
|
assertCondition(prBodyStdinPayload.body === prBodyStdinCommentBody, "pr comment --body-stdin payload should preserve stdin markdown", prBodyStdinPayload);
|
|
|
|
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 prCommentUpdateBody = "PR 评论原地修正";
|
|
const prCommentUpdateDryRunRequestCountBefore = mock2.requests.length;
|
|
const prCommentUpdateDryRun = await runCli(["gh", "pr", "comment", "update", "9101", "--repo", "pikasTech/unidesk", "--body", prCommentUpdateBody, "--dry-run"], env2);
|
|
assertCondition(prCommentUpdateDryRun.status === 0, "pr comment update dry-run should succeed", prCommentUpdateDryRun.json ?? { stdout: prCommentUpdateDryRun.stdout });
|
|
assertCondition(prCommentUpdateDryRun.json?.command === "gh pr comment update 9101 --repo pikasTech/unidesk --body <body:redacted> --dry-run", "outer gh command should redact PR comment update inline body", prCommentUpdateDryRun.json ?? {});
|
|
const prCommentUpdateDryRunData = dataOf(prCommentUpdateDryRun.json ?? {});
|
|
assertCondition(prCommentUpdateDryRunData.command === "pr comment update" && prCommentUpdateDryRunData.commentId === 9101 && prCommentUpdateDryRunData.dryRun === true, "pr comment update dry-run should report commentId", prCommentUpdateDryRunData);
|
|
const prCommentUpdateRequest = prCommentUpdateDryRunData.request as JsonRecord;
|
|
assertCondition(prCommentUpdateRequest.method === "PATCH" && String(prCommentUpdateRequest.path ?? "").includes("/issues/comments/{comment_id}"), "pr comment update dry-run should plan PATCH comment endpoint", prCommentUpdateRequest);
|
|
const prCommentUpdateDryRunWriteCount = mock2.requests.slice(prCommentUpdateDryRunRequestCountBefore).filter((request) => request.method === "PATCH" && request.url.includes("/issues/comments/")).length;
|
|
assertCondition(prCommentUpdateDryRunWriteCount === 0, "pr comment update dry-run must not PATCH GitHub", { requests: mock2.requests.slice(prCommentUpdateDryRunRequestCountBefore) });
|
|
|
|
const prCommentEditBody = "PR edit 别名\n\n- 保留 `code`\n";
|
|
const prCommentEditRequestCountBefore = mock2.requests.length;
|
|
const prCommentEdit = await runCli(["gh", "pr", "comment", "edit", "--number", "9101", "--repo", "pikasTech/unidesk", "--body-stdin"], env2, prCommentEditBody);
|
|
assertCondition(prCommentEdit.status === 0, "pr comment edit should accept --number compatibility alias and stdin", prCommentEdit.json ?? { stdout: prCommentEdit.stdout });
|
|
const prCommentEditData = dataOf(prCommentEdit.json ?? {});
|
|
assertCondition(prCommentEditData.command === "pr comment edit" && prCommentEditData.commentId === 9101, "pr comment edit should report alias command and commentId", prCommentEditData);
|
|
const prCommentEditHint = prCommentEditData.standardSyntaxHint as JsonRecord;
|
|
assertCondition(String(prCommentEditHint.standardCommand ?? "").includes("gh pr comment edit 9101 --repo pikasTech/unidesk"), "pr comment edit --number should point to positional commentId syntax", prCommentEditHint);
|
|
const prCommentEditSummary = prCommentEditData.comment as JsonRecord;
|
|
assertCondition(prCommentEditSummary.id === 9101 && prCommentEditSummary.bodyOmitted === true && !("body" in prCommentEditSummary), "pr comment edit should preserve id and omit full body", prCommentEditSummary);
|
|
const prCommentEditPatch = mock2.requests.slice(prCommentEditRequestCountBefore).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/comments/9101");
|
|
assertCondition(prCommentEditPatch !== undefined, "pr comment edit should PATCH issue comments endpoint", { requests: mock2.requests.slice(prCommentEditRequestCountBefore) });
|
|
const prCommentEditPayload = JSON.parse(prCommentEditPatch?.body ?? "{}") as JsonRecord;
|
|
assertCondition(prCommentEditPayload.body === prCommentEditBody, "pr comment edit payload should preserve stdin Markdown", prCommentEditPayload);
|
|
|
|
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);
|
|
|
|
const commentDeleteNumber = await runCli(["gh", "pr", "comment", "delete", "--number", "9101", "--repo", "pikasTech/unidesk", "--dry-run"], env2);
|
|
assertCondition(commentDeleteNumber.status === 0, "pr comment delete should accept --number commentId compatibility alias", commentDeleteNumber.json ?? { stdout: commentDeleteNumber.stdout });
|
|
const commentDeleteNumberData = dataOf(commentDeleteNumber.json ?? {});
|
|
const commentDeleteNumberHint = commentDeleteNumberData.standardSyntaxHint as JsonRecord;
|
|
assertCondition(commentDeleteNumberData.commentId === 9101 && String(commentDeleteNumberHint.standardCommand ?? "").includes("gh pr comment delete 9101 --repo pikasTech/unidesk"), "pr comment delete --number should point to positional commentId syntax", commentDeleteNumberData);
|
|
} 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 single numeric target commands accept --number compatibility with a standard syntax hint",
|
|
"pr view/read accept GitHub URL and owner/repo#number targets and reject conflicting --repo",
|
|
"pr view/read --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-stdin without echoing full body",
|
|
"pr update append and close/reopen are available",
|
|
"pr comment create/update/edit/delete follows CRUD shape, --body-stdin, 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`);
|
|
}
|