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

318 lines · 8.1 KB · TypeScript
import {
	ESCALATION_POLL_MS,
	TOTAL_CALL_BUDGET_MS,
	buildMergeIdempotencyKey,
	buildResult,
	classifyBlockedPreflight,
	isAlreadyMerged,
	readHeadSha,
	readMergeSha,
	remainingMs,
	type MergeMethod,
	type MergePrResult,
} from './merge-shared.ts'

export type WorkflowCreateInput = {
	workflowName?: string
	exportName: string
	packageId?: string
	idempotencyKey: string
	params?: Record<string, unknown>
}

export type WorkflowCreateResult = {
	ok?: true
	id: string
	status?: string
	workflow_name?: string
}

export type WorkflowRunSnapshot = {
	id: string
	status: string | null
	idempotency_key?: string
	last_error?: string | null
}

export type MergeWorkflowsClient = {
	create: (input: WorkflowCreateInput) => Promise<WorkflowCreateResult>
}

export type EscalateAfterTimeoutDeps = {
	workflows: MergeWorkflowsClient | null | undefined
	listWorkflowRuns: (limit?: number) => Promise<Array<WorkflowRunSnapshot>>
	fetchPullRequest: () => Promise<Record<string, unknown>>
	sleep: (ms: number) => Promise<void>
	now?: () => number
	packageId?: string
	totalBudgetMs?: number
	pollIntervalMs?: number
}

export type EscalateAfterTimeoutInput = {
	owner: string
	repo: string
	prNumber: number
	repository: string
	mergeMethod: MergeMethod
	account: string
	commitTitle?: string
	commitMessage?: string
	sha?: string
	pr: Record<string, unknown>
	startedAt: number
	preflightMs: number
	mergeMs: number | null
	recheckMs: number
	timeoutMs: number
	message: string
}

/**
 * After a direct merge abort (and an immediate re-check that found the PR still
 * open), dispatch a durable workflow and poll until the merge lands, the
 * workflow finishes, or the remaining call budget is exhausted.
 *
 * Idempotent: the workflow key is derived from owner/repo/prNumber/headSha, so
 * repeated escalations for the same head reuse one live workflow.
 */
export async function escalateAfterTimeout(
	deps: EscalateAfterTimeoutDeps,
	input: EscalateAfterTimeoutInput,
): Promise<MergePrResult> {
	const now = deps.now ?? Date.now
	const totalBudgetMs = deps.totalBudgetMs ?? TOTAL_CALL_BUDGET_MS
	const pollIntervalMs = deps.pollIntervalMs ?? ESCALATION_POLL_MS
	const escalationStartedAt = now()

	const finish = (
		partial: Omit<
			Parameters<typeof buildResult>[0],
			| 'startedAt'
			| 'preflightMs'
			| 'mergeMs'
			| 'recheckMs'
			| 'escalationMs'
			| 'timeoutMs'
			| 'totalBudgetMs'
			| 'now'
			| 'repository'
			| 'prNumber'
			| 'mergeMethod'
		> & {
			pr?: Record<string, unknown>
			workflowId?: string
			workflowIdempotencyKey?: string
		},
	): MergePrResult =>
		buildResult({
			merged: partial.merged,
			sha: partial.sha,
			message: partial.message,
			repository: input.repository,
			prNumber: input.prNumber,
			mergeMethod: input.mergeMethod,
			status: partial.status,
			note: partial.note,
			hint: partial.hint,
			workflowId: partial.workflowId,
			workflowIdempotencyKey: partial.workflowIdempotencyKey,
			pr: partial.pr ?? input.pr,
			startedAt: input.startedAt,
			preflightMs: input.preflightMs,
			mergeMs: input.mergeMs,
			recheckMs: input.recheckMs,
			escalationMs: now() - escalationStartedAt,
			timeoutMs: input.timeoutMs,
			totalBudgetMs,
			now,
		})

	if (!deps.workflows) {
		return finish({
			merged: false,
			sha: null,
			message: input.message,
			status: 'timed_out_unconfirmed',
			hint:
				'Durable workflows are unavailable in this runtime. Verify with pr/get-info before retrying. Retrying is safe because merge is idempotent.',
		})
	}

	const headSha = readHeadSha(input.pr)
	if (!headSha) {
		return finish({
			merged: false,
			sha: null,
			message: input.message,
			status: 'timed_out_unconfirmed',
			hint:
				'Could not determine head SHA for durable merge dedupe. Verify with pr/get-info before retrying.',
		})
	}

	const idempotencyKey = buildMergeIdempotencyKey({
		owner: input.owner,
		repo: input.repo,
		prNumber: input.prNumber,
		headSha,
	})

	let workflow: WorkflowCreateResult
	try {
		workflow = await deps.workflows.create({
			workflowName: 'github-pr-merge',
			exportName: './pr/merge-durable',
			packageId: deps.packageId ?? 'github',
			idempotencyKey,
			params: {
				owner: input.owner,
				repo: input.repo,
				prNumber: input.prNumber,
				mergeMethod: input.mergeMethod,
				account: input.account,
				commitTitle: input.commitTitle,
				commitMessage: input.commitMessage,
				sha: input.sha,
			},
		})
	} catch (error) {
		const detail =
			error instanceof Error && error.message
				? error.message
				: 'workflows.create failed'
		return finish({
			merged: false,
			sha: null,
			message: `${input.message} Durable escalation failed: ${detail}`,
			status: 'timed_out_unconfirmed',
			hint:
				'Verify with pr/get-info before retrying. Retrying is safe because merge is idempotent.',
			workflowIdempotencyKey: idempotencyKey,
		})
	}

	const workflowId = workflow.id

	while (remainingMs(input.startedAt, totalBudgetMs, now) > pollIntervalMs) {
		await deps.sleep(
			Math.min(
				pollIntervalMs,
				remainingMs(input.startedAt, totalBudgetMs, now),
			),
		)

		const pr = await deps.fetchPullRequest()
		if (isAlreadyMerged(pr)) {
			return finish({
				merged: true,
				sha: readMergeSha(pr),
				message: input.message,
				status: 'merged_via_workflow',
				note: 'merge completed via durable workflow after the direct attempt timed out',
				workflowId,
				workflowIdempotencyKey: idempotencyKey,
				pr,
			})
		}

		const blocked = classifyBlockedPreflight(pr)
		if (blocked) {
			return finish({
				merged: false,
				sha: null,
				message: blocked.message,
				status: blocked.status,
				hint: blocked.hint,
				workflowId,
				workflowIdempotencyKey: idempotencyKey,
				pr,
			})
		}

		const runs = await deps.listWorkflowRuns(50)
		const run =
			runs.find((candidate) => candidate.id === workflowId) ??
			runs.find((candidate) => candidate.idempotency_key === idempotencyKey)

		if (!run) continue

		if (run.status === 'complete') {
			const fresh = await deps.fetchPullRequest()
			if (isAlreadyMerged(fresh)) {
				return finish({
					merged: true,
					sha: readMergeSha(fresh),
					message: input.message,
					status: 'merged_via_workflow',
					note: 'merge completed via durable workflow after the direct attempt timed out',
					workflowId,
					workflowIdempotencyKey: idempotencyKey,
					pr: fresh,
				})
			}
			const blockedAfterComplete = classifyBlockedPreflight(fresh)
			if (blockedAfterComplete) {
				return finish({
					merged: false,
					sha: null,
					message: blockedAfterComplete.message,
					status: blockedAfterComplete.status,
					hint: blockedAfterComplete.hint,
					workflowId,
					workflowIdempotencyKey: idempotencyKey,
					pr: fresh,
				})
			}
			return finish({
				merged: false,
				sha: null,
				message:
					'Durable merge workflow completed without merging the pull request.',
				status: 'merge_workflow_failed',
				hint: 'Inspect the PR with pr/get-info. Retrying pr/merge is safe if the PR is still mergeable.',
				workflowId,
				workflowIdempotencyKey: idempotencyKey,
				pr: fresh,
			})
		}

		if (run.status === 'errored' || run.status === 'terminated') {
			const fresh = await deps.fetchPullRequest()
			if (isAlreadyMerged(fresh)) {
				return finish({
					merged: true,
					sha: readMergeSha(fresh),
					message: input.message,
					status: 'merged_via_workflow',
					note: 'merge completed via durable workflow after the direct attempt timed out',
					workflowId,
					workflowIdempotencyKey: idempotencyKey,
					pr: fresh,
				})
			}
			const detail = run.last_error?.trim() || `workflow ${run.status}`
			return finish({
				merged: false,
				sha: null,
				message: `Durable merge workflow ${run.status}: ${detail}`,
				status: 'merge_workflow_failed',
				hint: 'Inspect the PR with pr/get-info. Retrying pr/merge is safe if the PR is still mergeable.',
				workflowId,
				workflowIdempotencyKey: idempotencyKey,
				pr: fresh,
			})
		}
	}

	return finish({
		merged: false,
		sha: null,
		message:
			'Merge request timed out; a durable workflow will finish the merge.',
		status: 'merge_dispatched',
		note: 'you do not need to retry — the durable merge workflow will complete or fail on its own',
		workflowId,
		workflowIdempotencyKey: idempotencyKey,
	})
}