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.ts

432 lines · 11.0 KB · TypeScript
import { kody, packageContext, workflows } from 'kody:runtime'
import type { GitHubAccount } from '../types.ts'
import { escalateAfterTimeout } from './merge-escalation.ts'
import {
	DURABLE_MERGE_TIMEOUT_MS,
	MERGEABLE_POLL_MS,
	MIN_MERGE_BUDGET_MS,
	TOTAL_CALL_BUDGET_MS,
	buildMergeBody,
	buildResult,
	classifyBlockedPreflight,
	fetchPullRequest,
	isAbortLike,
	isAlreadyMerged,
	putMerge,
	readMergeMethod,
	readMergeSha,
	readMergeable,
	readTimeoutMs,
	remainingMs,
	sleep,
	type MergeMethod,
	type MergePrResult,
} from './merge-shared.ts'
import { normalizePrLocator } from './pr-input.ts'

export type {
	MergeMethod,
	MergePrResult,
	MergePrStatus,
	MergePrTimings,
} from './merge-shared.ts'

/**
 * Merge a pull request with a bounded direct attempt and durable escalation.
 *
 * Agents do not need to handle slow GitHub merges. The helper:
 * 1. Preflights mergeability (closed/draft/dirty/blocked fail fast).
 * 2. Issues a bounded merge PUT (default `timeoutMs: 25000`).
 * 3. On abort only: re-checks the PR, then dispatches a durable Kody workflow
 *    (idempotent per owner/repo/pr/headSha) and polls within a ~55s total call
 *    budget. If the merge has not landed by then, returns `merge_dispatched`
 *    with a `workflowId` — the workflow continues until the merge completes or
 *    the PR becomes unmergeable.
 *
 * `timed_out_unconfirmed` is reserved for the rare case where workflows are
 * unavailable or cannot be created.
 *
 * Calling merge on an already-merged PR remains idempotent and returns
 * `{ merged: true, status: 'already_merged', ... }`.
 *
 * @param params - PR locator (`prUrl` or `owner`/`repo`/`prNumber`) plus
 *   `mergeMethod`, optional `commitTitle`, `commitMessage`, `sha`, `account`,
 *   and `timeoutMs` (default 25000 for the direct attempt).
 * @returns Structured merge result. Success keeps `{ merged, sha, message,
 *   repository, prNumber, mergeMethod }` and adds `status` + `timings`.
 *   Escalation outcomes may include `workflowId` / `workflowIdempotencyKey`.
 * @example
 * import mergePr from 'kody:@kentcdodds/github/pr/merge'
 * const result = await mergePr({ owner: 'kentcdodds', repo: 'kody', prNumber: 1, mergeMethod: 'squash' })
 * // => { merged: true, sha: 'abc123', message: 'Pull Request successfully merged', status: 'merged', timings: { ... } }
 */
export default async function mergePr(
	params: Record<string, unknown> = {},
): Promise<MergePrResult> {
	return await runMerge(params, { escalate: true })
}

/**
 * Direct merge used by the durable workflow entrypoint. Same preflight and
 * idempotent already-merged behavior, but never escalates again (avoids
 * recursive workflow creation). On abort after re-check still-open, throws so
 * the Cloudflare Workflow step can retry within its bounded retry policy.
 *
 * @param params - Same shape as `pr/merge`.
 * @returns Structured merge result without `merge_dispatched` escalation.
 */
export async function mergePrDirect(
	params: Record<string, unknown> = {},
): Promise<MergePrResult> {
	return await runMerge(params, {
		escalate: false,
		timeoutMsOverride:
			typeof params.timeoutMs === 'number'
				? Number(params.timeoutMs)
				: DURABLE_MERGE_TIMEOUT_MS,
	})
}

async function runMerge(
	params: Record<string, unknown>,
	options: { escalate: boolean; timeoutMsOverride?: number },
): Promise<MergePrResult> {
	const startedAt = Date.now()
	const account = (params.account as GitHubAccount | undefined) ?? 'bot'
	const { owner, repo, prNumber } = normalizePrLocator(params)
	const mergeMethod = readMergeMethod(params)
	const timeoutMs = options.timeoutMsOverride ?? readTimeoutMs(params)
	const totalBudgetMs = options.escalate
		? TOTAL_CALL_BUDGET_MS
		: Math.max(timeoutMs, TOTAL_CALL_BUDGET_MS)
	const repository = `${owner}/${repo}`
	const body = buildMergeBody(params, mergeMethod)

	let preflightMs = 0
	let mergeMs: number | null = null

	const preflightStartedAt = Date.now()
	let pr = await fetchPullRequest(account, owner, repo, prNumber)

	const failFast = classifyBlockedPreflight(pr)
	if (failFast) {
		preflightMs = Date.now() - preflightStartedAt
		return buildResult({
			merged: false,
			sha: null,
			message: failFast.message,
			repository,
			prNumber,
			mergeMethod,
			status: failFast.status,
			hint: failFast.hint,
			pr,
			startedAt,
			preflightMs,
			mergeMs,
			recheckMs: null,
			escalationMs: null,
			timeoutMs,
			totalBudgetMs,
		})
	}

	if (isAlreadyMerged(pr)) {
		preflightMs = Date.now() - preflightStartedAt
		if (remainingMs(startedAt, timeoutMs) <= 0) {
			return await recheckAfterTimeout({
				account,
				owner,
				repo,
				prNumber,
				repository,
				mergeMethod,
				params,
				startedAt,
				preflightMs,
				mergeMs,
				timeoutMs,
				totalBudgetMs,
				escalate: options.escalate,
				message:
					'Timed out during preflight before a merge request could be issued. The PR merge state was re-checked.',
			})
		}
		return buildResult({
			merged: true,
			sha: readMergeSha(pr),
			message: 'Pull Request is already merged',
			repository,
			prNumber,
			mergeMethod,
			status: 'already_merged',
			pr,
			startedAt,
			preflightMs,
			mergeMs,
			recheckMs: null,
			escalationMs: null,
			timeoutMs,
			totalBudgetMs,
		})
	}

	// GitHub computes mergeable asynchronously; null means "still working".
	while (
		readMergeable(pr) === null &&
		remainingMs(startedAt, timeoutMs) > MIN_MERGE_BUDGET_MS
	) {
		await sleep(Math.min(MERGEABLE_POLL_MS, remainingMs(startedAt, timeoutMs)))
		pr = await fetchPullRequest(account, owner, repo, prNumber)

		const blockedAfterWait = classifyBlockedPreflight(pr)
		if (blockedAfterWait) {
			preflightMs = Date.now() - preflightStartedAt
			return buildResult({
				merged: false,
				sha: null,
				message: blockedAfterWait.message,
				repository,
				prNumber,
				mergeMethod,
				status: blockedAfterWait.status,
				hint: blockedAfterWait.hint,
				pr,
				startedAt,
				preflightMs,
				mergeMs,
				recheckMs: null,
				escalationMs: null,
				timeoutMs,
				totalBudgetMs,
			})
		}

		if (isAlreadyMerged(pr)) {
			preflightMs = Date.now() - preflightStartedAt
			return buildResult({
				merged: true,
				sha: readMergeSha(pr),
				message: 'Pull Request is already merged',
				repository,
				prNumber,
				mergeMethod,
				status: 'already_merged',
				pr,
				startedAt,
				preflightMs,
				mergeMs,
				recheckMs: null,
				escalationMs: null,
				timeoutMs,
				totalBudgetMs,
			})
		}
	}

	preflightMs = Date.now() - preflightStartedAt

	if (
		readMergeable(pr) === null &&
		remainingMs(startedAt, timeoutMs) <= MIN_MERGE_BUDGET_MS
	) {
		return buildResult({
			merged: false,
			sha: null,
			message:
				'GitHub has not finished computing mergeability within the timeout budget.',
			repository,
			prNumber,
			mergeMethod,
			status: 'mergeable_unknown',
			hint: 'Call pr/get-info (or retry pr/merge) shortly; mergeability is computed asynchronously.',
			pr,
			startedAt,
			preflightMs,
			mergeMs,
			recheckMs: null,
			escalationMs: null,
			timeoutMs,
			totalBudgetMs,
		})
	}

	const mergeBudgetMs = remainingMs(startedAt, timeoutMs)
	if (mergeBudgetMs <= 0) {
		return await recheckAfterTimeout({
			account,
			owner,
			repo,
			prNumber,
			repository,
			mergeMethod,
			params,
			startedAt,
			preflightMs,
			mergeMs,
			timeoutMs,
			totalBudgetMs,
			escalate: options.escalate,
			message: 'Timed out before the merge request could be issued.',
		})
	}

	const mergeStartedAt = Date.now()
	try {
		const result = await putMerge({
			account,
			owner,
			repo,
			prNumber,
			body,
			timeoutMs: mergeBudgetMs,
		})
		mergeMs = Date.now() - mergeStartedAt

		return buildResult({
			merged: Boolean(result.merged),
			sha: (result.sha as string | null | undefined) ?? null,
			message:
				(result.message as string | undefined) ??
				'Pull Request successfully merged',
			repository,
			prNumber,
			mergeMethod,
			status: result.merged ? 'merged' : 'not_mergeable',
			pr,
			startedAt,
			preflightMs,
			mergeMs,
			recheckMs: null,
			escalationMs: null,
			timeoutMs,
			totalBudgetMs,
		})
	} catch (error) {
		mergeMs = Date.now() - mergeStartedAt
		if (isAbortLike(error)) {
			return await recheckAfterTimeout({
				account,
				owner,
				repo,
				prNumber,
				repository,
				mergeMethod,
				params,
				startedAt,
				preflightMs,
				mergeMs,
				timeoutMs,
				totalBudgetMs,
				escalate: options.escalate,
				message:
					'Merge request timed out before GitHub responded. The merge may still have applied.',
			})
		}
		throw error
	}
}

async function recheckAfterTimeout(options: {
	account: GitHubAccount
	owner: string
	repo: string
	prNumber: number
	repository: string
	mergeMethod: MergeMethod
	params: Record<string, unknown>
	startedAt: number
	preflightMs: number
	mergeMs: number | null
	timeoutMs: number
	totalBudgetMs: number
	escalate: boolean
	message: string
}): Promise<MergePrResult> {
	const recheckStartedAt = Date.now()
	const pr = await fetchPullRequest(
		options.account,
		options.owner,
		options.repo,
		options.prNumber,
	)
	const recheckMs = Date.now() - recheckStartedAt

	if (isAlreadyMerged(pr)) {
		return buildResult({
			merged: true,
			sha: readMergeSha(pr),
			message: options.message,
			repository: options.repository,
			prNumber: options.prNumber,
			mergeMethod: options.mergeMethod,
			status: 'merged_after_timeout',
			note: 'merge completed after the client timed out',
			pr,
			startedAt: options.startedAt,
			preflightMs: options.preflightMs,
			mergeMs: options.mergeMs,
			recheckMs,
			escalationMs: null,
			timeoutMs: options.timeoutMs,
			totalBudgetMs: options.totalBudgetMs,
		})
	}

	if (!options.escalate) {
		// Durable workflow path: throw so Cloudflare Workflow retries (bounded).
		throw new Error(
			`${options.message} PR #${options.prNumber} is still open after the durable merge attempt timed out.`,
		)
	}

	return await escalateAfterTimeout(
		{
			workflows: workflows ?? null,
			listWorkflowRuns: async (limit = 50) => {
				const listed = await kody.workflow_run_list({ limit })
				return listed.workflows.map((run) => ({
					id: run.id,
					status: run.status,
					idempotency_key: run.idempotency_key,
					last_error: run.last_error,
				}))
			},
			fetchPullRequest: async () =>
				await fetchPullRequest(
					options.account,
					options.owner,
					options.repo,
					options.prNumber,
				),
			sleep,
			packageId: packageContext?.packageId ?? packageContext?.kodyId ?? 'github',
			totalBudgetMs: options.totalBudgetMs,
		},
		{
			owner: options.owner,
			repo: options.repo,
			prNumber: options.prNumber,
			repository: options.repository,
			mergeMethod: options.mergeMethod,
			account: options.account,
			commitTitle:
				typeof options.params.commitTitle === 'string'
					? options.params.commitTitle
					: undefined,
			commitMessage:
				typeof options.params.commitMessage === 'string'
					? options.params.commitMessage
					: undefined,
			sha:
				typeof options.params.sha === 'string' ? options.params.sha : undefined,
			pr,
			startedAt: options.startedAt,
			preflightMs: options.preflightMs,
			mergeMs: options.mergeMs,
			recheckMs,
			timeoutMs: options.timeoutMs,
			message: options.message,
		},
	)
}