fix: expose PR closeout metadata
This commit is contained in:
@@ -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
@@ -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}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user