Skip to content

Commit 048adfb

Browse files
brianfeuchtCopilot
andauthored
Allow Rootly plugin to collect incidents without service identifiers assigned (#8892)
* fix: map Rootly 'completed' status to DONE in incident converter The mapStatus function was missing 'completed' as a terminal status, causing incidents with that status to be written as IN_PROGRESS instead of DONE. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use updated_date as MTTR fallback for Rootly 'completed' incidents Rootly's 'completed' status represents a fully resolved incident that has gone through post-incident review. These incidents may not have a resolved_date populated. Fall back to updated_date so MTTR is computed correctly instead of showing the incident as unresolved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove service filter requirement from Rootly incident collection Allow collecting all incidents without filtering by service ID. Previously, all incidents required a service association to be collected; incidents created without service tags (common in Rootly) were silently dropped. Changes: - ValidateTaskOptions: service_id is now optional (no longer required) - GetParams: falls back to scope_id 'all' when service_id is empty - buildIncidentsQuery: only adds filter[service_ids] when service_id is set - extractRootlyIncident: skips the service-membership guard when collecting globally (service_id = '') to avoid false drops Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c1b617f commit 048adfb

6 files changed

Lines changed: 51 additions & 19 deletions

File tree

backend/plugins/rootly/tasks/incidents_collector.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error {
126126
// `filter[service_ids]`) is caught by a unit test.
127127
func buildIncidentsQuery(serviceId string, pageSize, pageNumber int, createdAfter *time.Time) url.Values {
128128
query := url.Values{}
129-
query.Set("filter[service_ids]", serviceId)
129+
if serviceId != "" {
130+
query.Set("filter[service_ids]", serviceId)
131+
}
130132
query.Set("page[size]", fmt.Sprintf("%d", pageSize))
131133
// Rootly's JSON:API pagination is 1-based.
132134
query.Set("page[number]", fmt.Sprintf("%d", pageNumber))

backend/plugins/rootly/tasks/incidents_collector_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ func TestBuildIncidentsQuery_FirstPageNoSince(t *testing.T) {
3434
assert.Equal(t, "", q.Get("filter[services]"), "regression guard: must be filter[service_ids], not filter[services]")
3535
}
3636

37+
func TestBuildIncidentsQuery_NoServiceFilter(t *testing.T) {
38+
q := buildIncidentsQuery("", 100, 1, nil)
39+
assert.Equal(t, "", q.Get("filter[service_ids]"), "empty serviceId must omit the service filter entirely")
40+
assert.Equal(t, "100", q.Get("page[size]"))
41+
assert.Equal(t, "1", q.Get("page[number]"))
42+
}
43+
3744
func TestBuildIncidentsQuery_SubsequentPage(t *testing.T) {
3845
q := buildIncidentsQuery("svc_42", 100, 3, nil)
3946
assert.Equal(t, "3", q.Get("page[number]"))

backend/plugins/rootly/tasks/incidents_converter.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ func ConvertIncidents(taskCtx plugin.SubTaskContext) errors.Error {
9090
logger.Warn(nil, "unknown rootly incident status: %s", incident.Status)
9191
}
9292

93-
leadTime, resolutionDate := computeLeadTime(incident.StartedDate, incident.ResolvedDate)
93+
leadTime, resolutionDate := computeLeadTime(incident.StartedDate, incident.ResolvedDate, incident.UpdatedDate, incident.Status)
9494

9595
domainIssueId := idGen.Generate(data.Options.ConnectionId, incident.Id)
9696

@@ -161,7 +161,7 @@ func mapStatus(status string) (mapped string, known bool) {
161161
return ticket.TODO, true
162162
case "mitigated":
163163
return ticket.IN_PROGRESS, true
164-
case "resolved", "closed", "cancelled":
164+
case "resolved", "closed", "cancelled", "completed":
165165
return ticket.DONE, true
166166
default:
167167
return ticket.IN_PROGRESS, false
@@ -183,18 +183,24 @@ func mapSeverityToPriority(severity string) string {
183183
}
184184
}
185185

186-
func computeLeadTime(started time.Time, resolved *time.Time) (*uint, *time.Time) {
187-
if resolved == nil {
186+
func computeLeadTime(started time.Time, resolved *time.Time, updated time.Time, status string) (*uint, *time.Time) {
187+
// For "completed" incidents Rootly may not populate resolved_date; fall
188+
// back to updated_date which reflects when the incident was last actioned.
189+
effective := resolved
190+
if effective == nil && status == "completed" {
191+
effective = &updated
192+
}
193+
if effective == nil {
188194
return nil, nil
189195
}
190196
// Clock skew / backfill can place resolved before started. A naive
191197
// uint() cast on a negative duration wraps to huge garbage and
192198
// silently corrupts MTTR; treat as unresolved instead.
193-
if resolved.Before(started) {
199+
if effective.Before(started) {
194200
return nil, nil
195201
}
196-
minutes := uint(resolved.Sub(started).Minutes())
197-
resolutionDate := *resolved
202+
minutes := uint(effective.Sub(started).Minutes())
203+
resolutionDate := *effective
198204
return &minutes, &resolutionDate
199205
}
200206

backend/plugins/rootly/tasks/incidents_converter_test.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ func TestMapSeverityToPriority(t *testing.T) {
8585
func TestComputeLeadTime_Resolved(t *testing.T) {
8686
started := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC)
8787
resolved := time.Date(2026, 5, 10, 11, 30, 0, 0, time.UTC)
88-
leadTime, resolutionDate := computeLeadTime(started, &resolved)
88+
updated := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
89+
leadTime, resolutionDate := computeLeadTime(started, &resolved, updated, "resolved")
8990
require.NotNil(t, leadTime)
9091
require.NotNil(t, resolutionDate)
9192
assert.Equal(t, uint(90), *leadTime)
@@ -94,15 +95,26 @@ func TestComputeLeadTime_Resolved(t *testing.T) {
9495

9596
func TestComputeLeadTime_Unresolved(t *testing.T) {
9697
started := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC)
97-
leadTime, resolutionDate := computeLeadTime(started, nil)
98+
updated := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
99+
leadTime, resolutionDate := computeLeadTime(started, nil, updated, "started")
98100
assert.Nil(t, leadTime)
99101
assert.Nil(t, resolutionDate)
100102
}
101103

104+
func TestComputeLeadTime_CompletedFallsBackToUpdated(t *testing.T) {
105+
started := time.Date(2026, 3, 27, 20, 0, 0, 0, time.UTC)
106+
updated := time.Date(2026, 3, 31, 14, 0, 0, 0, time.UTC)
107+
leadTime, resolutionDate := computeLeadTime(started, nil, updated, "completed")
108+
require.NotNil(t, leadTime)
109+
require.NotNil(t, resolutionDate)
110+
assert.Equal(t, updated, *resolutionDate)
111+
}
112+
102113
func TestComputeLeadTime_ZeroDuration(t *testing.T) {
103114
started := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC)
104115
resolved := started
105-
leadTime, resolutionDate := computeLeadTime(started, &resolved)
116+
updated := started
117+
leadTime, resolutionDate := computeLeadTime(started, &resolved, updated, "resolved")
106118
require.NotNil(t, leadTime)
107119
require.NotNil(t, resolutionDate)
108120
assert.Equal(t, uint(0), *leadTime)
@@ -111,7 +123,8 @@ func TestComputeLeadTime_ZeroDuration(t *testing.T) {
111123
func TestComputeLeadTime_ResolvedBeforeStarted(t *testing.T) {
112124
started := time.Date(2026, 5, 10, 11, 0, 0, 0, time.UTC)
113125
resolved := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC)
114-
leadTime, resolutionDate := computeLeadTime(started, &resolved)
126+
updated := started
127+
leadTime, resolutionDate := computeLeadTime(started, &resolved, updated, "resolved")
115128
assert.Nil(t, leadTime)
116129
assert.Nil(t, resolutionDate)
117130
}

backend/plugins/rootly/tasks/incidents_extractor.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,12 @@ func extractRootlyIncident(rawData []byte, op *RootlyOptions) ([]interface{}, er
6464

6565
// Safety net: filter[service_ids] in the collector is the primary
6666
// scope filter, but a regression there would let multi-service
67-
// incidents leak into a wrong scope's tool table.
68-
if services := rawIncident.Relationships.Services.Data; len(services) > 0 && !containsServiceId(services, op.ServiceId) {
69-
return nil, nil
67+
// incidents leak into a wrong scope's tool table. When ServiceId is
68+
// empty we are collecting all incidents globally, so skip this check.
69+
if op.ServiceId != "" {
70+
if services := rawIncident.Relationships.Services.Data; len(services) > 0 && !containsServiceId(services, op.ServiceId) {
71+
return nil, nil
72+
}
7073
}
7174

7275
if rawIncident.Attributes.StartedAt.IsZero() {

backend/plugins/rootly/tasks/task_data.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,13 @@ type RootlyTaskData struct {
3737
}
3838

3939
func (p *RootlyOptions) GetParams() any {
40+
scopeId := p.ServiceId
41+
if scopeId == "" {
42+
scopeId = "all"
43+
}
4044
return models.RootlyParams{
4145
ConnectionId: p.ConnectionId,
42-
ScopeId: p.ServiceId,
46+
ScopeId: scopeId,
4347
}
4448
}
4549

@@ -65,9 +69,6 @@ func DecodeTaskOptions(options map[string]interface{}) (*RootlyOptions, errors.E
6569
}
6670

6771
func ValidateTaskOptions(op *RootlyOptions) errors.Error {
68-
if op.ServiceId == "" {
69-
return errors.BadInput.New("not enough info for Rootly execution")
70-
}
7172
if op.ConnectionId == 0 {
7273
return errors.BadInput.New("connectionId is invalid")
7374
}

0 commit comments

Comments
 (0)