Skip to content

src/send-summary.ts

132 lines · 4.6 KB · TypeScript
Raw
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<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 }
}