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

272 lines · 7.1 KB · TypeScript
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
	escalateAfterTimeout,
	type EscalateAfterTimeoutDeps,
	type WorkflowCreateInput,
	type WorkflowCreateResult,
	type WorkflowRunSnapshot,
} from './merge-escalation.ts'

function openPr(overrides: Record<string, unknown> = {}) {
	return {
		number: 42,
		state: 'open',
		draft: false,
		merged: false,
		mergeable: true,
		mergeable_state: 'clean',
		head: { sha: 'abc123def456' },
		...overrides,
	}
}

function mergedPr(overrides: Record<string, unknown> = {}) {
	return openPr({
		merged: true,
		merged_at: '2026-07-27T00:00:00Z',
		merge_commit_sha: 'mergecommitsha',
		...overrides,
	})
}

function createFakeWorkflows(options?: {
	onCreate?: (input: WorkflowCreateInput) => void
	id?: string
}) {
	const creates: Array<WorkflowCreateInput> = []
	const id = options?.id ?? 'wf-1'
	const workflows = {
		async create(input: WorkflowCreateInput): Promise<WorkflowCreateResult> {
			creates.push(input)
			options?.onCreate?.(input)
			return { ok: true, id, status: 'queued', workflow_name: 'github-pr-merge' }
		},
	}
	return { workflows, creates, id }
}

function baseInput(
	overrides: Partial<Parameters<typeof escalateAfterTimeout>[1]> = {},
) {
	return {
		owner: 'kentcdodds',
		repo: 'kody',
		prNumber: 42,
		repository: 'kentcdodds/kody',
		mergeMethod: 'squash' as const,
		account: 'bot',
		pr: openPr(),
		startedAt: 0,
		preflightMs: 100,
		mergeMs: 25_000,
		recheckMs: 200,
		timeoutMs: 25_000,
		message:
			'Merge request timed out before GitHub responded. The merge may still have applied.',
		...overrides,
	}
}

describe('escalateAfterTimeout', () => {
	it('dispatches once, polls, and returns merged_via_workflow when the PR merges', async () => {
		const { workflows, creates, id } = createFakeWorkflows()
		let fetches = 0
		const runs: Array<WorkflowRunSnapshot> = [
			{ id, status: 'running', idempotency_key: '' },
		]

		const deps: EscalateAfterTimeoutDeps = {
			workflows,
			listWorkflowRuns: async () => runs,
			fetchPullRequest: async () => {
				fetches += 1
				if (fetches >= 2) return mergedPr()
				return openPr()
			},
			sleep: async () => {},
			now: (() => {
				let t = 0
				return () => {
					t += 1_000
					return t
				}
			})(),
			totalBudgetMs: 20_000,
			pollIntervalMs: 1_000,
			packageId: 'github',
		}

		const result = await escalateAfterTimeout(deps, baseInput({ startedAt: 0 }))

		assert.equal(creates.length, 1)
		assert.equal(
			creates[0]?.idempotencyKey,
			'github:pr-merge:kentcdodds/kody#42@abc123def456',
		)
		assert.equal(creates[0]?.exportName, './pr/merge-durable')
		assert.equal(result.status, 'merged_via_workflow')
		assert.equal(result.merged, true)
		assert.equal(result.workflowId, id)
		assert.equal(result.sha, 'mergecommitsha')
		assert.ok(result.timings.escalationMs !== null)
	})

	it('dedupes repeat calls with the same idempotency key', async () => {
		const sharedCreates: Array<WorkflowCreateInput> = []
		const sharedId = 'wf-shared'
		const workflows = {
			async create(input: WorkflowCreateInput): Promise<WorkflowCreateResult> {
				const existing = sharedCreates.find(
					(candidate) => candidate.idempotencyKey === input.idempotencyKey,
				)
				if (existing) {
					sharedCreates.push(input)
					return {
						ok: true,
						id: sharedId,
						status: 'running',
						workflow_name: 'github-pr-merge',
					}
				}
				sharedCreates.push(input)
				return {
					ok: true,
					id: sharedId,
					status: 'queued',
					workflow_name: 'github-pr-merge',
				}
			},
		}

		const makeDeps = (): EscalateAfterTimeoutDeps => ({
			workflows,
			listWorkflowRuns: async () => [
				{
					id: sharedId,
					status: 'running',
					idempotency_key: 'github:pr-merge:kentcdodds/kody#42@abc123def456',
				},
			],
			fetchPullRequest: async () => openPr(),
			sleep: async () => {},
			now: (() => {
				let t = 30_000
				return () => {
					t += 10_000
					return t
				}
			})(),
			totalBudgetMs: 40_000,
			pollIntervalMs: 1_000,
		})

		const first = await escalateAfterTimeout(
			makeDeps(),
			baseInput({ startedAt: 0 }),
		)
		const second = await escalateAfterTimeout(
			makeDeps(),
			baseInput({ startedAt: 0 }),
		)

		assert.equal(sharedCreates.length, 2)
		assert.equal(
			sharedCreates[0]?.idempotencyKey,
			sharedCreates[1]?.idempotencyKey,
		)
		assert.equal(first.status, 'merge_dispatched')
		assert.equal(second.status, 'merge_dispatched')
		assert.equal(first.workflowId, sharedId)
		assert.equal(second.workflowId, sharedId)
	})

	it('returns merge_dispatched when the poll budget ends while the workflow is live', async () => {
		const { workflows, id } = createFakeWorkflows()
		let now = 0
		const deps: EscalateAfterTimeoutDeps = {
			workflows,
			listWorkflowRuns: async () => [{ id, status: 'running' }],
			fetchPullRequest: async () => openPr(),
			sleep: async () => {},
			now: () => {
				now += 20_000
				return now
			},
			totalBudgetMs: 30_000,
			pollIntervalMs: 1_000,
		}

		const result = await escalateAfterTimeout(deps, baseInput({ startedAt: 0 }))
		assert.equal(result.status, 'merge_dispatched')
		assert.equal(result.merged, false)
		assert.equal(result.workflowId, id)
		assert.match(result.note ?? '', /do not need to retry/i)
	})

	it('returns timed_out_unconfirmed when workflows are unavailable', async () => {
		const deps: EscalateAfterTimeoutDeps = {
			workflows: null,
			listWorkflowRuns: async () => {
				throw new Error('should not list')
			},
			fetchPullRequest: async () => openPr(),
			sleep: async () => {},
			now: () => 1,
			totalBudgetMs: 55_000,
		}

		const result = await escalateAfterTimeout(deps, baseInput())
		assert.equal(result.status, 'timed_out_unconfirmed')
		assert.equal(result.workflowId, undefined)
		assert.match(result.hint ?? '', /workflows are unavailable/i)
	})

	it('returns merge_workflow_failed when the workflow errors and PR stays open', async () => {
		const { workflows, id } = createFakeWorkflows()
		let now = 0
		const deps: EscalateAfterTimeoutDeps = {
			workflows,
			listWorkflowRuns: async () => [
				{ id, status: 'errored', last_error: 'boom' },
			],
			fetchPullRequest: async () => openPr(),
			sleep: async () => {},
			now: () => {
				now += 1_000
				return now
			},
			totalBudgetMs: 20_000,
			pollIntervalMs: 1_000,
		}

		const result = await escalateAfterTimeout(deps, baseInput({ startedAt: 0 }))
		assert.equal(result.status, 'merge_workflow_failed')
		assert.equal(result.merged, false)
		assert.equal(result.workflowId, id)
		assert.match(result.message, /boom/)
	})

	it('surfaces blocked preflight status discovered while polling', async () => {
		const { workflows, id } = createFakeWorkflows()
		let now = 0
		const deps: EscalateAfterTimeoutDeps = {
			workflows,
			listWorkflowRuns: async () => [{ id, status: 'running' }],
			fetchPullRequest: async () =>
				openPr({ mergeable: false, mergeable_state: 'dirty' }),
			sleep: async () => {},
			now: () => {
				now += 1_000
				return now
			},
			totalBudgetMs: 20_000,
			pollIntervalMs: 1_000,
		}

		const result = await escalateAfterTimeout(deps, baseInput({ startedAt: 0 }))
		assert.equal(result.status, 'dirty')
		assert.equal(result.merged, false)
		assert.equal(result.workflowId, id)
	})
})