← Public packages
@kentcdodds/ship-pr
Progressive ship-pr loop for agents: PR status, Bugbot, review sort, 7-day decisions, merge, deploy, wrap-up.
src/send-summary.ts
132 lines · 4.6 KB · TypeScriptimport 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<string>
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<string> } : {}),
...(dryRun ? { dryRun: true } : {}),
})) as Record<string, unknown>
if (!dryRun) {
await recordEvent(locator.key, 'summary_sent', 'discord', {
status,
messageId: (result?.messageId as string | null) ?? null,
})
}
return { ...result, status, dryRun }
}