331 lines
16 KiB
TypeScript
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");
|
|
}
|