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 /** 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 { 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 } 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> = [] 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) } } : {}), } } }