import mergePr from 'kody:@kentcdodds/github/pr/merge' import { loadShip } from './brain.ts' import { asRecord, locatorKeys, oneOf, optionalBoolean, optionalString, parseLocator, rejectUnknownKeys, } from './input.ts' import { risks, type Risk } from './status.ts' import { recordEvent } from './storage.ts' const NAME = 'ship-pr/merge' const allowed = [...locatorKeys, 'risk', 'mergeAuthority', 'mergeMethod', 'commitTitle', 'dryRun'] const mergeMethods = ['squash', 'merge', 'rebase'] as const export type ShipMergeInput = { prUrl?: string owner?: string repo?: string prNumber?: number risk: Risk /** User explicitly granted merge authority (required for high risk). */ mergeAuthority?: boolean /** Default 'squash'. */ mergeMethod?: (typeof mergeMethods)[number] commitTitle?: string /** Preflight only. */ dryRun?: boolean } /** * Merge a PR as kody-bot after a ship-pr preflight. Re-runs the tick status: * refuses while ready / ci / ai-review are unsettled (open CI failures or * unhandled findings — decide them first if you judged them non-blocking), * when high risk lacks `mergeAuthority`, on conflicts, or when merge was * decided skipped (parked). Pins the merge to the current head SHA and * delegates to `kody:@kentcdodds/github/pr/merge` (bounded + durable). * * @param input - PR locator, `risk`, optional `mergeAuthority`, `mergeMethod` (default squash), `commitTitle`, `dryRun`. * @returns `{ merged, refused?, reasons?, dryRun, headSha, result? }` * * @example * import shipMerge from 'kody:@kentcdodds/ship-pr/merge' * const result = await shipMerge({ prUrl: 'https://github.com/kentcdodds/kody/pull/123', risk: 'medium', dryRun: true }) * // => { merged: false, dryRun: true, wouldMerge: true, headSha: 'abc…', reasons: [] } */ export default async function shipMerge(rawInput: ShipMergeInput) { const input = asRecord(rawInput, NAME) rejectUnknownKeys(input, allowed, NAME) const locator = parseLocator(input, NAME) const risk = oneOf(input, 'risk', risks, NAME, true) const mergeAuthority = optionalBoolean(input, 'mergeAuthority', NAME) ?? false const mergeMethod = oneOf(input, 'mergeMethod', mergeMethods, NAME) ?? 'squash' const commitTitle = optionalString(input, 'commitTitle', NAME) const dryRun = optionalBoolean(input, 'dryRun', NAME) ?? false const { snapshot, report } = await loadShip(locator, { risk, mergeAuthority, expand: [], report: {}, }) if (snapshot.merged) { return { merged: true, status: 'already_merged', mergeSha: snapshot.mergeCommitSha, prUrl: locator.prUrl } } const steps = [...report.done, ...report.remaining] as Array<{ id: string; status: string; summary: string }> const reasons: Array = [] for (const step of steps) { if (['ready', 'ci', 'ai-review'].includes(step.id) && !['done', 'skipped', 'optional', 'not_applicable'].includes(step.status)) { reasons.push(`${step.id}: ${step.status} — ${step.summary}`) } } const mergeStep = steps.find((s) => s.id === 'merge') if (!reasons.length && mergeStep && mergeStep.status !== 'todo') { reasons.push(`merge: ${mergeStep.status} — ${mergeStep.summary}`) } if (reasons.length) { return { merged: false, refused: true, dryRun, prUrl: locator.prUrl, headSha: snapshot.headSha, reasons } } if (dryRun) { return { merged: false, dryRun: true, wouldMerge: true, prUrl: locator.prUrl, headSha: snapshot.headSha, mergeMethod, reasons } } const result = (await mergePr({ owner: locator.owner, repo: locator.repo, prNumber: locator.prNumber, mergeMethod, sha: snapshot.headSha, ...(commitTitle ? { commitTitle } : {}), })) as { merged?: boolean; sha?: string; status?: string; message?: string; workflowId?: string } if (result?.merged) { await recordEvent(locator.key, 'merge', String(result.sha ?? snapshot.headSha), { status: result.status ?? null }) } return { merged: Boolean(result?.merged), dryRun: false, prUrl: locator.prUrl, headSha: snapshot.headSha, mergeMethod, result, } }