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

338 lines · 10.3 KB · TypeScript
import { packageContext, packageStorage } from 'kody:runtime'
import {
	agentModelId as listingAgentModelId,
	discordChannelId as listingDiscordChannelId,
	findTriageProjectForProjectId,
	findTriageProjectForSlug,
	sentryOrgSlug as listingSentryOrgSlug,
	shipAgentModelId as listingShipAgentModelId,
	triageProjects as listingTriageProjects,
} from './shared.ts'

export const settingNames = {
	discordChannelId: 'discordChannelId',
	sentryOrgSlug: 'sentryOrgSlug',
	agentModelId: 'agentModelId',
	shipAgentModelId: 'shipAgentModelId',
	ownerScope: 'ownerScope',
	selfPackageName: 'selfPackageName',
	sentryPackageName: 'sentryPackageName',
	cursorPackageName: 'cursorPackageName',
	discordPackageName: 'discordPackageName',
	projects: 'triageProjects',
	maxConcurrentWakesPerRepo: 'maxConcurrentWakesPerRepo',
} as const

export const listingSecretNames = {
	sentryWebhookSecret: 'sentryWebhookSecret',
	sentryAuthToken: 'sentryAuthToken',
	discordBotToken: 'discordBotTokenKentPersonalAutomation',
	grokBotWakeWebhookUrl: 'grokBotWakeWebhookUrl',
} as const

export const listingDefaults = {
	discordChannelId: listingDiscordChannelId,
	sentryOrgSlug: listingSentryOrgSlug,
	agentModelId: listingAgentModelId,
	shipAgentModelId: listingShipAgentModelId,
	ownerScope: 'kentcdodds',
	selfPackageName: '@kentcdodds/sentry-triage',
	sentryPackageName: '@kentcdodds/sentry',
	cursorPackageName: '@kentcdodds/cursor',
	discordPackageName: '@kentcdodds/discord',
	projects: listingTriageProjects,
	secretNames: listingSecretNames,
}

function trimString(value: unknown) {
	return typeof value === 'string' ? value.trim() : ''
}

function contextPackageName() {
	const ctx = packageContext as {
		packageName?: string
		name?: string
	} | null
	return trimString(ctx?.packageName) || trimString(ctx?.name)
}

/** True for Kent's published copy and for tests with no package context. */
export function isListingOwnerCopy() {
	const name = contextPackageName()
	if (name) return name === listingDefaults.selfPackageName
	return true
}

function looksLikeProject(value: unknown): value is Record<string, unknown> {
	if (!value || typeof value !== 'object') return false
	const project = value as Record<string, unknown>
	return (
		typeof project.slug === 'string' &&
		project.slug.trim() !== '' &&
		project.projectId != null &&
		String(project.projectId).trim() !== '' &&
		typeof project.repository === 'string' &&
		typeof project.repoSlug === 'string'
	)
}

function normalizeProject(value: Record<string, unknown>) {
	return {
		slug: String(value.slug).trim(),
		slugAliases: Array.isArray(value.slugAliases)
			? value.slugAliases.map((alias) => String(alias)).filter(Boolean)
			: undefined,
		projectId: String(value.projectId).trim(),
		repository: String(value.repository).trim(),
		repoSlug: String(value.repoSlug).trim(),
		contextNote:
			typeof value.contextNote === 'string' ? value.contextNote : '',
		repoFacts: typeof value.repoFacts === 'string' ? value.repoFacts : '',
		filterGuidance:
			typeof value.filterGuidance === 'string' ? value.filterGuidance : '',
		riskGuide:
			typeof value.riskGuide === 'string'
				? value.riskGuide
				: 'classify the risk yourself from the blast radius of the change',
	}
}

async function readStored(name: string) {
	try {
		return await packageStorage().get(name)
	} catch {
		return null
	}
}

async function writeStored(name: string, value: unknown) {
	await packageStorage().set(name, value)
	return value
}

async function readNamedString(name: string, fallback: string) {
	const stored = trimString(await readStored(name))
	return stored || fallback
}

async function readStoredProjects() {
	const stored = await readStored(settingNames.projects)
	const raw = Array.isArray(stored)
		? stored
		: typeof stored === 'string' && stored.trim()
			? JSON.parse(stored)
			: null
	if (!Array.isArray(raw)) return null
	const projects = raw.filter(looksLikeProject).map(normalizeProject)
	return projects
}

export type TriageSettings = {
	discordChannelId: string
	sentryOrgSlug: string
	agentModelId: string
	shipAgentModelId: string
	ownerScope: string
	selfPackageName: string
	sentryPackageName: string
	cursorPackageName: string
	discordPackageName: string
	projects: ReturnType<typeof normalizeProject>[]
	maxConcurrentWakesPerRepo: number
	secretNames: typeof listingSecretNames
	listingOwnerCopy: boolean
	sources: Record<string, 'package-storage' | 'listing-default' | 'empty-fork'>
}


async function readMaxConcurrentWakesPerRepo() {
	const stored = await readStored(settingNames.maxConcurrentWakesPerRepo)
	const n = typeof stored === 'number' ? stored : Number(stored)
	if (Number.isFinite(n) && n >= 1 && n <= 20) return Math.floor(n)
	return 3
}

export async function readTriageSettings(): Promise<TriageSettings> {
	const listingOwnerCopy = isListingOwnerCopy()
	const sources: TriageSettings['sources'] = {}
	const readOrDefault = async (name: string, fallback: string) => {
		const stored = trimString(await readStored(name))
		if (stored) {
			sources[name] = 'package-storage'
			return stored
		}
		sources[name] = 'listing-default'
		return fallback
	}
	const storedProjects = await readStoredProjects()
	let projects
	if (storedProjects && storedProjects.length > 0) {
		projects = storedProjects
		sources.projects = 'package-storage'
	} else if (listingOwnerCopy) {
		projects = listingDefaults.projects
		sources.projects = 'listing-default'
	} else {
		projects = []
		sources.projects = 'empty-fork'
	}
	return {
		discordChannelId: await readOrDefault(
			settingNames.discordChannelId,
			listingDefaults.discordChannelId,
		),
		sentryOrgSlug: await readOrDefault(
			settingNames.sentryOrgSlug,
			listingDefaults.sentryOrgSlug,
		),
		agentModelId: await readOrDefault(
			settingNames.agentModelId,
			listingDefaults.agentModelId,
		),
		shipAgentModelId: await readOrDefault(
			settingNames.shipAgentModelId,
			listingDefaults.shipAgentModelId,
		),
		ownerScope: await readOrDefault(
			settingNames.ownerScope,
			listingDefaults.ownerScope,
		),
		selfPackageName: await readOrDefault(
			settingNames.selfPackageName,
			listingDefaults.selfPackageName,
		),
		sentryPackageName: await readOrDefault(
			settingNames.sentryPackageName,
			listingDefaults.sentryPackageName,
		),
		cursorPackageName: await readOrDefault(
			settingNames.cursorPackageName,
			listingDefaults.cursorPackageName,
		),
		discordPackageName: await readOrDefault(
			settingNames.discordPackageName,
			listingDefaults.discordPackageName,
		),
		projects,
		maxConcurrentWakesPerRepo: await readMaxConcurrentWakesPerRepo(),
		secretNames: listingSecretNames,
		listingOwnerCopy,
		sources,
	}
}

export function promptOwnerFields(settings: Awaited<ReturnType<typeof readTriageSettings>>) {
	return {
		sentryOrgSlug: settings.sentryOrgSlug,
		discordChannelId: settings.discordChannelId,
		selfPackageName: settings.selfPackageName,
		sentryPackageName: settings.sentryPackageName,
	}
}

export async function resolveTriageProjectForSlug(slug: string | null | undefined) {
	const { projects } = await readTriageSettings()
	return findTriageProjectForSlug(projects, slug)
}

export async function resolveTriageProjectForProjectId(
	projectId: string | number | null | undefined,
) {
	const { projects } = await readTriageSettings()
	return findTriageProjectForProjectId(projects, projectId)
}

export type TriageSettingsInput = {
	discordChannelId?: string
	sentryOrgSlug?: string
	agentModelId?: string
	shipAgentModelId?: string
	ownerScope?: string
	selfPackageName?: string
	sentryPackageName?: string
	cursorPackageName?: string
	discordPackageName?: string
	projects?: unknown
	maxConcurrentWakesPerRepo?: number
}

function requireSnowflake(value: unknown, field: string) {
	const id = trimString(value)
	if (!/^\d{17,20}$/.test(id)) {
		throw new Error(`Save a valid Discord ${field} (snowflake).`)
	}
	return id
}

export async function updateTriageSettings(input: TriageSettingsInput = {}) {
	const updated: string[] = []
	if (typeof input.discordChannelId === 'string' && input.discordChannelId.trim()) {
		await writeStored(
			settingNames.discordChannelId,
			requireSnowflake(input.discordChannelId, 'discordChannelId'),
		)
		updated.push('discordChannelId')
	}
	const strings: Array<[keyof typeof settingNames, string | undefined]> = [
		['sentryOrgSlug', input.sentryOrgSlug],
		['agentModelId', input.agentModelId],
		['shipAgentModelId', input.shipAgentModelId],
		['ownerScope', input.ownerScope],
		['selfPackageName', input.selfPackageName],
		['sentryPackageName', input.sentryPackageName],
		['cursorPackageName', input.cursorPackageName],
		['discordPackageName', input.discordPackageName],
	]
	for (const [field, value] of strings) {
		if (typeof value !== 'string' || !value.trim()) continue
		await writeStored(settingNames[field], value.trim())
		updated.push(field)
	}

	if (input.maxConcurrentWakesPerRepo !== undefined) {
		const n = Number(input.maxConcurrentWakesPerRepo)
		if (!Number.isFinite(n) || n < 1 || n > 20) {
			throw new Error('maxConcurrentWakesPerRepo must be an integer from 1 to 20.')
		}
		await writeStored(settingNames.maxConcurrentWakesPerRepo, Math.floor(n))
		updated.push('maxConcurrentWakesPerRepo')
	}
	if (input.projects !== undefined) {
		if (!Array.isArray(input.projects)) {
			throw new Error('projects must be an array of triage project objects.')
		}
		const projects = input.projects.filter(looksLikeProject).map(normalizeProject)
		if (projects.length !== input.projects.length) {
			throw new Error(
				'Each project needs slug, projectId, repository, and repoSlug.',
			)
		}
		await writeStored(settingNames.projects, projects)
		updated.push('projects')
	}
	return { updated, settings: await readTriageSettings() }
}

/**
 * Read or update owner-specific triage settings in this package's storage.
 * Pass any field to persist it (no republish). Empty reads use Kent listing
 * defaults on his copy; a community fork with no stored projects gets [].
 */
export default async function triageSettings(input: TriageSettingsInput = {}) {
	if (
		input.discordChannelId ||
		input.sentryOrgSlug ||
		input.agentModelId ||
		input.shipAgentModelId ||
		input.ownerScope ||
		input.selfPackageName ||
		input.sentryPackageName ||
		input.cursorPackageName ||
		input.discordPackageName ||
		input.projects !== undefined ||
		input.maxConcurrentWakesPerRepo !== undefined
	) {
		return await updateTriageSettings(input)
	}
	return await readTriageSettings()
}