← Public packages
@kentcdodds/ship-pr
Progressive ship-pr loop for agents: PR status, Bugbot, review sort, 7-day decisions, merge, deploy, wrap-up.
src/tick.ts
239 lines · 9.2 KB · TypeScriptimport 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) } } : {}),
}
}
}