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