Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

579 lines
18 KiB
TypeScript

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<string, string>
}
/** 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<T> {
data?: T[]
links?: {
next?: string | null
}
meta?: {
total_count?: number
}
}
interface RootlyResourceResponse<T> {
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<string, string> {
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<string, string> | 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<RootlyEventAttributes[]> {
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<RootlyEventResource>
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<string, unknown>): 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<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>,
lastSyncAt?: Date
): Promise<ExternalDocumentList> => {
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<RootlyIncidentResource>
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<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
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<RootlyIncidentResource>
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<string, unknown>
): 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<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
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
},
}