Files
pikasTech-unidesk/scripts/src/gh/pr-merge.ts
T
2026-07-03 11:00:11 +00:00

331 lines
16 KiB
TypeScript

// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
// Moved mechanically from scripts/src/gh.ts:5233-5528.
import { repoParts, resolveToken } from "./auth-and-safety";
import { authStatus } from "./auth-pr-read";
import { authRequired, commandError, errorPayload, githubRequest, isGitHubError, runnerDisposition, validationError } from "./client";
import { isRecord } from "./notify-claudeqq";
import { runPrMergeCloseout } from "./pr-merge-closeout";
import { compactAuthCapability, deleteHeadBranchAfterMerge, loadPrMergeUnknownRetryConfig, mergeabilityHasUnknownPending, nextPrMergeRetryDelayMs, prCloseoutMetadata, prCloseoutSummary, preflightPullRequestSummary, prGraphqlMetadata, prMergeRetryCommand, prMetadataSummary, prPreflightPolicy, prSummary, sleepMs, statusRollupSummary } from "./pr-summary";
import { ghShort, ghTable, ghText } from "./render";
import { UNIDESK_CLI_CONFIG_PATH } from "./types";
import type { GitHubCommandResult, GitHubErrorPayload, GitHubOptions, GitHubPullRequest, GitHubPullRequestGraphqlMetadata, PrMergeUnknownRetryConfig } from "./types";
export async function prMerge(repo: string, token: string, number: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
let retryConfig: PrMergeUnknownRetryConfig;
try {
retryConfig = loadPrMergeUnknownRetryConfig();
} catch (error) {
return validationError("pr merge", repo, error instanceof Error ? error.message : String(error), { number, configPath: UNIDESK_CLI_CONFIG_PATH });
}
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return commandError("pr merge", repo, pr, { number, phase: "fetch-pr" });
const summary = prSummary(pr);
if (summary.merged === true) {
const branchDeletion = options.deleteBranch && !options.dryRun ? await deleteHeadBranchAfterMerge(repo, token, pr) : { attempted: false, skippedReason: options.deleteBranch ? "dry-run" : "keep-branch-requested" };
const closeout = runPrMergeCloseout(repo, pr, options);
return withPrMergeRendered({
ok: true,
command: "pr merge",
repo,
number,
method: options.mergeMethod,
deleteBranch: options.deleteBranch,
alreadyMerged: true,
pullRequest: summary,
branchDeletion,
closeout,
rest: true,
});
}
let metadata: GitHubPullRequestGraphqlMetadata | GitHubErrorPayload | null = null;
let statusChecks: Record<string, unknown> = {};
let mergeability: Record<string, unknown> = {};
let delayMs = retryConfig.initialDelayMs;
const retryAttempts: Array<Record<string, unknown>> = [];
for (let attempt = 1; attempt <= retryConfig.maxAttempts; attempt += 1) {
metadata = await prGraphqlMetadata(repo, token, number);
if (isGitHubError(metadata)) return commandError("pr merge", repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, retry: { attempt, maxAttempts: retryConfig.maxAttempts } });
statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, false);
mergeability = prCloseoutSummary(summary, prMetadataSummary(metadata), statusChecks);
const shouldRetryUnknown = mergeability.readyForCommanderMerge !== true && mergeabilityHasUnknownPending(mergeability);
const waitMs = shouldRetryUnknown && attempt < retryConfig.maxAttempts ? delayMs : 0;
retryAttempts.push({
attempt,
maxAttempts: retryConfig.maxAttempts,
mergeable: metadata.mergeable ?? null,
mergeStateStatus: metadata.mergeStateStatus ?? null,
statusCheckRollup: metadata.statusCheckRollup?.state ?? null,
conclusion: mergeability.conclusion ?? null,
blockers: Array.isArray(mergeability.blockers) ? mergeability.blockers : [],
pending: Array.isArray(mergeability.pending) ? mergeability.pending : [],
waitMs,
});
if (mergeability.readyForCommanderMerge === true || !shouldRetryUnknown || attempt >= retryConfig.maxAttempts) break;
await sleepMs(delayMs);
delayMs = nextPrMergeRetryDelayMs(retryConfig, delayMs);
}
if (metadata === null || isGitHubError(metadata)) return commandError("pr merge", repo, metadata ?? errorPayload("invalid-response", "PR merge metadata was not collected"), { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary });
const retry = {
reason: "github-mergeability-unknown",
maxAttempts: retryConfig.maxAttempts,
attempts: retryAttempts.length,
exhausted: mergeability.readyForCommanderMerge !== true && mergeabilityHasUnknownPending(mergeability) && retryAttempts.length >= retryConfig.maxAttempts,
config: retryConfig,
attemptsLog: retryAttempts,
};
if (mergeability.readyForCommanderMerge !== true) {
const details = {
number,
method: options.mergeMethod,
deleteBranch: options.deleteBranch,
pullRequest: preflightPullRequestSummary(summary),
mergeability,
statusChecks,
closeoutMetadata: prCloseoutMetadata(metadata),
retry,
};
return withPrMergeRendered(validationError("pr merge", repo, retry.exhausted ? "PR mergeability is still UNKNOWN after automatic retry; no merge performed" : "PR is not ready for merge; blockers or pending states remain", details), details);
}
if (options.dryRun) {
return withPrMergeRendered({
ok: true,
command: "pr merge",
repo,
number,
dryRun: true,
wouldMerge: true,
method: options.mergeMethod,
deleteBranch: options.deleteBranch,
pullRequest: preflightPullRequestSummary(summary),
mergeability,
statusChecks,
retry,
closeout: runPrMergeCloseout(repo, pr, options),
});
}
const merged = await githubRequest<Record<string, unknown>>(token, "PUT", `/repos/${owner}/${name}/pulls/${number}/merge`, {
merge_method: options.mergeMethod,
});
if (isGitHubError(merged)) return commandError("pr merge", repo, merged, { number, phase: "merge", method: options.mergeMethod, pullRequest: summary });
const after = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(after)) return commandError("pr merge", repo, after, { number, phase: "fetch-after-merge", mergeResult: merged });
const branchDeletion = options.deleteBranch ? await deleteHeadBranchAfterMerge(repo, token, after) : { attempted: false, skippedReason: "keep-branch-requested" };
const closeout = runPrMergeCloseout(repo, after, options);
return withPrMergeRendered({
ok: true,
command: "pr merge",
repo,
number,
method: options.mergeMethod,
deleteBranch: options.deleteBranch,
mergeResult: merged,
pullRequest: prSummary(after),
mergeability,
statusChecks,
retry,
branchDeletion,
closeout,
rest: true,
});
}
export function withPrMergeRendered(result: GitHubCommandResult, fallbackDetails?: Record<string, unknown>): GitHubCommandResult {
return {
...result,
contentType: "text/plain",
renderedText: renderPrMergeTable(result, fallbackDetails),
};
}
export function renderPrMergeTable(result: GitHubCommandResult, fallbackDetails?: Record<string, unknown>): string {
const details = fallbackDetails ?? {};
const pullRequest = isRecord(result.pullRequest)
? result.pullRequest
: isRecord(details.pullRequest)
? details.pullRequest
: {};
const mergeability = isRecord(result.mergeability)
? result.mergeability
: isRecord(details.mergeability)
? details.mergeability
: {};
const statusChecks = isRecord(result.statusChecks)
? result.statusChecks
: isRecord(details.statusChecks)
? details.statusChecks
: {};
const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {};
const branchDeletion = isRecord(result.branchDeletion) ? result.branchDeletion : {};
const closeout = isRecord(result.closeout) ? result.closeout : {};
const localWorktree = isRecord(closeout.localWorktree) ? closeout.localWorktree : {};
const mainWorktree = isRecord(closeout.mainWorktree) ? closeout.mainWorktree : {};
const nodeSyncs = Array.isArray(closeout.nodeSyncs) ? closeout.nodeSyncs.filter(isRecord) : [];
const retry = isRecord(result.retry)
? result.retry
: isRecord(details.retry)
? details.retry
: {};
const mergeMethod = result.method ?? result.mergeMethod ?? details.method;
const deleteBranch = result.deleteBranch ?? details.deleteBranch;
const status = result.ok === true
? result.alreadyMerged === true
? "already-merged"
: result.dryRun === true
? "dry-run"
: "merged"
: ghText(mergeability.conclusion) === "pending"
? "pending"
: "blocked";
const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : [];
const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map(String) : [];
const rows = [[
`#${ghText(result.number ?? details.number)}`,
status,
ghText(mergeMethod),
ghText(mergeability.mergeable),
ghText(mergeability.mergeStateStatus),
retry.attempts !== undefined && retry.maxAttempts !== undefined ? `${ghText(retry.attempts)}/${ghText(retry.maxAttempts)}${retry.exhausted === true ? " exhausted" : ""}` : "-",
`${ghText(statusChecks.totalContexts)} total ${ghText(counts.success)} ok ${ghText(counts.failure)} fail ${ghText(counts.pending)} pending`,
]];
const lines = [
`gh pr merge (${status})`,
"",
ghTable(["PR", "STATUS", "METHOD", "MERGEABLE", "MERGE_STATE", "RETRY", "CHECKS"], rows),
"",
"Summary:",
` repo=${ghText(result.repo)} title=${ghShort(ghText(pullRequest.title), 96)}`,
` blockers=${blockers.length === 0 ? "-" : blockers.join(",")} pending=${pending.length === 0 ? "-" : pending.join(",")}`,
` retry=${retry.attempts !== undefined && retry.maxAttempts !== undefined ? `${ghText(retry.attempts)}/${ghText(retry.maxAttempts)} exhausted=${ghText(retry.exhausted)}` : "-"}`,
` branchDeletion=${ghText(branchDeletion.ok ?? branchDeletion.skippedReason ?? branchDeletion.attempted)}`,
` closeout localWorktree=${closeoutCell(localWorktree)} mainWorktree=${closeoutCell(mainWorktree)} nodeSyncs=${nodeSyncs.length === 0 ? "-" : nodeSyncs.map(closeoutCell).join(",")}`,
"",
"Next:",
];
if (result.ok === true && result.alreadyMerged !== true && result.dryRun !== true) {
lines.push(` bun scripts/cli.ts gh pr view ${ghText(result.number ?? details.number)} --repo ${ghText(result.repo)}`);
} else {
lines.push(` ${prMergeRetryCommand(ghText(result.repo), result.number ?? details.number, mergeMethod, deleteBranch)}`);
lines.push(` bun scripts/cli.ts gh pr preflight ${ghText(result.number ?? details.number)} --repo ${ghText(result.repo)} --full`);
}
lines.push("", "Disclosure:");
lines.push(" default view is a bounded table; use --full or --raw on read/preflight commands for structured metadata.");
return lines.join("\n");
}
function closeoutCell(value: Record<string, unknown>): string {
if (value.planned === true) return "planned";
if (value.skipped === true) return `skipped:${ghText(value.skippedReason)}`;
if (value.ok === true) return "ok";
if (value.ok === false) return `failed:${ghText(value.degradedReason)}`;
return "-";
}
export 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);
const result: GitHubCommandResult = {
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 } } : {}),
};
if (includeRaw) return result;
return {
...result,
contentType: "text/plain",
renderedText: renderPrPreflightTable(result),
};
}
export function renderPrPreflightTable(result: GitHubCommandResult): string {
const pullRequest = isRecord(result.pullRequest) ? result.pullRequest : {};
const mergeability = isRecord(result.mergeability) ? result.mergeability : {};
const statusChecks = isRecord(result.statusChecks) ? result.statusChecks : {};
const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {};
const policy = isRecord(result.policy) ? result.policy : {};
const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : [];
const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map(String) : [];
const conclusion = ghText(mergeability.conclusion);
const rows = [[
`#${ghText(result.number)}`,
ghText(pullRequest.stateDetail ?? pullRequest.state),
ghText(mergeability.mergeable),
ghText(mergeability.mergeStateStatus),
`${ghText(statusChecks.totalContexts)} total ${ghText(counts.success)} ok ${ghText(counts.failure)} fail ${ghText(counts.pending)} pending`,
conclusion,
]];
const lines = [
`gh pr preflight (${conclusion})`,
"",
ghTable(["PR", "STATE", "MERGEABLE", "MERGE_STATE", "CHECKS", "CONCLUSION"], rows),
"",
"Summary:",
` repo=${ghText(result.repo)} title=${ghShort(ghText(pullRequest.title), 96)}`,
` head=${ghText(isRecord(pullRequest.head) ? pullRequest.head.ref : null)} base=${ghText(isRecord(pullRequest.base) ? pullRequest.base.ref : null)}`,
` blockers=${blockers.length === 0 ? "-" : blockers.join(",")} pending=${pending.length === 0 ? "-" : pending.join(",")}`,
"",
"Next:",
];
const mergeCommand = ghText(policy.mergeCommand);
if (mergeability.readyForCommanderMerge === true && mergeCommand !== "-") {
lines.push(` ${mergeCommand}`);
}
lines.push(` bun scripts/cli.ts gh pr preflight ${ghText(result.number)} --repo ${ghText(result.repo)} --full`);
lines.push("", "Disclosure:");
lines.push(" default view is a bounded table; use --full or --raw for structured PR/check metadata.");
return lines.join("\n");
}