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",
+60 -8
View File
@@ -22,6 +22,8 @@ const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number",
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const;
const PR_LIST_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const;
const PR_READ_JSON_FIELDS = ["body", "title", "state", "stateDetail", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "closed", "closedAt", "merged", "mergedAt", "mergeCommit", "headRefName", "baseRefName", "mergeable", "mergeStateStatus", "statusCheckRollup"] as const;
const PR_CLOSEOUT_JSON_FIELDS = ["mergeable", "mergeStateStatus", "statusCheckRollup"] as const;
const PR_CLOSEOUT_VIEW_JSON = "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup";
const ISSUE_LIST_STATES = ["open", "closed", "all"] as const;
const BODY_UPDATE_MODES = ["replace", "append"] as const;
const BOARD_MUTATION_SECTIONS = ["open", "closed"] as const;
@@ -594,6 +596,17 @@ function parseIssueListJsonFields(requested: string[] | undefined): IssueListJso
}
function parsePrListJsonFields(requested: string[] | undefined): PrListJsonField[] | undefined {
if (requested !== undefined) {
const listFields = new Set<string>(PR_LIST_JSON_FIELDS);
const closeoutFields = requested.filter((field) => (PR_CLOSEOUT_JSON_FIELDS as readonly string[]).includes(field));
const unsupported = requested.filter((field) => !listFields.has(field));
if (closeoutFields.length > 0) {
throw new Error(`unsupported gh pr list --json closeout field(s): ${closeoutFields.join(", ")}; use gh pr view <number> --json ${PR_CLOSEOUT_VIEW_JSON} for mergeability/statusCheckRollup metadata; pr list supported fields: ${PR_LIST_JSON_FIELDS.join(",")}`);
}
if (unsupported.length > 0) {
throw new Error(`unsupported gh pr list --json field(s): ${unsupported.join(", ")}; supported fields: ${PR_LIST_JSON_FIELDS.join(",")}; closeout metadata requires gh pr view <number> --json ${PR_CLOSEOUT_VIEW_JSON}`);
}
}
return validateJsonFields("gh pr list", requested, PR_LIST_JSON_FIELDS);
}
@@ -3854,6 +3867,47 @@ function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record<s
};
}
function prMergeBoundary(): Record<string, unknown> {
return {
runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close"],
ordinaryRunnerFinalActionAllowed: true,
commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"],
hostAllowedToolsAfterReview: ["system gh pr merge", "GitHub UI merge/close"],
unideskCliMergeSupported: false,
degradedReason: "unsupported-command",
};
}
function prCloseoutMetadata(metadata: GitHubPullRequestGraphqlMetadata): Record<string, unknown> {
const summary = prMetadataSummary(metadata);
const missingOrUnknownFields = PR_CLOSEOUT_JSON_FIELDS.filter((field) => {
const value = summary[field];
if (value === null || value === undefined) return true;
return typeof value === "string" && value.toUpperCase() === "UNKNOWN";
});
return {
ok: missingOrUnknownFields.length === 0,
source: "github-graphql",
missingOrUnknownFields,
advice: missingOrUnknownFields.length === 0
? "Closeout GraphQL metadata is present; still review checks, branch scope, and task boundary before final action."
: "Some closeout GraphQL metadata is missing or unknown; retry or cross-check with system gh/GitHub UI before treating the PR as merge-ready.",
mergeBoundary: prMergeBoundary(),
};
}
function prCloseoutMetadataError(error: GitHubErrorPayload): Record<string, unknown> {
return {
ok: false,
source: "github-graphql",
degradedReason: error.degradedReason,
runnerDisposition: error.runnerDisposition,
message: error.message,
advice: "Closeout GraphQL metadata was not available; retry or use a read-only system gh/GitHub UI cross-check before deciding merge readiness.",
mergeBoundary: prMergeBoundary(),
};
}
function needsPrGraphqlMetadata(fields: readonly string[] | undefined): boolean {
if (fields === undefined) return false;
return fields.some((field) => field === "mergeable" || field === "mergeStateStatus" || field === "statusCheckRollup");
@@ -5453,7 +5507,7 @@ async function prRead(repo: string, token: string, number: number, jsonFields: P
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
const summary = prSummary(pr);
const metadata = needsPrGraphqlMetadata(jsonFields) ? await prGraphqlMetadata(repo, token, number) : null;
if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, requestedJsonFields: jsonFields ?? [] });
if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, requestedJsonFields: jsonFields ?? [], closeoutMetadata: prCloseoutMetadataError(metadata) });
const selectionSummary = metadata === null ? summary : { ...summary, ...prMetadataSummary(metadata) };
return {
ok: true,
@@ -5461,6 +5515,7 @@ async function prRead(repo: string, token: string, number: number, jsonFields: P
repo,
...(disclosure === null ? {} : { disclosure }),
pullRequest: summary,
...(metadata === null ? {} : { closeoutMetadata: prCloseoutMetadata(metadata) }),
...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(selectionSummary, jsonFields) }),
};
}
@@ -5539,7 +5594,8 @@ export function ghHelp(): unknown {
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
"PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.",
"PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.",
"PR read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. PR read/view supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure.",
"PR read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. PR read/view supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
"PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.",
"PR preflight is a low-noise read-only closeout helper. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and the explicit UniDesk REST CLI no-merge policy. Use --full or --raw to include all fetched status contexts.",
"PR create/edit/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
],
@@ -5836,12 +5892,8 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
"PR merge is intentionally unsupported by the UniDesk REST CLI; PR-bound GPT-5.5 runners may self-close/merge ordinary in-boundary PRs after checks using repo-owned GitHub paths, while high-risk or ambiguous PRs stay commander-reviewed.",
{
closeoutBoundary: {
runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close"],
ordinaryRunnerFinalActionAllowed: true,
commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"],
hostAllowedToolsAfterReview: ["system gh pr merge", "GitHub UI merge/close"],
unideskCliMergeSupported: false,
degradedReason: "unsupported-command",
...prMergeBoundary(),
readOnlyCloseoutCommand: `bun scripts/cli.ts gh pr view ${third ?? "<number>"} --repo ${options.repo} --json ${PR_CLOSEOUT_VIEW_JSON}`,
},
},
);