feat(cli): unify gh issue and pr crud
This commit is contained in:
@@ -79,6 +79,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
|
||||
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 === "/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/pulls?state=all&per_page=4") {
|
||||
sendJson(res, 200, [pullRequest]);
|
||||
return;
|
||||
@@ -87,6 +91,21 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
|
||||
sendJson(res, 200, pullRequest);
|
||||
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));
|
||||
@@ -128,11 +147,13 @@ export async function runGhCliPrContract(): Promise<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]);
|
||||
|
||||
const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk"], env);
|
||||
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 through REST", view.json ?? { stdout: view.stdout });
|
||||
const viewData = dataOf(view.json ?? {});
|
||||
const pullRequest = viewData.pullRequest as JsonRecord;
|
||||
assertCondition(pullRequest.number === 42 && pullRequest.url === "https://github.com/pikasTech/unidesk/pull/42", "pr view should expose PR details", viewData);
|
||||
const selected = viewData.json as JsonRecord;
|
||||
assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr view --json should select fields", viewData);
|
||||
} finally {
|
||||
await mock.close();
|
||||
}
|
||||
@@ -163,12 +184,58 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
|
||||
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 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);
|
||||
|
||||
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);
|
||||
assertCondition(finalBody.containsBackticks === true && finalBody.containsMarkdownTable === true, "pr append should preserve markdown signals", updateAppendData);
|
||||
|
||||
const closePr = await runCli(["gh", "pr", "close", "42", "--repo", "pikasTech/unidesk"], env2);
|
||||
assertCondition(closePr.status === 0, "pr close should succeed", closePr.json ?? { stdout: closePr.stdout });
|
||||
const closeData = dataOf(closePr.json ?? {});
|
||||
assertCondition(closeData.command === "pr close", "pr close command should be explicit", closeData);
|
||||
|
||||
const reopenPr = await runCli(["gh", "pr", "reopen", "42", "--repo", "pikasTech/unidesk", "--dry-run"], env2);
|
||||
assertCondition(reopenPr.status === 0, "pr reopen dry-run should succeed", reopenPr.json ?? { stdout: reopenPr.stdout });
|
||||
const reopenData = dataOf(reopenPr.json ?? {});
|
||||
assertCondition(reopenData.command === "pr reopen" && reopenData.dryRun === true, "pr reopen dry-run should be explicit", reopenData);
|
||||
|
||||
const commentCreate = await runCli(["gh", "pr", "comment", "create", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile], env2);
|
||||
assertCondition(commentCreate.status === 0, "pr comment create should succeed", commentCreate.json ?? { stdout: commentCreate.stdout });
|
||||
const commentCreateData = dataOf(commentCreate.json ?? {});
|
||||
assertCondition(commentCreateData.command === "pr comment create", "pr comment create should use CRUD command name", commentCreateData);
|
||||
|
||||
const commentDelete = await runCli(["gh", "pr", "comment", "delete", "9101", "--repo", "pikasTech/unidesk"], env2);
|
||||
assertCondition(commentDelete.status === 0, "pr comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout });
|
||||
const commentDeleteData = dataOf(commentDelete.json ?? {});
|
||||
assertCondition(commentDeleteData.deleted === true, "pr comment delete should report deleted", commentDeleteData);
|
||||
} finally {
|
||||
await mock2.close();
|
||||
}
|
||||
|
||||
const mergeBlocked = await runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk"]);
|
||||
assertCondition(mergeBlocked.status !== 0, "pr merge should fail", mergeBlocked.json ?? { stdout: mergeBlocked.stdout });
|
||||
const mergeData = mergeBlocked.json?.data as JsonRecord | undefined;
|
||||
assertCondition(String(mergeData?.message ?? "").includes("intentionally unsupported"), "merge block message should be explicit", mergeData ?? {});
|
||||
assertCondition(mergeData?.runnerDisposition === "business-failed", "merge block should classify as business-failed", mergeData ?? {});
|
||||
|
||||
const deleteBlocked = await runCli(["gh", "pr", "delete", "42", "--repo", "pikasTech/unidesk"]);
|
||||
assertCondition(deleteBlocked.status !== 0, "pr hard delete should fail", deleteBlocked.json ?? { stdout: deleteBlocked.stdout });
|
||||
const deleteData = deleteBlocked.json?.data as JsonRecord | undefined;
|
||||
assertCondition(deleteData?.degradedReason === "unsupported-command", "pr delete should be unsupported-command", deleteData ?? {});
|
||||
|
||||
const createMissingBody = await runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--base", "master", "--head", "feature/pr-contract", "--dry-run"]);
|
||||
assertCondition(createMissingBody.status !== 0, "pr create without body source should fail", createMissingBody.json ?? { stdout: createMissingBody.stdout });
|
||||
const createMissingBodyData = createMissingBody.json?.data as JsonRecord | undefined;
|
||||
@@ -191,7 +258,10 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
|
||||
"pr list/view work through REST with token and no gh binary dependency",
|
||||
"pr create dry-run exposes planned operation",
|
||||
"pr comment dry-run preserves markdown text",
|
||||
"pr update replace/append and close/reopen are available",
|
||||
"pr comment create/delete follows CRUD shape",
|
||||
"pr merge is blocked",
|
||||
"pr hard delete is blocked",
|
||||
"pr create validation failures are structured",
|
||||
"unknown gh options are structured",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user