← Public packages
@kentcdodds/ship-pr
Progressive ship-pr loop for agents: PR status, Bugbot, review sort, 7-day decisions, merge, deploy, wrap-up.
src/decide.ts
180 lines · 5.9 KB · TypeScriptimport { resolveReplyText } from './decide-reply.ts'
import { replyToReviewComment } from './github.ts'
import {
asRecord,
locatorKeys,
oneOf,
optionalBoolean,
optionalString,
parseLocator,
rejectUnknownKeys,
requiredString,
} from './input.ts'
import { isOutsideDiffDecideTarget } from './outside-diff.ts'
import { dispositions, stepIds, type Disposition } from './status.ts'
import {
DEFAULT_TTL_DAYS,
MAX_TTL_DAYS,
decisionKinds,
expiryFrom,
listDecisions,
removeDecision,
upsertDecision,
type Decision,
type DecisionKind,
} from './storage.ts'
const NAME = 'ship-pr/decide'
const allowed = [
...locatorKeys,
'kind',
'target',
'disposition',
'reason',
'ttlDays',
'headSha',
'agentId',
'reply',
'remove',
]
export type DecideInput = {
prUrl?: string
owner?: string
repo?: string
prNumber?: number
/** What the decision covers. */
kind: DecisionKind
/**
* thread: root review comment id (from tick), thread node id `PRRT_…`, or an
* outside-diff decideTarget (`cr-comment:v1:…` / `outside-diff:…`).
* check: check name exactly as tick lists it, or an AI reviewer (`bugbot`, `coderabbit`, `devin`, `seer`) to stop waiting on it.
* step: one of ready | ci | ai-review | merge | deploy | friction | report.
*/
target: string | number
disposition?: Disposition
/** Required (≥ 8 chars) when recording. Shown back in tick output. */
reason?: string
/** 1–7 days (default 7). */
ttlDays?: number
headSha?: string
agentId?: string
/**
* kind 'thread' only: also post a kody-bot reply on the thread.
* `true` posts `reason`; a string posts that text instead.
*/
reply?: boolean | string
/** Remove an existing decision instead of recording one. */
remove?: boolean
}
/**
* Record (or remove) a ship-pr disposition so `./tick` hides that review
* thread, check, or step for up to 7 days. Use after you judged an item:
* noise you will not fix (`ignored` / `wontfix`), a step that does not apply
* (`skipped`), or something you handled / accept as-is (`accepted`). A reason
* is required and is echoed in tick's `activeDecisions`.
*
* Recording `step` decisions also drives progress: `friction` (accepted =
* pass done, skipped = nothing qualified), `merge` (skipped = park),
* `deploy` (accepted/skipped = verified or nothing deploys).
*
* @param input - PR locator plus `kind` ('thread' | 'check' | 'step'), `target`,
* `disposition` ('ignored' | 'skipped' | 'wontfix' | 'accepted'), `reason`,
* optional `ttlDays` (1–7), `reply` (thread only, posts as kody-bot: `true` posts
* `reason`, a string posts that text), or `remove: true`.
* @returns The stored decision with `expiresAt`, plus the PR's active decisions.
*
* @example
* import decide from 'kody:@kentcdodds/ship-pr/decide'
* await decide({
* prUrl: 'https://github.com/kentcdodds/kody/pull/123',
* kind: 'thread',
* target: '4185778067',
* disposition: 'wontfix',
* reason: 'Builtins are resolved by nodejs_compat; intentional.',
* reply: true, // also post the reason on the thread as kody-bot
* })
* // => { recorded: true, decision: { kind: 'thread', target: '4185778067', expiresAt: '…' }, active: [...] }
*/
export default async function decide(rawInput: DecideInput) {
const input = asRecord(rawInput, NAME)
rejectUnknownKeys(input, allowed, NAME)
const locator = parseLocator(input, NAME)
const kind = oneOf(input, 'kind', decisionKinds, NAME, true)
const rawTarget = input.target
const target =
typeof rawTarget === 'number' && Number.isFinite(rawTarget)
? String(rawTarget)
: typeof rawTarget === 'string'
? rawTarget.trim()
: ''
if (!target) throw new Error(`${NAME}: target is required (thread comment id / thread id, check name, or step id).`)
if (kind === 'step' && !stepIds.includes(target as (typeof stepIds)[number])) {
throw new Error(`${NAME}: step target must be one of ${stepIds.join(', ')}.`)
}
if (
kind === 'thread' &&
!/^\d+$/.test(target) &&
!/^PRRT_/.test(target) &&
!isOutsideDiffDecideTarget(target)
) {
throw new Error(
`${NAME}: thread target must be the root review comment id (digits), a PRRT_ thread id, or an outside-diff id (cr-comment:v1:… / outside-diff:…).`,
)
}
if (optionalBoolean(input, 'remove', NAME)) {
await removeDecision(locator.key, kind, target)
return { removed: true, kind, target, active: await listDecisions(locator.key) }
}
const disposition = oneOf(input, 'disposition', dispositions, NAME, true)
const reason = requiredString(input, 'reason', NAME, 'Say why (shown back in tick output).')
if (reason.length < 8) throw new Error(`${NAME}: reason is too short; explain the judgement in a sentence.`)
const ttlRaw = input.ttlDays
const ttlDays = ttlRaw == null ? DEFAULT_TTL_DAYS : Number(ttlRaw)
if (!Number.isFinite(ttlDays) || ttlDays <= 0 || ttlDays > MAX_TTL_DAYS) {
throw new Error(`${NAME}: ttlDays must be > 0 and ≤ ${MAX_TTL_DAYS}.`)
}
const replyText = resolveReplyText(input.reply, reason, NAME)
if (replyText && kind !== 'thread') throw new Error(`${NAME}: reply is only valid for kind 'thread'.`)
if (replyText && !/^\d+$/.test(target)) {
throw new Error(`${NAME}: reply needs the numeric root review comment id as target.`)
}
const nowMs = Date.now()
const decision: Decision = {
prKey: locator.key,
kind,
target,
disposition,
reason,
headSha: optionalString(input, 'headSha', NAME) ?? null,
agentId: optionalString(input, 'agentId', NAME) ?? null,
createdAt: new Date(nowMs).toISOString(),
expiresAt: expiryFrom(nowMs, ttlDays),
}
let reply: { url: string | null } | null = null
if (replyText) reply = await replyToReviewComment(locator, Number(target), replyText)
await upsertDecision(decision)
return {
recorded: true,
decision: {
kind,
target,
disposition,
reason,
expiresAt: decision.expiresAt,
},
...(reply ? { reply } : {}),
active: (await listDecisions(locator.key)).map((d) => ({
kind: d.kind,
target: d.target,
disposition: d.disposition,
reason: d.reason,
expiresAt: d.expiresAt,
})),
}
}