feat: add gh pr preflight helper
This commit is contained in:
@@ -239,6 +239,8 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
|
||||
assertCondition(usage.some((line) => line.includes("gh pr read")), "gh help should list pr read", { usage });
|
||||
assertCondition(usage.some((line) => line.includes("gh pr view")), "gh help should list pr view", { usage });
|
||||
assertCondition(usage.some((line) => line.includes("gh pr read") && line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document pr shorthand and raw/full disclosure", { usage });
|
||||
assertCondition(usage.some((line) => line.includes("gh preflight")), "gh help should list top-level preflight alias", { usage });
|
||||
assertCondition(usage.some((line) => line.includes("gh pr preflight")), "gh help should list pr preflight", { usage });
|
||||
assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage });
|
||||
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 });
|
||||
@@ -250,6 +252,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
|
||||
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 });
|
||||
assertCondition(notes.some((line) => line.includes("low-noise read-only closeout helper")), "gh help should document PR preflight closeout helper", { notes });
|
||||
|
||||
const mock = await startMockGitHub();
|
||||
const env = {
|
||||
@@ -367,6 +370,41 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
|
||||
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 closeoutPreflight = await runCli(["gh", "pr", "preflight", "42", "--repo", "pikasTech/unidesk"], env);
|
||||
assertCondition(closeoutPreflight.status === 0, "pr preflight should succeed through REST and GraphQL", closeoutPreflight.json ?? { stdout: closeoutPreflight.stdout });
|
||||
assertCondition(!closeoutPreflight.stdout.includes("contract-token"), "pr preflight must not print token values", { stdout: closeoutPreflight.stdout });
|
||||
const closeoutPreflightData = dataOf(closeoutPreflight.json ?? {});
|
||||
assertCondition(closeoutPreflightData.command === "pr preflight", "pr preflight should report command", closeoutPreflightData);
|
||||
assertCondition(closeoutPreflightData.readOnly === true && closeoutPreflightData.writesRemote === false, "pr preflight must stay read-only", closeoutPreflightData);
|
||||
assertCondition(!("raw" in closeoutPreflightData), "pr preflight default output should omit raw payloads", closeoutPreflightData);
|
||||
const authCapability = closeoutPreflightData.authCapability as JsonRecord;
|
||||
assertCondition(authCapability.ok === true && authCapability.tokenPresent === true && authCapability.tokenSource === "GH_TOKEN", "pr preflight should expose redacted auth capability", authCapability);
|
||||
assertCondition(authCapability.valuesPrinted === false, "pr preflight should explicitly avoid secret values", authCapability);
|
||||
const preflightPr = closeoutPreflightData.pullRequest as JsonRecord;
|
||||
assertCondition(preflightPr.number === 42 && preflightPr.bodyOmitted === true, "pr preflight should return bounded PR metadata", preflightPr);
|
||||
const mergeability = closeoutPreflightData.mergeability as JsonRecord;
|
||||
assertCondition(mergeability.mergeable === "MERGEABLE" && mergeability.mergeStateStatus === "CLEAN", "pr preflight should expose mergeability", mergeability);
|
||||
assertCondition(mergeability.readyForCommanderMerge === true && mergeability.conclusion === "ready", "pr preflight should summarize closeout readiness", mergeability);
|
||||
const preflightStatus = closeoutPreflightData.statusChecks as JsonRecord;
|
||||
const preflightCounts = preflightStatus.counts as JsonRecord;
|
||||
assertCondition(preflightStatus.state === "SUCCESS" && preflightStatus.rawOmitted === true, "pr preflight default status rollup should be compact", preflightStatus);
|
||||
assertCondition(preflightCounts.success === 2, "pr preflight should count successful contexts", preflightStatus);
|
||||
const policy = closeoutPreflightData.policy as JsonRecord;
|
||||
assertCondition(policy.mergesPr === false && policy.mergeCommandSupported === false && policy.unideskCliMergeSupported === false, "pr preflight policy should block UniDesk CLI merge execution", policy);
|
||||
|
||||
const aliasPreflight = await runCli(["gh", "preflight", "42", "--repo", "pikasTech/unidesk"], env);
|
||||
assertCondition(aliasPreflight.status === 0, "top-level gh preflight alias should succeed", aliasPreflight.json ?? { stdout: aliasPreflight.stdout });
|
||||
const aliasPreflightData = dataOf(aliasPreflight.json ?? {});
|
||||
assertCondition(aliasPreflightData.command === "preflight", "top-level gh preflight should report alias command", aliasPreflightData);
|
||||
assertCondition((aliasPreflightData.policy as JsonRecord).mergesPr === false, "top-level gh preflight alias must not merge", aliasPreflightData);
|
||||
|
||||
const fullPreflight = await runCli(["gh", "pr", "preflight", "42", "--repo", "pikasTech/unidesk", "--full"], env);
|
||||
assertCondition(fullPreflight.status === 0, "pr preflight --full should succeed", fullPreflight.json ?? { stdout: fullPreflight.stdout });
|
||||
const fullPreflightData = dataOf(fullPreflight.json ?? {});
|
||||
const fullStatus = fullPreflightData.statusChecks as JsonRecord;
|
||||
assertCondition(fullStatus.rawOmitted === false && Array.isArray(fullStatus.contexts), "pr preflight --full should include status contexts", fullStatus);
|
||||
assertCondition(typeof fullPreflightData.raw === "object" && fullPreflightData.raw !== null, "pr preflight --full should include raw read payload summary", fullPreflightData);
|
||||
|
||||
const preflight = await runBun([
|
||||
"scripts/code-queue-pr-preflight-example.ts",
|
||||
"--repo",
|
||||
@@ -558,6 +596,9 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
|
||||
"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 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",
|
||||
"pr preflight --full is the explicit status-context disclosure path",
|
||||
"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",
|
||||
|
||||
+277
-3
@@ -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
@@ -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." },
|
||||
|
||||
Reference in New Issue
Block a user