diff --git a/docs/components/Rootly.mdx b/docs/components/Rootly.mdx index 4e24d366c7..4fa688ef5c 100644 --- a/docs/components/Rootly.mdx +++ b/docs/components/Rootly.mdx @@ -10,6 +10,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + ## Actions @@ -71,6 +72,87 @@ This trigger automatically sets up a Rootly webhook endpoint when configured. Th } ``` + + +## On Incident Timeline Event + +The On Incident Timeline Event trigger starts a workflow execution when Rootly incident timeline events are created or updated. +Only events with kind "event" are emitted. + +### Use Cases + +- **Note automation**: Run workflows when investigation notes are added +- **Timeline sync**: Sync incident timeline events to Slack or external systems +- **Annotation tracking**: Track updates to incident annotations +- **Audit logging**: Capture timeline events for compliance or reporting + +### Configuration + +- **Incident Status**: Optional filter by incident status (open, resolved, etc.) +- **Severity**: Optional filter by incident severity +- **Service**: Optional filter by service name +- **Team**: Optional filter by team name +- **Event Source**: Optional filter by event source (web, api, system) +- **Visibility**: Optional filter by event visibility (internal or external) + +### Event Data + +Each incident event includes: +- **id**: Event ID +- **event**: Event content +- **event_raw**: Raw event content +- **event_id**: Webhook event ID +- **event_type**: Event type (incident_event.created or incident_event.updated) +- **kind**: Event kind +- **source**: Event source +- **visibility**: Event visibility +- **occurred_at**: When the event occurred +- **created_at**: When the event was created +- **updated_at**: When the event was last updated +- **issued_at**: When the webhook was issued +- **incident_id**: Incident ID +- **incident**: Incident information + +### Webhook Setup + +This trigger automatically sets up a Rootly webhook endpoint when configured. The endpoint is managed by SuperPlane and will be cleaned up when the trigger is removed. + +### Example Data + +```json +{ + "data": { + "created_at": "2026-02-22T09:46:23.868-08:00", + "event": "Investigation started, will update accordingly", + "event_id": "b3065ca8-69a6-4781-b6b4-94d6f0317ccf", + "event_raw": "Investigation started, will update accordingly", + "event_type": "incident_event.created", + "id": "56f7b488-e3c5-4091-9bb4-cf132007f98c", + "incident": { + "id": "64c39fde-1626-4f78-874e-9db91c0639d3", + "services": [ + "UI - User Profile Block" + ], + "severity": "sev2", + "status": "mitigated", + "teams": [ + "Customer Relations" + ], + "title": "new remake from main" + }, + "incident_id": "64c39fde-1626-4f78-874e-9db91c0639d3", + "issued_at": "2026-02-22T09:46:24.018-08:00", + "kind": "event", + "occurred_at": "2026-02-22T09:46:23.868-08:00", + "source": "web", + "updated_at": "2026-02-22T09:46:23.868-08:00", + "visibility": "internal" + }, + "timestamp": "2026-02-22T17:46:40.603539728Z", + "type": "rootly.onIncidentTimelineEvent" +} +``` + ## Create Event diff --git a/pkg/integrations/rootly/client.go b/pkg/integrations/rootly/client.go index c82789c156..669a3bffc4 100644 --- a/pkg/integrations/rootly/client.go +++ b/pkg/integrations/rootly/client.go @@ -259,19 +259,43 @@ type IncidentEventResponse struct { Data IncidentEventData `json:"data"` } -// severityString extracts the severity slug from the API response. -// Rootly returns severity as a string (slug) or an object with slug/name fields. +// severityString extracts a severity slug/name from Rootly responses. +// Rootly may return severity as a string, a flat object, or a JSON:API nested object. func severityString(v any) string { switch s := v.(type) { case string: + // Severity already provided as a slug/name string. return s case map[string]any: - if slug, ok := s["slug"].(string); ok { - return slug + // Severity provided as a flat object with slug/name fields. + if value := severityFromMap(s); value != "" { + return value } - if name, ok := s["name"].(string); ok { - return name + + // Severity provided as a nested JSON:API object: {data:{attributes:{slug/name}}}. + data, ok := s["data"].(map[string]any) + if !ok { + return "" + } + + attrs, ok := data["attributes"].(map[string]any) + if !ok { + return "" } + + return severityFromMap(attrs) + default: + return "" + } +} + +func severityFromMap(values map[string]any) string { + if slug, ok := values["slug"].(string); ok && slug != "" { + return slug + } + + if name, ok := values["name"].(string); ok && name != "" { + return name } return "" @@ -709,9 +733,11 @@ func (c *Client) UpdateIncident(id string, attrs UpdateIncidentAttributes) (*Inc // WebhookEndpoint represents a Rootly webhook endpoint type WebhookEndpoint struct { - ID string `json:"id"` - URL string `json:"url"` - Secret string `json:"secret"` + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Secret string `json:"secret"` + Events []string `json:"event_types"` } type WebhookEndpointData struct { @@ -721,6 +747,7 @@ type WebhookEndpointData struct { } type WebhookEndpointAttributes struct { + Name string `json:"name"` URL string `json:"url"` Secret string `json:"secret"` EventTypes []string `json:"event_types"` @@ -732,6 +759,10 @@ type WebhookEndpointResponse struct { Data WebhookEndpointData `json:"data"` } +type WebhookEndpointsResponse struct { + Data []WebhookEndpointData `json:"data"` +} + type CreateWebhookEndpointRequest struct { Data CreateWebhookEndpointData `json:"data"` } @@ -749,12 +780,12 @@ type CreateWebhookEndpointAttributes struct { SigningEnabled bool `json:"signing_enabled"` } -func (c *Client) CreateWebhookEndpoint(url string, events []string) (*WebhookEndpoint, error) { +func (c *Client) CreateWebhookEndpoint(name, url string, events []string) (*WebhookEndpoint, error) { request := CreateWebhookEndpointRequest{ Data: CreateWebhookEndpointData{ Type: "webhooks_endpoints", Attributes: CreateWebhookEndpointAttributes{ - Name: "SuperPlane", + Name: name, URL: url, EventTypes: events, Enabled: true, @@ -782,8 +813,75 @@ func (c *Client) CreateWebhookEndpoint(url string, events []string) (*WebhookEnd return &WebhookEndpoint{ ID: response.Data.ID, + Name: response.Data.Attributes.Name, + URL: response.Data.Attributes.URL, + Secret: response.Data.Attributes.Secret, + Events: response.Data.Attributes.EventTypes, + }, nil +} + +func (c *Client) ListWebhookEndpoints() ([]WebhookEndpoint, error) { + apiURL := fmt.Sprintf("%s/webhooks/endpoints", c.BaseURL) + responseBody, err := c.execRequest(http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + + var response WebhookEndpointsResponse + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + endpoints := make([]WebhookEndpoint, 0, len(response.Data)) + for _, data := range response.Data { + endpoints = append(endpoints, WebhookEndpoint{ + ID: data.ID, + Name: data.Attributes.Name, + URL: data.Attributes.URL, + Secret: data.Attributes.Secret, + Events: data.Attributes.EventTypes, + }) + } + + return endpoints, nil +} + +func (c *Client) UpdateWebhookEndpoint(id, name, url string, events []string, enabled bool) (*WebhookEndpoint, error) { + request := CreateWebhookEndpointRequest{ + Data: CreateWebhookEndpointData{ + Type: "webhooks_endpoints", + Attributes: CreateWebhookEndpointAttributes{ + Name: name, + URL: url, + EventTypes: events, + Enabled: enabled, + SigningEnabled: true, + }, + }, + } + + body, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %v", err) + } + + apiURL := fmt.Sprintf("%s/webhooks/endpoints/%s", c.BaseURL, id) + responseBody, err := c.execRequest(http.MethodPut, apiURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + var response WebhookEndpointResponse + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return &WebhookEndpoint{ + ID: response.Data.ID, + Name: response.Data.Attributes.Name, URL: response.Data.Attributes.URL, Secret: response.Data.Attributes.Secret, + Events: response.Data.Attributes.EventTypes, }, nil } diff --git a/pkg/integrations/rootly/example.go b/pkg/integrations/rootly/example.go index 6798eda8db..e8a802f7cf 100644 --- a/pkg/integrations/rootly/example.go +++ b/pkg/integrations/rootly/example.go @@ -31,6 +31,12 @@ var exampleDataOnIncidentBytes []byte var exampleDataOnIncidentOnce sync.Once var exampleDataOnIncident map[string]any +//go:embed example_data_on_incident_timeline_event.json +var exampleDataOnIncidentTimelineEventBytes []byte + +var exampleDataOnIncidentTimelineEventOnce sync.Once +var exampleDataOnIncidentTimelineEvent map[string]any + func (c *CreateIncident) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateIncidentOnce, exampleOutputCreateIncidentBytes, &exampleOutputCreateIncident) } @@ -56,3 +62,7 @@ var exampleOutputGetIncident map[string]any func (c *GetIncident) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputGetIncidentOnce, exampleOutputGetIncidentBytes, &exampleOutputGetIncident) } + +func (t *OnIncidentTimelineEvent) ExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleDataOnIncidentTimelineEventOnce, exampleDataOnIncidentTimelineEventBytes, &exampleDataOnIncidentTimelineEvent) +} diff --git a/pkg/integrations/rootly/example_data_on_incident_timeline_event.json b/pkg/integrations/rootly/example_data_on_incident_timeline_event.json new file mode 100644 index 0000000000..e0356c5e81 --- /dev/null +++ b/pkg/integrations/rootly/example_data_on_incident_timeline_event.json @@ -0,0 +1,31 @@ +{ + "data": { + "created_at": "2026-02-22T09:46:23.868-08:00", + "event": "Investigation started, will update accordingly", + "event_id": "b3065ca8-69a6-4781-b6b4-94d6f0317ccf", + "event_raw": "Investigation started, will update accordingly", + "event_type": "incident_event.created", + "id": "56f7b488-e3c5-4091-9bb4-cf132007f98c", + "incident": { + "id": "64c39fde-1626-4f78-874e-9db91c0639d3", + "services": [ + "UI - User Profile Block" + ], + "severity": "sev2", + "status": "mitigated", + "teams": [ + "Customer Relations" + ], + "title": "new remake from main" + }, + "incident_id": "64c39fde-1626-4f78-874e-9db91c0639d3", + "issued_at": "2026-02-22T09:46:24.018-08:00", + "kind": "event", + "occurred_at": "2026-02-22T09:46:23.868-08:00", + "source": "web", + "updated_at": "2026-02-22T09:46:23.868-08:00", + "visibility": "internal" + }, + "timestamp": "2026-02-22T17:46:40.603539728Z", + "type": "rootly.onIncidentTimelineEvent" +} diff --git a/pkg/integrations/rootly/on_incident_timeline_event.go b/pkg/integrations/rootly/on_incident_timeline_event.go new file mode 100644 index 0000000000..5fda2c4135 --- /dev/null +++ b/pkg/integrations/rootly/on_incident_timeline_event.go @@ -0,0 +1,583 @@ +package rootly + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const rootlyIncidentTimelineEventPayloadType = "rootly.onIncidentTimelineEvent" + +var rootlyIncidentTimelineEventWebhookTypes = []string{ + "incident_event.created", + "incident_event.updated", +} + +type OnIncidentTimelineEvent struct{} + +type OnIncidentTimelineEventConfiguration struct { + IncidentStatus []string `json:"incidentStatus" mapstructure:"incidentStatus"` + Severity []string `json:"severity" mapstructure:"severity"` + Service []string `json:"service" mapstructure:"service"` + Team []string `json:"team" mapstructure:"team"` + EventSource []string `json:"eventSource" mapstructure:"eventSource"` + Visibility string `json:"visibility" mapstructure:"visibility"` +} + +type OnIncidentTimelineEventMetadata struct { + EventStates map[string]string `json:"eventStates"` +} + +func (t *OnIncidentTimelineEvent) Name() string { + return "rootly.onIncidentTimelineEvent" +} + +func (t *OnIncidentTimelineEvent) Label() string { + return "On Incident Timeline Event" +} + +func (t *OnIncidentTimelineEvent) Description() string { + return "Listen to incident timeline events" +} + +func (t *OnIncidentTimelineEvent) Documentation() string { + return `The On Incident Timeline Event trigger starts a workflow execution when Rootly incident timeline events are created or updated. +Only events with kind "event" are emitted. + +## Use Cases + +- **Note automation**: Run workflows when investigation notes are added +- **Timeline sync**: Sync incident timeline events to Slack or external systems +- **Annotation tracking**: Track updates to incident annotations +- **Audit logging**: Capture timeline events for compliance or reporting + +## Configuration + +- **Incident Status**: Optional filter by incident status (open, resolved, etc.) +- **Severity**: Optional filter by incident severity +- **Service**: Optional filter by service name +- **Team**: Optional filter by team name +- **Event Source**: Optional filter by event source (web, api, system) +- **Visibility**: Optional filter by event visibility (internal or external) + +## Event Data + +Each incident event includes: +- **id**: Event ID +- **event**: Event content +- **event_raw**: Raw event content +- **event_id**: Webhook event ID +- **event_type**: Event type (incident_event.created or incident_event.updated) +- **kind**: Event kind +- **source**: Event source +- **visibility**: Event visibility +- **occurred_at**: When the event occurred +- **created_at**: When the event was created +- **updated_at**: When the event was last updated +- **issued_at**: When the webhook was issued +- **incident_id**: Incident ID +- **incident**: Incident information + +## Webhook Setup + +This trigger automatically sets up a Rootly webhook endpoint when configured. The endpoint is managed by SuperPlane and will be cleaned up when the trigger is removed.` +} + +func (t *OnIncidentTimelineEvent) Icon() string { + return "message-square" +} + +func (t *OnIncidentTimelineEvent) Color() string { + return "gray" +} + +func (t *OnIncidentTimelineEvent) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "incidentStatus", + Label: "Incident Status", + Type: configuration.FieldTypeMultiSelect, + Required: false, + Togglable: true, + Description: "Only emit events for incidents with this status", + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "In Triage", Value: "in_triage"}, + {Label: "Started", Value: "started"}, + {Label: "Detected", Value: "detected"}, + {Label: "Acknowledged", Value: "acknowledged"}, + {Label: "Mitigated", Value: "mitigated"}, + {Label: "Resolved", Value: "resolved"}, + {Label: "Closed", Value: "closed"}, + {Label: "Cancelled", Value: "cancelled"}, + }, + }, + }, + }, + { + Name: "severity", + Label: "Severity", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Togglable: true, + Description: "Only emit events for incidents with this severity", + Placeholder: "Select a severity", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "severity", + UseNameAsValue: true, + Multi: true, + }, + }, + }, + { + Name: "service", + Label: "Service", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Togglable: true, + Description: "Only emit events for incidents impacting this service", + Placeholder: "Select a service", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "service", + UseNameAsValue: true, + Multi: true, + }, + }, + }, + { + Name: "team", + Label: "Team", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Togglable: true, + Description: "Only emit events for incidents owned by this team", + Placeholder: "Select a team", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "team", + UseNameAsValue: true, + Multi: true, + }, + }, + }, + { + Name: "eventSource", + Label: "Event Source", + Type: configuration.FieldTypeMultiSelect, + Required: false, + Togglable: true, + Description: "Only emit events from these sources", + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Web", Value: "web"}, + {Label: "API", Value: "api"}, + {Label: "System", Value: "system"}, + }, + }, + }, + }, + { + Name: "visibility", + Label: "Visibility", + Type: configuration.FieldTypeSelect, + Required: false, + Togglable: true, + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Internal", Value: "internal"}, + {Label: "External", Value: "external"}, + }, + }, + }, + Description: "Only emit events with this visibility", + }, + } +} + +func (t *OnIncidentTimelineEvent) Setup(ctx core.TriggerContext) error { + return ctx.Integration.RequestWebhook(WebhookConfiguration{ + Events: rootlyIncidentTimelineEventWebhookTypes, + }) +} + +func (t *OnIncidentTimelineEvent) Actions() []core.Action { + return []core.Action{} +} + +func (t *OnIncidentTimelineEvent) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +func (t *OnIncidentTimelineEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + config := OnIncidentTimelineEventConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) + } + + signature := ctx.Headers.Get("X-Rootly-Signature") + secret, err := ctx.Webhook.GetSecret() + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("error getting secret: %v", err) + } + + if err := verifyWebhookSignature(signature, ctx.Body, secret); err != nil { + return http.StatusForbidden, fmt.Errorf("invalid signature: %v", err) + } + + var webhook WebhookPayload + if err := json.Unmarshal(ctx.Body, &webhook); err != nil { + return http.StatusBadRequest, fmt.Errorf("error parsing request body: %v", err) + } + + if !slices.Contains(rootlyIncidentTimelineEventWebhookTypes, webhook.Event.Type) { + return http.StatusOK, nil + } + + data := webhook.Data + if data == nil { + return http.StatusOK, nil + } + + incidentEvent := extractEventFromData(data) + if incidentEvent == nil { + return http.StatusOK, nil + } + + // Apply event-level filters directly from the webhook payload. + if !matchesEventFilter(config.EventSource, extractString(incidentEvent, "source")) { + return http.StatusOK, nil + } + + if config.Visibility != "" && !strings.EqualFold(config.Visibility, extractString(incidentEvent, "visibility")) { + return http.StatusOK, nil + } + + incidentID := extractString(incidentEvent, "incident_id", "incidentId") + + // Only process events with kind "event" to avoid emitting for non-timeline events like "trail/start/close". + if !strings.EqualFold(extractString(incidentEvent, "kind"), "event") { + return http.StatusOK, nil + } + + incidentFiltersEnabled := len(config.IncidentStatus) > 0 || len(config.Severity) > 0 || len(config.Service) > 0 || len(config.Team) > 0 + var incidentDetails map[string]any + if incidentID != "" && ctx.HTTP != nil && ctx.Integration != nil { + // Fetch incident details to apply status/severity/service/team filters + // and enrich the emitted payload with incident context. + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("error creating client: %v", err) + } + + incidentDetails, err = client.GetIncidentDetailed(incidentID) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("error fetching incident: %v", err) + } + } + + if incidentFiltersEnabled { + if incidentID == "" { + return http.StatusOK, nil + } + + if incidentDetails == nil { + return http.StatusInternalServerError, fmt.Errorf("incident details unavailable for filtering") + } + + if !matchesIncidentFilters(incidentDetails, config) { + return http.StatusOK, nil + } + } + + metadata := loadOnIncidentTimelineEventMetadata(ctx.Metadata) + updatedStates := map[string]string{} + for key, value := range metadata.EventStates { + updatedStates[key] = value + } + + emitted := 0 + eventID := extractString(incidentEvent, "id") + fingerprint := eventFingerprint(incidentEvent) + + if eventID != "" { + updatedStates[eventID] = fingerprint + } + + if eventID != "" { + if previous, exists := metadata.EventStates[eventID]; exists && previous == fingerprint { + return http.StatusOK, nil + } + } + + payload := buildIncidentTimelineEventPayload(incidentDetails, incidentEvent, webhook.Event) + if err := ctx.Events.Emit(rootlyIncidentTimelineEventPayloadType, payload); err != nil { + return http.StatusInternalServerError, fmt.Errorf("error emitting event: %v", err) + } + emitted++ + + if ctx.Metadata != nil { + if err := ctx.Metadata.Set(OnIncidentTimelineEventMetadata{EventStates: updatedStates}); err != nil { + return http.StatusInternalServerError, fmt.Errorf("error updating metadata: %v", err) + } + } + + if emitted == 0 { + return http.StatusOK, nil + } + + return http.StatusOK, nil +} + +func (t *OnIncidentTimelineEvent) Cleanup(ctx core.TriggerContext) error { + return nil +} + +// loadOnIncidentTimelineEventMetadata pulls persisted state for deduping repeated webhook deliveries. +func loadOnIncidentTimelineEventMetadata(ctx core.MetadataContext) OnIncidentTimelineEventMetadata { + if ctx == nil { + return OnIncidentTimelineEventMetadata{EventStates: map[string]string{}} + } + + metadata := OnIncidentTimelineEventMetadata{} + if err := mapstructure.Decode(ctx.Get(), &metadata); err != nil || metadata.EventStates == nil { + return OnIncidentTimelineEventMetadata{EventStates: map[string]string{}} + } + + return metadata +} + +// extractEventFromData normalizes either top-level or nested incident_event payloads. +func extractEventFromData(data map[string]any) map[string]any { + if event, ok := data["incident_event"].(map[string]any); ok { + if isIncidentEventPayload(event) { + return event + } + } + + if isIncidentEventPayload(data) { + return data + } + + return nil +} + +// buildIncidentTimelineEventPayload keeps the Rootly event shape and augments with incident context. +func buildIncidentTimelineEventPayload(incident map[string]any, incidentEvent map[string]any, webhookEvent WebhookEvent) map[string]any { + incidentPayload := buildIncidentSummaryPayload(incident, incidentEvent) + payload := map[string]any{ + "id": extractString(incidentEvent, "id"), + "event": extractString(incidentEvent, "event"), + "event_raw": extractString(incidentEvent, "event_raw"), + "kind": extractString(incidentEvent, "kind"), + "source": extractString(incidentEvent, "source"), + "visibility": extractString(incidentEvent, "visibility"), + "occurred_at": extractString(incidentEvent, "occurred_at"), + "created_at": extractString(incidentEvent, "created_at"), + "updated_at": extractString(incidentEvent, "updated_at"), + "incident_id": extractString(incidentEvent, "incident_id", "incidentId"), + "event_id": webhookEvent.ID, + "event_type": webhookEvent.Type, + "issued_at": webhookEvent.IssuedAt, + "incident": incidentPayload, + } + + return payload +} + +// buildIncidentSummaryPayload emits a compact incident stub for filtering context. +func buildIncidentSummaryPayload(incident map[string]any, incidentEvent map[string]any) map[string]any { + incidentID := "" + if incident != nil { + incidentID = extractString(incident, "id") + } + if incidentID == "" { + incidentID = extractString(incidentEvent, "incident_id", "incidentId") + } + + if incidentID == "" && incident == nil { + return nil + } + + payload := map[string]any{ + "id": incidentID, + } + + if incident == nil { + return payload + } + + if title := extractString(incident, "title"); title != "" { + payload["title"] = title + } + if status := extractString(incident, "status", "state"); status != "" { + payload["status"] = status + } + if severity := severityString(incident["severity"]); severity != "" { + payload["severity"] = severity + } + if services := extractResourceNames(incident, "services"); len(services) > 0 { + payload["services"] = services + } + if teams := extractResourceNames(incident, "groups"); len(teams) > 0 { + payload["teams"] = teams + } + + return payload +} + +func extractString(data map[string]any, keys ...string) string { + for _, key := range keys { + value, ok := data[key] + if !ok || value == nil { + continue + } + + str, ok := value.(string) + if ok && str != "" { + return str + } + } + + return "" +} + +func matchesEventFilter(filters []string, value string) bool { + if len(filters) == 0 { + return true + } + + return slices.ContainsFunc(filters, func(filter string) bool { + return strings.EqualFold(filter, value) + }) +} + +// eventFingerprint helps avoid double-processing when Rootly retries a webhook. +func eventFingerprint(event map[string]any) string { + if value := extractString(event, "updated_at"); value != "" { + return value + } + + if value := extractString(event, "created_at"); value != "" { + return value + } + + if value := extractString(event, "occurred_at"); value != "" { + return value + } + + raw, err := json.Marshal(event) + if err != nil { + return "" + } + + return string(raw) +} + +// isIncidentEventPayload checks for expected incident event fields. +func isIncidentEventPayload(data map[string]any) bool { + if data == nil { + return false + } + + if extractString(data, "event", "kind") != "" { + return true + } + + if extractString(data, "occurred_at", "created_at") != "" { + return true + } + + return false +} + +// matchesIncidentFilters applies incident-level filters from fetched incident details. +func matchesIncidentFilters(incident map[string]any, config OnIncidentTimelineEventConfiguration) bool { + if len(config.IncidentStatus) > 0 { + status := extractString(incident, "status", "state") + if !matchesEventFilter(config.IncidentStatus, status) { + return false + } + } + + if len(config.Severity) > 0 { + severity := severityString(incident["severity"]) + if severity == "" { + severity = extractString(incident, "severity") + } + if !matchesEventFilter(config.Severity, severity) { + return false + } + } + + if len(config.Service) > 0 { + services := extractResourceNames(incident, "services") + if !matchesAnyResource(services, config.Service) { + return false + } + } + + if len(config.Team) > 0 { + teams := extractResourceNames(incident, "groups") + if !matchesAnyResource(teams, config.Team) { + return false + } + } + + return true +} + +// extractResourceNames pulls service/team names from varied response shapes. +func extractResourceNames(incident map[string]any, key string) []string { + raw, ok := incident[key] + if !ok || raw == nil { + return nil + } + + switch items := raw.(type) { + case []any: + names := make([]string, 0, len(items)) + for _, item := range items { + resource, ok := item.(map[string]any) + if !ok { + continue + } + if name := extractString(resource, "name", "slug"); name != "" { + names = append(names, name) + } + } + return names + case []map[string]any: + names := make([]string, 0, len(items)) + for _, resource := range items { + if name := extractString(resource, "name", "slug"); name != "" { + names = append(names, name) + } + } + return names + case map[string]any: + if name := extractString(items, "name", "slug"); name != "" { + return []string{name} + } + } + + return nil +} + +func matchesAnyResource(resources []string, filters []string) bool { + return slices.ContainsFunc(filters, func(filter string) bool { + return slices.ContainsFunc(resources, func(resource string) bool { + return strings.EqualFold(resource, filter) + }) + }) +} diff --git a/pkg/integrations/rootly/on_incident_timeline_event_test.go b/pkg/integrations/rootly/on_incident_timeline_event_test.go new file mode 100644 index 0000000000..9e2ed3804e --- /dev/null +++ b/pkg/integrations/rootly/on_incident_timeline_event_test.go @@ -0,0 +1,313 @@ +package rootly + +import ( + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__OnIncidentTimelineEvent__HandleWebhook(t *testing.T) { + trigger := &OnIncidentTimelineEvent{} + + signatureFor := func(secret string, timestamp string, body []byte) string { + payload := append([]byte(timestamp), body...) + sig := computeHMACSHA256([]byte(secret), payload) + return "t=" + timestamp + ",v1=" + sig + } + + baseConfig := map[string]any{} + + t.Run("missing X-Rootly-Signature -> 403", func(t *testing.T) { + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: http.Header{}, + Configuration: baseConfig, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + Events: &contexts.EventContext{}, + }) + + assert.Equal(t, http.StatusForbidden, code) + assert.ErrorContains(t, err, "invalid signature") + }) + + t.Run("invalid JSON body -> 400", func(t *testing.T) { + body := []byte("invalid json") + secret := "test-secret" + timestamp := "1234567890" + + headers := http.Header{} + headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body)) + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: baseConfig, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: &contexts.EventContext{}, + }) + + assert.Equal(t, http.StatusBadRequest, code) + assert.ErrorContains(t, err, "error parsing request body") + }) + + t.Run("event type not configured -> no emit", func(t *testing.T) { + body := []byte(`{"event":{"type":"incident.created","id":"evt-123","issued_at":"2026-02-10T15:34:36Z"},"data":{"id":"6bbb5dac-b5a4-4569-a398-0897f683cc54","event":"another try","kind":"event","visibility":"internal","occurred_at":"2026-02-10T15:34:29Z","created_at":"2026-02-10T15:34:29Z","incident_id":"inc-123"}}`) + secret := "test-secret" + timestamp := "1234567890" + + headers := http.Header{} + headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body)) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: baseConfig, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + Metadata: &contexts.MetadataContext{}, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 0, eventContext.Count()) + }) + + t.Run("incident_event.updated emits when metadata empty", func(t *testing.T) { + body := []byte(`{"event":{"type":"incident_event.updated","id":"evt-123","issued_at":"2026-02-10T15:34:36Z"},"data":{"id":"ev-1","event":"Initial note","kind":"event","visibility":"internal","occurred_at":"2026-02-10T15:00:00Z","created_at":"2026-02-10T15:00:01Z","incident_id":"inc-123"}}`) + secret := "test-secret" + timestamp := "1234567890" + + headers := http.Header{} + headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body)) + + metadata := &contexts.MetadataContext{} + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: baseConfig, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + Metadata: metadata, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, eventContext.Count()) + }) + + t.Run("incident_event.updated dedupes unchanged event", func(t *testing.T) { + body := []byte(`{"event":{"type":"incident_event.updated","id":"evt-124","issued_at":"2026-02-10T15:40:00Z"},"data":{"id":"ev-1","event":"Initial note","kind":"event","visibility":"internal","occurred_at":"2026-02-10T15:00:00Z","created_at":"2026-02-10T15:00:01Z","updated_at":"2026-02-10T15:00:02Z","incident_id":"inc-123"}}`) + secret := "test-secret" + timestamp := "1234567890" + + headers := http.Header{} + headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body)) + + metadata := &contexts.MetadataContext{Metadata: OnIncidentTimelineEventMetadata{EventStates: map[string]string{"ev-1": "2026-02-10T15:00:02Z"}}} + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: baseConfig, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + Metadata: metadata, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 0, eventContext.Count()) + }) + + t.Run("visibility filter -> skips non-matching events", func(t *testing.T) { + body := []byte(`{"event":{"type":"incident_event.updated","id":"evt-125","issued_at":"2026-02-10T15:40:00Z"},"data":{"id":"ev-2","event":"External update","kind":"event","visibility":"external","occurred_at":"2026-02-10T15:10:00Z","created_at":"2026-02-10T15:10:01Z","incident_id":"inc-123"}}`) + secret := "test-secret" + timestamp := "1234567890" + + headers := http.Header{} + headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body)) + + metadata := &contexts.MetadataContext{Metadata: OnIncidentTimelineEventMetadata{EventStates: map[string]string{"ev-2": "2026-02-10T15:10:01Z"}}} + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: map[string]any{"visibility": "internal"}, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + Metadata: metadata, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + assert.Equal(t, 0, eventContext.Count()) + }) + + t.Run("incident_event.created -> emits even without baseline", func(t *testing.T) { + body := []byte(`{"event":{"type":"incident_event.created","id":"evt-200","issued_at":"2026-02-10T16:00:00Z"},"data":{"id":"ev-200","event":"Failover initiated","kind":"event","visibility":"internal","occurred_at":"2026-02-10T16:00:00Z","created_at":"2026-02-10T16:00:01Z","incident_id":"inc-200"}}`) + secret := "test-secret" + timestamp := "1234567890" + + headers := http.Header{} + headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body)) + + metadata := &contexts.MetadataContext{} + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: baseConfig, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + Metadata: metadata, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, eventContext.Count()) + }) + + t.Run("incident_event.created flat payload -> emits", func(t *testing.T) { + body := []byte(`{"event":{"id":"d390b7e6-2f80-4d71-92e3-5d19dc7f9c68","type":"incident_event.created","issued_at":"2026-02-17T05:54:29.581-08:00"},"data":{"id":"6bbb5dac-b5a4-4569-a398-0897f683cc54","event":"another try","event_raw":"another try","kind":"event","source":"web","visibility":"internal","occurred_at":"2026-02-17T05:54:29.522-08:00","created_at":"2026-02-17T05:54:29.522-08:00","updated_at":"2026-02-17T05:54:29.522-08:00","incident_id":"5dcbfe70-0416-469a-8629-be5353f4fd60"}}`) + secret := "test-secret" + timestamp := "1234567890" + + headers := http.Header{} + headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body)) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: baseConfig, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + Metadata: &contexts.MetadataContext{}, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, eventContext.Count()) + + payload := eventContext.Payloads[0].Data.(map[string]any) + assert.Equal(t, "another try", payload["event"]) + assert.Equal(t, "another try", payload["event_raw"]) + assert.Equal(t, "d390b7e6-2f80-4d71-92e3-5d19dc7f9c68", payload["event_id"]) + assert.Equal(t, "incident_event.created", payload["event_type"]) + assert.Equal(t, "2026-02-17T05:54:29.581-08:00", payload["issued_at"]) + assert.Equal(t, "5dcbfe70-0416-469a-8629-be5353f4fd60", payload["incident_id"]) + assert.Equal(t, "2026-02-17T05:54:29.522-08:00", payload["updated_at"]) + incident := payload["incident"].(map[string]any) + assert.Equal(t, "5dcbfe70-0416-469a-8629-be5353f4fd60", incident["id"]) + }) + + t.Run("non-event kind -> no emit", func(t *testing.T) { + body := []byte(`{"event":{"type":"incident_event.updated","id":"evt-300","issued_at":"2026-02-10T16:10:00Z"},"data":{"id":"ev-300","event":"Note update","kind":"trail","visibility":"internal","occurred_at":"2026-02-10T16:10:00Z","created_at":"2026-02-10T16:10:01Z","incident_id":"inc-300"}}`) + secret := "test-secret" + timestamp := "1234567890" + + headers := http.Header{} + headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body)) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: baseConfig, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + Metadata: &contexts.MetadataContext{}, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 0, eventContext.Count()) + }) + + t.Run("incident filters -> fetches incident and emits", func(t *testing.T) { + now := time.Now().UTC().Format(time.RFC3339) + body := []byte(`{"event":{"type":"incident_event.updated","id":"evt-200","issued_at":"` + now + `"},"data":{"id":"ev-200","event":"Status update","kind":"event","source":"web","visibility":"internal","occurred_at":"` + now + `","created_at":"` + now + `","incident_id":"inc-200"}}`) + secret := "test-secret" + timestamp := "1234567890" + + headers := http.Header{} + headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body)) + + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "data": { + "id": "inc-200", + "attributes": { + "status": "resolved", + "severity": "sev2" + }, + "relationships": { + "services": {"data": [{"type": "services", "id": "svc-1"}]}, + "groups": {"data": [{"type": "groups", "id": "team-1"}]} + } + }, + "included": [ + {"id": "svc-1", "type": "services", "attributes": {"name": "API"}}, + {"id": "team-1", "type": "groups", "attributes": {"name": "Platform"}} + ] +}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "test-key"}, + } + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: map[string]any{"incidentStatus": []string{"resolved"}, "severity": []string{"sev2"}, "service": []string{"API"}, "team": []string{"Platform"}}, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + Metadata: &contexts.MetadataContext{}, + HTTP: httpCtx, + Integration: integrationCtx, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, eventContext.Count()) + require.Len(t, httpCtx.Requests, 1) + + payload := eventContext.Payloads[0].Data.(map[string]any) + incident := payload["incident"].(map[string]any) + assert.Equal(t, "inc-200", incident["id"]) + assert.Equal(t, "resolved", incident["status"]) + assert.Equal(t, "sev2", incident["severity"]) + assert.Equal(t, []string{"API"}, incident["services"]) + assert.Equal(t, []string{"Platform"}, incident["teams"]) + }) +} + +func Test__OnIncidentTimelineEvent__Setup(t *testing.T) { + trigger := &OnIncidentTimelineEvent{} + + integrationCtx := &contexts.IntegrationContext{} + err := trigger.Setup(core.TriggerContext{ + Integration: integrationCtx, + }) + + require.NoError(t, err) + require.Len(t, integrationCtx.WebhookRequests, 1) + + webhookConfig := integrationCtx.WebhookRequests[0].(WebhookConfiguration) + assert.Equal(t, []string{"incident_event.created", "incident_event.updated"}, webhookConfig.Events) +} diff --git a/pkg/integrations/rootly/rootly.go b/pkg/integrations/rootly/rootly.go index 34790b4083..c1282b46c9 100644 --- a/pkg/integrations/rootly/rootly.go +++ b/pkg/integrations/rootly/rootly.go @@ -71,6 +71,7 @@ func (r *Rootly) Components() []core.Component { func (r *Rootly) Triggers() []core.Trigger { return []core.Trigger{ &OnIncident{}, + &OnIncidentTimelineEvent{}, } } diff --git a/pkg/integrations/rootly/webhook_handler.go b/pkg/integrations/rootly/webhook_handler.go index 68833d9717..9771769126 100644 --- a/pkg/integrations/rootly/webhook_handler.go +++ b/pkg/integrations/rootly/webhook_handler.go @@ -1,8 +1,10 @@ package rootly import ( + "crypto/sha256" "fmt" "slices" + "strings" "github.com/mitchellh/mapstructure" "github.com/superplanehq/superplane/pkg/core" @@ -18,6 +20,8 @@ type WebhookMetadata struct { type RootlyWebhookHandler struct{} +const rootlyLegacyWebhookName = "SuperPlane" + func (h *RootlyWebhookHandler) CompareConfig(a, b any) (bool, error) { configA := WebhookConfiguration{} configB := WebhookConfiguration{} @@ -43,7 +47,29 @@ func (h *RootlyWebhookHandler) CompareConfig(a, b any) (bool, error) { } func (h *RootlyWebhookHandler) Merge(current, requested any) (any, bool, error) { - return current, false, nil + currentConfig := WebhookConfiguration{} + requestedConfig := WebhookConfiguration{} + + if err := mapstructure.Decode(current, ¤tConfig); err != nil { + return nil, false, err + } + + if err := mapstructure.Decode(requested, &requestedConfig); err != nil { + return nil, false, err + } + + merged := WebhookConfiguration{ + Events: append([]string{}, currentConfig.Events...), + } + + for _, event := range requestedConfig.Events { + if !slices.Contains(merged.Events, event) { + merged.Events = append(merged.Events, event) + } + } + + changed := len(merged.Events) != len(currentConfig.Events) + return merged, changed, nil } func (h *RootlyWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { @@ -58,14 +84,17 @@ func (h *RootlyWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error return nil, fmt.Errorf("error decoding webhook configuration: %v", err) } - endpoint, err := client.CreateWebhookEndpoint(ctx.Webhook.GetURL(), config.Events) + webhookURL := ctx.Webhook.GetURL() + endpoint, err := h.findOrCreateWebhookEndpoint(client, ctx.Webhook.GetID(), webhookURL, config.Events) if err != nil { - return nil, fmt.Errorf("error creating webhook endpoint: %v", err) + return nil, err } - err = ctx.Webhook.SetSecret([]byte(endpoint.Secret)) - if err != nil { - return nil, fmt.Errorf("error updating webhook secret: %v", err) + if endpoint.Secret != "" { + err = ctx.Webhook.SetSecret([]byte(endpoint.Secret)) + if err != nil { + return nil, fmt.Errorf("error updating webhook secret: %v", err) + } } return WebhookMetadata{ @@ -80,6 +109,10 @@ func (h *RootlyWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { return fmt.Errorf("error decoding webhook metadata: %v", err) } + if metadata.EndpointID == "" { + return nil + } + client, err := NewClient(ctx.HTTP, ctx.Integration) if err != nil { return err @@ -92,3 +125,101 @@ func (h *RootlyWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { return nil } + +// locates a matching Rootly endpoint for this webhook and updates its event types if needed. +func (h *RootlyWebhookHandler) findOrCreateWebhookEndpoint(client *Client, webhookID, webhookURL string, requestedEvents []string) (*WebhookEndpoint, error) { + webhookName := rootlyWebhookName(webhookID) + endpoints, err := client.ListWebhookEndpoints() + if err == nil { + if endpoint := selectWebhookEndpoint(endpoints, webhookName, webhookURL); endpoint != nil { + mergedEvents := mergeEventTypes(endpoint.Events, requestedEvents) + if slices.Equal(normalizeEventTypes(endpoint.Events), normalizeEventTypes(mergedEvents)) { + return &WebhookEndpoint{ + ID: endpoint.ID, + Name: endpoint.Name, + URL: endpoint.URL, + Secret: "", + Events: endpoint.Events, + }, nil + } + + updated, updateErr := client.UpdateWebhookEndpoint(endpoint.ID, webhookName, endpoint.URL, mergedEvents, true) + if updateErr != nil { + return nil, fmt.Errorf("error updating webhook endpoint: %v", updateErr) + } + return updated, nil + } + } + + endpoint, err := client.CreateWebhookEndpoint(webhookName, webhookURL, requestedEvents) + if err != nil { + return nil, fmt.Errorf("error creating webhook endpoint: %v", err) + } + + return endpoint, nil +} + +// prefers the deterministic name and falls back to matching webhook URLs. +func selectWebhookEndpoint(endpoints []WebhookEndpoint, webhookName, webhookURL string) *WebhookEndpoint { + for _, endpoint := range endpoints { + if endpoint.Name == webhookName { + return &endpoint + } + } + + var urlMatches []WebhookEndpoint + for _, endpoint := range endpoints { + if endpoint.URL == webhookURL { + urlMatches = append(urlMatches, endpoint) + } + } + + if len(urlMatches) == 0 { + return nil + } + + for _, endpoint := range urlMatches { + if endpoint.Name == rootlyLegacyWebhookName { + return &endpoint + } + } + + return &urlMatches[0] +} + +// unions event types and normalizes ordering for stable comparisons. +func mergeEventTypes(current, requested []string) []string { + merged := append([]string{}, current...) + for _, event := range requested { + if !slices.Contains(merged, event) { + merged = append(merged, event) + } + } + + return normalizeEventTypes(merged) +} + +// deduplicates and sorts event types for stable comparisons/updates. +func normalizeEventTypes(events []string) []string { + normalized := make([]string, 0, len(events)) + for _, event := range events { + event = strings.TrimSpace(event) + if event == "" { + continue + } + if !slices.Contains(normalized, event) { + normalized = append(normalized, event) + } + } + + slices.Sort(normalized) + return normalized +} + +// creates a deterministic Rootly webhook name, +func rootlyWebhookName(webhookID string) string { + hash := sha256.New() + hash.Write([]byte(webhookID)) + suffix := fmt.Sprintf("%x", hash.Sum(nil)) + return fmt.Sprintf("SuperPlane-%s", suffix[:12]) +} diff --git a/pkg/integrations/rootly/webhook_handler_test.go b/pkg/integrations/rootly/webhook_handler_test.go index f7eb999fe5..6b4d69e09f 100644 --- a/pkg/integrations/rootly/webhook_handler_test.go +++ b/pkg/integrations/rootly/webhook_handler_test.go @@ -3,6 +3,7 @@ package rootly import ( "testing" + "github.com/mitchellh/mapstructure" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -106,3 +107,72 @@ func Test__RootlyWebhookHandler__CompareConfig(t *testing.T) { }) } } + +func Test__RootlyWebhookHandler__Merge(t *testing.T) { + handler := &RootlyWebhookHandler{} + + current := WebhookConfiguration{ + Events: []string{"incident.created", "incident.updated"}, + } + + requested := WebhookConfiguration{ + Events: []string{"incident_event.created", "incident.updated"}, + } + + merged, changed, err := handler.Merge(current, requested) + require.NoError(t, err) + + result := WebhookConfiguration{} + require.NoError(t, mapstructure.Decode(merged, &result)) + + assert.ElementsMatch(t, []string{"incident.created", "incident.updated", "incident_event.created"}, result.Events) + assert.True(t, changed) +} + +func Test__SelectWebhookEndpoint(t *testing.T) { + webhookID := "whk-123" + targetURL := "https://hooks.superplane.dev/rootly" + deterministicName := rootlyWebhookName(webhookID) + + t.Run("prefers deterministic name match", func(t *testing.T) { + endpoints := []WebhookEndpoint{ + {ID: "1", Name: "SuperPlane", URL: targetURL}, + {ID: "2", Name: deterministicName, URL: "https://other.example.com"}, + } + + selected := selectWebhookEndpoint(endpoints, deterministicName, targetURL) + require.NotNil(t, selected) + assert.Equal(t, "2", selected.ID) + }) + + t.Run("falls back to legacy name on URL match", func(t *testing.T) { + endpoints := []WebhookEndpoint{ + {ID: "legacy", Name: rootlyLegacyWebhookName, URL: targetURL}, + {ID: "other", Name: "Other", URL: targetURL}, + } + + selected := selectWebhookEndpoint(endpoints, deterministicName, targetURL) + require.NotNil(t, selected) + assert.Equal(t, "legacy", selected.ID) + }) + + t.Run("falls back to first URL match when no name matches", func(t *testing.T) { + endpoints := []WebhookEndpoint{ + {ID: "a", Name: "Alpha", URL: targetURL}, + {ID: "b", Name: "Beta", URL: targetURL}, + } + + selected := selectWebhookEndpoint(endpoints, deterministicName, targetURL) + require.NotNil(t, selected) + assert.Equal(t, "a", selected.ID) + }) + + t.Run("returns nil when no URL or name matches", func(t *testing.T) { + endpoints := []WebhookEndpoint{ + {ID: "a", Name: "Alpha", URL: "https://example.com"}, + } + + selected := selectWebhookEndpoint(endpoints, deterministicName, targetURL) + assert.Nil(t, selected) + }) +} diff --git a/web_src/src/pages/workflowv2/mappers/rootly/index.ts b/web_src/src/pages/workflowv2/mappers/rootly/index.ts index 27e8a8e439..4505f189ae 100644 --- a/web_src/src/pages/workflowv2/mappers/rootly/index.ts +++ b/web_src/src/pages/workflowv2/mappers/rootly/index.ts @@ -1,5 +1,6 @@ import { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types"; import { onIncidentTriggerRenderer } from "./on_incident"; +import { onEventTriggerRenderer } from "./on_incident_timeline_event"; import { createIncidentMapper } from "./create_incident"; import { createEventMapper } from "./create_event"; import { updateIncidentMapper } from "./update_incident"; @@ -15,6 +16,7 @@ export const componentMappers: Record = { export const triggerRenderers: Record = { onIncident: onIncidentTriggerRenderer, + onIncidentTimelineEvent: onEventTriggerRenderer, }; export const eventStateRegistry: Record = { diff --git a/web_src/src/pages/workflowv2/mappers/rootly/on_incident_timeline_event.ts b/web_src/src/pages/workflowv2/mappers/rootly/on_incident_timeline_event.ts new file mode 100644 index 0000000000..ab0f158b24 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/rootly/on_incident_timeline_event.ts @@ -0,0 +1,206 @@ +import { getBackgroundColorClass } from "@/utils/colors"; +import { formatTimeAgo } from "@/utils/date"; +import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import { TriggerProps } from "@/ui/trigger"; +import rootlyIcon from "@/assets/icons/integrations/rootly.svg"; +import { IncidentEvent } from "./types"; + +interface IncidentSummary { + id?: string; + title?: string; + status?: string; + severity?: string; + services?: string[]; + teams?: string[]; +} + +interface OnEventEventData extends IncidentEvent { + incident?: IncidentSummary; + event_type?: string; +} + +/** + * Renderer for the "rootly.onIncidentTimelineEvent" trigger type + */ +export const onEventTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const eventData = context.event?.data as OnEventEventData; + const incident = eventData?.incident; + const title = eventData?.event || incident?.title || "Incident event"; + const contentParts = [incident?.title, incident?.severity, incident?.status].filter(Boolean).join(" · "); + const subtitle = buildSubtitle(contentParts, context.event?.createdAt); + + return { + title, + subtitle, + }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as OnEventEventData; + return getDetailsForIncidentEventPayload(eventData); + }, + + getTriggerProps: (context: TriggerRendererContext) => { + const { node, definition, lastEvent } = context; + const configuration = node.configuration as { + incidentStatus?: string[]; + severity?: string[]; + service?: string[]; + team?: string[]; + eventSource?: string[]; + visibility?: string; + }; + const metadataItems = []; + + if (configuration?.incidentStatus?.length) { + metadataItems.push({ + icon: "funnel", + label: `Status: ${configuration.incidentStatus.join(", ")}`, + }); + } + + if (configuration?.severity?.length) { + metadataItems.push({ + icon: "alert-circle", + label: `Severity: ${configuration.severity.join(", ")}`, + }); + } + + if (configuration?.service?.length) { + metadataItems.push({ + icon: "layers", + label: `Service: ${configuration.service.join(", ")}`, + }); + } + + if (configuration?.team?.length) { + metadataItems.push({ + icon: "users", + label: `Team: ${configuration.team.join(", ")}`, + }); + } + + if (configuration?.eventSource?.length) { + metadataItems.push({ + icon: "activity", + label: `Source: ${configuration.eventSource.join(", ")}`, + }); + } + + if (configuration?.visibility) { + metadataItems.push({ + icon: "eye", + label: `Visibility: ${configuration.visibility}`, + }); + } + + const props: TriggerProps = { + title: node.name!, + iconSrc: rootlyIcon, + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const eventData = lastEvent.data as OnEventEventData; + const incident = eventData?.incident; + const title = eventData?.event || incident?.title || "Incident event"; + const contentParts = [eventData?.kind, incident?.severity, incident?.status].filter(Boolean).join(" · "); + const subtitle = buildSubtitle(contentParts, lastEvent.createdAt); + + props.lastEventData = { + title, + subtitle, + receivedAt: new Date(lastEvent.createdAt), + state: "triggered", + eventId: lastEvent.id, + }; + } + + return props; + }, +}; + +function buildSubtitle(content: string, createdAt?: string): string { + const timeAgo = createdAt ? formatTimeAgo(new Date(createdAt)) : ""; + if (content && timeAgo) { + return `${content} · ${timeAgo}`; + } + + return content || timeAgo; +} + +function getDetailsForIncidentEventPayload(eventData?: OnEventEventData): Record { + const details: Record = {}; + + if (!eventData) { + return details; + } + + if (eventData.created_at) { + details["Created At"] = new Date(eventData.created_at).toLocaleString(); + } + + if (eventData.event) { + details.Event = eventData.event; + } + + if (eventData.id) { + details["Event ID"] = eventData.id; + } + + if (eventData.kind) { + details.Kind = eventData.kind; + } + + if (eventData.source) { + details.Source = eventData.source; + } + + if (eventData.visibility) { + details.Visibility = eventData.visibility; + } + + if (eventData.event_type) { + details["Event Type"] = eventData.event_type; + } + + if (eventData.occurred_at) { + details["Occurred At"] = new Date(eventData.occurred_at).toLocaleString(); + } + + if (eventData.incident) { + Object.assign(details, getDetailsForIncidentSummary(eventData.incident)); + } + + return details; +} + +function getDetailsForIncidentSummary(incident: IncidentSummary): Record { + const details: Record = {}; + + details.ID = incident.id || "-"; + + if (incident.title) { + details.Title = incident.title; + } + + if (incident.status) { + details.Status = incident.status; + } + + if (incident.severity) { + details.Severity = incident.severity; + } + + if (incident.services?.length) { + details.Services = incident.services.join(", "); + } + + if (incident.teams?.length) { + details.Teams = incident.teams.join(", "); + } + + return details; +} diff --git a/web_src/src/pages/workflowv2/mappers/rootly/types.ts b/web_src/src/pages/workflowv2/mappers/rootly/types.ts index 7ceaa56aca..6bceff418c 100644 --- a/web_src/src/pages/workflowv2/mappers/rootly/types.ts +++ b/web_src/src/pages/workflowv2/mappers/rootly/types.ts @@ -31,7 +31,14 @@ export interface Incident { export interface IncidentEvent { id?: string; event?: string; + event_raw?: string; + kind?: string; + source?: string; visibility?: string; occurred_at?: string; created_at?: string; + updated_at?: string; + incident_id?: string; + event_id?: string; + issued_at?: string; }