Files
pikasTech-unidesk/scripts/src/gh/pr-summary.ts
T
2026-06-26 00:16:53 +08:00

517 lines
21 KiB
TypeScript

// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
// Moved mechanically from scripts/src/gh.ts:4730-5231.
import path from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { bodySha, preview, previewLines, repoParts } from "./auth-and-safety";
import { errorPayload, githubGraphqlRequest, githubRequest, isGitHubError, runnerDisposition } from "./client";
import { issueRead } from "./issue-read";
import { isRecord } from "./notify-claudeqq";
import { prState } from "./pr-commands";
import { ghText } from "./render";
import { PR_CLOSEOUT_JSON_FIELDS, UNIDESK_CLI_CONFIG_PATH } from "./types";
import type { GitHubCommandResult, GitHubErrorPayload, GitHubPullRequest, GitHubPullRequestFile, GitHubPullRequestGraphqlMetadata, GitHubPullRequestGraphqlStatusCheckRollup, GitHubPullRequestGraphqlStatusContext, PrMergeUnknownRetryConfig } from "./types";
export function prStateDetail(pr: GitHubPullRequest): "open" | "closed" | "merged" {
if (pr.merged === true || pr.merged_at !== null && pr.merged_at !== undefined) return "merged";
return pr.state === "closed" ? "closed" : "open";
}
export function prSummary(pr: GitHubPullRequest, options: { includeBody?: boolean; includePreview?: boolean; previewLineCount?: number } = {}): Record<string, unknown> {
const stateDetail = prStateDetail(pr);
const closedAt = pr.closed_at ?? pr.merged_at ?? null;
const mergedAt = pr.merged_at ?? null;
const mergeCommitSha = pr.merge_commit_sha ?? null;
const body = pr.body ?? "";
const includeBody = options.includeBody ?? true;
const includePreview = options.includePreview ?? true;
const summary: Record<string, unknown> = {
id: pr.id,
number: pr.number,
title: pr.title,
state: pr.state,
stateDetail,
draft: pr.draft ?? false,
url: pr.html_url,
author: pr.user?.login ?? null,
head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null },
base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null },
headRefName: pr.head?.ref ?? null,
baseRefName: pr.base?.ref ?? null,
createdAt: pr.created_at ?? null,
updatedAt: pr.updated_at ?? null,
closed: stateDetail !== "open",
closedAt,
merged: stateDetail === "merged",
mergedAt,
mergeCommit: stateDetail === "merged" && mergeCommitSha !== null ? { oid: mergeCommitSha } : null,
bodyChars: body.length,
bodySha: bodySha(body),
};
if (includeBody) {
summary.body = body;
} else {
summary.bodyOmitted = true;
summary.fullBodyIncluded = false;
if (includePreview) {
summary.bodyPreview = preview(body);
summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8);
}
}
return summary;
}
export function numberOrNull(value: number | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export function prCompactSummary(pr: GitHubPullRequest): Record<string, unknown> {
return {
id: pr.id,
number: pr.number,
title: pr.title,
state: pr.state,
draft: pr.draft ?? false,
url: pr.html_url,
author: pr.user?.login ?? null,
head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null },
base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null },
createdAt: pr.created_at ?? null,
updatedAt: pr.updated_at ?? null,
};
}
export function prFileSummary(file: GitHubPullRequestFile): Record<string, unknown> {
return {
filename: file.filename,
status: file.status ?? null,
additions: numberOrNull(file.additions),
deletions: numberOrNull(file.deletions),
changes: numberOrNull(file.changes),
previousFilename: file.previous_filename ?? null,
sha: file.sha ?? null,
blobUrl: file.blob_url ?? null,
contentsUrl: file.contents_url ?? null,
};
}
export function sumPrFileStats(files: GitHubPullRequestFile[]): { additions: number; deletions: number; changes: number } {
return files.reduce((accumulator, file) => {
accumulator.additions += file.additions ?? 0;
accumulator.deletions += file.deletions ?? 0;
accumulator.changes += file.changes ?? ((file.additions ?? 0) + (file.deletions ?? 0));
return accumulator;
}, { additions: 0, deletions: 0, changes: 0 });
}
export function selectedPrJson(summary: Record<string, unknown>, fields: readonly string[]): Record<string, unknown> {
const selected: Record<string, unknown> = {};
for (const field of fields) selected[field] = summary[field];
return selected;
}
export function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record<string, unknown> {
return {
mergeable: metadata.mergeable ?? null,
mergeStateStatus: metadata.mergeStateStatus ?? null,
statusCheckRollup: metadata.statusCheckRollup ?? null,
headRefName: metadata.headRefName ?? null,
baseRefName: metadata.baseRefName ?? null,
};
}
export function prMergeBoundary(): Record<string, unknown> {
return {
runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close", "pr merge after explicit command authorization"],
ordinaryRunnerFinalActionAllowed: true,
commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"],
hostAllowedToolsAfterReview: ["bun scripts/cli.ts gh pr merge", "GitHub UI merge/close"],
unideskCliMergeSupported: true,
};
}
export 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(),
};
}
export 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(),
};
}
export function needsPrGraphqlMetadata(fields: readonly string[] | undefined): boolean {
if (fields === undefined) return false;
return fields.some((field) => field === "mergeable" || field === "mergeStateStatus" || field === "statusCheckRollup");
}
export async function prGraphqlMetadata(repo: string, token: string, number: number): Promise<GitHubPullRequestGraphqlMetadata | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
const query = `
query PullRequestCloseoutMetadata($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
mergeable
mergeStateStatus
headRefName
baseRefName
statusCheckRollup {
state
contexts(first: 100) {
nodes {
__typename
... on CheckRun {
name
status
conclusion
}
... on StatusContext {
context
state
targetUrl
description
}
}
}
}
}
}
}
`;
const response = await githubGraphqlRequest<{ repository?: { pullRequest?: GitHubPullRequestGraphqlMetadata | null } }>(token, query, { owner, name, number });
if (isGitHubError(response)) return response;
const pullRequest = response.repository?.pullRequest;
if (pullRequest === null || pullRequest === undefined) {
return errorPayload("invalid-response", "GitHub GraphQL PR metadata response was missing repository.pullRequest", { details: response });
}
return pullRequest;
}
export function statusContextName(context: GitHubPullRequestGraphqlStatusContext): string {
return context.name ?? context.context ?? "(unnamed status check)";
}
export 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";
}
export 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),
};
}
export 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` }),
};
}
export 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;
}
export 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,
};
}
export function prPreflightPolicy(repo: string, number: number): Record<string, unknown> {
return {
readOnly: true,
writesRemote: false,
createsPr: false,
comments: false,
mergesPr: false,
mergeCommandSupported: true,
mergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo} --merge`,
unideskCliMergeSupported: true,
ordinaryRunnerFinalActionAllowed: true,
note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks for diagnosis; gh pr merge performs its own guarded readiness check and does not require this preflight step.",
};
}
export 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,
};
}
export 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 bun scripts/cli.ts gh pr merge when task boundaries allow"
: "resolve blockers or rerun after GitHub finishes computing mergeability/status checks",
};
}
export function stripYamlComment(value: string): string {
const index = value.indexOf("#");
return (index >= 0 ? value.slice(0, index) : value).trim();
}
export function yamlScalarByPath(text: string, keyPath: string[]): string | null {
const stack: Array<{ indent: number; key: string }> = [];
for (const line of text.split(/\r?\n/u)) {
if (line.trim().length === 0 || line.trimStart().startsWith("#")) continue;
const match = /^(\s*)([A-Za-z0-9_-]+):(?:\s*(.*))?$/u.exec(line);
if (match === null) continue;
const indent = match[1].length;
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
const key = match[2];
const rawValue = stripYamlComment(match[3] ?? "");
const currentPath = [...stack.map((item) => item.key), key];
if (currentPath.length === keyPath.length && currentPath.every((item, index) => item === keyPath[index])) {
return rawValue.length === 0 ? null : rawValue;
}
stack.push({ indent, key });
}
return null;
}
export function yamlNumberByPath(text: string, keyPath: string[], configPath: string): number {
const value = yamlScalarByPath(text, keyPath);
if (value === null) throw new Error(`${configPath} missing numeric config ${keyPath.join(".")}`);
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${configPath} config ${keyPath.join(".")} must be a positive number`);
return parsed;
}
export function loadPrMergeUnknownRetryConfig(): PrMergeUnknownRetryConfig {
const configPath = path.resolve(process.cwd(), UNIDESK_CLI_CONFIG_PATH);
if (!existsSync(configPath)) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} is required for gh pr merge retry policy`);
const text = readFileSync(configPath, "utf8");
const prefix = ["github", "prMerge", "unknownRetry"];
const config = {
maxAttempts: Math.trunc(yamlNumberByPath(text, [...prefix, "maxAttempts"], UNIDESK_CLI_CONFIG_PATH)),
initialDelayMs: Math.trunc(yamlNumberByPath(text, [...prefix, "initialDelayMs"], UNIDESK_CLI_CONFIG_PATH)),
maxDelayMs: Math.trunc(yamlNumberByPath(text, [...prefix, "maxDelayMs"], UNIDESK_CLI_CONFIG_PATH)),
factor: yamlNumberByPath(text, [...prefix, "factor"], UNIDESK_CLI_CONFIG_PATH),
};
if (config.maxAttempts < 1) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.maxAttempts must be >= 1`);
if (config.maxDelayMs < config.initialDelayMs) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.maxDelayMs must be >= initialDelayMs`);
if (config.factor < 1) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.factor must be >= 1`);
return config;
}
export function sleepMs(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function mergeabilityHasUnknownPending(mergeability: Record<string, unknown>): boolean {
const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : [];
if (blockers.length > 0) return false;
const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map((item) => item.toLowerCase()) : [];
return pending.some((item) => item.includes("unknown"));
}
export function nextPrMergeRetryDelayMs(config: PrMergeUnknownRetryConfig, previousDelayMs: number): number {
return Math.min(config.maxDelayMs, Math.max(1, Math.ceil(previousDelayMs * config.factor)));
}
export function prMergeMethodFlag(method: unknown): string {
if (method === "squash") return "--squash";
if (method === "rebase") return "--rebase";
return "--merge";
}
export function prMergeRetryCommand(repo: string, number: unknown, method: unknown, deleteBranch: unknown): string {
return [
"bun scripts/cli.ts gh pr merge",
ghText(number),
"--repo",
repo,
prMergeMethodFlag(method),
deleteBranch === true ? "--delete-branch" : "",
].filter((item) => item.length > 0).join(" ");
}
export async function deleteHeadBranchAfterMerge(repo: string, token: string, pr: GitHubPullRequest): Promise<Record<string, unknown>> {
const { owner, name } = repoParts(repo);
const headRepo = pr.head?.repo?.full_name ?? null;
const headRef = pr.head?.ref ?? null;
if (headRepo !== repo || headRef === null || headRef.length === 0) {
return {
attempted: false,
skippedReason: "head-repo-differs-or-ref-missing",
headRepo,
headRef,
};
}
const encodedRef = encodeURIComponent(`heads/${headRef}`);
const deleted = await githubRequest<unknown>(token, "DELETE", `/repos/${owner}/${name}/git/refs/${encodedRef}`);
if (isGitHubError(deleted)) {
return {
attempted: true,
ok: false,
headRepo,
headRef,
error: deleted,
};
}
return {
attempted: true,
ok: true,
headRepo,
headRef,
};
}