Skip to content

src/merge.ts

105 lines · 3.9 KB · TypeScript
Raw
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<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,
	}
}