Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/github

src/pr/merge-shared.ts

324 lines · 10.0 KB · TypeScript
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}`
}