import { GitHubRequestTimeoutError, request } from '../core.ts'
import type { GitHubAccount } from '../types.ts'
export type MergeMethod = 'merge' | 'squash' | 'rebase'
/**
* Result statuses for `pr/merge`.
*
* - `merged` — merge request succeeded within the direct timeout.
* - `already_merged` — PR was already merged (idempotent success).
* - `merged_after_timeout` — client aborted, then an immediate re-check found
* the PR merged (before or without needing a durable workflow).
* - `merged_via_workflow` — client aborted, a durable workflow was dispatched,
* and the merge completed while this call was still polling.
* - `merge_dispatched` — durable workflow accepted the merge and will finish it;
* callers do not need to retry. Includes `workflowId`.
* - `merge_workflow_failed` — durable workflow ended in error and the PR is
* still unmerged.
* - `timed_out_unconfirmed` — client aborted and durable workflows were
* unavailable or could not be created; verify with `pr/get-info` before
* retrying (retry is safe; merge is idempotent).
* - `closed` / `draft` / `dirty` / `blocked` / `not_mergeable` — fail-fast
* preflight; no merge was attempted.
* - `mergeable_unknown` — GitHub never reported mergeability within the budget.
*/
export type MergePrStatus =
| 'merged'
| 'already_merged'
| 'merged_after_timeout'
| 'merged_via_workflow'
| 'merge_dispatched'
| 'merge_workflow_failed'
| 'timed_out_unconfirmed'
| 'closed'
| 'draft'
| 'dirty'
| 'blocked'
| 'not_mergeable'
| 'mergeable_unknown'
export type MergePrTimings = {
/** Total wall time for the helper call. */
totalMs: number
/** Time spent on the initial PR GET (and mergeable polling). */
preflightMs: number
/** Time spent on PUT /merge, when attempted. */
mergeMs: number | null
/** Time spent re-fetching the PR after a client timeout. */
recheckMs: number | null
/** Time spent dispatching/polling the durable escalation workflow. */
escalationMs: number | null
/** Configured direct-attempt timeout budget. */
timeoutMs: number
/** Overall call budget (direct attempt + escalation poll). */
totalBudgetMs: number
}
export type MergePrResult = {
merged: boolean
sha: string | null
message: string
repository: string
prNumber: number
mergeMethod: MergeMethod
status: MergePrStatus
/** Present when the merge landed after the client gave up. */
note?: string
/** Present when the caller should verify before retrying. */
hint?: string
/** Durable workflow id when escalation was dispatched. */
workflowId?: string
/** Idempotency key used for the durable merge workflow. */
workflowIdempotencyKey?: string
mergeable?: boolean | null
mergeableState?: string | null
timings: MergePrTimings
}
export const MERGE_METHODS: Array<MergeMethod> = ['merge', 'squash', 'rebase']
/** Default direct merge PUT budget. */
export const DEFAULT_TIMEOUT_MS = 25_000
/**
* Whole-call wall budget including escalation polling. Kept under the ~90s
* MCP execute cap with comfortable headroom.
*/
export const TOTAL_CALL_BUDGET_MS = 55_000
export const MERGEABLE_POLL_MS = 1_000
/** Keep enough remaining budget to attempt the merge after waiting on mergeable. */
export const MIN_MERGE_BUDGET_MS = 5_000
/** Polling interval while waiting on a durable merge workflow. */
export const ESCALATION_POLL_MS = 1_500
/** Longer PUT budget inside the durable workflow (step timeout is 5 minutes). */
export const DURABLE_MERGE_TIMEOUT_MS = 120_000
export type BuildResultOptions = {
merged: boolean
sha: string | null
message: string
repository: string
prNumber: number
mergeMethod: MergeMethod
status: MergePrStatus
note?: string
hint?: string
workflowId?: string
workflowIdempotencyKey?: string
pr: Record<string, unknown>
startedAt: number
preflightMs: number
mergeMs: number | null
recheckMs: number | null
escalationMs: number | null
timeoutMs: number
totalBudgetMs: number
now?: () => number
}
export function buildResult(options: BuildResultOptions): MergePrResult {
const now = options.now ?? Date.now
const result: MergePrResult = {
merged: options.merged,
sha: options.sha,
message: options.message,
repository: options.repository,
prNumber: options.prNumber,
mergeMethod: options.mergeMethod,
status: options.status,
mergeable: readMergeable(options.pr),
mergeableState: readMergeableState(options.pr),
timings: {
totalMs: now() - options.startedAt,
preflightMs: options.preflightMs,
mergeMs: options.mergeMs,
recheckMs: options.recheckMs,
escalationMs: options.escalationMs,
timeoutMs: options.timeoutMs,
totalBudgetMs: options.totalBudgetMs,
},
}
if (options.note) result.note = options.note
if (options.hint) result.hint = options.hint
if (options.workflowId) result.workflowId = options.workflowId
if (options.workflowIdempotencyKey) {
result.workflowIdempotencyKey = options.workflowIdempotencyKey
}
return result
}
export function classifyBlockedPreflight(pr: Record<string, unknown>): {
status: 'closed' | 'draft' | 'dirty' | 'blocked' | 'not_mergeable'
message: string
hint: string
} | null {
const draft = Boolean(pr.draft)
const mergeable = readMergeable(pr)
const mergeableState = readMergeableState(pr)
if (pr.state === 'closed' && !isAlreadyMerged(pr)) {
return {
status: 'closed',
message: `PR #${pr.number} is closed and not merged.`,
hint: 'Reopen the PR before merging, or confirm the intended PR number.',
}
}
if (draft || mergeableState === 'draft') {
return {
status: 'draft',
message: `PR #${pr.number} is a draft and cannot be merged.`,
hint: 'Mark the PR ready for review with pr/set-review-status, then retry.',
}
}
if (mergeableState === 'dirty') {
return {
status: 'dirty',
message: `PR #${pr.number} has merge conflicts (mergeable_state=dirty).`,
hint: 'Resolve conflicts on the branch, then retry pr/merge.',
}
}
if (mergeableState === 'blocked') {
return {
status: 'blocked',
message: `PR #${pr.number} is blocked from merging (mergeable_state=blocked).`,
hint: 'Satisfy required reviews/checks/branch protections, then retry.',
}
}
if (mergeable === false) {
return {
status: 'not_mergeable',
message: `PR #${pr.number} is not mergeable (mergeable=false, mergeable_state=${mergeableState ?? 'unknown'}).`,
hint: 'Inspect mergeable_state / branch protection, fix blockers, then retry.',
}
}
return null
}
export async function fetchPullRequest(
account: GitHubAccount,
owner: string,
repo: string,
prNumber: number,
) {
const response = await request<Record<string, unknown>>({
account,
path: `/repos/${owner}/${repo}/pulls/${prNumber}`,
throwOnError: true,
})
return response.data!
}
export async function putMerge(options: {
account: GitHubAccount
owner: string
repo: string
prNumber: number
body: Record<string, unknown>
timeoutMs: number
}) {
const response = await request<Record<string, unknown>>({
account: options.account,
method: 'PUT',
path: `/repos/${options.owner}/${options.repo}/pulls/${options.prNumber}/merge`,
body: options.body,
throwOnError: true,
timeoutMs: options.timeoutMs,
})
return response.data!
}
export function isAlreadyMerged(pr: Record<string, unknown>): boolean {
return pr.merged === true || typeof pr.merged_at === 'string'
}
export function readMergeSha(pr: Record<string, unknown>): string | null {
const sha = pr.merge_commit_sha
return typeof sha === 'string' && sha.length > 0 ? sha : null
}
export function readHeadSha(pr: Record<string, unknown>): string | null {
const head = pr.head
if (!head || typeof head !== 'object' || Array.isArray(head)) return null
const sha = (head as Record<string, unknown>).sha
return typeof sha === 'string' && sha.length > 0 ? sha : null
}
export function readMergeable(pr: Record<string, unknown>): boolean | null {
if (pr.mergeable === true) return true
if (pr.mergeable === false) return false
return null
}
export function readMergeableState(pr: Record<string, unknown>): string | null {
return typeof pr.mergeable_state === 'string' ? pr.mergeable_state : null
}
export function buildMergeBody(
params: Record<string, unknown>,
mergeMethod: MergeMethod,
): Record<string, unknown> {
const body: Record<string, unknown> = { merge_method: mergeMethod }
if (typeof params.commitTitle === 'string' && params.commitTitle.trim()) {
body.commit_title = params.commitTitle.trim()
}
if (typeof params.commitMessage === 'string' && params.commitMessage.trim()) {
body.commit_message = params.commitMessage.trim()
}
if (typeof params.sha === 'string' && params.sha.trim()) {
body.sha = params.sha.trim()
}
return body
}
export function readMergeMethod(params: Record<string, unknown>): MergeMethod {
const raw = params.mergeMethod ?? params.merge_method
if (raw === undefined) return 'squash'
if (typeof raw === 'string' && MERGE_METHODS.includes(raw as MergeMethod)) {
return raw as MergeMethod
}
throw new Error(
`mergeMethod must be one of ${MERGE_METHODS.map((method) => `"${method}"`).join(', ')}`,
)
}
export function readTimeoutMs(params: Record<string, unknown>): number {
if (params.timeoutMs === undefined) return DEFAULT_TIMEOUT_MS
const value = Number(params.timeoutMs)
if (!Number.isFinite(value) || value <= 0) {
throw new Error('timeoutMs must be a positive number of milliseconds')
}
return value
}
export function remainingMs(
startedAt: number,
budgetMs: number,
now: () => number = Date.now,
): number {
return Math.max(0, budgetMs - (now() - startedAt))
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function isAbortLike(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
if (error instanceof GitHubRequestTimeoutError) return true
const name = 'name' in error ? String((error as { name?: unknown }).name) : ''
return name === 'AbortError' || name === 'TimeoutError'
}
export function buildMergeIdempotencyKey(options: {
owner: string
repo: string
prNumber: number
headSha: string
}): string {
return `github:pr-merge:${options.owner}/${options.repo}#${options.prNumber}@${options.headSha}`
}