From fd80b301c8317e909fc31a79a854adf7f1ed856d Mon Sep 17 00:00:00 2001 From: Nenad Ilic Date: Fri, 13 Feb 2026 11:44:20 +0000 Subject: [PATCH] feat(rootly): add Get Incident action component Add a new Rootly component that retrieves a single incident by ID. This enables workflows to fetch incident details for enrichment, status checks, and downstream processing. Signed-off-by: Nenad Ilic --- docs/components/Rootly.mdx | 54 +++++++ pkg/integrations/rootly/example.go | 10 ++ .../rootly/example_output_get_incident.json | 16 ++ pkg/integrations/rootly/get_incident.go | 147 +++++++++++++++++ pkg/integrations/rootly/get_incident_test.go | 151 ++++++++++++++++++ pkg/integrations/rootly/rootly.go | 1 + .../workflowv2/mappers/rootly/get_incident.ts | 63 ++++++++ .../pages/workflowv2/mappers/rootly/index.ts | 3 + 8 files changed, 445 insertions(+) create mode 100644 pkg/integrations/rootly/example_output_get_incident.json create mode 100644 pkg/integrations/rootly/get_incident.go create mode 100644 pkg/integrations/rootly/get_incident_test.go create mode 100644 web_src/src/pages/workflowv2/mappers/rootly/get_incident.ts diff --git a/docs/components/Rootly.mdx b/docs/components/Rootly.mdx index e107de59a2..d26991baea 100644 --- a/docs/components/Rootly.mdx +++ b/docs/components/Rootly.mdx @@ -17,6 +17,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + @@ -160,6 +161,59 @@ Returns the created incident object including: } ``` + + +## Get Incident + +The Get Incident component retrieves a single incident from Rootly by its ID. + +### Use Cases + +- **Incident enrichment**: Fetch current incident details to use in downstream workflow steps +- **Status checks**: Check the current status of an incident before taking action +- **Data retrieval**: Pull incident information for reporting or notifications + +### Configuration + +- **Incident ID**: The UUID of the incident to retrieve (required, supports expressions) + +### Output + +Returns the incident object including: +- **id**: Incident UUID +- **sequential_id**: Sequential incident number +- **title**: Incident title +- **slug**: URL-friendly slug +- **summary**: Incident summary +- **status**: Current incident status +- **severity**: Incident severity level +- **started_at**: When the incident started +- **mitigated_at**: When the incident was mitigated +- **resolved_at**: When the incident was resolved +- **updated_at**: Last update timestamp +- **url**: Link to the incident in Rootly + +### Example Output + +```json +{ + "data": { + "id": "abc123-def456", + "sequential_id": 42, + "severity": "sev1", + "slug": "database-connection-issues", + "started_at": "2026-01-19T12:00:00Z", + "status": "started", + "summary": "Users are experiencing slow database queries and connection timeouts.", + "title": "Database connection issues", + "updated_at": "2026-01-19T12:00:00Z", + "url": "https://app.rootly.com/incidents/abc123-def456" + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "rootly.incident" +} +``` + ## Update Incident diff --git a/pkg/integrations/rootly/example.go b/pkg/integrations/rootly/example.go index 89e88c19bf..dcfae178cd 100644 --- a/pkg/integrations/rootly/example.go +++ b/pkg/integrations/rootly/example.go @@ -25,6 +25,12 @@ var exampleOutputUpdateIncidentBytes []byte var exampleOutputUpdateIncidentOnce sync.Once var exampleOutputUpdateIncident map[string]any +//go:embed example_output_get_incident.json +var exampleOutputGetIncidentBytes []byte + +var exampleOutputGetIncidentOnce sync.Once +var exampleOutputGetIncident map[string]any + //go:embed example_data_on_incident.json var exampleDataOnIncidentBytes []byte @@ -43,6 +49,10 @@ func (c *UpdateIncident) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputUpdateIncidentOnce, exampleOutputUpdateIncidentBytes, &exampleOutputUpdateIncident) } +func (c *GetIncident) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputGetIncidentOnce, exampleOutputGetIncidentBytes, &exampleOutputGetIncident) +} + func (t *OnIncident) ExampleData() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleDataOnIncidentOnce, exampleDataOnIncidentBytes, &exampleDataOnIncident) } diff --git a/pkg/integrations/rootly/example_output_get_incident.json b/pkg/integrations/rootly/example_output_get_incident.json new file mode 100644 index 0000000000..00543aedf2 --- /dev/null +++ b/pkg/integrations/rootly/example_output_get_incident.json @@ -0,0 +1,16 @@ +{ + "type": "rootly.incident", + "data": { + "id": "abc123-def456", + "sequential_id": 42, + "title": "Database connection issues", + "slug": "database-connection-issues", + "summary": "Users are experiencing slow database queries and connection timeouts.", + "status": "started", + "severity": "sev1", + "started_at": "2026-01-19T12:00:00Z", + "updated_at": "2026-01-19T12:00:00Z", + "url": "https://app.rootly.com/incidents/abc123-def456" + }, + "timestamp": "2026-01-19T12:00:00Z" +} diff --git a/pkg/integrations/rootly/get_incident.go b/pkg/integrations/rootly/get_incident.go new file mode 100644 index 0000000000..f26acc2be5 --- /dev/null +++ b/pkg/integrations/rootly/get_incident.go @@ -0,0 +1,147 @@ +package rootly + +import ( + "errors" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type GetIncident struct{} + +type GetIncidentSpec struct { + IncidentID string `json:"incidentId"` +} + +func (c *GetIncident) Name() string { + return "rootly.getIncident" +} + +func (c *GetIncident) Label() string { + return "Get Incident" +} + +func (c *GetIncident) Description() string { + return "Retrieve a single incident from Rootly by ID" +} + +func (c *GetIncident) Documentation() string { + return `The Get Incident component retrieves a single incident from Rootly by its ID. + +## Use Cases + +- **Incident enrichment**: Fetch current incident details to use in downstream workflow steps +- **Status checks**: Check the current status of an incident before taking action +- **Data retrieval**: Pull incident information for reporting or notifications + +## Configuration + +- **Incident ID**: The UUID of the incident to retrieve (required, supports expressions) + +## Output + +Returns the incident object including: +- **id**: Incident UUID +- **sequential_id**: Sequential incident number +- **title**: Incident title +- **slug**: URL-friendly slug +- **summary**: Incident summary +- **status**: Current incident status +- **severity**: Incident severity level +- **started_at**: When the incident started +- **mitigated_at**: When the incident was mitigated +- **resolved_at**: When the incident was resolved +- **updated_at**: Last update timestamp +- **url**: Link to the incident in Rootly` +} + +func (c *GetIncident) Icon() string { + return "search" +} + +func (c *GetIncident) Color() string { + return "gray" +} + +func (c *GetIncident) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *GetIncident) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "incidentId", + Label: "Incident ID", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "e.g., abc123-def456", + Description: "The UUID of the incident to retrieve", + }, + } +} + +func (c *GetIncident) Setup(ctx core.SetupContext) error { + spec := GetIncidentSpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + if spec.IncidentID == "" { + return errors.New("incidentId is required") + } + + return nil +} + +func (c *GetIncident) Execute(ctx core.ExecutionContext) error { + spec := GetIncidentSpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + incident, err := client.GetIncident(spec.IncidentID) + if err != nil { + return fmt.Errorf("failed to get incident: %v", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "rootly.incident", + []any{incident}, + ) +} + +func (c *GetIncident) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *GetIncident) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *GetIncident) Actions() []core.Action { + return []core.Action{} +} + +func (c *GetIncident) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *GetIncident) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (c *GetIncident) Cleanup(ctx core.SetupContext) error { + return nil +} diff --git a/pkg/integrations/rootly/get_incident_test.go b/pkg/integrations/rootly/get_incident_test.go new file mode 100644 index 0000000000..1291db042a --- /dev/null +++ b/pkg/integrations/rootly/get_incident_test.go @@ -0,0 +1,151 @@ +package rootly + +import ( + "io" + "net/http" + "strings" + "testing" + + "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__GetIncident__Setup(t *testing.T) { + component := &GetIncident{} + + t.Run("valid configuration with incidentId", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "incidentId": "abc123-def456", + }, + }) + + require.NoError(t, err) + }) + + t.Run("missing incidentId returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{}, + }) + + require.ErrorContains(t, err, "incidentId is required") + }) + + t.Run("empty incidentId returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "incidentId": "", + }, + }) + + require.ErrorContains(t, err, "incidentId is required") + }) + + t.Run("invalid configuration format -> decode error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: "invalid-config", + }) + + require.ErrorContains(t, err, "error decoding configuration") + }) +} + +func Test__GetIncident__Execute(t *testing.T) { + component := &GetIncident{} + + t.Run("successful get emits incident", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "data": { + "id": "inc-uuid-123", + "type": "incidents", + "attributes": { + "title": "Database connection issues", + "sequential_id": 42, + "slug": "database-connection-issues", + "summary": "Users are experiencing slow database queries.", + "status": "started", + "severity": "sev1", + "started_at": "2026-01-19T12:00:00Z", + "updated_at": "2026-01-19T12:00:00Z", + "url": "https://app.rootly.com/incidents/inc-uuid-123" + } + } + }`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "test-api-key", + }, + } + + execState := &contexts.ExecutionStateContext{ + KVs: make(map[string]string), + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "incidentId": "inc-uuid-123", + }, + HTTP: httpContext, + Integration: integrationCtx, + ExecutionState: execState, + }) + + require.NoError(t, err) + assert.True(t, execState.Passed) + assert.Equal(t, core.DefaultOutputChannel.Name, execState.Channel) + assert.Equal(t, "rootly.incident", execState.Type) + assert.Len(t, execState.Payloads, 1) + + // Verify request + require.Len(t, httpContext.Requests, 1) + req := httpContext.Requests[0] + assert.Equal(t, http.MethodGet, req.Method) + assert.Contains(t, req.URL.String(), "/incidents/inc-uuid-123") + assert.Equal(t, "application/vnd.api+json", req.Header.Get("Content-Type")) + }) + + t.Run("API error returns error and does not emit", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`{"errors": [{"title": "Record not found"}]}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "test-api-key", + }, + } + + execState := &contexts.ExecutionStateContext{ + KVs: make(map[string]string), + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "incidentId": "nonexistent-id", + }, + HTTP: httpContext, + Integration: integrationCtx, + ExecutionState: execState, + }) + + require.Error(t, err) + assert.ErrorContains(t, err, "failed to get incident") + assert.False(t, execState.Passed) + assert.Empty(t, execState.Channel) + }) +} diff --git a/pkg/integrations/rootly/rootly.go b/pkg/integrations/rootly/rootly.go index cf32d97772..34790b4083 100644 --- a/pkg/integrations/rootly/rootly.go +++ b/pkg/integrations/rootly/rootly.go @@ -64,6 +64,7 @@ func (r *Rootly) Components() []core.Component { &CreateIncident{}, &CreateEvent{}, &UpdateIncident{}, + &GetIncident{}, } } diff --git a/web_src/src/pages/workflowv2/mappers/rootly/get_incident.ts b/web_src/src/pages/workflowv2/mappers/rootly/get_incident.ts new file mode 100644 index 0000000000..ae52c49f8a --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/rootly/get_incident.ts @@ -0,0 +1,63 @@ +import { ComponentBaseProps } from "@/ui/componentBase"; +import { getBackgroundColorClass } from "@/utils/colors"; +import { getStateMap } from ".."; +import { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import { MetadataItem } from "@/ui/metadataList"; +import rootlyIcon from "@/assets/icons/integrations/rootly.svg"; +import { Incident } from "./types"; +import { baseEventSections, getDetailsForIncident } from "./base"; +import { formatTimeAgo } from "@/utils/date"; + +export const getIncidentMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + iconSrc: rootlyIcon, + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + title: + context.node.name || + context.componentDefinition.label || + context.componentDefinition.name || + "Unnamed component", + eventSections: lastExecution ? baseEventSections(context.nodes, lastExecution, componentName) : undefined, + metadata: metadataList(context.node), + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default: OutputPayload[] }; + if (!outputs?.default || outputs.default.length === 0) { + return {}; + } + const incident = outputs.default[0].data as Incident; + return getDetailsForIncident(incident); + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) return ""; + return formatTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as Record; + + if (configuration?.incidentId) { + metadata.push({ icon: "alert-triangle", label: `Incident: ${configuration.incidentId}` }); + } + + return metadata; +} diff --git a/web_src/src/pages/workflowv2/mappers/rootly/index.ts b/web_src/src/pages/workflowv2/mappers/rootly/index.ts index a02e55f3fd..27e8a8e439 100644 --- a/web_src/src/pages/workflowv2/mappers/rootly/index.ts +++ b/web_src/src/pages/workflowv2/mappers/rootly/index.ts @@ -3,12 +3,14 @@ import { onIncidentTriggerRenderer } from "./on_incident"; import { createIncidentMapper } from "./create_incident"; import { createEventMapper } from "./create_event"; import { updateIncidentMapper } from "./update_incident"; +import { getIncidentMapper } from "./get_incident"; import { buildActionStateRegistry } from "../utils"; export const componentMappers: Record = { createIncident: createIncidentMapper, createEvent: createEventMapper, updateIncident: updateIncidentMapper, + getIncident: getIncidentMapper, }; export const triggerRenderers: Record = { @@ -19,4 +21,5 @@ export const eventStateRegistry: Record = { createIncident: buildActionStateRegistry("created"), createEvent: buildActionStateRegistry("created"), updateIncident: buildActionStateRegistry("updated"), + getIncident: buildActionStateRegistry("retrieved"), };