feat: add REST PR edit CLI

This commit is contained in:
Codex
2026-05-23 05:14:57 +00:00
parent 190a4e3ea1
commit bcac2bced3
5 changed files with 150 additions and 52 deletions
+48 -7
View File
@@ -11,11 +11,12 @@ function assertCondition(condition: unknown, message: string, detail: unknown =
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function runBun(args: string[], env: Record<string, string> = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> {
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[] = [];
@@ -37,11 +38,13 @@ function runBun(args: string[], env: Record<string, string> = {}): Promise<{ sta
json,
});
});
if (stdin !== undefined) child.stdin.end(stdin);
else child.stdin.end();
});
}
function runCli(args: string[], env: Record<string, string> = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> {
return runBun(["scripts/cli.ts", ...args], env);
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 {
@@ -201,6 +204,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(usage.some((line) => line.includes("gh pr view")), "gh help should list pr view", { usage });
assertCondition(usage.some((line) => line.includes("gh pr read") && line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document pr shorthand and raw/full disclosure", { usage });
assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage });
assertCondition(usage.some((line) => line.includes("gh pr edit")), "gh help should list pr edit", { usage });
assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage });
assertCondition(usage.some((line) => line.includes("gh pr list") && line.includes("--state open|closed|all")), "gh help should document pr list state filtering", { usage });
assertCondition(notes.some((line) => line.includes("canonical read path")), "gh help should state pr read is canonical", { notes });
@@ -356,18 +360,53 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
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" && updateReplaceData.mode === "replace", "pr update replace should report mode", updateReplaceData);
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 finalBody = updateAppendData.finalBody as JsonRecord;
assertCondition(updateAppendData.mode === "append", "pr append mode should be explicit", updateAppendData);
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 ?? {});
@@ -427,7 +466,9 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
"pr view closeout metadata fields are accepted and hydrated through GraphQL",
"pr create dry-run exposes planned operation",
"pr comment dry-run preserves markdown text",
"pr update replace/append and close/reopen are available",
"pr update/edit use low-noise REST PATCH without GraphQL projectCards",
"pr edit supports --body-file - stdin without echoing full body",
"pr update append and close/reopen are available",
"pr comment create/delete follows CRUD shape",
"pr merge is blocked",
"pr hard delete is blocked",