← Public packages
@kentcdodds/ship-pr
Progressive ship-pr loop for agents: PR status, Bugbot, review sort, 7-day decisions, merge, deploy, wrap-up.
src/merge.ts
105 lines · 3.9 KB · TypeScriptimport 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<string> = []
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,
}
}