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