Skip to content
← Public packages

@kentcdodds/kody-issue-triage

Loop-safe triage for Kody run errors and fleet package-runtime error-rate elevations. Wakes Cole (Grok Bot) to decide; Cursor agents only when Cole escalates.

src/update-card.ts

181 lines · 4.5 KB · TypeScript
import editMessage from 'kody:@kentcdodds/discord/edit-message'
import { formatKodyIssueReport } from './format-discord-report.ts'
import { discordChannelId, truncate } from './shared.ts'
import {
	getFingerprint,
	initSchema,
	updateFingerprint,
} from './storage.ts'

export type CardStage = 'started' | 'pr' | 'merged' | 'deployed'

export type UpdateCardInput = {
	fingerprint: string
	stage: CardStage
	/** Convenience URL mapped by stage when stage-specific fields are omitted. */
	url?: string
	agentUrl?: string
	prUrl?: string
	mergeCommitUrl?: string
	deployUrl?: string
	status?: string
	summary?: string
	discordMessageId?: string
}

const STAGES = new Set<CardStage>(['started', 'pr', 'merged', 'deployed'])

const STAGE_STATUS: Record<CardStage, string> = {
	started: 'in-progress',
	pr: 'pr-open',
	merged: 'merged',
	deployed: 'deployed',
}

function trimUrl(value: unknown): string | null {
	if (typeof value !== 'string') return null
	const trimmed = value.trim()
	return trimmed ? trimmed : null
}

/**
 * Progressive Discord card updater for an in-flight Kody issue fingerprint.
 * Edits the stored Discord message in place and accumulates lifecycle links
 * (agent → PR → merge commit → deploy) without releasing the lease or marking
 * a terminal outcome. Use `./record-outcome` for the final report.
 *
 * @example
 * import updateCard from 'kody:@kentcdodds/kody-issue-triage/update-card'
 * await updateCard({
 *   fingerprint: 'workflow:skills:timeout-90s',
 *   stage: 'started',
 *   agentUrl: 'https://cursor.com/agents/…',
 * })
 *
 * @example
 * import updateCard from 'kody:@kentcdodds/kody-issue-triage/update-card'
 * await updateCard({
 *   fingerprint: 'workflow:skills:timeout-90s',
 *   stage: 'pr',
 *   prUrl: 'https://github.com/kentcdodds/kody/pull/1',
 * })
 */
export default async function updateCard(input: UpdateCardInput) {
	const fingerprint = String(input?.fingerprint || '').trim()
	const stage = String(input?.stage || '').trim() as CardStage
	if (!fingerprint) {
		return {
			ok: true as const,
			skipped: 'invalid-input',
			error: 'fingerprint is required',
		}
	}
	if (!STAGES.has(stage)) {
		return {
			ok: true as const,
			skipped: 'invalid-input',
			error: `stage must be one of: ${[...STAGES].join(', ')}`,
		}
	}

	await initSchema()
	const record = await getFingerprint(fingerprint)
	if (!record) {
		return {
			ok: true as const,
			skipped: 'unknown-fingerprint',
			error: `unknown fingerprint: ${fingerprint}`,
		}
	}

	const aliasUrl = trimUrl(input.url)
	const agentUrl =
		trimUrl(input.agentUrl) ||
		(stage === 'started' ? aliasUrl : null) ||
		record.agent_url
	const prUrl =
		trimUrl(input.prUrl) ||
		(stage === 'pr' ? aliasUrl : null) ||
		record.pr_url
	const mergeCommitUrl =
		trimUrl(input.mergeCommitUrl) ||
		(stage === 'merged' ? aliasUrl : null) ||
		record.merge_commit_url
	const deployUrl =
		trimUrl(input.deployUrl) ||
		(stage === 'deployed' ? aliasUrl : null) ||
		record.deploy_url
	const summary =
		typeof input.summary === 'string' && input.summary.trim()
			? truncate(input.summary.trim(), 1800)
			: record.summary
	const terminal = new Set([
		'fixed',
		'ignored',
		'resolved_noise',
		'recommendation',
		'loop_detected',
		'failed',
	])
	const status =
		(typeof input.status === 'string' && input.status.trim()) ||
		(terminal.has(record.status) ? record.status : STAGE_STATUS[stage])

	await updateFingerprint(fingerprint, {
		agent_url: agentUrl,
		pr_url: prUrl,
		merge_commit_url: mergeCommitUrl,
		deploy_url: deployUrl,
		card_stage: stage,
		summary,
		status,
	})

	const updated = {
		...record,
		agent_url: agentUrl,
		pr_url: prUrl,
		merge_commit_url: mergeCommitUrl,
		deploy_url: deployUrl,
		card_stage: stage,
		summary,
		status,
	}
	const discordMessageId =
		trimUrl(input.discordMessageId) || record.discord_message_id
	const report = formatKodyIssueReport({
		status,
		record: updated,
		summary,
		prUrl,
		mergeCommitUrl,
		deployUrl,
	})
	if (discordMessageId) {
		try {
			await editMessage({
				channelId: discordChannelId,
				messageId: discordMessageId,
				content: report.content,
				flags: report.flags,
			})
		} catch {
			// Best-effort Discord edit; storage already updated.
		}
	}

	return {
		ok: true as const,
		fingerprint,
		stage,
		discordMessageId,
		links: {
			activity: updated.activity_url,
			agent: updated.agent_url,
			pr: updated.pr_url,
			merge: updated.merge_commit_url,
			deploy: updated.deploy_url,
		},
		...(discordMessageId ? {} : { skipped: 'no-discord-message' }),
	}
}