← 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 · TypeScriptimport { 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' }),
}
}