edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
517 lines
21 KiB
TypeScript
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,
|
|
};
|
|
}
|