import { 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, })), } }