feat: add gh pr preflight helper

This commit is contained in:
Codex
2026-05-23 08:42:41 +00:00
parent cdaf28a5c4
commit 90ffd028de
6 changed files with 323 additions and 8 deletions
+277 -3
View File
@@ -3901,6 +3901,264 @@ async function prGraphqlMetadata(repo: string, token: string, number: number): P
return pullRequest;
}
function statusContextName(context: GitHubPullRequestGraphqlStatusContext): string {
return context.name ?? context.context ?? "(unnamed status check)";
}
function statusContextBucket(context: GitHubPullRequestGraphqlStatusContext): string {
const type = context.__typename ?? "StatusContext";
const status = (context.status ?? "").toUpperCase();
const conclusion = (context.conclusion ?? "").toUpperCase();
const state = (context.state ?? "").toUpperCase();
if (type === "CheckRun") {
if (status.length > 0 && status !== "COMPLETED") return "pending";
if (conclusion === "SUCCESS") return "success";
if (conclusion === "NEUTRAL") return "neutral";
if (conclusion === "SKIPPED") return "skipped";
if (conclusion === "CANCELLED") return "cancelled";
if (conclusion === "TIMED_OUT") return "timed-out";
if (["FAILURE", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(conclusion)) return "failure";
return "unknown";
}
if (state === "SUCCESS") return "success";
if (state === "PENDING" || state === "EXPECTED") return "pending";
if (state === "FAILURE" || state === "ERROR") return "failure";
return "unknown";
}
function compactStatusContext(context: GitHubPullRequestGraphqlStatusContext): Record<string, unknown> {
const bucket = statusContextBucket(context);
return {
name: statusContextName(context),
type: context.__typename ?? "StatusContext",
bucket,
status: context.status ?? null,
conclusion: context.conclusion ?? null,
state: context.state ?? null,
targetUrl: context.targetUrl ?? null,
description: context.description === undefined || context.description === null ? null : preview(context.description),
};
}
function statusRollupSummary(
repo: string,
number: number,
rollup: GitHubPullRequestGraphqlStatusCheckRollup | null | undefined,
includeRaw: boolean,
): Record<string, unknown> {
const contexts = rollup?.contexts?.nodes ?? [];
const compactContexts = contexts.map(compactStatusContext);
const counts: Record<string, number> = {
success: 0,
failure: 0,
pending: 0,
neutral: 0,
skipped: 0,
cancelled: 0,
"timed-out": 0,
unknown: 0,
};
for (const context of compactContexts) {
const bucket = String(context.bucket ?? "unknown");
counts[bucket] = (counts[bucket] ?? 0) + 1;
}
const nonSuccess = compactContexts.filter((context) => context.bucket !== "success");
const failing = compactContexts.filter((context) => context.bucket === "failure" || context.bucket === "cancelled" || context.bucket === "timed-out");
const pending = compactContexts.filter((context) => context.bucket === "pending" || context.bucket === "unknown");
const neutral = compactContexts.filter((context) => context.bucket === "neutral" || context.bucket === "skipped");
return {
state: rollup?.state ?? null,
totalContexts: compactContexts.length,
counts,
failingContexts: failing.slice(0, 10),
pendingContexts: pending.slice(0, 10),
neutralContexts: neutral.slice(0, 10),
nonSuccessPreview: nonSuccess.slice(0, 12),
omittedNonSuccessContexts: Math.max(0, nonSuccess.length - 12),
rawOmitted: !includeRaw,
...(includeRaw
? { contexts: compactContexts, raw: rollup ?? null }
: { fullHint: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo} --full` }),
};
}
function compactProbeOk(value: unknown): boolean | null {
if (value === "ok") return true;
if (value === "skipped") return null;
if (isRecord(value) && value.ok === true) return true;
if (isRecord(value) && value.ok === false) return false;
return null;
}
function compactAuthCapability(auth: GitHubCommandResult): Record<string, unknown> {
const token = isRecord(auth.token) ? auth.token : {};
const gh = isRecord(auth.gh) ? auth.gh : {};
const probes = isRecord(auth.probes) ? auth.probes : {};
return {
ok: auth.ok === true,
degradedReason: auth.ok === false ? auth.degradedReason ?? null : null,
runnerDisposition: auth.ok === false ? auth.runnerDisposition ?? null : null,
tokenPresent: token.present === true,
tokenSource: typeof token.source === "string" ? token.source : null,
ghFallbackAttempted: token.ghFallbackAttempted === true,
ghBinaryFound: gh.binaryFound === true,
restApi: compactProbeOk(probes.restApi),
repoVisible: compactProbeOk(probes.repo),
issueRead: compactProbeOk(probes.issueRead),
valuesPrinted: false,
};
}
function prPreflightPolicy(repo: string, number: number): Record<string, unknown> {
return {
readOnly: true,
writesRemote: false,
createsPr: false,
comments: false,
mergesPr: false,
mergeCommandSupported: false,
unsupportedMergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo}`,
unideskCliMergeSupported: false,
ordinaryRunnerFinalActionAllowed: true,
note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks; the UniDesk REST CLI still never merges PRs.",
};
}
function preflightPullRequestSummary(summary: Record<string, unknown>): Record<string, unknown> {
return {
number: summary.number ?? null,
title: summary.title ?? null,
state: summary.state ?? null,
stateDetail: summary.stateDetail ?? null,
draft: summary.draft ?? null,
url: summary.url ?? null,
author: summary.author ?? null,
head: summary.head ?? null,
base: summary.base ?? null,
headRefName: summary.headRefName ?? null,
baseRefName: summary.baseRefName ?? null,
closed: summary.closed ?? null,
merged: summary.merged ?? null,
mergedAt: summary.mergedAt ?? null,
updatedAt: summary.updatedAt ?? null,
bodyOmitted: true,
};
}
function prCloseoutSummary(
pr: Record<string, unknown>,
metadata: Record<string, unknown>,
statusChecks: Record<string, unknown>,
): Record<string, unknown> {
const blockers: string[] = [];
const pending: string[] = [];
const prState = typeof pr.state === "string" ? pr.state.toLowerCase() : "";
if (prState !== "open") blockers.push(`state:${pr.state ?? "unknown"}`);
if (pr.draft === true) blockers.push("draft:true");
const mergeable = typeof metadata.mergeable === "string" ? metadata.mergeable.toUpperCase() : null;
if (mergeable === "MERGEABLE") {
// Ready signal, combined with mergeStateStatus and checks below.
} else if (mergeable === "CONFLICTING" || mergeable === "NOT_MERGEABLE") {
blockers.push(`mergeable:${mergeable}`);
} else {
pending.push(`mergeable:${mergeable ?? "unknown"}`);
}
const mergeStateStatus = typeof metadata.mergeStateStatus === "string" ? metadata.mergeStateStatus.toUpperCase() : null;
if (mergeStateStatus === "CLEAN") {
// Ready signal.
} else if (mergeStateStatus === "UNKNOWN" || mergeStateStatus === null) {
pending.push(`mergeStateStatus:${mergeStateStatus ?? "unknown"}`);
} else {
blockers.push(`mergeStateStatus:${mergeStateStatus}`);
}
const rollupState = typeof statusChecks.state === "string" ? statusChecks.state.toUpperCase() : null;
const totalContexts = typeof statusChecks.totalContexts === "number" ? statusChecks.totalContexts : 0;
const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {};
const failureCount = Number(counts.failure ?? 0) + Number(counts.cancelled ?? 0) + Number(counts["timed-out"] ?? 0);
const pendingCount = Number(counts.pending ?? 0) + Number(counts.unknown ?? 0);
if (failureCount > 0) blockers.push(`statusChecks:failure:${failureCount}`);
if (pendingCount > 0) pending.push(`statusChecks:pending:${pendingCount}`);
if (rollupState === "SUCCESS" || rollupState === null && totalContexts === 0) {
// Ready signal.
} else if (rollupState === "PENDING" || rollupState === "EXPECTED" || rollupState === null) {
pending.push(`statusCheckRollup:${rollupState ?? "unknown"}`);
} else if (rollupState !== null) {
blockers.push(`statusCheckRollup:${rollupState}`);
}
const readyForCommanderMerge = blockers.length === 0 && pending.length === 0;
return {
readyForCommanderMerge,
conclusion: readyForCommanderMerge ? "ready" : blockers.length > 0 ? "blocked" : "pending",
blockers,
pending,
commanderAction: readyForCommanderMerge
? "review and merge through a repo-owned GitHub path when task boundaries allow; UniDesk REST gh pr merge remains unsupported"
: "resolve blockers or rerun after GitHub finishes computing mergeability/status checks",
};
}
async function prPreflight(repo: string, number: number, commandName: "preflight" | "pr preflight" | "pr closeout", includeRaw: boolean): Promise<GitHubCommandResult> {
const auth = await authStatus(repo);
const authCapability = compactAuthCapability(auth);
const policy = prPreflightPolicy(repo, number);
if (auth.ok === false) {
return {
ok: false,
command: commandName,
repo,
number,
degradedReason: auth.degradedReason,
runnerDisposition: auth.runnerDisposition,
authCapability,
policy,
details: auth,
};
}
const { token, probe } = resolveToken(true);
const missing = authRequired(repo, commandName, probe);
if (missing !== null || token === null) {
const missingResult = missing ?? authRequired(repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
return {
...(missingResult ?? commandError(commandName, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required"))),
command: commandName,
number,
authCapability,
policy,
} as GitHubCommandResult;
}
const { owner, name } = repoParts(repo);
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, authCapability, policy });
const metadata = await prGraphqlMetadata(repo, token, number);
const summary = prSummary(pr);
const pullRequest = preflightPullRequestSummary(summary);
if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest, authCapability, policy });
const metadataSummary = prMetadataSummary(metadata);
const statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, includeRaw);
const closeout = prCloseoutSummary(summary, metadataSummary, statusChecks);
return {
ok: true,
command: commandName,
canonicalCommand: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo}`,
aliases: ["bun scripts/cli.ts gh preflight <number>", "bun scripts/cli.ts gh pr closeout <number>"],
repo,
number,
readOnly: true,
writesRemote: false,
valuesPrinted: false,
authCapability,
pullRequest,
mergeability: {
mergeable: metadataSummary.mergeable,
mergeStateStatus: metadataSummary.mergeStateStatus,
...closeout,
},
statusChecks,
policy,
...(includeRaw ? { raw: { authStatus: auth, pullRequest, closeoutMetadata: metadataSummary } } : {}),
};
}
function repoSummary(repo: GitHubRepository): Record<string, unknown> {
return {
id: repo.id ?? null,
@@ -5238,11 +5496,14 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue board-row upsert <issueNumber> [--repo owner/name] --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] --board-issue 20 [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh preflight <prNumber> [--repo owner/name] [--full|--raw] [compatibility alias for gh pr preflight]",
"bun scripts/cli.ts gh pr list [--repo owner/name] [--state open|closed|all] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
"bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--limit N]",
"bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--limit N] [compatibility alias for pr files; no raw diff]",
"bun scripts/cli.ts gh pr read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]",
"bun scripts/cli.ts gh pr view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for pr read]",
"bun scripts/cli.ts gh pr preflight <number> [--repo owner/name] [--full|--raw]",
"bun scripts/cli.ts gh pr closeout <number> [--repo owner/name] [--full|--raw] [compatibility alias for pr preflight]",
"bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
"bun scripts/cli.ts gh pr edit <number> [--title title] [--body-file <file>|--body-file -|--body <text>] [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title title] [--repo owner/name] [--dry-run]",
@@ -5279,6 +5540,7 @@ export function ghHelp(): unknown {
"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 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.",
],
};
@@ -5313,14 +5575,15 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
return validationError(command, options.repo, "--json field selection is only supported by gh issue read/view/list and gh pr read/view/list");
}
}
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && isIssueReadCommand(sub)) || (top === "pr" && isPrReadCommand(sub)))) {
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && isIssueReadCommand(sub)) || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "preflight" || sub === "closeout")))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue read/view and gh pr read/view.", {
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue read/view, gh pr read/view, and gh pr preflight/closeout.", {
supportedCommands: [
"bun scripts/cli.ts gh issue read owner/name#<number> --raw",
"bun scripts/cli.ts gh issue read <number> --repo owner/name --json body,title,state,comments",
"bun scripts/cli.ts gh pr read owner/name#<number> --raw",
`bun scripts/cli.ts gh pr read <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`,
"bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full",
],
});
}
@@ -5380,6 +5643,12 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (top === "auth" && sub === "status") return authStatus(options.repo);
if (top === "preflight") {
const number = parseNumberForCommand(options.repo, sub, "preflight");
if (typeof number !== "number") return number;
return prPreflight(options.repo, number, "preflight", options.full || options.raw);
}
if (top === "issue") {
if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics.");
if (sub === "comment" && third === "delete") {
@@ -5498,6 +5767,11 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
return prFiles(options.repo, token, number, options.limit, "pr files");
}
if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics.");
if (sub === "preflight" || sub === "closeout") {
const number = parseNumberForCommand(options.repo, third, `pr ${sub}`);
if (typeof number !== "number") return number;
return prPreflight(options.repo, number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw);
}
if (sub === "comment" && third === "delete") {
const commentId = parseNumberForCommand(options.repo, args[3], "pr comment delete");
if (typeof commentId !== "number") return commentId;
@@ -5573,7 +5847,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
);
}
if (sub !== "list" && !isPrReadCommand(sub)) {
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, create, update/edit, close, reopen, comment create/delete, and unsupported merge/delete.");
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, comment create/delete, and unsupported merge/delete.");
}
if (sub === "read" || sub === "view") {
const resolved = resolveReadViewNumberReference("pr", sub, third, options, args);
+1 -1
View File
@@ -46,7 +46,7 @@ export function rootHelp(): unknown {
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
{ command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." },
{ command: "gh auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, hard delete unsupported, and merge blocked." },
{ command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and merge blocked." },
{ command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run preview; exposes local health, state, trace summary, and approval draft helpers without live bridges or message sends." },
{ command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." },
{ command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." },