fix(cli): expose merged PR closeout fields

This commit is contained in:
Codex
2026-05-23 05:15:09 +00:00
parent df5f5be16b
commit 6c1282bb99
5 changed files with 97 additions and 18 deletions
+59 -2
View File
@@ -80,6 +80,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
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",
};
@@ -93,6 +97,19 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
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 server = createServer(async (req, res) => {
const body = await collectBody(req);
requests.push({ method: req.method ?? "", url: req.url ?? "", body });
@@ -117,7 +134,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=closed&per_page=4") {
sendJson(res, 200, [{ ...pullRequest, number: 43, state: "closed", html_url: "https://github.com/pikasTech/unidesk/pull/43" }]);
sendJson(res, 200, [mergedPullRequest]);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/42") {
@@ -128,6 +145,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 200, shorthandPullRequest);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/43") {
sendJson(res, 200, mergedPullRequest);
return;
}
if (req.method === "POST" && req.url === "/graphql") {
sendJson(res, 200, {
data: {
@@ -207,11 +228,13 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(usage.some((line) => line.includes("gh pr edit")), "gh help should list pr edit", { usage });
assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage });
assertCondition(usage.some((line) => line.includes("gh pr list") && line.includes("--state open|closed|all")), "gh help should document pr list state filtering", { usage });
assertCondition(usage.some((line) => line.includes("mergedAt") && line.includes("mergeCommit")), "gh help should document merged PR closeout fields", { usage });
assertCondition(notes.some((line) => line.includes("canonical read path")), "gh help should state pr read is canonical", { notes });
assertCondition(notes.some((line) => line.includes("compatibility alias")), "gh help should state pr view is alias", { notes });
assertCondition(notes.some((line) => line.includes("PR read/view accept owner/repo#number shorthand")), "gh help should explain pr read/view shorthand", { notes });
assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes });
assertCondition(notes.some((line) => line.includes("PR list defaults to --state all")), "gh help should document pr list default state", { notes });
assertCondition(notes.some((line) => line.includes("stateDetail") && line.includes("mergedAt")), "gh help should describe closeout field normalization", { notes });
const mock = await startMockGitHub();
const env = {
@@ -259,6 +282,14 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
const selected = readData.json as JsonRecord;
assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr read --json should select fields", readData);
const openLifecycle = await runCli(["gh", "pr", "read", "42", "--repo", "pikasTech/unidesk", "--json", "state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName"], env);
assertCondition(openLifecycle.status === 0, "open pr lifecycle fields should succeed through REST", openLifecycle.json ?? { stdout: openLifecycle.stdout });
const openLifecycleData = dataOf(openLifecycle.json ?? {});
const openLifecycleJson = openLifecycleData.json as JsonRecord;
assertCondition(openLifecycleJson.state === "open" && openLifecycleJson.stateDetail === "open", "open pr should distinguish stateDetail=open", openLifecycleData);
assertCondition(openLifecycleJson.closed === false && openLifecycleJson.closedAt === null, "open pr should expose closed=false", openLifecycleData);
assertCondition(openLifecycleJson.merged === false && openLifecycleJson.mergedAt === null && openLifecycleJson.mergeCommit === null, "open pr should expose merged=false", openLifecycleData);
const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env);
assertCondition(view.status === 0, "pr view alias should succeed through REST", view.json ?? { stdout: view.stdout });
const viewData = dataOf(view.json ?? {});
@@ -284,7 +315,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(shorthandConflictData.degradedReason === "validation-failed", "pr conflicting --repo should be validation-failed", shorthandConflictData);
assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "pr conflict message should name the derived repo", shorthandConflictData);
const prConflictCommands = shorthandConflictData.supportedCommands as string[];
assertCondition(Array.isArray(prConflictCommands) && prConflictCommands.some((command) => command === "bun scripts/cli.ts gh pr read 7 --repo pikasTech/HWLAB --json body,title,state,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"), "pr conflict should include exact supported read command", shorthandConflictData);
assertCondition(Array.isArray(prConflictCommands) && prConflictCommands.some((command) => command === "bun scripts/cli.ts gh pr read 7 --repo pikasTech/HWLAB --json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"), "pr conflict should include exact supported read command", shorthandConflictData);
const closeout = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "mergeable,mergeStateStatus,statusCheckRollup,headRefName,baseRefName"], env);
assertCondition(closeout.status === 0, "pr view closeout metadata fields should not be rejected", closeout.json ?? { stdout: closeout.stdout });
@@ -297,6 +328,30 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(rollup.state === "SUCCESS", "pr view should expose statusCheckRollup", closeoutData);
assertCondition(mock.requests.some((request) => request.method === "POST" && request.url === "/graphql"), "closeout metadata should use GitHub GraphQL when requested", mock.requests);
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 preflight = await runBun([
"scripts/code-queue-pr-preflight-example.ts",
"--repo",
@@ -463,7 +518,9 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
"pr list/read/view work through REST with token and no gh binary dependency",
"pr read/view accept owner/repo#number shorthand and reject conflicting --repo",
"pr read/view --raw is explicit full disclosure",
"pr read normalizes open and merged lifecycle fields from REST",
"pr view closeout metadata fields are accepted and hydrated through GraphQL",
"pr read unsupported fields fail structurally with supported closeout fields listed",
"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",