import sendShippedPr from 'kody:@kentcdodds/discord/send-shipped-pr' import { fetchSnapshot } from './github.ts' import { asRecord, locatorKeys, oneOf, optionalBoolean, optionalString, parseLocator, rejectUnknownKeys, requiredString, } from './input.ts' import { cleanups, difficulties, doors, type Difficulty } from './status.ts' import { recordEvent } from './storage.ts' const NAME = 'ship-pr/send-summary' const allowed = [ ...locatorKeys, 'agentId', 'model', 'title', 'difficulty', 'door', 'cleanup', 'cleanupNote', 'status', 'summary', 'extras', 'dryRun', ] const statuses = ['Shipped', 'Parked', 'Blocked'] as const export type SendSummaryInput = { prUrl?: string owner?: string repo?: string prNumber?: number /** Cursor agent id (`bc-…`). */ agentId: string /** Deterministic model id only; omit when unknown (posts "Model pending"). */ model?: string /** Human headline of the change (not "ship owner/repo#N"). */ title: string difficulty: Difficulty /** Door direction; omit when unknown. Never invent. */ door?: (typeof doors)[number] /** Cleanup status; omit when unknown. Never invent. */ cleanup?: (typeof cleanups)[number] /** Short note when `cleanup` is `needed`. */ cleanupNote?: string /** Default: Shipped when merged, otherwise Parked. */ status?: (typeof statuses)[number] summary: string /** Extra lines, e.g. `[Account](https://kody.codes/account)` for pages that actually deployed. */ extras?: Array dryRun?: boolean } /** * Send the ship-pr Discord summary through * `kody:@kentcdodds/discord/send-shipped-pr` and mark tick's `report` step * done. Fills `prUrl` / `repo` and defaults `status` from the PR (merged → * Shipped, else Parked). Pass optional `door` / `cleanup` / `cleanupNote` * through (or let send-shipped-pr parse them from the PR body). Never invent * `model`, door/cleanup, or URLs in `extras`. No kind/category field. * * @param input - PR locator, `agentId`, `title`, `difficulty`, `summary`, optional `model`, `door`, `cleanup`, `cleanupNote`, `status`, `extras`, `dryRun`. * @returns The send-shipped-pr result (`messageId`, `content`, cost/diff) plus `status` and `dryRun`. * * @example * import sendSummary from 'kody:@kentcdodds/ship-pr/send-summary' * await sendSummary({ * prUrl: 'https://github.com/kentcdodds/kody/pull/123', * agentId: 'bc-…', * title: 'OpenAPI spec fetches now count against daily quota', * difficulty: 'Medium', * door: 'two-way', * cleanup: 'needed', * cleanupNote: 'delete temp feature flag', * summary: 'Squash-merged after CI and Bugbot; deploy verified.', * dryRun: true, * }) * // => { dryRun: true, status: 'Shipped', content: '…' } */ export default async function sendSummary(rawInput: SendSummaryInput) { const input = asRecord(rawInput, NAME) rejectUnknownKeys(input, allowed, NAME) const locator = parseLocator(input, NAME) const agentId = requiredString(input, 'agentId', NAME, 'Cursor agent id (bc-…).') const title = requiredString(input, 'title', NAME) if (/^ship\s+\S+#\d+$/i.test(title)) { throw new Error(`${NAME}: title should be a human headline of the change, not "ship owner/repo#N".`) } const difficulty = oneOf(input, 'difficulty', difficulties, NAME, true) const door = oneOf(input, 'door', doors, NAME) const cleanup = oneOf(input, 'cleanup', cleanups, NAME) const cleanupNote = optionalString(input, 'cleanupNote', NAME) const summary = requiredString(input, 'summary', NAME) const model = optionalString(input, 'model', NAME) const dryRun = optionalBoolean(input, 'dryRun', NAME) ?? false const extrasRaw = input.extras if (extrasRaw != null && (!Array.isArray(extrasRaw) || extrasRaw.some((e) => typeof e !== 'string'))) { throw new Error(`${NAME}: extras must be an array of strings.`) } let status = oneOf(input, 'status', statuses, NAME) if (!status) { const snapshot = await fetchSnapshot(locator) status = snapshot.merged ? 'Shipped' : snapshot.state === 'CLOSED' ? 'Blocked' : 'Parked' } const result = (await sendShippedPr({ agentId, title, summary, difficulty, status, prUrl: locator.prUrl, repo: `${locator.owner}/${locator.repo}`, ...(model ? { model } : {}), ...(door ? { door } : {}), ...(cleanup ? { cleanup } : {}), ...(cleanupNote ? { cleanupNote } : {}), ...(extrasRaw ? { extras: extrasRaw as Array } : {}), ...(dryRun ? { dryRun: true } : {}), })) as Record if (!dryRun) { await recordEvent(locator.key, 'summary_sent', 'discord', { status, messageId: (result?.messageId as string | null) ?? null, }) } return { ...result, status, dryRun } }