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)
})
})