import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { rootlyConnectorMeta } from '@/connectors/rootly/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' const logger = createLogger('RootlyConnector') const ROOTLY_API_BASE = 'https://api.rootly.com/v1' /** JSON:API media type required by Rootly for all requests. */ const JSON_API_CONTENT_TYPE = 'application/vnd.api+json' const PAGE_SIZE = 50 /** Cap on timeline events appended to a document to keep content bounded. */ const MAX_TIMELINE_EVENTS = 200 /** * JSON:API relationships to embed inline within each incident's `attributes`. * Rootly omits these unless requested via `include`, so both the list (stub) and * detail requests pass them to ensure tag metadata is identical on either path. * Scoped to exactly the relationships this connector reads — `environments`, * `services`, and `groups` (Rootly's API token for teams) — to avoid fetching * unused relationship payloads on every incident. */ const INCIDENT_INCLUDE = 'environments,services,groups' /** * JSON:API named-resource entry as embedded directly inside incident * `attributes` for relationships (environments, services, etc.). Each entry * wraps a `data` object whose `attributes.name` is the human-readable label. */ interface RootlyNamedResource { data?: { id?: string type?: string attributes?: { name?: string } } } /** * Minimal shape of a Rootly incident's `attributes` object. * Only the fields this connector reads are typed; Rootly returns many more. * * Relationship arrays (environments, services, groups) and the freeform * `labels` map are embedded inline in the `attributes` of both the list and * detail responses, so the deferred list stub can derive every tag without an * extra request. */ interface RootlyIncidentAttributes { title?: string slug?: string summary?: string kind?: string status?: string url?: string short_url?: string mitigation_message?: string resolution_message?: string cancellation_message?: string retrospective_progress_status?: string started_at?: string detected_at?: string mitigated_at?: string resolved_at?: string closed_at?: string created_at?: string updated_at?: string severity?: { data?: { id?: string attributes?: { name?: string severity?: string } } } environments?: RootlyNamedResource[] services?: RootlyNamedResource[] groups?: RootlyNamedResource[] labels?: Record } /** A single JSON:API resource object for an incident. */ interface RootlyIncidentResource { id?: string type?: string attributes?: RootlyIncidentAttributes } /** Attributes of a Rootly incident timeline event. */ interface RootlyEventAttributes { event?: string visibility?: string occurred_at?: string created_at?: string updated_at?: string } interface RootlyEventResource { id?: string type?: string attributes?: RootlyEventAttributes } /** JSON:API list envelope shared by incidents and events list endpoints. */ interface RootlyListResponse { data?: T[] links?: { next?: string | null } meta?: { total_count?: number } } interface RootlyResourceResponse { data?: T } /** * Metadata persisted on every incident document, identical between the list * stub and the hydrated document so `contentHash` and tags stay stable. */ interface IncidentMetadata { status?: string severityName?: string severityLevel?: string kind?: string incidentDate?: string resolvedDate?: string environments?: string[] services?: string[] teams?: string[] labels?: string[] updatedAt?: string } /** * Builds the standard JSON:API request headers with Bearer auth. */ function buildHeaders(accessToken: string): Record { return { Authorization: `Bearer ${accessToken}`, 'Content-Type': JSON_API_CONTENT_TYPE, Accept: JSON_API_CONTENT_TYPE, } } /** * Derives the metadata bag from an incident's attributes. Used by both the list * stub and getDocument so the two produce an identical `contentHash`. */ function buildMetadata(attrs: RootlyIncidentAttributes): IncidentMetadata { const severityData = attrs.severity?.data return { status: attrs.status ?? undefined, severityName: severityData?.attributes?.name ?? undefined, severityLevel: severityData?.attributes?.severity ?? undefined, kind: attrs.kind ?? undefined, incidentDate: attrs.started_at ?? attrs.created_at ?? undefined, resolvedDate: attrs.resolved_at ?? undefined, environments: namedResourceLabels(attrs.environments), services: namedResourceLabels(attrs.services), teams: namedResourceLabels(attrs.groups), labels: labelPairs(attrs.labels), updatedAt: attrs.updated_at ?? undefined, } } /** * Extracts the human-readable `name` from each JSON:API named-resource entry, * dropping any without a usable label. */ function namedResourceLabels(resources: RootlyNamedResource[] | undefined): string[] | undefined { if (!Array.isArray(resources)) return undefined const names: string[] = [] for (const resource of resources) { const name = resource.data?.attributes?.name?.trim() if (name) names.push(name) } return names.length > 0 ? names : undefined } /** * Flattens Rootly's freeform `labels` map (e.g. `{platform: "osx"}`) into * `key:value` strings so they can be joined into a single searchable tag. */ function labelPairs(labels: Record | undefined): string[] | undefined { if (!labels || typeof labels !== 'object') return undefined const pairs: string[] = [] for (const [key, value] of Object.entries(labels)) { const trimmedKey = key.trim() if (!trimmedKey) continue const trimmedValue = typeof value === 'string' ? value.trim() : '' pairs.push(trimmedValue ? `${trimmedKey}:${trimmedValue}` : trimmedKey) } return pairs.length > 0 ? pairs : undefined } /** * Computes a metadata-based content hash. The formula depends only on the * incident ID and its `updated_at` timestamp, so the deferred list stub and the * hydrated `getDocument` result hash identically — change detection keys off * Rootly's own change indicator rather than the rendered text. */ function buildContentHash(id: string, updatedAt: string | undefined): string { return `rootly:${id}:${updatedAt ?? ''}` } function buildSourceUrl(attrs: RootlyIncidentAttributes): string | undefined { return attrs.url || attrs.short_url || undefined } /** * Fetches the incident timeline events, following JSON:API pagination until * exhausted or the event cap is reached. Returns an empty array on any failure * so timeline enrichment never blocks document creation. */ async function fetchTimelineEvents( accessToken: string, incidentId: string ): Promise { const events: RootlyEventAttributes[] = [] let pageNumber = 1 try { while (events.length < MAX_TIMELINE_EVENTS) { const url = `${ROOTLY_API_BASE}/incidents/${encodeURIComponent(incidentId)}/events?page[number]=${pageNumber}&page[size]=${PAGE_SIZE}` const response = await fetchWithRetry(url, { method: 'GET', headers: buildHeaders(accessToken), }) if (!response.ok) { logger.warn('Failed to fetch Rootly incident timeline', { incidentId, status: response.status, }) break } const body = (await response.json()) as RootlyListResponse const pageEvents = body.data ?? [] for (const event of pageEvents) { if (event.attributes) events.push(event.attributes) } if (!body.links?.next || pageEvents.length === 0) break pageNumber += 1 } } catch (error) { logger.warn('Error fetching Rootly incident timeline', { incidentId, error: toError(error).message, }) } return events.slice(0, MAX_TIMELINE_EVENTS) } /** * Renders an incident plus its timeline into plain-text content. Only sections * with data are emitted, so resolved incidents read cleanly while open ones omit * empty resolution fields. */ function formatIncidentContent( attrs: RootlyIncidentAttributes, events: RootlyEventAttributes[] ): string { const parts: string[] = [] if (attrs.title) parts.push(`Incident: ${attrs.title}`) if (attrs.status) parts.push(`Status: ${attrs.status}`) if (attrs.kind) parts.push(`Kind: ${attrs.kind}`) const severityName = attrs.severity?.data?.attributes?.name if (severityName) parts.push(`Severity: ${severityName}`) const services = namedResourceLabels(attrs.services) if (services) parts.push(`Services: ${services.join(', ')}`) const teams = namedResourceLabels(attrs.groups) if (teams) parts.push(`Teams: ${teams.join(', ')}`) const environments = namedResourceLabels(attrs.environments) if (environments) parts.push(`Environments: ${environments.join(', ')}`) if (attrs.started_at) parts.push(`Started: ${attrs.started_at}`) if (attrs.resolved_at) parts.push(`Resolved: ${attrs.resolved_at}`) if (attrs.summary?.trim()) { parts.push('') parts.push('--- Summary ---') parts.push(attrs.summary.trim()) } if (attrs.mitigation_message?.trim()) { parts.push('') parts.push('--- Mitigation ---') parts.push(attrs.mitigation_message.trim()) } if (attrs.resolution_message?.trim()) { parts.push('') parts.push('--- Resolution ---') parts.push(attrs.resolution_message.trim()) } if (attrs.cancellation_message?.trim()) { parts.push('') parts.push('--- Cancellation ---') parts.push(attrs.cancellation_message.trim()) } if (events.length > 0) { parts.push('') parts.push('--- Timeline ---') for (const event of events) { if (!event.event?.trim()) continue const when = event.occurred_at || event.created_at parts.push(when ? `${when}: ${event.event.trim()}` : event.event.trim()) } } return parts.join('\n') } /** * Builds a deferred list stub for an incident — no content, but carrying the * exact metadata and hash the hydrated document will produce. */ function incidentToStub(resource: RootlyIncidentResource): ExternalDocument | null { const id = resource.id const attrs = resource.attributes if (!id || !attrs) return null const metadata = buildMetadata(attrs) return { externalId: id, title: attrs.title?.trim() || `Incident ${id}`, content: '', contentDeferred: true, mimeType: 'text/plain', sourceUrl: buildSourceUrl(attrs), contentHash: buildContentHash(id, attrs.updated_at), metadata: { ...metadata }, } } /** * Reads the optional `maxIncidents` cap from sourceConfig, returning 0 when * unset or invalid (treated as unlimited). */ function parseMaxIncidents(sourceConfig: Record): number { const raw = sourceConfig.maxIncidents if (raw == null || raw === '') return 0 const value = Number(raw) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0 } export const rootlyConnector: ConnectorConfig = { ...rootlyConnectorMeta, listDocuments: async ( accessToken: string, sourceConfig: Record, cursor?: string, syncContext?: Record, lastSyncAt?: Date ): Promise => { const maxIncidents = parseMaxIncidents(sourceConfig) const status = typeof sourceConfig.status === 'string' ? sourceConfig.status.trim() : '' const severity = typeof sourceConfig.severity === 'string' ? sourceConfig.severity.trim() : '' const services = parseMultiValue(sourceConfig.services) const teams = parseMultiValue(sourceConfig.teams) const environments = parseMultiValue(sourceConfig.environments) const pageNumber = cursor ? Number(cursor) : 1 const startPage = Number.isFinite(pageNumber) && pageNumber > 0 ? pageNumber : 1 const queryParams = new URLSearchParams() queryParams.set('page[number]', String(startPage)) queryParams.set('page[size]', String(PAGE_SIZE)) queryParams.set('include', INCIDENT_INCLUDE) if (status) queryParams.set('filter[status]', status) if (severity) queryParams.set('filter[severity]', severity) if (services.length > 0) queryParams.set('filter[services]', services.join(',')) if (teams.length > 0) queryParams.set('filter[teams]', teams.join(',')) if (environments.length > 0) queryParams.set('filter[environments]', environments.join(',')) if (lastSyncAt) { queryParams.set('filter[updated_at][gt]', lastSyncAt.toISOString()) queryParams.set('sort', '-updated_at') } const url = `${ROOTLY_API_BASE}/incidents?${queryParams.toString()}` logger.info('Listing Rootly incidents', { pageNumber: startPage, pageSize: PAGE_SIZE, status: status || undefined, incremental: Boolean(lastSyncAt), }) const response = await fetchWithRetry(url, { method: 'GET', headers: buildHeaders(accessToken), }) if (!response.ok) { const errorText = await response.text().catch(() => '') logger.error('Failed to list Rootly incidents', { status: response.status, error: errorText.slice(0, 500), }) throw new Error(`Failed to list Rootly incidents: ${response.status}`) } const body = (await response.json()) as RootlyListResponse const incidents = body.data ?? [] const allDocuments: ExternalDocument[] = [] for (const incident of incidents) { const stub = incidentToStub(incident) if (stub) allDocuments.push(stub) } const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0 let documents = allDocuments if (maxIncidents > 0) { const remaining = Math.max(0, maxIncidents - prevFetched) if (allDocuments.length > remaining) { documents = allDocuments.slice(0, remaining) } } const totalFetched = prevFetched + documents.length if (syncContext) syncContext.totalDocsFetched = totalFetched const hitLimit = maxIncidents > 0 && totalFetched >= maxIncidents if (hitLimit && syncContext) syncContext.listingCapped = true const hasNextLink = Boolean(body.links?.next) const hasMore = !hitLimit && hasNextLink && incidents.length > 0 return { documents, nextCursor: hasMore ? String(startPage + 1) : undefined, hasMore, } }, getDocument: async ( accessToken: string, _sourceConfig: Record, externalId: string ): Promise => { try { if (!externalId) return null const url = `${ROOTLY_API_BASE}/incidents/${encodeURIComponent(externalId)}?include=${encodeURIComponent(INCIDENT_INCLUDE)}` const response = await fetchWithRetry(url, { method: 'GET', headers: buildHeaders(accessToken), }) if (!response.ok) { if (response.status === 404 || response.status === 410) return null throw new Error(`Failed to fetch Rootly incident: ${response.status}`) } const body = (await response.json()) as RootlyResourceResponse const resource = body.data const attrs = resource?.attributes const id = resource?.id if (!id || !attrs) return null const events = await fetchTimelineEvents(accessToken, id) const content = formatIncidentContent(attrs, events) if (!content.trim()) { logger.info('Skipping Rootly incident with no indexable content', { externalId: id }) return null } const metadata = buildMetadata(attrs) return { externalId: id, title: attrs.title?.trim() || `Incident ${id}`, content, contentDeferred: false, mimeType: 'text/plain', sourceUrl: buildSourceUrl(attrs), contentHash: buildContentHash(id, attrs.updated_at), metadata: { ...metadata }, } } catch (error) { logger.warn('Failed to get Rootly incident', { externalId, error: toError(error).message, }) return null } }, validateConfig: async ( accessToken: string, sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const maxIncidents = sourceConfig.maxIncidents as string | undefined if (maxIncidents && (Number.isNaN(Number(maxIncidents)) || Number(maxIncidents) < 0)) { return { valid: false, error: 'Max incidents must be a non-negative number' } } try { const response = await fetchWithRetry( `${ROOTLY_API_BASE}/incidents?page[size]=1`, { method: 'GET', headers: buildHeaders(accessToken), }, VALIDATE_RETRY_OPTIONS ) if (!response.ok) { const errorText = await response.text().catch(() => '') return { valid: false, error: `Rootly access failed: ${response.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`, } } return { valid: true } } catch (error) { const message = getErrorMessage(error, 'Failed to validate configuration') return { valid: false, error: message } } }, mapTags: (metadata: Record): Record => { const result: Record = {} if (typeof metadata.status === 'string' && metadata.status.trim()) { result.status = metadata.status } const severity = (typeof metadata.severityName === 'string' && metadata.severityName.trim() ? metadata.severityName : undefined) ?? (typeof metadata.severityLevel === 'string' && metadata.severityLevel.trim() ? metadata.severityLevel : undefined) if (severity) result.severity = severity if (typeof metadata.kind === 'string' && metadata.kind.trim()) { result.kind = metadata.kind } const services = joinTagArray(metadata.services) if (services) result.services = services const teams = joinTagArray(metadata.teams) if (teams) result.teams = teams const environments = joinTagArray(metadata.environments) if (environments) result.environments = environments const labels = joinTagArray(metadata.labels) if (labels) result.labels = labels const incidentDate = parseTagDate(metadata.incidentDate) if (incidentDate) result.incidentDate = incidentDate const resolvedDate = parseTagDate(metadata.resolvedDate) if (resolvedDate) result.resolvedDate = resolvedDate return result }, }