feat: add REST PR edit CLI
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user