Skip to content
← Public packages

@kentcdodds/sentry-triage

Sentry triage wakes Cole (Grok Bot) per issue: one active wake per repo (lease + queue), loop-safe Discord. Cole may spawn Cursor for isolated repo work.

src/update-card.ts

218 lines · 6.5 KB · TypeScript
import { packageStorage } from 'kody:runtime'
import { formatSentryReport, editSentryReport } from './format-discord-report.ts'
import { issueStorageKey, truncate } from './shared.ts'
import type { TriageIssueRecord } from './types.ts'

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

export type UpdateCardInput = {
	/** Sentry issue id (numeric string). */
	issueId: string
	/** Progressive lifecycle stage to record on the Discord card. */
	stage: CardStage
	/**
	 * URL for this stage. Prefer stage-specific fields when known; `url` is
	 * a convenience alias mapped by stage (`started`→agent, `pr`→PR,
	 * `merged`→merge commit, `deployed`→deploy/job).
	 */
	url?: string
	/** Cursor / Cole agent URL when work starts. */
	agentUrl?: string
	/** Pull request URL when a PR is opened or pushed. */
	prUrl?: string
	/** Merge commit URL after the PR lands. */
	mergeCommitUrl?: string
	/** Deploy / CI job URL after production ships. */
	deployUrl?: string
	/** Optional status override for the Discord card. */
	status?: string
	/** Short note appended as the card summary. */
	summary?: string
	/** Recovery: Discord message id when the spawn record is missing. */
	discordMessageId?: string
}

export type UpdateCardResult = {
	ok: true
	issueId: string
	stage: CardStage
	discordMessageId: string | null
	links: {
		issue: string | null
		agent: string | null
		pr: string | null
		merge: string | null
		deploy: string | null
	}
	skipped?: 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 Sentry triage issue.
 * Edits the stored Discord message in place and accumulates lifecycle links
 * (agent → PR → merge commit → deploy) without releasing the repo lease or
 * marking a terminal outcome. Use `./record-outcome` for the final report.
 *
 * @example
 * import updateCard from 'kody:@kentcdodds/sentry-triage/update-card'
 * await updateCard({
 *   issueId: '12345',
 *   stage: 'started',
 *   agentUrl: 'https://cursor.com/agents/…',
 * })
 *
 * @example
 * import updateCard from 'kody:@kentcdodds/sentry-triage/update-card'
 * await updateCard({
 *   issueId: '12345',
 *   stage: 'pr',
 *   prUrl: 'https://github.com/kentcdodds/kody/pull/1',
 * })
 *
 * @example
 * import updateCard from 'kody:@kentcdodds/sentry-triage/update-card'
 * await updateCard({
 *   issueId: '12345',
 *   stage: 'merged',
 *   mergeCommitUrl: 'https://github.com/kentcdodds/kody/commit/abc',
 * })
 *
 * @example
 * import updateCard from 'kody:@kentcdodds/sentry-triage/update-card'
 * await updateCard({
 *   issueId: '12345',
 *   stage: 'deployed',
 *   deployUrl: 'https://github.com/kentcdodds/kody/actions/runs/1',
 * })
 */
export default async function updateCard(
	input: UpdateCardInput,
): Promise<UpdateCardResult> {
	const issueId = String(input?.issueId ?? '').trim()
	const stage = String(input?.stage ?? '').trim() as CardStage
	if (!issueId) throw new Error('issueId is required.')
	if (!STAGES.has(stage)) {
		throw new Error(`stage must be one of: ${[...STAGES].join(', ')}`)
	}

	const storage = packageStorage()
	const key = issueStorageKey(issueId)
	let record = (await storage.get(key)) as TriageIssueRecord | null
	if (!record) {
		const recoveryMessageId = trimUrl(input.discordMessageId)
		if (!recoveryMessageId) {
			throw new Error(`No triage record for issue ${issueId}.`)
		}
		record = {
			issueId,
			discordMessageId: recoveryMessageId,
			status: 'agent-spawned',
			seenAt: new Date().toISOString(),
		}
	}

	const aliasUrl = trimUrl(input.url)
	const agentUrl =
		trimUrl(input.agentUrl) ||
		(stage === 'started' ? aliasUrl : null) ||
		(typeof record.agentUrl === 'string' ? record.agentUrl : null)
	const prUrl =
		trimUrl(input.prUrl) ||
		(stage === 'pr' ? aliasUrl : null) ||
		(typeof record.prUrl === 'string' ? record.prUrl : null)
	const mergeCommitUrl =
		trimUrl(input.mergeCommitUrl) ||
		(stage === 'merged' ? aliasUrl : null) ||
		(typeof record.mergeCommitUrl === 'string' ? record.mergeCommitUrl : null)
	const deployUrl =
		trimUrl(input.deployUrl) ||
		(stage === 'deployed' ? aliasUrl : null) ||
		(typeof record.deployUrl === 'string' ? record.deployUrl : null)

	const summary = truncate(
		typeof input.summary === 'string' && input.summary.trim()
			? input.summary.trim()
			: typeof record.summary === 'string'
				? record.summary
				: '',
		600,
	)
	const status =
		(typeof input.status === 'string' && input.status.trim()) ||
		STAGE_STATUS[stage]

	const updated: TriageIssueRecord = {
		...record,
		agentUrl: agentUrl || record.agentUrl || null,
		prUrl: prUrl || record.prUrl || null,
		mergeCommitUrl: mergeCommitUrl || null,
		deployUrl: deployUrl || null,
		cardStage: stage,
		summary: summary || record.summary,
		// Keep terminal outcomes intact; only annotate in-flight cards.
		status:
			record.status &&
			['fixed', 'filtered', 'ignored', 'recommendation', 'loop_detected', 'failed'].includes(
				String(record.status),
			)
				? record.status
				: status,
		cardUpdatedAt: new Date().toISOString(),
	}
	await storage.set(key, updated)

	const discordMessageId =
		typeof updated.discordMessageId === 'string'
			? updated.discordMessageId
			: null
	if (discordMessageId) {
		await editSentryReport(discordMessageId, {
			status: String(updated.status || status),
			title: updated.title,
			shortId: updated.shortId,
			issueId,
			projectSlug: updated.projectSlug,
			link: updated.link,
			summary: updated.summary,
			prUrl: updated.prUrl,
			agentUrl: updated.agentUrl,
			mergeCommitUrl: updated.mergeCommitUrl,
			deployUrl: updated.deployUrl,
			activityUrl:
				typeof updated.activityUrl === 'string' ? updated.activityUrl : null,
		}).catch(() => {})
	}

	return {
		ok: true,
		issueId,
		stage,
		discordMessageId,
		links: {
			issue: typeof updated.link === 'string' ? updated.link : null,
			agent: typeof updated.agentUrl === 'string' ? updated.agentUrl : null,
			pr: typeof updated.prUrl === 'string' ? updated.prUrl : null,
			merge:
				typeof updated.mergeCommitUrl === 'string'
					? updated.mergeCommitUrl
					: null,
			deploy: typeof updated.deployUrl === 'string' ? updated.deployUrl : null,
		},
		...(discordMessageId ? {} : { skipped: 'no-discord-message' }),
	}
}