Skip to content

src/tick.ts

239 lines · 9.2 KB · TypeScript
Raw
import triggerBugbot from 'kody:@kentcdodds/bugbot'
import setPrReviewStatus from 'kody:@kentcdodds/github/pr/set-review-status'
import { loadShip } from './brain.ts'
import { replyToReviewComment, takeFetchCount } from './github.ts'
import {
	asRecord,
	locatorKeys,
	oneOf,
	optionalBoolean,
	optionalString,
	parseLocator,
	rejectUnknownKeys,
} from './input.ts'
import { cleanups, difficulties, doors, risks, stepIds, type Difficulty, type Risk, type StepId } from './status.ts'
import { adjustPoll, isPlanLimitError, nextUtcMidnight, quotaLevel, quotaWarning } from './quota.ts'
import { getFetchQuota, recordEvent } from './storage.ts'

const NAME = 'ship-pr/tick'
const allowed = [
	...locatorKeys,
	'risk',
	'agentId',
	'model',
	'title',
	'difficulty',
	'door',
	'cleanup',
	'cleanupNote',
	'summary',
	'expand',
	'brief',
	'applySafeAutomations',
	'mergeAuthority',
]

export type TickInput = {
	prUrl?: string
	owner?: string
	repo?: string
	prNumber?: number
	/** Merge authority tier. Required. */
	risk: Risk
	/** Cursor agent id (`bc-…`) for the Discord report. */
	agentId?: string
	/** Deterministic model id (metadata socket / launch record). Never inferred. */
	model?: string
	/** Human headline of the change (Discord first line). */
	title?: string
	difficulty?: Difficulty
	/** Door direction for Discord (`two-way` | `one-way`). Omit when unknown. */
	door?: 'two-way' | 'one-way'
	/** Cleanup status for Discord (`none` | `needed` | `done`). Omit when unknown. */
	cleanup?: 'none' | 'needed' | 'done'
	/** Short note when `cleanup` is `needed`. */
	cleanupNote?: string
	/** One-screen summary for the Discord report. */
	summary?: string
	/** `true` / `'all'` for details on every step, or step ids to expand beyond the focus. */
	expand?: boolean | 'all' | Array<StepId>
	/** Compact output for repeat ticks: no checklist, no prose notes, hidden items as counts. Re-tick exampleInvokes pass it. */
	brief?: boolean
	/** Apply safe automations before reporting: mark ready, trigger Bugbot (medium/high), reply "invalid" on sorted invalid findings. Default false (read-only). */
	applySafeAutomations?: boolean
	/** User explicitly granted merge authority (needed to merge high risk). */
	mergeAuthority?: boolean
}

function parseExpand(value: unknown): 'all' | Array<StepId> {
	if (value == null || value === false) return []
	if (value === true || value === 'all') return 'all'
	if (Array.isArray(value)) {
		const bad = value.filter((v) => !stepIds.includes(v as StepId))
		if (bad.length) throw new Error(`${NAME}: expand accepts true, 'all', or step ids (${stepIds.join(', ')}); got ${JSON.stringify(bad)}.`)
		return value as Array<StepId>
	}
	throw new Error(`${NAME}: expand accepts true, 'all', or an array of step ids.`)
}

/**
 * Ship-pr brain: one tick of the PR shipping loop. Call it, read the focused
 * step, act (fix, reply, decide, merge), and call it again until `exit.done`.
 *
 * Returns a brief full-process `checklist`, `done[]` / `remaining[]` progress,
 * and `details` + `exampleInvokes` only for the current focus step (or steps
 * named in `expand`). It describes state and options; the agent decides what to
 * do about CI and AI feedback. Active 7-day decisions from `./decide` hide
 * matching checks / threads / steps. Read-only unless `applySafeAutomations`.
 *
 * @param input - PR locator (`prUrl` or `owner`/`repo`/`prNumber`), required `risk`
 *   ('low' | 'medium' | 'high'), optional Discord fields (`agentId`, `model`,
 *   `title`, `difficulty`, `door`, `cleanup`, `cleanupNote`, `summary`), `expand`, `brief`, `applySafeAutomations`, `mergeAuthority`.
 * Cost: an open-PR tick makes 2 outbound fetches (one GraphQL snapshot, one
 * workflow-runs read); a merged kody PR adds the health check and a compare.
 * `applySafeAutomations` adds one call per automation and re-reads the PR only
 * when an automation changed something. `cost` reports the count. Near the
 * account's daily `outbound_fetches_per_day` cap (80%+), tick adds `quota`
 * with a warning and raises `exit.pollAfterSeconds` (5 min; 15 min at 95%+).
 * If the cap is already hit, tick returns `exit.status: 'blocked'` with
 * `retryAt` (next 00:00 UTC) instead of throwing.
 *
 * @returns `{ pr, risk, phase, focus, exit, checklist, done, remaining, exampleInvokes, automations, activeDecisions, cost, quota? }`
 *
 * @example
 * import tick from 'kody:@kentcdodds/ship-pr/tick'
 * const state = await tick({
 *   prUrl: 'https://github.com/kentcdodds/kody/pull/123',
 *   risk: 'medium',
 *   applySafeAutomations: true,
 * })
 * // => { phase: 'iterate', focus: 'ci', exit: { done: false, status: 'waiting', pollAfterSeconds: 60, wakeHints: { requireTimerFallback: true, … } }, ... }
 */
export default async function tick(rawInput: TickInput) {
	const input = asRecord(rawInput, NAME)
	rejectUnknownKeys(input, allowed, NAME)
	const locator = parseLocator(input, NAME)
	const risk = oneOf(input, 'risk', risks, NAME, true)
	const difficulty = oneOf(input, 'difficulty', difficulties, NAME)
	const door = oneOf(input, 'door', doors, NAME)
	const cleanup = oneOf(input, 'cleanup', cleanups, NAME)
	const apply = optionalBoolean(input, 'applySafeAutomations', NAME) ?? false
	const options = {
		risk,
		mergeAuthority: optionalBoolean(input, 'mergeAuthority', NAME) ?? false,
		expand: parseExpand(input.expand),
		brief: optionalBoolean(input, 'brief', NAME) ?? false,
		report: {
			agentId: optionalString(input, 'agentId', NAME),
			model: optionalString(input, 'model', NAME),
			title: optionalString(input, 'title', NAME),
			difficulty,
			door,
			cleanup,
			cleanupNote: optionalString(input, 'cleanupNote', NAME),
			summary: optionalString(input, 'summary', NAME),
		},
	}

	takeFetchCount()
	let automationCalls = 0
	const cost = () => ({ outboundFetches: takeFetchCount() + automationCalls, automationCalls })
	try {
		return await runTick()
	} catch (error) {
		if (!isPlanLimitError(error)) throw error
		const retryAt = nextUtcMidnight(Date.now())
		return {
			pr: { url: locator.prUrl },
			risk,
			phase: null,
			focus: null,
			exit: {
				done: false,
				status: 'blocked' as const,
				reason: 'plan_limit',
				retryAt: retryAt.toISOString(),
				pollAfterSeconds: Math.max(60, Math.ceil((retryAt.getTime() - Date.now()) / 1000)),
			},
			error: error instanceof Error ? error.message : String(error),
			hint: 'The Kody account hit a daily plan cap. Every Kody call that fetches will fail until retryAt (00:00 UTC). Stop ticking until then.',
			cost: cost(),
		}
	}

	async function runTick() {
		let loaded = await loadShip(locator, options)
		const applied: Array<Record<string, unknown>> = []
		let changed = false
		if (apply && loaded.report.automations.length) {
			for (const automation of loaded.report.automations) {
				try {
					if (automation.id === 'mark_ready') {
						automationCalls++
						const result = (await setPrReviewStatus({
							owner: locator.owner,
							repo: locator.repo,
							prNumber: locator.prNumber,
							status: 'ready',
						})) as { changed?: boolean; message?: string }
						if (result?.changed !== false) changed = true
						applied.push({ id: automation.id, ok: true, changed: result?.changed ?? null })
					} else if (automation.id === 'trigger_bugbot') {
						automationCalls++
						const result = (await triggerBugbot({ prUrl: locator.prUrl })) as {
							posted?: boolean
							code?: string
							commentUrl?: string
							message?: string
						}
						if (result?.posted) {
							changed = true
							await recordEvent(locator.key, 'bugbot_trigger', loaded.snapshot.headSha, {
								commentUrl: result.commentUrl ?? null,
							})
						}
						applied.push({
							id: automation.id,
							ok: Boolean(result?.posted),
							...(result?.code ? { code: result.code } : {}),
							message: result?.message,
						})
					} else if (automation.id === 'reply_invalid' && automation.commentId && automation.body) {
						const reply = await replyToReviewComment(locator, automation.commentId, automation.body)
						changed = true
						await recordEvent(locator.key, 'invalid_reply', String(automation.commentId), { url: reply.url })
						applied.push({ id: automation.id, ok: true, commentId: automation.commentId, url: reply.url })
					}
				} catch (error) {
					if (isPlanLimitError(error)) throw error
					applied.push({
						id: automation.id,
						ok: false,
						...(automation.commentId ? { commentId: automation.commentId } : {}),
						error: error instanceof Error ? error.message : String(error),
					})
				}
			}
			// Re-read only when an automation changed PR state (saves a snapshot per tick).
			if (changed) loaded = await loadShip(locator, options)
		}

		const { _internal, automations, ...report } = loaded.report
		void _internal
		const quota = await getFetchQuota()
		const level = quotaLevel(quota)
		const exit = level === 'ok' ? report.exit : { ...report.exit, pollAfterSeconds: adjustPoll(report.exit.pollAfterSeconds, level) }
		return {
			...report,
			exit,
			automations: apply
				? { applied, stillAvailable: automations.map(({ body, ...a }) => (void body, a)) }
				: {
						available: automations.map(({ body, ...a }) => (void body, a)),
						...(automations.length ? { hint: 'Pass applySafeAutomations: true to run these.' } : {}),
					},
			cost: cost(),
			...(quota && level !== 'ok' ? { quota: { ...quota, level, warning: quotaWarning(quota, level) } } : {}),
		}
	}
}