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/types.ts

401 lines · 9.3 KB · TypeScript
/** Terminal / in-progress statuses stored on a triage issue record. */
export type TriageOutcome =
	| 'fixed'
	| 'filtered'
	| 'ignored'
	| 'recommendation'
	| 'loop_detected'
	| 'failed'

export type TriageIssueRecord = {
	issueId?: string
	projectSlug?: string
	shortId?: string | null
	title?: string
	link?: string
	status?: string
	summary?: string
	prUrl?: string | null
	mergeCommitUrl?: string | null
	deployUrl?: string | null
	activityUrl?: string | null
	cardStage?: string | null
	agentId?: string
	agentUrl?: string | null
	discordMessageId?: string
	seenAt?: string
	spawnedAt?: string
	completedAt?: string
	hourBucket?: string
	backfill?: boolean
	[key: string]: unknown
}

export type GetIssueStateInput = {
	/** Sentry issue id (numeric string). */
	issueId: string
}

export type GetIssueStateResult = {
	record: TriageIssueRecord | null
	spawnedThisHour: number
	issuesSeenThisHour: number
	capRemaining: number
}

export type RecordOutcomeInput = {
	/** Sentry issue id (numeric string). */
	issueId: string
	/** Final triage outcome. */
	outcome: TriageOutcome
	/** Short human summary written into Discord. Prefer title/context/recommendation/options for `recommendation`. */
	summary?: string
	/**
	 * Glanceable decision title for `recommendation` Discord. Recovery stubs
	 * still use this as the Sentry issue title when the spawn record is missing.
	 */
	title?: string
	/** 1–3 sentences of situation. Not a log dump. */
	context?: string
	/** What you think Kent should choose. */
	recommendation?: string
	/** Correct long-term fix. Defaults to recommendation when omitted. */
	rightFix?: string
	/** Required when rightFix differs from recommendation. */
	whyNotRightFix?: string
	/** 2–4 choosable options (strings, newline list, or `{ label, difficulty, change }`). Each needs an impact tag. */
	options?: Array<string | { label?: string; text?: string; difficulty?: string; change?: string }> | string
	/** PR URL when outcome is `fixed`. */
	prUrl?: string
	/** Merge commit URL after the PR lands (kept on the Discord card). */
	mergeCommitUrl?: string
	/** Deploy / CI job URL after production ships. */
	deployUrl?: string
	/**
	 * Recovery only: Discord status message id from the agent prompt when the
	 * spawn record is missing from package storage.
	 */
	discordMessageId?: string
	/** Recovery metadata used with `discordMessageId` when seeding a stub record. */
	projectSlug?: string
	shortId?: string
	link?: string
	agentUrl?: string
}

export type RecordOutcomeResult = {
	ok: true
	issueId: string
	outcome: TriageOutcome
	/** Present when a per-repo queue flush was attempted after releasing the lease. */
	queueFlush?: {
		ok?: boolean
		flushed?: number
		agentId?: string
		issueIds?: string[]
		skipped?: string
		error?: string
		projectSlug?: string
		[key: string]: unknown
	}
}

export type ResetIssueInput = {
	/** Sentry issue id (numeric string). */
	issueId: string
}

export type ResetIssueResult = {
	ok: true
	issueId: string
	hadRecord: boolean
}

export type ReconcileInput = {
	/** How far back to look for missed issues (hours, 1–72; default 30). */
	lookbackHours?: number
}

export type SweepStaleAwaitingInput = {
	/** Max stale awaiting records to redeliver (1–20; default 8). */
	limit?: number
	/** Preview stale records without dispatching rescue workflows. */
	dryRun?: boolean
}

export type SweepStaleAwaitingAction = {
	issueId: string
	shortId?: string | null
	projectSlug: string
	status?: string
	delivery?: unknown
	error?: string
}

export type SweepStaleAwaitingResult = {
	ok: true
	dryRun?: boolean
	swept: number
	actions: SweepStaleAwaitingAction[]
}

export type ReconcileResult = {
	ok: true
	lookbackHours: number
	reconciled: number
	actions: Array<{
		project: string
		issueId?: string
		shortId?: string | null
		reason?: 'missing-record' | 'awaiting-seer-stale' | 'spawn-failed-stale'
		delivery?: unknown
		error?: string
	}>
}

export type FlushQueueInput = {
	/** Triage project slug (see `triageProjects` in shared.ts). */
	projectSlug: string
}

export type FlushQueueResult = {
	ok: boolean
	projectSlug: string
	flushed?: number
	agentId?: string
	issueIds?: string[]
	skipped?: string
	error?: string
	[key: string]: unknown
}

export type TriageReportResult = {
	count: number
	records: TriageIssueRecord[]
	counters: unknown
	debugShape?: string
	/** Snapshot of `repo_leases` rows when the table exists. */
	repoLeases?: unknown
	/** Per-project queue depths from `repo_queue` when the table exists. */
	repoQueue?: unknown
}

export type SentryWebhookBackfillSibling = {
	id: string
	shortId?: string | null
	title?: string
	culprit?: string
	permalink?: string | null
}

/** Sentry Seer autofix root-cause analysis (from seer.root_cause_completed). */
export type SeerRootCause = {
	one_line_description?: string | null
	five_whys?: string[]
	reproduction_steps?: string[]
	relevant_repo?: string | null
	fixability?: string | null
	runId?: string | number | null
}

export type SentryWebhookPayload = {
	action?: string
	data?: {
		issue?: {
			id: string | number
			shortId?: string | null
			title?: string
			culprit?: string | null
			level?: string
			permalink?: string | null
			project?: { slug?: string; id?: string | number } | null
		}
		event?: {
			issue_id?: string | number
			title?: string
			message?: string
			culprit?: string | null
			level?: string
			web_url?: string | null
			project?: string | number
		}
		/** Present on Seer lifecycle webhooks (same ingress URL). */
		group_id?: string | number
		run_id?: string | number
		root_cause?: SeerRootCause
		pull_requests?: Array<{
			pull_request?: { pr_number?: number; pr_url?: string; pr_id?: number }
			repo_name?: string
			provider?: string
		}>
	}
	/** Direct-invocation only; Sentry never sends this key. */
	kodyBackfill?: {
		siblings?: Array<{
			id?: string | number
			shortId?: string | null
			title?: string
			culprit?: string
			permalink?: string | null
		}>
		note?: string
	}
	/** Direct-invocation only; Sentry never sends this key. */
	kodyDryRun?: boolean
	/** Internal sweeper-rescue redelivery marker; bounds recursion to depth one. */
	kodySweeperRescue?: boolean
}

export type HandleSentryWebhookInput = {
	request?: {
		json?: SentryWebhookPayload
		headers?: Record<string, string | undefined>
	}
}

/**
 * Staged webhook body written to `packageStorage` before `workflows.create`.
 * The durable processor loads via `payloadKey` so workflow params stay tiny.
 */
export type StagedWebhookPayload = {
	headers?: Record<string, string | undefined>
	json?: SentryWebhookPayload
	stagedAt?: string
}

/**
 * Durable processor input. Workflow dispatches pass a bounded storage
 * reference (`payloadKey` + identity fields); direct / dry-run invocations
 * still use the full `request` envelope.
 */
export type ProcessSentryWebhookInput = HandleSentryWebhookInput & {
	/** packageStorage key for a staged webhook body (`webhook-payload:…`). */
	payloadKey?: string
	issueId?: string
	resource?: string | null
	action?: string | null
}

export type ProcessSentryWebhookResult =
	| {
			ok: true
			skipped: string
			resource?: string | null
			action?: string | null
			issueId?: string
			projectSlug?: string | null
			issuesSeen?: number
			spawned?: number
	  }
	| {
			ok: true
			issueId: string
			projectSlug: string
			agentId: string
	  }
	| {
			ok: true
			queued: true
			issueId: string
			projectSlug: string
	  }
	| {
			ok: true
			dryRun: true
			issueId: string
			projectSlug: string
			seerFirstEnabled: boolean
			existingStatus: string | null
			leaseHeld: boolean
			queueDepth: number
			wouldDeferToSeer: boolean
			seerAutomationOwnsRun?: boolean
	  }
	| {
			ok: true
			issueId: string
			projectSlug: string
			deferredToSeer: true
			seerRunId?: string | number | null
	  }
	| {
			ok: true
			issueId: string
			projectSlug: string
			deferredToSeerAutomation: true
	  }
	| SeerWebhookResult

export type HandleSentryWebhookResult =
	| {
			ok: true
			skipped: string
			resource?: string | null
			action?: string | null
			issueId?: string
			projectSlug?: string | null
			issuesSeen?: number
			spawned?: number
	  }
	| {
			ok: true
			accepted: true
			dispatched: true
			issueId?: string
			resource?: string | null
			action?: string | null
			workflowId: string
			workflowStatus?: string | null
			workflowName?: string
			idempotencyKey: string
			exportName: './process-sentry-webhook'
	  }
	| ProcessSentryWebhookResult

export type SeerWebhookPayload = {
	action?: string
	data?: {
		run_id?: string | number
		group_id?: string | number
		root_cause?: SeerRootCause
		pull_requests?: Array<{
			pull_request?: { pr_number?: number; pr_url?: string; pr_id?: number }
			repo_name?: string
			provider?: string
		}>
	}
	/** Direct-invocation only; Sentry never sends this key. */
	kodyDryRun?: boolean
}

export type HandleSeerWebhookInput = {
	request?: {
		json?: SeerWebhookPayload
		headers?: Record<string, string | undefined>
	}
}

export type SeerWebhookResult = {
	ok: boolean
	action?: string | null
	skipped?: string
	issueId?: string
	projectSlug?: string | null
	agentId?: string
	seededBy?: string
	seerPrUrls?: string[]
	dryRun?: boolean
	error?: string
	[key: string]: unknown
}

export type DescribeSentryTriageResult = {
	name: string
	webhooks: string[]
	projects: Array<{
		sentryProject: string
		repository: string
	}>
	exports: Record<string, string>
	discordChannel: string
}