Skip to content

src/decide.ts

180 lines · 5.9 KB · TypeScript
Raw
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,
		})),
	}
}