fix: expose PR closeout metadata

This commit is contained in:
Codex
2026-05-23 08:45:58 +00:00
parent fac14a1ea5
commit 3ad0cb888e
4 changed files with 148 additions and 10 deletions
+86
View File
@@ -110,6 +110,22 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
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 });
@@ -149,7 +165,41 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
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: {
@@ -253,6 +303,8 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
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 = {
@@ -292,6 +344,13 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
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 ?? {});
@@ -344,8 +403,32 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
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 === false, "closeout metadata should keep UniDesk CLI merge unsupported", 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 });
@@ -564,6 +647,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
const closeoutBoundary = mergeData?.closeoutBoundary as JsonRecord | undefined;
assertCondition(closeoutBoundary?.ordinaryRunnerFinalActionAllowed === true, "merge block should preserve ordinary runner PR closeout policy", closeoutBoundary ?? {});
assertCondition(closeoutBoundary?.unideskCliMergeSupported === false, "merge block should state UniDesk REST CLI merge remains unsupported", closeoutBoundary ?? {});
assertCondition(String(closeoutBoundary?.readOnlyCloseoutCommand ?? "").includes("gh pr view 42"), "merge block should point to read-only closeout command", closeoutBoundary ?? {});
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 });
@@ -592,9 +676,11 @@ 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 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",