From 999596cdcbaa192f3d5c4d1b1459f349c862f53c Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Tue, 12 May 2026 13:37:35 -0700 Subject: [PATCH 01/14] feat(rootly): scaffold plugin skeleton Add the plugin entry point, impl shell with all required plugin interfaces except DataSourcePluginBlueprintV200 (wired in U2), connection model with Bearer auth, service + scope-config + placeholder incident/user/assignment models, and the initial migration script. API resources and subtasks are intentionally empty and will be populated by U2-U5. --- backend/plugins/rootly/impl/impl.go | 135 ++++++++++++++++++ backend/plugins/rootly/models/assignment.go | 29 ++++ backend/plugins/rootly/models/connection.go | 82 +++++++++++ backend/plugins/rootly/models/incident.go | 31 ++++ .../20260512_add_init_tables.go | 46 ++++++ .../migrationscripts/archived/assignment.go | 37 +++++ .../migrationscripts/archived/connection.go | 32 +++++ .../migrationscripts/archived/incident.go | 47 ++++++ .../migrationscripts/archived/scope_config.go | 30 ++++ .../migrationscripts/archived/service.go | 34 +++++ .../models/migrationscripts/archived/user.go | 33 +++++ .../models/migrationscripts/register.go | 29 ++++ backend/plugins/rootly/models/scope_config.go | 30 ++++ backend/plugins/rootly/models/service.go | 60 ++++++++ backend/plugins/rootly/models/user.go | 28 ++++ backend/plugins/rootly/rootly.go | 38 +++++ backend/plugins/rootly/tasks/task_data.go | 84 +++++++++++ 17 files changed, 805 insertions(+) create mode 100644 backend/plugins/rootly/impl/impl.go create mode 100644 backend/plugins/rootly/models/assignment.go create mode 100644 backend/plugins/rootly/models/connection.go create mode 100644 backend/plugins/rootly/models/incident.go create mode 100644 backend/plugins/rootly/models/migrationscripts/20260512_add_init_tables.go create mode 100644 backend/plugins/rootly/models/migrationscripts/archived/assignment.go create mode 100644 backend/plugins/rootly/models/migrationscripts/archived/connection.go create mode 100644 backend/plugins/rootly/models/migrationscripts/archived/incident.go create mode 100644 backend/plugins/rootly/models/migrationscripts/archived/scope_config.go create mode 100644 backend/plugins/rootly/models/migrationscripts/archived/service.go create mode 100644 backend/plugins/rootly/models/migrationscripts/archived/user.go create mode 100644 backend/plugins/rootly/models/migrationscripts/register.go create mode 100644 backend/plugins/rootly/models/scope_config.go create mode 100644 backend/plugins/rootly/models/service.go create mode 100644 backend/plugins/rootly/models/user.go create mode 100644 backend/plugins/rootly/rootly.go create mode 100644 backend/plugins/rootly/tasks/task_data.go diff --git a/backend/plugins/rootly/impl/impl.go b/backend/plugins/rootly/impl/impl.go new file mode 100644 index 00000000000..6cbb9b535b5 --- /dev/null +++ b/backend/plugins/rootly/impl/impl.go @@ -0,0 +1,135 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +import ( + "fmt" + + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/rootly/models" + "github.com/apache/incubator-devlake/plugins/rootly/models/migrationscripts" + "github.com/apache/incubator-devlake/plugins/rootly/tasks" +) + +// make sure interface is implemented + +var _ interface { + plugin.PluginMeta + plugin.PluginInit + plugin.PluginTask + plugin.PluginApi + plugin.PluginModel + plugin.CloseablePluginTask + plugin.PluginSource +} = (*Rootly)(nil) + +type Rootly struct{} + +func (p Rootly) Description() string { + return "collect Rootly incident data" +} + +func (p Rootly) Name() string { + return "rootly" +} + +func (p Rootly) Init(basicRes context.BasicRes) errors.Error { + return nil +} + +func (p Rootly) Connection() dal.Tabler { + return &models.RootlyConnection{} +} + +func (p Rootly) Scope() plugin.ToolLayerScope { + return &models.Service{} +} + +func (p Rootly) ScopeConfig() dal.Tabler { + return &models.RootlyScopeConfig{} +} + +func (p Rootly) SubTaskMetas() []plugin.SubTaskMeta { + return []plugin.SubTaskMeta{} +} + +func (p Rootly) GetTablesInfo() []dal.Tabler { + return []dal.Tabler{ + &models.Service{}, + &models.Incident{}, + &models.User{}, + &models.Assignment{}, + &models.RootlyConnection{}, + &models.RootlyScopeConfig{}, + } +} + +func (p Rootly) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]interface{}) (interface{}, errors.Error) { + op, err := tasks.DecodeAndValidateTaskOptions(options) + if err != nil { + return nil, err + } + connectionHelper := helper.NewConnectionHelper( + taskCtx, + nil, + p.Name(), + ) + connection := &models.RootlyConnection{} + err = connectionHelper.FirstById(connection, op.ConnectionId) + if err != nil { + return nil, errors.Default.Wrap(err, "unable to get Rootly connection by the given connection ID") + } + + client, err := helper.NewApiClientFromConnection(taskCtx.GetContext(), taskCtx, connection) + if err != nil { + return nil, err + } + asyncClient, err := helper.CreateAsyncApiClient(taskCtx, client, nil) + if err != nil { + return nil, err + } + return &tasks.RootlyTaskData{ + Options: op, + Client: asyncClient, + }, nil +} + +// RootPkgPath information lost when compiled as plugin(.so) +func (p Rootly) RootPkgPath() string { + return "github.com/apache/incubator-devlake/plugins/rootly" +} + +func (p Rootly) MigrationScripts() []plugin.MigrationScript { + return migrationscripts.All() +} + +func (p Rootly) ApiResources() map[string]map[string]plugin.ApiResourceHandler { + return map[string]map[string]plugin.ApiResourceHandler{} +} + +func (p Rootly) Close(taskCtx plugin.TaskContext) errors.Error { + _, ok := taskCtx.GetData().(*tasks.RootlyTaskData) + if !ok { + return errors.Default.New(fmt.Sprintf("GetData failed when try to close %+v", taskCtx)) + } + return nil +} diff --git a/backend/plugins/rootly/models/assignment.go b/backend/plugins/rootly/models/assignment.go new file mode 100644 index 00000000000..52c3fbc4120 --- /dev/null +++ b/backend/plugins/rootly/models/assignment.go @@ -0,0 +1,29 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import "github.com/apache/incubator-devlake/core/models/common" + +type Assignment struct { + common.NoPKModel + ConnectionId uint64 + IncidentId string `gorm:"primaryKey"` + UserId string `gorm:"primaryKey"` +} + +func (Assignment) TableName() string { return "_tool_rootly_assignments" } diff --git a/backend/plugins/rootly/models/connection.go b/backend/plugins/rootly/models/connection.go new file mode 100644 index 00000000000..13bf1cf388b --- /dev/null +++ b/backend/plugins/rootly/models/connection.go @@ -0,0 +1,82 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "fmt" + "net/http" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/utils" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +// RootlyAccessToken implements HTTP Bearer Authentication with an access token +type RootlyAccessToken helper.AccessToken + +// SetupAuthentication sets up the request headers for authentication +func (at *RootlyAccessToken) SetupAuthentication(request *http.Request) errors.Error { + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", at.Token)) + return nil +} + +// RootlyConn holds the essential information to connect to the Rootly API +type RootlyConn struct { + helper.RestConnection `mapstructure:",squash"` + RootlyAccessToken `mapstructure:",squash"` +} + +// RootlyConnection holds RootlyConn plus ID/Name for database storage +type RootlyConnection struct { + helper.BaseConnection `mapstructure:",squash"` + RootlyConn `mapstructure:",squash"` +} + +func (connection *RootlyConnection) MergeFromRequest(target *RootlyConnection, body map[string]interface{}) error { + token := target.Token + if err := helper.DecodeMapStruct(body, target, true); err != nil { + return err + } + modifiedToken := target.Token + if modifiedToken == "" || modifiedToken == utils.SanitizeString(token) { + target.Token = token + } + return nil +} + +// This object conforms to what the frontend currently expects. +type RootlyResponse struct { + Name string `json:"name"` + ID int `json:"id"` + RootlyConnection +} + +// ApiUserResponse represents the Rootly /users/current response for token validation. +type ApiUserResponse struct { + Id string + Name string `json:"name"` +} + +func (RootlyConnection) TableName() string { + return "_tool_rootly_connections" +} + +func (connection RootlyConnection) Sanitize() RootlyConnection { + connection.Token = utils.SanitizeString(connection.Token) + return connection +} diff --git a/backend/plugins/rootly/models/incident.go b/backend/plugins/rootly/models/incident.go new file mode 100644 index 00000000000..65c55278ae6 --- /dev/null +++ b/backend/plugins/rootly/models/incident.go @@ -0,0 +1,31 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/incubator-devlake/core/models/common" +) + +type Incident struct { + common.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + Id string `gorm:"primaryKey;autoIncrement:false"` + ServiceId string +} + +func (Incident) TableName() string { return "_tool_rootly_incidents" } diff --git a/backend/plugins/rootly/models/migrationscripts/20260512_add_init_tables.go b/backend/plugins/rootly/models/migrationscripts/20260512_add_init_tables.go new file mode 100644 index 00000000000..a8d06269c24 --- /dev/null +++ b/backend/plugins/rootly/models/migrationscripts/20260512_add_init_tables.go @@ -0,0 +1,46 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/helpers/migrationhelper" + "github.com/apache/incubator-devlake/plugins/rootly/models/migrationscripts/archived" +) + +type addInitTables struct{} + +func (*addInitTables) Up(baseRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables(baseRes, + &archived.Connection{}, + &archived.Service{}, + &archived.Incident{}, + &archived.User{}, + &archived.Assignment{}, + &archived.ScopeConfig{}, + ) +} + +func (*addInitTables) Version() uint64 { + return 20260512000001 +} + +func (*addInitTables) Name() string { + return "Rootly init schemas" +} diff --git a/backend/plugins/rootly/models/migrationscripts/archived/assignment.go b/backend/plugins/rootly/models/migrationscripts/archived/assignment.go new file mode 100644 index 00000000000..0f77dab61d7 --- /dev/null +++ b/backend/plugins/rootly/models/migrationscripts/archived/assignment.go @@ -0,0 +1,37 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archived + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" +) + +type Assignment struct { + archived.NoPKModel + ConnectionId uint64 + IncidentId string `gorm:"primaryKey"` + UserId string `gorm:"primaryKey"` + AssignedAt time.Time + Role string +} + +func (Assignment) TableName() string { + return "_tool_rootly_assignments" +} diff --git a/backend/plugins/rootly/models/migrationscripts/archived/connection.go b/backend/plugins/rootly/models/migrationscripts/archived/connection.go new file mode 100644 index 00000000000..212cfa5f1ab --- /dev/null +++ b/backend/plugins/rootly/models/migrationscripts/archived/connection.go @@ -0,0 +1,32 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archived + +import ( + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" +) + +type Connection struct { + archived.Model + Name string `gorm:"type:varchar(100);uniqueIndex" json:"name" validate:"required"` + Token string `mapstructure:"token" env:"ROOTLY_AUTH" validate:"required" encrypt:"yes"` +} + +func (Connection) TableName() string { + return "_tool_rootly_connections" +} diff --git a/backend/plugins/rootly/models/migrationscripts/archived/incident.go b/backend/plugins/rootly/models/migrationscripts/archived/incident.go new file mode 100644 index 00000000000..54449cdc5af --- /dev/null +++ b/backend/plugins/rootly/models/migrationscripts/archived/incident.go @@ -0,0 +1,47 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archived + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" +) + +type Incident struct { + archived.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + Id string `gorm:"primaryKey;autoIncrement:false"` + Number int + ServiceId string + Url string + Title string + Summary string + Status string + Severity string + Urgency string + StartedDate time.Time + AcknowledgedDate *time.Time + MitigatedDate *time.Time + ResolvedDate *time.Time + UpdatedDate time.Time +} + +func (Incident) TableName() string { + return "_tool_rootly_incidents" +} diff --git a/backend/plugins/rootly/models/migrationscripts/archived/scope_config.go b/backend/plugins/rootly/models/migrationscripts/archived/scope_config.go new file mode 100644 index 00000000000..91689beead9 --- /dev/null +++ b/backend/plugins/rootly/models/migrationscripts/archived/scope_config.go @@ -0,0 +1,30 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archived + +import ( + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" +) + +type ScopeConfig struct { + archived.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` +} + +func (ScopeConfig) TableName() string { + return "_tool_rootly_scope_configs" +} diff --git a/backend/plugins/rootly/models/migrationscripts/archived/service.go b/backend/plugins/rootly/models/migrationscripts/archived/service.go new file mode 100644 index 00000000000..c322594eddd --- /dev/null +++ b/backend/plugins/rootly/models/migrationscripts/archived/service.go @@ -0,0 +1,34 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archived + +import ( + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" +) + +type Service struct { + archived.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + Id string `gorm:"primaryKey;autoIncrement:false"` + Url string + Name string +} + +func (Service) TableName() string { + return "_tool_rootly_services" +} diff --git a/backend/plugins/rootly/models/migrationscripts/archived/user.go b/backend/plugins/rootly/models/migrationscripts/archived/user.go new file mode 100644 index 00000000000..13cde223dde --- /dev/null +++ b/backend/plugins/rootly/models/migrationscripts/archived/user.go @@ -0,0 +1,33 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archived + +import "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" + +type User struct { + archived.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + Id string `gorm:"primaryKey;autoIncrement:false"` + Email string + Name string + Url string +} + +func (User) TableName() string { + return "_tool_rootly_users" +} diff --git a/backend/plugins/rootly/models/migrationscripts/register.go b/backend/plugins/rootly/models/migrationscripts/register.go new file mode 100644 index 00000000000..d333899d309 --- /dev/null +++ b/backend/plugins/rootly/models/migrationscripts/register.go @@ -0,0 +1,29 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/plugin" +) + +// All returns all the migration scripts for the rootly plugin +func All() []plugin.MigrationScript { + return []plugin.MigrationScript{ + new(addInitTables), + } +} diff --git a/backend/plugins/rootly/models/scope_config.go b/backend/plugins/rootly/models/scope_config.go new file mode 100644 index 00000000000..e2926554147 --- /dev/null +++ b/backend/plugins/rootly/models/scope_config.go @@ -0,0 +1,30 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/incubator-devlake/core/models/common" +) + +type RootlyScopeConfig struct { + common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` +} + +func (RootlyScopeConfig) TableName() string { + return "_tool_rootly_scope_configs" +} diff --git a/backend/plugins/rootly/models/service.go b/backend/plugins/rootly/models/service.go new file mode 100644 index 00000000000..317cfe9f5a2 --- /dev/null +++ b/backend/plugins/rootly/models/service.go @@ -0,0 +1,60 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/plugin" +) + +type RootlyParams struct { + ConnectionId uint64 + ScopeId string +} + +type Service struct { + common.Scope `mapstructure:",squash"` + Id string `json:"id" mapstructure:"id" gorm:"primaryKey;autoIncrement:false" ` + Url string `json:"url" mapstructure:"url"` + Name string `json:"name" mapstructure:"name"` +} + +func (s Service) ScopeId() string { + return s.Id +} + +func (s Service) ScopeName() string { + return s.Name +} + +func (s Service) ScopeFullName() string { + return s.Name +} + +func (s Service) ScopeParams() interface{} { + return &RootlyParams{ + ConnectionId: s.ConnectionId, + ScopeId: s.Id, + } +} + +func (s Service) TableName() string { + return "_tool_rootly_services" +} + +var _ plugin.ToolLayerScope = (*Service)(nil) diff --git a/backend/plugins/rootly/models/user.go b/backend/plugins/rootly/models/user.go new file mode 100644 index 00000000000..271287bcc24 --- /dev/null +++ b/backend/plugins/rootly/models/user.go @@ -0,0 +1,28 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import "github.com/apache/incubator-devlake/core/models/common" + +type User struct { + common.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + Id string `gorm:"primaryKey;autoIncrement:false"` +} + +func (User) TableName() string { return "_tool_rootly_users" } diff --git a/backend/plugins/rootly/rootly.go b/backend/plugins/rootly/rootly.go new file mode 100644 index 00000000000..c1bb7d5001e --- /dev/null +++ b/backend/plugins/rootly/rootly.go @@ -0,0 +1,38 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "github.com/apache/incubator-devlake/core/runner" + "github.com/apache/incubator-devlake/plugins/rootly/impl" + "github.com/spf13/cobra" +) + +// PluginEntry Export a variable named PluginEntry for Framework to search and load +var PluginEntry impl.Rootly //nolint + +// standalone mode for debugging +func main() { + cmd := &cobra.Command{Use: "rootly"} + timeAfter := cmd.Flags().StringP("timeAfter", "a", "", "collect data that are created after specified time, ie 2006-01-02T15:04:05Z") + + cmd.Run = func(cmd *cobra.Command, args []string) { + runner.DirectRun(cmd, args, PluginEntry, map[string]interface{}{}, *timeAfter) + } + runner.RunCmd(cmd) +} diff --git a/backend/plugins/rootly/tasks/task_data.go b/backend/plugins/rootly/tasks/task_data.go new file mode 100644 index 00000000000..a0a881bc0d7 --- /dev/null +++ b/backend/plugins/rootly/tasks/task_data.go @@ -0,0 +1,84 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/rootly/models" +) + +type RootlyOptions struct { + ConnectionId uint64 `json:"connectionId" mapstructure:"connectionId,omitempty"` + ServiceId string `json:"serviceId,omitempty" mapstructure:"serviceId,omitempty"` + ServiceName string `json:"serviceName,omitempty" mapstructure:"serviceName,omitempty"` + ScopeConfigId uint64 `json:"scopeConfigId,omitempty" mapstructure:"scopeConfigId,omitempty"` + ScopeConfig *models.RootlyScopeConfig `json:"scopeConfig,omitempty" mapstructure:"scopeConfig,omitempty"` +} + +type RootlyTaskData struct { + Options *RootlyOptions + Client api.RateLimitedApiClient +} + +func (p *RootlyOptions) GetParams() any { + return models.RootlyParams{ + ConnectionId: p.ConnectionId, + ScopeId: p.ServiceId, + } +} + +func DecodeAndValidateTaskOptions(options map[string]interface{}) (*RootlyOptions, errors.Error) { + op, err := DecodeTaskOptions(options) + if err != nil { + return nil, err + } + err = ValidateTaskOptions(op) + if err != nil { + return nil, err + } + return op, nil +} + +func DecodeTaskOptions(options map[string]interface{}) (*RootlyOptions, errors.Error) { + var op RootlyOptions + err := api.Decode(options, &op, nil) + if err != nil { + return nil, err + } + return &op, nil +} + +func EncodeTaskOptions(op *RootlyOptions) (map[string]interface{}, errors.Error) { + var result map[string]interface{} + err := api.Decode(op, &result, nil) + if err != nil { + return nil, err + } + return result, nil +} + +func ValidateTaskOptions(op *RootlyOptions) errors.Error { + if op.ServiceId == "" { + return errors.BadInput.New("not enough info for Rootly execution") + } + if op.ConnectionId == 0 { + return errors.BadInput.New("connectionId is invalid") + } + return nil +} From f0241cb08ff5c4788c63ba66db646d7409245f6c Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Tue, 12 May 2026 13:47:22 -0700 Subject: [PATCH 02/14] feat(rootly): add API handlers and blueprint v200 Wire up connection test, remote scope list/search, scope CRUD, scope sync state, and blueprint v200 endpoints. Remote scope listing speaks JSON:API and page-based pagination to match the Rootly API shape. TestConnection validates the bearer token via GET /users/current. impl.go now wires api.Init and restores DataSourcePluginBlueprintV200 so the plugin can produce pipeline plans for selected Rootly services. --- backend/plugins/rootly/api/blueprint_v200.go | 103 +++++++++ backend/plugins/rootly/api/connection_api.go | 155 ++++++++++++++ backend/plugins/rootly/api/init.go | 55 +++++ backend/plugins/rootly/api/remote_api.go | 201 ++++++++++++++++++ backend/plugins/rootly/api/scope_api.go | 107 ++++++++++ backend/plugins/rootly/api/scope_state_api.go | 37 ++++ backend/plugins/rootly/api/swagger.go | 32 +++ backend/plugins/rootly/impl/impl.go | 47 +++- 8 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 backend/plugins/rootly/api/blueprint_v200.go create mode 100644 backend/plugins/rootly/api/connection_api.go create mode 100644 backend/plugins/rootly/api/init.go create mode 100644 backend/plugins/rootly/api/remote_api.go create mode 100644 backend/plugins/rootly/api/scope_api.go create mode 100644 backend/plugins/rootly/api/scope_state_api.go create mode 100644 backend/plugins/rootly/api/swagger.go diff --git a/backend/plugins/rootly/api/blueprint_v200.go b/backend/plugins/rootly/api/blueprint_v200.go new file mode 100644 index 00000000000..9871beffdee --- /dev/null +++ b/backend/plugins/rootly/api/blueprint_v200.go @@ -0,0 +1,103 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/errors" + coreModels "github.com/apache/incubator-devlake/core/models" + "github.com/apache/incubator-devlake/core/models/domainlayer/didgen" + "github.com/apache/incubator-devlake/core/models/domainlayer/ticket" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/core/utils" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/helpers/srvhelper" + "github.com/apache/incubator-devlake/plugins/rootly/models" + "github.com/apache/incubator-devlake/plugins/rootly/tasks" +) + +func MakeDataSourcePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + connectionId uint64, + bpScopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + connection, err := dsHelper.ConnSrv.FindByPk(connectionId) + if err != nil { + return nil, nil, err + } + scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes) + if err != nil { + return nil, nil, err + } + plan, err := makePipelinePlanV200(subtaskMetas, scopeDetails, connection) + if err != nil { + return nil, nil, err + } + scopes, err := makeScopesV200(scopeDetails, connection) + return plan, scopes, err +} + +func makePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + scopeDetails []*srvhelper.ScopeDetail[models.Service, models.RootlyScopeConfig], + connection *models.RootlyConnection, +) (coreModels.PipelinePlan, errors.Error) { + plan := make(coreModels.PipelinePlan, len(scopeDetails)) + for i, scopeDetail := range scopeDetails { + stage := plan[i] + if stage == nil { + stage = coreModels.PipelineStage{} + } + + scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig + task, err := api.MakePipelinePlanTask( + "rootly", + subtaskMetas, + scopeConfig.Entities, + tasks.RootlyOptions{ + ConnectionId: connection.ID, + ServiceId: scope.Id, + }, + ) + if err != nil { + return nil, err + } + stage = append(stage, task) + plan[i] = stage + } + + return plan, nil +} + +func makeScopesV200( + scopeDetails []*srvhelper.ScopeDetail[models.Service, models.RootlyScopeConfig], + connection *models.RootlyConnection, +) ([]plugin.Scope, errors.Error) { + scopes := make([]plugin.Scope, 0, len(scopeDetails)) + + idgen := didgen.NewDomainIdGenerator(&models.Service{}) + for _, scopeDetail := range scopeDetails { + scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig + id := idgen.Generate(connection.ID, scope.Id) + + if utils.StringsContains(scopeConfig.Entities, plugin.DOMAIN_TYPE_TICKET) { + scopes = append(scopes, ticket.NewBoard(id, scope.Name)) + } + } + + return scopes, nil +} diff --git a/backend/plugins/rootly/api/connection_api.go b/backend/plugins/rootly/api/connection_api.go new file mode 100644 index 00000000000..eba67ba2953 --- /dev/null +++ b/backend/plugins/rootly/api/connection_api.go @@ -0,0 +1,155 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "context" + "net/http" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/rootly/models" +) + +func testConnection(ctx context.Context, connection models.RootlyConn) (*plugin.ApiResourceOutput, errors.Error) { + if vld != nil { + if err := vld.Struct(connection); err != nil { + return nil, errors.Default.Wrap(err, "error validating target") + } + } + apiClient, err := api.NewApiClientFromConnection(ctx, basicRes, &connection) + if err != nil { + return nil, err + } + response, err := apiClient.Get("users/current", nil, nil) + if err != nil { + return nil, err + } + if response.StatusCode == http.StatusUnauthorized { + return nil, errors.HttpStatus(http.StatusBadRequest).New("StatusUnauthorized error while testing connection") + } + if response.StatusCode == http.StatusOK { + return &plugin.ApiResourceOutput{Body: nil, Status: http.StatusOK}, nil + } + return &plugin.ApiResourceOutput{Body: nil, Status: response.StatusCode}, errors.HttpStatus(response.StatusCode).Wrap(err, "could not validate connection") +} + +// TestConnection test rootly connection +// @Summary test rootly connection +// @Description Test Rootly Connection +// @Tags plugins/rootly +// @Param body body models.RootlyConn true "json body" +// @Success 200 {object} shared.ApiBody "Success" +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/rootly/test [POST] +func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var connection models.RootlyConn + err := api.Decode(input.Body, &connection, vld) + if err != nil { + return nil, err + } + testConnectionResult, testConnectionErr := testConnection(context.TODO(), connection) + if testConnectionErr != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, testConnectionErr) + } + return testConnectionResult, nil +} + +// TestExistingConnection test rootly connection +// @Summary test rootly connection +// @Description Test Rootly Connection +// @Tags plugins/rootly +// @Param connectionId path int true "connection ID" +// @Success 200 {object} shared.ApiBody "Success" +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/rootly/connections/{connectionId}/test [POST] +func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection, err := dsHelper.ConnApi.GetMergedConnection(input) + if err != nil { + return nil, errors.BadInput.Wrap(err, "find connection from db") + } + if err := api.DecodeMapStruct(input.Body, connection, false); err != nil { + return nil, err + } + testConnectionResult, testConnectionErr := testConnection(context.TODO(), connection.RootlyConn) + if testConnectionErr != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, testConnectionErr) + } + return testConnectionResult, nil +} + +// @Summary create rootly connection +// @Description Create Rootly connection +// @Tags plugins/rootly +// @Param body body models.RootlyConnection true "json body" +// @Success 200 {object} models.RootlyConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/rootly/connections [POST] +func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Post(input) +} + +// @Summary patch rootly connection +// @Description Patch Rootly connection +// @Tags plugins/rootly +// @Param body body models.RootlyConnection true "json body" +// @Success 200 {object} models.RootlyConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/rootly/connections/{connectionId} [PATCH] +func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Patch(input) +} + +// @Summary delete rootly connection +// @Description Delete Rootly connection +// @Tags plugins/rootly +// @Success 200 {object} models.RootlyConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 409 {object} services.BlueprintProjectPairs "References exist to this connection" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/rootly/connections/{connectionId} [DELETE] +func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Delete(input) +} + +// @Summary list rootly connections +// @Description List Rootly connections +// @Tags plugins/rootly +// @Success 200 {object} models.RootlyConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/rootly/connections [GET] +func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.GetAll(input) +} + +// @Summary get rootly connection +// @Description Get Rootly connection +// @Tags plugins/rootly +// @Success 200 {object} models.RootlyConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/rootly/connections/{connectionId} [GET] +func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.GetDetail(input) +} diff --git a/backend/plugins/rootly/api/init.go b/backend/plugins/rootly/api/init.go new file mode 100644 index 00000000000..3c16101329b --- /dev/null +++ b/backend/plugins/rootly/api/init.go @@ -0,0 +1,55 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/rootly/models" + "github.com/go-playground/validator/v10" +) + +var vld *validator.Validate +var basicRes context.BasicRes + +var dsHelper *api.DsHelper[models.RootlyConnection, models.Service, models.RootlyScopeConfig] +var raProxy *api.DsRemoteApiProxyHelper[models.RootlyConnection] +var raScopeList *api.DsRemoteApiScopeListHelper[models.RootlyConnection, models.Service, RootlyRemotePagination] + +var raScopeSearch *api.DsRemoteApiScopeSearchHelper[models.RootlyConnection, models.Service] + +func Init(br context.BasicRes, p plugin.PluginMeta) { + vld = validator.New() + basicRes = br + dsHelper = api.NewDataSourceHelper[ + models.RootlyConnection, models.Service, models.RootlyScopeConfig, + ]( + br, + p.Name(), + []string{"name"}, + func(c models.RootlyConnection) models.RootlyConnection { + return c.Sanitize() + }, + nil, + nil, + ) + raProxy = api.NewDsRemoteApiProxyHelper[models.RootlyConnection](dsHelper.ConnApi.ModelApiHelper) + raScopeList = api.NewDsRemoteApiScopeListHelper[models.RootlyConnection, models.Service, RootlyRemotePagination](raProxy, listRootlyRemoteScopes) + raScopeSearch = api.NewDsRemoteApiScopeSearchHelper[models.RootlyConnection, models.Service](raProxy, searchRootlyRemoteScopes) +} diff --git a/backend/plugins/rootly/api/remote_api.go b/backend/plugins/rootly/api/remote_api.go new file mode 100644 index 00000000000..c79c15e99af --- /dev/null +++ b/backend/plugins/rootly/api/remote_api.go @@ -0,0 +1,201 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "net/http" + "net/url" + "strconv" + "time" + + "github.com/apache/incubator-devlake/core/models/common" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + dsmodels "github.com/apache/incubator-devlake/helpers/pluginhelper/api/models" + "github.com/apache/incubator-devlake/plugins/rootly/models" +) + +// RootlyRemotePagination holds JSON:API page-based pagination state. +type RootlyRemotePagination struct { + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +// ServiceResponse mirrors Rootly's JSON:API envelope for GET /services. +type ServiceResponse struct { + Data []struct { + Id string `json:"id"` + Type string `json:"type"` + Attributes struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description"` + HtmlUrl *string `json:"html_url"` + CreatedAt *time.Time `json:"created_at"` + } `json:"attributes"` + } `json:"data"` + Meta struct { + TotalCount int `json:"total_count"` + TotalPages int `json:"total_pages"` + CurrentPage int `json:"current_page"` + } `json:"meta"` +} + +func queryRootlyRemoteScopes( + apiClient plugin.ApiClient, + _ string, + page RootlyRemotePagination, + search string, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.Service], + nextPage *RootlyRemotePagination, + err errors.Error, +) { + if page.PerPage == 0 { + page.PerPage = 50 + } + if page.Page == 0 { + page.Page = 1 + } + query := url.Values{ + "page[number]": {strconv.Itoa(page.Page)}, + "page[size]": {strconv.Itoa(page.PerPage)}, + } + if search != "" { + query.Set("filter[search]", search) + } + var res *http.Response + res, err = apiClient.Get("services", query, nil) + if err != nil { + return + } + response := &ServiceResponse{} + err = api.UnmarshalResponse(res, response) + if err != nil { + return + } + for _, item := range response.Data { + htmlUrl := "" + if item.Attributes.HtmlUrl != nil { + htmlUrl = *item.Attributes.HtmlUrl + } + entry := dsmodels.DsRemoteApiScopeListEntry[models.Service]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + Id: item.Id, + Name: item.Attributes.Name, + FullName: item.Attributes.Name, + Data: &models.Service{ + Url: htmlUrl, + Id: item.Id, + Name: item.Attributes.Name, + Scope: common.Scope{ + NoPKModel: common.NoPKModel{}, + }, + }, + } + if item.Attributes.CreatedAt != nil { + entry.Data.Scope.NoPKModel.CreatedAt = *item.Attributes.CreatedAt + } + children = append(children, entry) + } + + if response.Meta.CurrentPage > 0 && response.Meta.CurrentPage < response.Meta.TotalPages { + nextPage = &RootlyRemotePagination{ + Page: page.Page + 1, + PerPage: page.PerPage, + } + } + + return +} + +func listRootlyRemoteScopes( + connection *models.RootlyConnection, + apiClient plugin.ApiClient, + groupId string, + page RootlyRemotePagination, +) ( + []dsmodels.DsRemoteApiScopeListEntry[models.Service], + *RootlyRemotePagination, + errors.Error, +) { + return queryRootlyRemoteScopes(apiClient, groupId, page, "") +} + +func searchRootlyRemoteScopes( + apiClient plugin.ApiClient, + params *dsmodels.DsRemoteApiScopeSearchParams, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.Service], + err errors.Error, +) { + page := params.Page + if page == 0 { + page = 1 + } + children, _, err = queryRootlyRemoteScopes(apiClient, "", RootlyRemotePagination{ + Page: page, + PerPage: params.PageSize, + }, params.Search) + return +} + +// RemoteScopes list all available scopes (services) for this connection +// @Summary list all available scopes (services) for this connection +// @Description list all available scopes (services) for this connection +// @Tags plugins/rootly +// @Accept application/json +// @Param connectionId path int false "connection ID" +// @Param groupId query string false "group ID" +// @Param pageToken query string false "page Token" +// @Success 200 {object} RemoteScopesOutput +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/rootly/connections/{connectionId}/remote-scopes [GET] +func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return raScopeList.Get(input) +} + +// SearchRemoteScopes use the Search API and only return project +// @Summary use the Search API and only return project +// @Description use the Search API and only return project +// @Tags plugins/rootly +// @Accept application/json +// @Param connectionId path int false "connection ID" +// @Param search query string false "search" +// @Param page query int false "page number" +// @Param pageSize query int false "page size per page" +// @Success 200 {object} SearchRemoteScopesOutput +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/rootly/connections/{connectionId}/search-remote-scopes [GET] +func SearchRemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return raScopeSearch.Get(input) +} + +// @Summary Remote server API proxy +// @Description Forward API requests to the specified remote server +// @Param connectionId path int true "connection ID" +// @Param path path string true "path to a API endpoint" +// @Tags plugins/rootly +// @Router /plugins/rootly/connections/{connectionId}/proxy/{path} [GET] +func Proxy(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return raProxy.Proxy(input) +} diff --git a/backend/plugins/rootly/api/scope_api.go b/backend/plugins/rootly/api/scope_api.go new file mode 100644 index 00000000000..7624f1bbba8 --- /dev/null +++ b/backend/plugins/rootly/api/scope_api.go @@ -0,0 +1,107 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/rootly/models" +) + +type PutScopesReqBody api.PutScopesReqBody[models.Service] +type ScopeDetail api.ScopeDetail[models.Service, models.RootlyScopeConfig] + +// PutScopes create or update rootly service +// @Summary create or update rootly service +// @Description Create or update rootly service +// @Tags plugins/rootly +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param scope body ScopeReq true "json" +// @Success 200 {object} []ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/rootly/connections/{connectionId}/scopes [PUT] +func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.PutMultiple(input) +} + +// PatchScope patch to rootly service +// @Summary patch to rootly service +// @Description patch to rootly service +// @Tags plugins/rootly +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param serviceId path string true "service ID" +// @Param scope body models.Service true "json" +// @Success 200 {object} models.Service +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/rootly/connections/{connectionId}/scopes/{serviceId} [PATCH] +func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.Patch(input) +} + +// GetScopeList get Rootly services +// @Summary get Rootly services +// @Description get Rootly services +// @Tags plugins/rootly +// @Param connectionId path int true "connection ID" +// @Param searchTerm query string false "search term for scope name" +// @Param pageSize query int false "page size, default 50" +// @Param page query int false "page size, default 1" +// @Param blueprints query bool false "also return blueprints using these scopes as part of the payload" +// @Success 200 {object} []ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/rootly/connections/{connectionId}/scopes/ [GET] +func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetPage(input) +} + +// GetScope get one Rootly service +// @Summary get one Rootly service +// @Description get one Rootly service +// @Tags plugins/rootly +// @Param connectionId path int true "connection ID" +// @Param serviceId path string true "service ID" +// @Param blueprints query bool false "also return blueprints using this scope as part of the payload" +// @Success 200 {object} ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/rootly/connections/{connectionId}/scopes/{serviceId} [GET] +func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetScopeDetail(input) +} + +// DeleteScope delete plugin data associated with the scope and optionally the scope itself +// @Summary delete plugin data associated with the scope and optionally the scope itself +// @Description delete data associated with plugin scope +// @Tags plugins/rootly +// @Param connectionId path int true "connection ID" +// @Param serviceId path string true "service ID" +// @Param delete_data_only query bool false "Only delete the scope data, not the scope itself" +// @Success 200 +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 409 {object} api.ScopeRefDoc "References exist to this scope" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/rootly/connections/{connectionId}/scopes/{serviceId} [DELETE] +func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.Delete(input) +} diff --git a/backend/plugins/rootly/api/scope_state_api.go b/backend/plugins/rootly/api/scope_state_api.go new file mode 100644 index 00000000000..01be7dc951e --- /dev/null +++ b/backend/plugins/rootly/api/scope_state_api.go @@ -0,0 +1,37 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" +) + +// GetScopeLatestSyncState get one rootly service's latest sync state +// @Summary get one rootly service's latest sync state +// @Description get one rootly service's latest sync state +// @Tags plugins/rootly +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Success 200 {object} []models.LatestSyncState +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/rootly/connections/{connectionId}/scopes/{scopeId}/latest-sync-state [GET] +func GetScopeLatestSyncState(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetScopeLatestSyncState(input) +} diff --git a/backend/plugins/rootly/api/swagger.go b/backend/plugins/rootly/api/swagger.go new file mode 100644 index 00000000000..aa60b939865 --- /dev/null +++ b/backend/plugins/rootly/api/swagger.go @@ -0,0 +1,32 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/plugins/rootly/tasks" +) + +type RootlyTaskOptions tasks.RootlyOptions + +// @Summary rootly task options for pipelines +// @Description This is a dummy API to demonstrate the available task options for rootly pipelines +// @Tags plugins/rootly +// @Accept application/json +// @Param pipeline body RootlyTaskOptions true "json" +// @Router /pipelines/rootly/pipeline-task [post] +func _() {} diff --git a/backend/plugins/rootly/impl/impl.go b/backend/plugins/rootly/impl/impl.go index 6cbb9b535b5..225ec5f479e 100644 --- a/backend/plugins/rootly/impl/impl.go +++ b/backend/plugins/rootly/impl/impl.go @@ -23,8 +23,10 @@ import ( "github.com/apache/incubator-devlake/core/context" "github.com/apache/incubator-devlake/core/dal" "github.com/apache/incubator-devlake/core/errors" + coreModels "github.com/apache/incubator-devlake/core/models" "github.com/apache/incubator-devlake/core/plugin" helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/rootly/api" "github.com/apache/incubator-devlake/plugins/rootly/models" "github.com/apache/incubator-devlake/plugins/rootly/models/migrationscripts" "github.com/apache/incubator-devlake/plugins/rootly/tasks" @@ -38,6 +40,7 @@ var _ interface { plugin.PluginTask plugin.PluginApi plugin.PluginModel + plugin.DataSourcePluginBlueprintV200 plugin.CloseablePluginTask plugin.PluginSource } = (*Rootly)(nil) @@ -53,6 +56,7 @@ func (p Rootly) Name() string { } func (p Rootly) Init(basicRes context.BasicRes) errors.Error { + api.Init(basicRes, p) return nil } @@ -123,7 +127,48 @@ func (p Rootly) MigrationScripts() []plugin.MigrationScript { } func (p Rootly) ApiResources() map[string]map[string]plugin.ApiResourceHandler { - return map[string]map[string]plugin.ApiResourceHandler{} + return map[string]map[string]plugin.ApiResourceHandler{ + "test": { + "POST": api.TestConnection, + }, + "connections": { + "POST": api.PostConnections, + "GET": api.ListConnections, + }, + "connections/:connectionId": { + "GET": api.GetConnection, + "PATCH": api.PatchConnection, + "DELETE": api.DeleteConnection, + }, + "connections/:connectionId/test": { + "POST": api.TestExistingConnection, + }, + "connections/:connectionId/remote-scopes": { + "GET": api.RemoteScopes, + }, + "connections/:connectionId/search-remote-scopes": { + "GET": api.SearchRemoteScopes, + }, + "connections/:connectionId/scopes": { + "GET": api.GetScopeList, + "PUT": api.PutScopes, + }, + "connections/:connectionId/scopes/:scopeId": { + "GET": api.GetScope, + "PATCH": api.PatchScope, + "DELETE": api.DeleteScope, + }, + "connections/:connectionId/scopes/:scopeId/latest-sync-state": { + "GET": api.GetScopeLatestSyncState, + }, + } +} + +func (p Rootly) MakeDataSourcePipelinePlanV200( + connectionId uint64, + scopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + return api.MakeDataSourcePipelinePlanV200(p.SubTaskMetas(), connectionId, scopes) } func (p Rootly) Close(taskCtx plugin.TaskContext) errors.Error { From b459586c67ab9395c19a9491d659952c202c5201 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Tue, 12 May 2026 13:58:11 -0700 Subject: [PATCH 03/14] feat(rootly): collect and convert services to domain boards Add services_collector, services_extractor, and service_converter subtasks. Collector fetches GET /services/{id} for the scoped service, unwraps the JSON:API envelope, and populates _tool_rootly_services. Converter emits one ticket.Board per service using the standard domain-id generator, feeding into the existing TICKET pipeline. Also harden remote-scope pagination termination to key off the page we requested rather than meta.current_page so a response missing that field does not silently truncate the service list. --- backend/plugins/rootly/api/remote_api.go | 2 +- backend/plugins/rootly/impl/impl.go | 6 +- backend/plugins/rootly/models/raw/service.go | 38 +++++++++ .../plugins/rootly/tasks/service_converter.go | 82 +++++++++++++++++++ .../rootly/tasks/services_collector.go | 75 +++++++++++++++++ .../rootly/tasks/services_extractor.go | 70 ++++++++++++++++ 6 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 backend/plugins/rootly/models/raw/service.go create mode 100644 backend/plugins/rootly/tasks/service_converter.go create mode 100644 backend/plugins/rootly/tasks/services_collector.go create mode 100644 backend/plugins/rootly/tasks/services_extractor.go diff --git a/backend/plugins/rootly/api/remote_api.go b/backend/plugins/rootly/api/remote_api.go index c79c15e99af..1f627c3117b 100644 --- a/backend/plugins/rootly/api/remote_api.go +++ b/backend/plugins/rootly/api/remote_api.go @@ -116,7 +116,7 @@ func queryRootlyRemoteScopes( children = append(children, entry) } - if response.Meta.CurrentPage > 0 && response.Meta.CurrentPage < response.Meta.TotalPages { + if page.Page < response.Meta.TotalPages { nextPage = &RootlyRemotePagination{ Page: page.Page + 1, PerPage: page.PerPage, diff --git a/backend/plugins/rootly/impl/impl.go b/backend/plugins/rootly/impl/impl.go index 225ec5f479e..2185f2a3970 100644 --- a/backend/plugins/rootly/impl/impl.go +++ b/backend/plugins/rootly/impl/impl.go @@ -73,7 +73,11 @@ func (p Rootly) ScopeConfig() dal.Tabler { } func (p Rootly) SubTaskMetas() []plugin.SubTaskMeta { - return []plugin.SubTaskMeta{} + return []plugin.SubTaskMeta{ + tasks.CollectServicesMeta, + tasks.ExtractServicesMeta, + tasks.ConvertServicesMeta, + } } func (p Rootly) GetTablesInfo() []dal.Tabler { diff --git a/backend/plugins/rootly/models/raw/service.go b/backend/plugins/rootly/models/raw/service.go new file mode 100644 index 00000000000..55f40393096 --- /dev/null +++ b/backend/plugins/rootly/models/raw/service.go @@ -0,0 +1,38 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package raw + +import "time" + +// Service is the JSON:API shape of a single Rootly service as returned +// by GET /services/{id}. The top-level Id lives on the JSON:API envelope; +// all display fields are nested under Attributes. +type Service struct { + Id string `json:"id"` + Type string `json:"type"` + Attributes ServiceAttributes `json:"attributes"` +} + +type ServiceAttributes struct { + Name string `json:"name"` + Slug *string `json:"slug"` + Description *string `json:"description"` + HtmlUrl *string `json:"html_url"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} diff --git a/backend/plugins/rootly/tasks/service_converter.go b/backend/plugins/rootly/tasks/service_converter.go new file mode 100644 index 00000000000..be5e890ff85 --- /dev/null +++ b/backend/plugins/rootly/tasks/service_converter.go @@ -0,0 +1,82 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "reflect" + + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/domainlayer" + "github.com/apache/incubator-devlake/core/models/domainlayer/didgen" + "github.com/apache/incubator-devlake/core/models/domainlayer/ticket" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/rootly/models" +) + +var ConvertServicesMeta = plugin.SubTaskMeta{ + Name: "convertServices", + EntryPoint: ConvertServices, + EnabledByDefault: true, + Description: "Convert Rootly services", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ConvertServices(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + data := taskCtx.GetData().(*RootlyTaskData) + rawDataSubTaskArgs := &helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Options: data.Options, + Table: RAW_SERVICES_TABLE, + } + clauses := []dal.Clause{ + dal.Select("services.*"), + dal.From("_tool_rootly_services services"), + dal.Where("id = ? and connection_id = ?", data.Options.ServiceId, data.Options.ConnectionId), + } + cursor, err := db.Cursor(clauses...) + if err != nil { + return err + } + defer cursor.Close() + + converter, err := helper.NewDataConverter(helper.DataConverterArgs{ + RawDataSubTaskArgs: *rawDataSubTaskArgs, + InputRowType: reflect.TypeOf(models.Service{}), + Input: cursor, + Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { + service := inputRow.(*models.Service) + domainBoard := &ticket.Board{ + DomainEntity: domainlayer.DomainEntity{ + Id: didgen.NewDomainIdGenerator(service).Generate(service.ConnectionId, service.Id), + }, + Name: service.Name, + Url: service.Url, + } + return []interface{}{ + domainBoard, + }, nil + }, + }) + if err != nil { + return err + } + return converter.Execute() +} diff --git a/backend/plugins/rootly/tasks/services_collector.go b/backend/plugins/rootly/tasks/services_collector.go new file mode 100644 index 00000000000..140fc7e464e --- /dev/null +++ b/backend/plugins/rootly/tasks/services_collector.go @@ -0,0 +1,75 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "net/http" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +const RAW_SERVICES_TABLE = "rootly_services" + +// singleServiceResponse is the JSON:API envelope returned by +// GET /services/{id}. Unlike list responses, `data` is an object, +// not an array. +type singleServiceResponse struct { + Data json.RawMessage `json:"data"` +} + +var _ plugin.SubTaskEntryPoint = CollectServices + +var CollectServicesMeta = plugin.SubTaskMeta{ + Name: "collectServices", + EntryPoint: CollectServices, + EnabledByDefault: true, + Description: "Collect Rootly services", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func CollectServices(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*RootlyTaskData) + collector, err := api.NewApiCollector(api.ApiCollectorArgs{ + RawDataSubTaskArgs: api.RawDataSubTaskArgs{ + Ctx: taskCtx, + Options: data.Options, + Table: RAW_SERVICES_TABLE, + }, + ApiClient: data.Client, + UrlTemplate: "services/{{ .Params.ScopeId }}", + Query: nil, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + rawResult := singleServiceResponse{} + err := api.UnmarshalResponse(res, &rawResult) + if err != nil { + return nil, err + } + if len(rawResult.Data) == 0 { + return []json.RawMessage{}, nil + } + return []json.RawMessage{rawResult.Data}, nil + }, + }) + if err != nil { + return err + } + return collector.Execute() +} diff --git a/backend/plugins/rootly/tasks/services_extractor.go b/backend/plugins/rootly/tasks/services_extractor.go new file mode 100644 index 00000000000..326eff92d77 --- /dev/null +++ b/backend/plugins/rootly/tasks/services_extractor.go @@ -0,0 +1,70 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/rootly/models" + "github.com/apache/incubator-devlake/plugins/rootly/models/raw" +) + +var _ plugin.SubTaskEntryPoint = ExtractServices + +var ExtractServicesMeta = plugin.SubTaskMeta{ + Name: "extractServices", + EntryPoint: ExtractServices, + EnabledByDefault: true, + Description: "Extract Rootly services", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ExtractServices(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*RootlyTaskData) + extractor, err := api.NewApiExtractor(api.ApiExtractorArgs{ + RawDataSubTaskArgs: api.RawDataSubTaskArgs{ + Ctx: taskCtx, + Options: data.Options, + Table: RAW_SERVICES_TABLE, + }, + Extract: func(row *api.RawData) ([]interface{}, errors.Error) { + rawService := &raw.Service{} + if err := errors.Convert(json.Unmarshal(row.Data, rawService)); err != nil { + return nil, err + } + url := "" + if rawService.Attributes.HtmlUrl != nil { + url = *rawService.Attributes.HtmlUrl + } + service := &models.Service{ + Id: rawService.Id, + Name: rawService.Attributes.Name, + Url: url, + } + service.ConnectionId = data.Options.ConnectionId + return []interface{}{service}, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} From 4ca12d2e1b96d2cb3a7d6a267694083f31d722bc Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Tue, 12 May 2026 14:29:29 -0700 Subject: [PATCH 04/14] feat(rootly): collect and extract incidents with inline users Finalize the Incident and User models and add role-specific user-id fields (creator, started_by, mitigated_by, resolved_by, closed_by) directly on the Incident row. Add the incidents_collector and incidents_extractor subtasks. The collector is single-phase, filter[services]-scoped and filter[updated_at][gt]-incremental; the extractor unwraps the JSON:API envelope and pulls inline nested user objects from the incident attributes, emitting deduplicated User rows per incident. Drop the Assignment table entirely. Rootly's incident data model is role-per-lifecycle-event, not a list of assignees, so PagerDuty's n-assignees shape does not fit. The schema and GetTablesInfo are adjusted accordingly. --- backend/plugins/rootly/impl/impl.go | 3 +- backend/plugins/rootly/models/assignment.go | 29 -- backend/plugins/rootly/models/incident.go | 29 +- .../20260512_add_init_tables.go | 1 - .../migrationscripts/archived/assignment.go | 37 -- .../migrationscripts/archived/incident.go | 35 +- backend/plugins/rootly/models/raw/incident.go | 98 ++++ backend/plugins/rootly/models/user.go | 3 + .../rootly/tasks/incidents_collector.go | 135 ++++++ .../rootly/tasks/incidents_extractor.go | 202 ++++++++ .../rootly/tasks/incidents_extractor_test.go | 443 ++++++++++++++++++ 11 files changed, 929 insertions(+), 86 deletions(-) delete mode 100644 backend/plugins/rootly/models/assignment.go delete mode 100644 backend/plugins/rootly/models/migrationscripts/archived/assignment.go create mode 100644 backend/plugins/rootly/models/raw/incident.go create mode 100644 backend/plugins/rootly/tasks/incidents_collector.go create mode 100644 backend/plugins/rootly/tasks/incidents_extractor.go create mode 100644 backend/plugins/rootly/tasks/incidents_extractor_test.go diff --git a/backend/plugins/rootly/impl/impl.go b/backend/plugins/rootly/impl/impl.go index 2185f2a3970..93f03ae8792 100644 --- a/backend/plugins/rootly/impl/impl.go +++ b/backend/plugins/rootly/impl/impl.go @@ -77,6 +77,8 @@ func (p Rootly) SubTaskMetas() []plugin.SubTaskMeta { tasks.CollectServicesMeta, tasks.ExtractServicesMeta, tasks.ConvertServicesMeta, + tasks.CollectIncidentsMeta, + tasks.ExtractIncidentsMeta, } } @@ -85,7 +87,6 @@ func (p Rootly) GetTablesInfo() []dal.Tabler { &models.Service{}, &models.Incident{}, &models.User{}, - &models.Assignment{}, &models.RootlyConnection{}, &models.RootlyScopeConfig{}, } diff --git a/backend/plugins/rootly/models/assignment.go b/backend/plugins/rootly/models/assignment.go deleted file mode 100644 index 52c3fbc4120..00000000000 --- a/backend/plugins/rootly/models/assignment.go +++ /dev/null @@ -1,29 +0,0 @@ -/* -Licensed to the Apache Software Foundation (ASF) under one or more -contributor license agreements. See the NOTICE file distributed with -this work for additional information regarding copyright ownership. -The ASF licenses this file to You under the Apache License, Version 2.0 -(the "License"); you may not use this file except in compliance with -the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package models - -import "github.com/apache/incubator-devlake/core/models/common" - -type Assignment struct { - common.NoPKModel - ConnectionId uint64 - IncidentId string `gorm:"primaryKey"` - UserId string `gorm:"primaryKey"` -} - -func (Assignment) TableName() string { return "_tool_rootly_assignments" } diff --git a/backend/plugins/rootly/models/incident.go b/backend/plugins/rootly/models/incident.go index 65c55278ae6..b638aa434b5 100644 --- a/backend/plugins/rootly/models/incident.go +++ b/backend/plugins/rootly/models/incident.go @@ -18,14 +18,37 @@ limitations under the License. package models import ( + "time" + "github.com/apache/incubator-devlake/core/models/common" ) type Incident struct { common.NoPKModel - ConnectionId uint64 `gorm:"primaryKey"` - Id string `gorm:"primaryKey;autoIncrement:false"` - ServiceId string + ConnectionId uint64 `gorm:"primaryKey"` + Id string `gorm:"primaryKey;autoIncrement:false"` + Number int + ServiceId string + Url string + Title string + Summary string + Status string + Severity string + Urgency string + StartedDate time.Time + AcknowledgedDate *time.Time + MitigatedDate *time.Time + ResolvedDate *time.Time + UpdatedDate time.Time + // Role-specific user ids, extracted from the nested user objects on + // attributes.user (creator), started_by, mitigated_by, resolved_by, + // and closed_by. Any may be empty if the role was not filled (e.g., + // an unresolved incident has no ResolvedByUserId). + CreatorUserId string + StartedByUserId string + MitigatedByUserId string + ResolvedByUserId string + ClosedByUserId string } func (Incident) TableName() string { return "_tool_rootly_incidents" } diff --git a/backend/plugins/rootly/models/migrationscripts/20260512_add_init_tables.go b/backend/plugins/rootly/models/migrationscripts/20260512_add_init_tables.go index a8d06269c24..ec4ee5fcad6 100644 --- a/backend/plugins/rootly/models/migrationscripts/20260512_add_init_tables.go +++ b/backend/plugins/rootly/models/migrationscripts/20260512_add_init_tables.go @@ -32,7 +32,6 @@ func (*addInitTables) Up(baseRes context.BasicRes) errors.Error { &archived.Service{}, &archived.Incident{}, &archived.User{}, - &archived.Assignment{}, &archived.ScopeConfig{}, ) } diff --git a/backend/plugins/rootly/models/migrationscripts/archived/assignment.go b/backend/plugins/rootly/models/migrationscripts/archived/assignment.go deleted file mode 100644 index 0f77dab61d7..00000000000 --- a/backend/plugins/rootly/models/migrationscripts/archived/assignment.go +++ /dev/null @@ -1,37 +0,0 @@ -/* -Licensed to the Apache Software Foundation (ASF) under one or more -contributor license agreements. See the NOTICE file distributed with -this work for additional information regarding copyright ownership. -The ASF licenses this file to You under the Apache License, Version 2.0 -(the "License"); you may not use this file except in compliance with -the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package archived - -import ( - "time" - - "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" -) - -type Assignment struct { - archived.NoPKModel - ConnectionId uint64 - IncidentId string `gorm:"primaryKey"` - UserId string `gorm:"primaryKey"` - AssignedAt time.Time - Role string -} - -func (Assignment) TableName() string { - return "_tool_rootly_assignments" -} diff --git a/backend/plugins/rootly/models/migrationscripts/archived/incident.go b/backend/plugins/rootly/models/migrationscripts/archived/incident.go index 54449cdc5af..b6ca97529de 100644 --- a/backend/plugins/rootly/models/migrationscripts/archived/incident.go +++ b/backend/plugins/rootly/models/migrationscripts/archived/incident.go @@ -25,21 +25,26 @@ import ( type Incident struct { archived.NoPKModel - ConnectionId uint64 `gorm:"primaryKey"` - Id string `gorm:"primaryKey;autoIncrement:false"` - Number int - ServiceId string - Url string - Title string - Summary string - Status string - Severity string - Urgency string - StartedDate time.Time - AcknowledgedDate *time.Time - MitigatedDate *time.Time - ResolvedDate *time.Time - UpdatedDate time.Time + ConnectionId uint64 `gorm:"primaryKey"` + Id string `gorm:"primaryKey;autoIncrement:false"` + Number int + ServiceId string + Url string + Title string + Summary string + Status string + Severity string + Urgency string + StartedDate time.Time + AcknowledgedDate *time.Time + MitigatedDate *time.Time + ResolvedDate *time.Time + UpdatedDate time.Time + CreatorUserId string + StartedByUserId string + MitigatedByUserId string + ResolvedByUserId string + ClosedByUserId string } func (Incident) TableName() string { diff --git a/backend/plugins/rootly/models/raw/incident.go b/backend/plugins/rootly/models/raw/incident.go new file mode 100644 index 00000000000..0d733c1c0b0 --- /dev/null +++ b/backend/plugins/rootly/models/raw/incident.go @@ -0,0 +1,98 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package raw + +import ( + "encoding/json" + "time" +) + +// Incident is the JSON:API envelope for a Rootly incident as returned by +// GET /incidents. The top-level Id is the incident id; display fields live +// under Attributes, and Relationships carries cross-entity references +// (services, users) that we may or may not consult during extraction. +type Incident struct { + Id string `json:"id"` + Type string `json:"type"` + Attributes IncidentAttributes `json:"attributes"` + Relationships json.RawMessage `json:"relationships"` +} + +// IncidentAttributes carries the display fields for a Rootly incident. +// +// The severity response shape is defensive: Rootly may return severity +// as a flat slug string or as a nested object; we accept either. The +// extractor prefers SeverityObj.Slug when non-nil, otherwise falls back +// to SeveritySlug, otherwise empty string. +// +// Role-bearing user objects (User, StartedBy, MitigatedBy, ResolvedBy, +// ClosedBy) are nested user records inlined directly on the incident — +// NOT JSON:API-wrapped and NOT surfaced through a relationships +// `included` array. Any of them may be nil if the role was not filled. +type IncidentAttributes struct { + SequentialId *int `json:"sequential_id"` + Title string `json:"title"` + Summary *string `json:"summary"` + Url *string `json:"url"` + Status string `json:"status"` + SeveritySlug *string `json:"severity"` + SeverityObj *SeverityAttrs `json:"severity_attributes"` + Urgency *string `json:"urgency"` + StartedAt time.Time `json:"started_at"` + AcknowledgedAt *time.Time `json:"acknowledged_at"` + MitigatedAt *time.Time `json:"mitigated_at"` + ResolvedAt *time.Time `json:"resolved_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Role-bearing user objects. Each is a nullable nested user record. + // User is the incident creator; the others track lifecycle actors. + User *NestedUser `json:"user"` + StartedBy *NestedUser `json:"started_by"` + MitigatedBy *NestedUser `json:"mitigated_by"` + ResolvedBy *NestedUser `json:"resolved_by"` + ClosedBy *NestedUser `json:"closed_by"` +} + +type SeverityAttrs struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +// NestedUser is the shape of a user object as inlined on an incident's +// attributes. Plain JSON — NOT a JSON:API envelope. Rootly exposes the +// display name under either `name` or `full_name` depending on endpoint +// shape; the extractor prefers FullName when non-empty. +type NestedUser struct { + Id string `json:"id"` + Email *string `json:"email"` + Name *string `json:"name"` + FullName *string `json:"full_name"` + Url *string `json:"url"` +} + +// IncidentRelationships is a narrow view of the JSON:API relationships +// envelope used only for the safety-net service-scope check. It ignores +// every relationship type except services. +type IncidentRelationships struct { + Services struct { + Data []struct { + Id string `json:"id"` + Type string `json:"type"` + } `json:"data"` + } `json:"services"` +} diff --git a/backend/plugins/rootly/models/user.go b/backend/plugins/rootly/models/user.go index 271287bcc24..03569d107a1 100644 --- a/backend/plugins/rootly/models/user.go +++ b/backend/plugins/rootly/models/user.go @@ -23,6 +23,9 @@ type User struct { common.NoPKModel ConnectionId uint64 `gorm:"primaryKey"` Id string `gorm:"primaryKey;autoIncrement:false"` + Email string + Name string + Url string } func (User) TableName() string { return "_tool_rootly_users" } diff --git a/backend/plugins/rootly/tasks/incidents_collector.go b/backend/plugins/rootly/tasks/incidents_collector.go new file mode 100644 index 00000000000..ea17055a435 --- /dev/null +++ b/backend/plugins/rootly/tasks/incidents_collector.go @@ -0,0 +1,135 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +const RAW_INCIDENTS_TABLE = "rootly_incidents" + +var _ plugin.SubTaskEntryPoint = CollectIncidents + +// collectedIncidents is the JSON:API envelope for a paginated list of +// incidents. `data` is an array of raw resource objects (id, type, +// attributes, relationships) — one per incident — and `meta`/`links` +// drive pagination termination. +type collectedIncidents struct { + Data []json.RawMessage `json:"data"` + Meta *collectedListMeta `json:"meta"` + Links *collectedListLinks `json:"links"` +} + +type collectedListMeta struct { + CurrentPage *int `json:"current_page"` + TotalPages *int `json:"total_pages"` + TotalCount *int `json:"total_count"` +} + +type collectedListLinks struct { + Next *string `json:"next"` +} + +var CollectIncidentsMeta = plugin.SubTaskMeta{ + Name: "collectIncidents", + EntryPoint: CollectIncidents, + EnabledByDefault: true, + Description: "Collect Rootly incidents", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, + ProductTables: []string{RAW_INCIDENTS_TABLE}, +} + +func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*RootlyTaskData) + args := api.RawDataSubTaskArgs{ + Ctx: taskCtx, + Options: data.Options, + Table: RAW_INCIDENTS_TABLE, + } + collector, err := api.NewStatefulApiCollectorForFinalizableEntity(api.FinalizableApiCollectorArgs{ + RawDataSubTaskArgs: args, + ApiClient: data.Client, + CollectNewRecordsByList: api.FinalizableApiCollectorListArgs{ + PageSize: 100, + // GetNextPageCustomData terminates pagination by reading the + // JSON:API links.next / meta.current_page / meta.total_pages + // fields from the previous response. If either signal says + // "no more pages", return ErrFinishCollect. + GetNextPageCustomData: func(prevReqData *api.RequestData, prevPageResponse *http.Response) (interface{}, errors.Error) { + if prevPageResponse == nil { + return nil, nil + } + parsed := collectedIncidents{} + if perr := api.UnmarshalResponse(prevPageResponse, &parsed); perr != nil { + return nil, perr + } + if parsed.Links != nil && parsed.Links.Next != nil && *parsed.Links.Next != "" { + return nil, nil + } + if parsed.Meta != nil && parsed.Meta.CurrentPage != nil && parsed.Meta.TotalPages != nil { + if *parsed.Meta.CurrentPage >= *parsed.Meta.TotalPages { + return nil, api.ErrFinishCollect + } + return nil, nil + } + // No signal either way — if the page came back empty, + // stop. Otherwise continue and let the next empty page + // terminate us. + if len(parsed.Data) == 0 { + return nil, api.ErrFinishCollect + } + return nil, nil + }, + FinalizableApiCollectorCommonArgs: api.FinalizableApiCollectorCommonArgs{ + UrlTemplate: "incidents", + Query: func(reqData *api.RequestData, createdAfter *time.Time) (url.Values, errors.Error) { + query := url.Values{} + query.Set("filter[services]", data.Options.ServiceId) + query.Set("page[size]", fmt.Sprintf("%d", reqData.Pager.Size)) + // Rootly's JSON:API pagination is 1-based. + pageNumber := reqData.Pager.Skip/reqData.Pager.Size + 1 + query.Set("page[number]", fmt.Sprintf("%d", pageNumber)) + query.Set("sort", "-updated_at") + if createdAfter != nil { + query.Set("filter[updated_at][gt]", createdAfter.UTC().Format(time.RFC3339)) + } + return query, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + rawResult := collectedIncidents{} + if err := api.UnmarshalResponse(res, &rawResult); err != nil { + return nil, err + } + return rawResult.Data, nil + }, + }, + }, + }) + if err != nil { + return err + } + return collector.Execute() +} diff --git a/backend/plugins/rootly/tasks/incidents_extractor.go b/backend/plugins/rootly/tasks/incidents_extractor.go new file mode 100644 index 00000000000..3295c0520ff --- /dev/null +++ b/backend/plugins/rootly/tasks/incidents_extractor.go @@ -0,0 +1,202 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/rootly/models" + "github.com/apache/incubator-devlake/plugins/rootly/models/raw" +) + +var _ plugin.SubTaskEntryPoint = ExtractIncidents + +var ExtractIncidentsMeta = plugin.SubTaskMeta{ + Name: "extractIncidents", + EntryPoint: ExtractIncidents, + EnabledByDefault: true, + Description: "Extract Rootly incidents", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ExtractIncidents(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*RootlyTaskData) + extractor, err := api.NewApiExtractor(api.ApiExtractorArgs{ + RawDataSubTaskArgs: api.RawDataSubTaskArgs{ + Ctx: taskCtx, + Options: data.Options, + Table: RAW_INCIDENTS_TABLE, + }, + Extract: func(row *api.RawData) ([]interface{}, errors.Error) { + return extractRootlyIncident(row.Data, data.Options) + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} + +// extractRootlyIncident is the pure-function core of the extractor: +// take a raw JSON:API incident envelope + the task options, return the +// tool-layer rows to persist. Factored out of the closure so it can be +// unit-tested without a SubTaskContext. +// +// Output shape per incident: +// - exactly one *models.Incident (always), with role-specific user-id +// fields populated from the nested attributes.user / started_by / +// mitigated_by / resolved_by / closed_by blocks +// - zero-to-N *models.User rows, one per distinct user id seen across +// those role fields (deduplicated within a single incident — if the +// same user is both creator and resolver, only one User row is emitted) +// +// The nested user objects are plain JSON on the incident's attributes, +// NOT JSON:API-wrapped and NOT surfaced through a relationships +// `included` array. That is the whole reason this extractor can emit +// users directly without a separate users-collector. +func extractRootlyIncident(rawData []byte, op *RootlyOptions) ([]interface{}, errors.Error) { + rawIncident := &raw.Incident{} + if err := errors.Convert(json.Unmarshal(rawData, rawIncident)); err != nil { + return nil, err + } + + // Safety-net scope filter. The collector already sends + // filter[services]=, but if Rootly ever returns an + // incident that touches multiple services (or the filter regresses), + // dropping anything whose relationships.services.data does not + // include our scoped service keeps the tool table clean. When the + // envelope has no relationships at all we accept the incident — the + // API-side filter is the only scoping signal we have. + if len(rawIncident.Relationships) > 0 { + relationships := raw.IncidentRelationships{} + // Ignore unmarshal errors here: a malformed relationships block + // should not fail the entire row — fall through to accept. + if err := json.Unmarshal(rawIncident.Relationships, &relationships); err == nil { + if len(relationships.Services.Data) > 0 && !containsService(relationships.Services.Data, op.ServiceId) { + return nil, nil + } + } + } + + if rawIncident.Attributes.StartedAt.IsZero() { + return nil, errors.Default.New("rootly incident missing started_at") + } + + incident := &models.Incident{ + ConnectionId: op.ConnectionId, + Id: rawIncident.Id, + Number: resolveInt(rawIncident.Attributes.SequentialId), + ServiceId: op.ServiceId, + Url: resolve(rawIncident.Attributes.Url), + Title: rawIncident.Attributes.Title, + Summary: resolve(rawIncident.Attributes.Summary), + Status: rawIncident.Attributes.Status, + Severity: resolveSeverity(rawIncident.Attributes), + Urgency: resolve(rawIncident.Attributes.Urgency), + StartedDate: rawIncident.Attributes.StartedAt, + AcknowledgedDate: rawIncident.Attributes.AcknowledgedAt, + MitigatedDate: rawIncident.Attributes.MitigatedAt, + ResolvedDate: rawIncident.Attributes.ResolvedAt, + UpdatedDate: rawIncident.Attributes.UpdatedAt, + } + + results := []interface{}{incident} + seen := map[string]bool{} + addUser := func(u *raw.NestedUser, setRoleId func(string)) { + if u == nil || u.Id == "" { + return + } + setRoleId(u.Id) + if seen[u.Id] { + return + } + seen[u.Id] = true + results = append(results, &models.User{ + ConnectionId: op.ConnectionId, + Id: u.Id, + Email: resolve(u.Email), + Name: pickUserName(u), + Url: resolve(u.Url), + }) + } + addUser(rawIncident.Attributes.User, func(id string) { incident.CreatorUserId = id }) + addUser(rawIncident.Attributes.StartedBy, func(id string) { incident.StartedByUserId = id }) + addUser(rawIncident.Attributes.MitigatedBy, func(id string) { incident.MitigatedByUserId = id }) + addUser(rawIncident.Attributes.ResolvedBy, func(id string) { incident.ResolvedByUserId = id }) + addUser(rawIncident.Attributes.ClosedBy, func(id string) { incident.ClosedByUserId = id }) + + return results, nil +} + +// pickUserName chooses the best display name for a nested user: +// FullName when set, otherwise Name, otherwise Email, otherwise empty. +// Email is a last-resort fallback so the User row is never nameless. +func pickUserName(u *raw.NestedUser) string { + if u.FullName != nil && *u.FullName != "" { + return *u.FullName + } + if u.Name != nil && *u.Name != "" { + return *u.Name + } + if u.Email != nil { + return *u.Email + } + return "" +} + +// containsService checks whether the given service id appears in the +// JSON:API relationships.services.data array. +func containsService(data []struct { + Id string `json:"id"` + Type string `json:"type"` +}, serviceId string) bool { + for _, s := range data { + if s.Id == serviceId { + return true + } + } + return false +} + +// resolveSeverity picks whichever severity shape Rootly returned: +// a nested severity_attributes.slug if present, else the flat +// `severity` field. See raw.IncidentAttributes for the shape +// decision deferred to implementation. +func resolveSeverity(attrs raw.IncidentAttributes) string { + if attrs.SeverityObj != nil && attrs.SeverityObj.Slug != "" { + return attrs.SeverityObj.Slug + } + return resolve(attrs.SeveritySlug) +} + +func resolve[T any](t *T) T { + if t == nil { + return *new(T) + } + return *t +} + +func resolveInt(i *int) int { + if i == nil { + return 0 + } + return *i +} diff --git a/backend/plugins/rootly/tasks/incidents_extractor_test.go b/backend/plugins/rootly/tasks/incidents_extractor_test.go new file mode 100644 index 00000000000..8378631dbc2 --- /dev/null +++ b/backend/plugins/rootly/tasks/incidents_extractor_test.go @@ -0,0 +1,443 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/incubator-devlake/plugins/rootly/models" +) + +// buildRawIncident produces a minimally-valid JSON:API incident envelope +// so individual tests can override only the fields they exercise. When +// overrides is non-empty it is used verbatim as the raw payload. +func buildRawIncident(overrides string) []byte { + base := `{ + "id": "inc_01", + "type": "incidents", + "attributes": { + "sequential_id": 42, + "title": "db outage", + "summary": "replica lag blew past threshold", + "url": "https://rootly.example.com/incidents/inc_01", + "status": "started", + "severity": "sev1", + "urgency": "high", + "started_at": "2026-05-10T10:00:00Z", + "updated_at": "2026-05-10T10:05:00Z", + "user": { + "id": "usr_100", + "email": "reporter@example.com", + "full_name": "Reporter One" + } + }, + "relationships": { + "services": { + "data": [{"id": "svc_02", "type": "services"}] + } + } + }` + if overrides != "" { + return []byte(overrides) + } + return []byte(base) +} + +func newTestOptions() *RootlyOptions { + return &RootlyOptions{ + ConnectionId: 7, + ServiceId: "svc_02", + } +} + +// collectUsers pulls the *models.User rows out of a heterogeneous result +// slice so individual tests can make assertions without worrying about +// the incident row's ordering. +func collectUsers(results []interface{}) []*models.User { + users := []*models.User{} + for _, r := range results { + if u, ok := r.(*models.User); ok { + users = append(users, u) + } + } + return users +} + +// TestExtractRootlyIncident_HappyPathActive covers the base case: a +// started incident with a creator user in attributes.user produces one +// Incident row (with CreatorUserId populated) and one User row. +func TestExtractRootlyIncident_HappyPathActive(t *testing.T) { + op := newTestOptions() + results, err := extractRootlyIncident(buildRawIncident(""), op) + require.NoError(t, err) + require.Len(t, results, 2) + + incident, ok := results[0].(*models.Incident) + require.True(t, ok, "first result should be *models.Incident") + assert.Equal(t, uint64(7), incident.ConnectionId) + assert.Equal(t, "inc_01", incident.Id) + assert.Equal(t, 42, incident.Number) + assert.Equal(t, "svc_02", incident.ServiceId) + assert.Equal(t, "db outage", incident.Title) + assert.Equal(t, "replica lag blew past threshold", incident.Summary) + assert.Equal(t, "https://rootly.example.com/incidents/inc_01", incident.Url) + assert.Equal(t, "started", incident.Status) + assert.Equal(t, "sev1", incident.Severity) + assert.Equal(t, "high", incident.Urgency) + assert.Equal(t, time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC), incident.StartedDate) + assert.Nil(t, incident.AcknowledgedDate) + assert.Nil(t, incident.MitigatedDate) + assert.Nil(t, incident.ResolvedDate) + assert.Equal(t, time.Date(2026, 5, 10, 10, 5, 0, 0, time.UTC), incident.UpdatedDate) + + assert.Equal(t, "usr_100", incident.CreatorUserId) + assert.Empty(t, incident.StartedByUserId) + assert.Empty(t, incident.MitigatedByUserId) + assert.Empty(t, incident.ResolvedByUserId) + assert.Empty(t, incident.ClosedByUserId) + + users := collectUsers(results) + require.Len(t, users, 1) + assert.Equal(t, "usr_100", users[0].Id) + assert.Equal(t, uint64(7), users[0].ConnectionId) + assert.Equal(t, "Reporter One", users[0].Name) + assert.Equal(t, "reporter@example.com", users[0].Email) +} + +// TestExtractRootlyIncident_Resolved verifies that a resolved incident +// populates AcknowledgedDate / MitigatedDate / ResolvedDate as non-nil +// pointers AND populates CreatorUserId + ResolvedByUserId from the +// nested user objects. Both users are emitted as User rows. +func TestExtractRootlyIncident_Resolved(t *testing.T) { + raw := []byte(`{ + "id": "inc_02", + "type": "incidents", + "attributes": { + "sequential_id": 43, + "title": "cache cleared", + "status": "resolved", + "severity": "sev3", + "started_at": "2026-05-09T08:00:00Z", + "acknowledged_at": "2026-05-09T08:05:00Z", + "mitigated_at": "2026-05-09T08:30:00Z", + "resolved_at": "2026-05-09T09:00:00Z", + "updated_at": "2026-05-09T09:01:00Z", + "user": {"id": "usr_100", "full_name": "Reporter One"}, + "resolved_by": {"id": "usr_200", "full_name": "Resolver Two"} + }, + "relationships": { + "services": {"data": [{"id": "svc_02", "type": "services"}]} + } + }`) + op := newTestOptions() + results, err := extractRootlyIncident(raw, op) + require.NoError(t, err) + require.Len(t, results, 3) + + incident := results[0].(*models.Incident) + require.NotNil(t, incident.AcknowledgedDate) + require.NotNil(t, incident.MitigatedDate) + require.NotNil(t, incident.ResolvedDate) + assert.Equal(t, "resolved", incident.Status) + assert.Equal(t, time.Date(2026, 5, 9, 9, 0, 0, 0, time.UTC), *incident.ResolvedDate) + assert.Equal(t, time.Date(2026, 5, 9, 8, 30, 0, 0, time.UTC), *incident.MitigatedDate) + assert.Equal(t, time.Date(2026, 5, 9, 8, 5, 0, 0, time.UTC), *incident.AcknowledgedDate) + + assert.Equal(t, "usr_100", incident.CreatorUserId) + assert.Equal(t, "usr_200", incident.ResolvedByUserId) + + users := collectUsers(results) + require.Len(t, users, 2) + ids := map[string]string{} + for _, u := range users { + ids[u.Id] = u.Name + } + assert.Equal(t, "Reporter One", ids["usr_100"]) + assert.Equal(t, "Resolver Two", ids["usr_200"]) +} + +// TestExtractRootlyIncident_MissingOptionalTimestamps asserts that +// missing mitigated_at and resolved_at yield nil pointers rather than +// zero-time values (which would pollute downstream DORA math). +func TestExtractRootlyIncident_MissingOptionalTimestamps(t *testing.T) { + raw := []byte(`{ + "id": "inc_03", + "type": "incidents", + "attributes": { + "sequential_id": 44, + "title": "ongoing issue", + "status": "started", + "started_at": "2026-05-10T12:00:00Z", + "updated_at": "2026-05-10T12:05:00Z" + }, + "relationships": { + "services": {"data": [{"id": "svc_02", "type": "services"}]} + } + }`) + op := newTestOptions() + results, err := extractRootlyIncident(raw, op) + require.NoError(t, err) + require.Len(t, results, 1) + incident := results[0].(*models.Incident) + assert.Nil(t, incident.MitigatedDate) + assert.Nil(t, incident.ResolvedDate) + assert.Nil(t, incident.AcknowledgedDate) +} + +// TestExtractRootlyIncident_SeverityObjectShape covers the defensive +// alternate response shape: when severity comes in as a nested +// severity_attributes object, the extractor prefers its Slug over +// the flat `severity` field. +func TestExtractRootlyIncident_SeverityObjectShape(t *testing.T) { + raw := []byte(`{ + "id": "inc_04", + "type": "incidents", + "attributes": { + "sequential_id": 45, + "title": "nested severity", + "status": "mitigated", + "severity_attributes": {"slug": "sev0", "name": "Critical"}, + "started_at": "2026-05-10T14:00:00Z", + "updated_at": "2026-05-10T14:05:00Z" + }, + "relationships": { + "services": {"data": [{"id": "svc_02", "type": "services"}]} + } + }`) + op := newTestOptions() + results, err := extractRootlyIncident(raw, op) + require.NoError(t, err) + require.Len(t, results, 1) + incident := results[0].(*models.Incident) + assert.Equal(t, "sev0", incident.Severity) +} + +// TestExtractRootlyIncident_NoRolesFilled verifies that an incident +// with every role-bearing user field null produces exactly one result +// (the incident row) with all role user-id fields empty and zero User +// rows. +func TestExtractRootlyIncident_NoRolesFilled(t *testing.T) { + raw := []byte(`{ + "id": "inc_05", + "type": "incidents", + "attributes": { + "sequential_id": 46, + "title": "ghost incident", + "status": "started", + "started_at": "2026-05-10T15:00:00Z", + "updated_at": "2026-05-10T15:05:00Z", + "user": null, + "started_by": null, + "mitigated_by": null, + "resolved_by": null, + "closed_by": null + }, + "relationships": { + "services": {"data": [{"id": "svc_02", "type": "services"}]} + } + }`) + op := newTestOptions() + results, err := extractRootlyIncident(raw, op) + require.NoError(t, err) + require.Len(t, results, 1) + incident := results[0].(*models.Incident) + assert.Empty(t, incident.CreatorUserId) + assert.Empty(t, incident.StartedByUserId) + assert.Empty(t, incident.MitigatedByUserId) + assert.Empty(t, incident.ResolvedByUserId) + assert.Empty(t, incident.ClosedByUserId) + assert.Empty(t, collectUsers(results)) +} + +// TestExtractRootlyIncident_SameUserInMultipleRoles verifies the +// dedupe invariant: if one person is both the creator and the +// resolver, only one User row is emitted but BOTH role id fields on +// the incident point to that user. +func TestExtractRootlyIncident_SameUserInMultipleRoles(t *testing.T) { + raw := []byte(`{ + "id": "inc_dup", + "type": "incidents", + "attributes": { + "sequential_id": 47, + "title": "solo fire", + "status": "resolved", + "started_at": "2026-05-10T16:00:00Z", + "resolved_at": "2026-05-10T16:30:00Z", + "updated_at": "2026-05-10T16:31:00Z", + "user": {"id": "usr_100", "full_name": "Solo Operator"}, + "resolved_by": {"id": "usr_100", "full_name": "Solo Operator"} + }, + "relationships": { + "services": {"data": [{"id": "svc_02", "type": "services"}]} + } + }`) + op := newTestOptions() + results, err := extractRootlyIncident(raw, op) + require.NoError(t, err) + require.Len(t, results, 2, "one incident + one deduped user") + + incident := results[0].(*models.Incident) + assert.Equal(t, "usr_100", incident.CreatorUserId) + assert.Equal(t, "usr_100", incident.ResolvedByUserId) + + users := collectUsers(results) + require.Len(t, users, 1) + assert.Equal(t, "usr_100", users[0].Id) + assert.Equal(t, "Solo Operator", users[0].Name) +} + +// TestExtractRootlyIncident_UserNamePreference verifies the name +// preference order: FullName > Name > Email > empty string. Three +// users exercise the three fallbacks in a single incident. +func TestExtractRootlyIncident_UserNamePreference(t *testing.T) { + raw := []byte(`{ + "id": "inc_names", + "type": "incidents", + "attributes": { + "sequential_id": 48, + "title": "name preference", + "status": "started", + "started_at": "2026-05-10T17:00:00Z", + "updated_at": "2026-05-10T17:05:00Z", + "user": {"id": "usr_full", "full_name": "Full Name", "name": "Ignored", "email": "ignored@example.com"}, + "started_by": {"id": "usr_short", "name": "Short Name", "email": "ignored@example.com"}, + "resolved_by": {"id": "usr_mail", "email": "fallback@example.com"} + }, + "relationships": { + "services": {"data": [{"id": "svc_02", "type": "services"}]} + } + }`) + op := newTestOptions() + results, err := extractRootlyIncident(raw, op) + require.NoError(t, err) + + users := collectUsers(results) + require.Len(t, users, 3) + byId := map[string]*models.User{} + for _, u := range users { + byId[u.Id] = u + } + require.Contains(t, byId, "usr_full") + require.Contains(t, byId, "usr_short") + require.Contains(t, byId, "usr_mail") + assert.Equal(t, "Full Name", byId["usr_full"].Name) + assert.Equal(t, "Short Name", byId["usr_short"].Name) + assert.Equal(t, "fallback@example.com", byId["usr_mail"].Name) +} + +// TestExtractRootlyIncident_WrongServiceSkipped asserts the safety-net +// scope filter: if the incident's relationships don't include the +// configured ServiceId, the extractor returns an empty slice and no +// error. This protects us from multi-service incidents leaking into +// the wrong scope even if the API-side filter[services] query failed. +func TestExtractRootlyIncident_WrongServiceSkipped(t *testing.T) { + raw := []byte(`{ + "id": "inc_wrong_svc", + "type": "incidents", + "attributes": { + "sequential_id": 49, + "title": "other service", + "status": "started", + "started_at": "2026-05-10T18:00:00Z", + "updated_at": "2026-05-10T18:05:00Z" + }, + "relationships": { + "services": {"data": [{"id": "svc_99", "type": "services"}]} + } + }`) + op := newTestOptions() + results, err := extractRootlyIncident(raw, op) + require.NoError(t, err) + assert.Empty(t, results, "incident for unrelated service should produce no rows") +} + +// TestExtractRootlyIncident_NoRelationshipsAccepted covers the case +// where the API response omits relationships entirely. We cannot fail +// closed here — `filter[services]` already scoped the list — so the +// incident is accepted with incident.ServiceId = op.ServiceId. +func TestExtractRootlyIncident_NoRelationshipsAccepted(t *testing.T) { + raw := []byte(`{ + "id": "inc_no_rel", + "type": "incidents", + "attributes": { + "sequential_id": 50, + "title": "relationships omitted", + "status": "started", + "started_at": "2026-05-10T19:00:00Z", + "updated_at": "2026-05-10T19:05:00Z" + } + }`) + op := newTestOptions() + results, err := extractRootlyIncident(raw, op) + require.NoError(t, err) + require.Len(t, results, 1) + incident := results[0].(*models.Incident) + assert.Equal(t, "svc_02", incident.ServiceId) +} + +// TestExtractRootlyIncident_MissingStartedAtReturnsError covers the +// single required-field validation. A missing started_at would write +// a zero-time row, breaking downstream MTTR math silently. Fail loud. +func TestExtractRootlyIncident_MissingStartedAtReturnsError(t *testing.T) { + raw := []byte(`{ + "id": "inc_bad", + "type": "incidents", + "attributes": { + "sequential_id": 51, + "title": "bad row", + "status": "started", + "updated_at": "2026-05-10T20:05:00Z" + }, + "relationships": { + "services": {"data": [{"id": "svc_02", "type": "services"}]} + } + }`) + op := newTestOptions() + _, err := extractRootlyIncident(raw, op) + assert.Error(t, err) +} + +// TestExtractRootlyIncident_MissingSequentialId verifies graceful +// degradation when the Rootly response omits the incident number. +// We want the row to still land in the tool table so downstream +// conversion can fall back to the string id. +func TestExtractRootlyIncident_MissingSequentialId(t *testing.T) { + raw := []byte(`{ + "id": "inc_no_num", + "type": "incidents", + "attributes": { + "title": "no sequential id", + "status": "started", + "started_at": "2026-05-10T21:00:00Z", + "updated_at": "2026-05-10T21:05:00Z" + }, + "relationships": { + "services": {"data": [{"id": "svc_02", "type": "services"}]} + } + }`) + op := newTestOptions() + results, err := extractRootlyIncident(raw, op) + require.NoError(t, err) + require.Len(t, results, 1) + incident := results[0].(*models.Incident) + assert.Equal(t, 0, incident.Number) +} From 250b4cb23d66ec01d14e7db97d0d8cf575bbd9c7 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Tue, 12 May 2026 14:41:21 -0700 Subject: [PATCH 05/14] feat(rootly): convert incidents to domain issues Add incidents_converter. For each tool-layer incident, emit a ticket.Issue (Type=INCIDENT) with status, priority, lead time, and resolution date derived from the corresponding Rootly fields; emit one ticket.IssueAssignee per distinct role user (creator, started_by, mitigated_by, resolved_by, closed_by); and emit a ticket.BoardIssue linking the incident to its service board. This feeds DevLake's existing DORA pipeline so change-failure rate and MTTR compute correctly for teams running on Rootly. Unknown incident statuses fall back to IN_PROGRESS with a warning log rather than panicking (a deliberate divergence from the PagerDuty plugin, motivated by Rootly's more volatile status enum). Severity mapping accepts case-insensitive sev0-sev4; unknown severities are preserved as-is. Guard computeLeadTime against resolved-before-started timestamps (clock skew or backfill anomalies) by returning nil rather than the wraparound garbage a naive uint() cast would produce. Tighten test coverage on the dedup, key-fallback, and known-status-warning boundaries flagged during code review. --- backend/plugins/rootly/impl/impl.go | 3 +- .../rootly/tasks/incidents_converter.go | 266 ++++++++++++++++++ .../rootly/tasks/incidents_converter_test.go | 256 +++++++++++++++++ 3 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 backend/plugins/rootly/tasks/incidents_converter.go create mode 100644 backend/plugins/rootly/tasks/incidents_converter_test.go diff --git a/backend/plugins/rootly/impl/impl.go b/backend/plugins/rootly/impl/impl.go index 93f03ae8792..066a9dd2ca6 100644 --- a/backend/plugins/rootly/impl/impl.go +++ b/backend/plugins/rootly/impl/impl.go @@ -76,9 +76,10 @@ func (p Rootly) SubTaskMetas() []plugin.SubTaskMeta { return []plugin.SubTaskMeta{ tasks.CollectServicesMeta, tasks.ExtractServicesMeta, - tasks.ConvertServicesMeta, tasks.CollectIncidentsMeta, tasks.ExtractIncidentsMeta, + tasks.ConvertServicesMeta, + tasks.ConvertIncidentsMeta, } } diff --git a/backend/plugins/rootly/tasks/incidents_converter.go b/backend/plugins/rootly/tasks/incidents_converter.go new file mode 100644 index 00000000000..f10031724c1 --- /dev/null +++ b/backend/plugins/rootly/tasks/incidents_converter.go @@ -0,0 +1,266 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + "reflect" + "strings" + "time" + + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/domainlayer" + "github.com/apache/incubator-devlake/core/models/domainlayer/didgen" + "github.com/apache/incubator-devlake/core/models/domainlayer/ticket" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/rootly/models" +) + +var _ plugin.SubTaskEntryPoint = ConvertIncidents + +var ConvertIncidentsMeta = plugin.SubTaskMeta{ + Name: "convertIncidents", + EntryPoint: ConvertIncidents, + EnabledByDefault: true, + Description: "Convert Rootly incidents into domain-layer ticket issues", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +// ConvertIncidents turns _tool_rootly_incidents rows into the domain +// TICKET trio: ticket.Issue (one per incident), ticket.IssueAssignee +// (one per distinct role user referenced by the incident), and +// ticket.BoardIssue (linking the incident to its service board). +// +// Unlike PagerDuty's converter, there is no assignments join — U4 +// reshaped the tool layer so incidents carry role-specific user-id +// columns directly. The only auxiliary lookup is a per-connection map +// of user display names, built once before the cursor opens. +func ConvertIncidents(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + data := taskCtx.GetData().(*RootlyTaskData) + logger := taskCtx.GetLogger() + + // Load all users for this connection up-front so the per-row + // Convert closure can resolve role user ids to display names + // without re-querying. This is cheaper than a per-incident join + // when the user table is small (Rootly users, not incidents), and + // incidents outnumber users. + var userRows []models.User + if err := db.All( + &userRows, + dal.Where("connection_id = ?", data.Options.ConnectionId), + ); err != nil { + return err + } + userNames := make(map[string]string, len(userRows)) + for _, u := range userRows { + userNames[u.Id] = u.Name + } + + cursor, err := db.Cursor( + dal.From(&models.Incident{}), + dal.Where("connection_id = ? AND service_id = ?", data.Options.ConnectionId, data.Options.ServiceId), + ) + if err != nil { + return err + } + defer cursor.Close() + + idGen := didgen.NewDomainIdGenerator(&models.Incident{}) + serviceIdGen := didgen.NewDomainIdGenerator(&models.Service{}) + userIdGen := didgen.NewDomainIdGenerator(&models.User{}) + boardId := serviceIdGen.Generate(data.Options.ConnectionId, data.Options.ServiceId) + + converter, err := helper.NewDataConverter(helper.DataConverterArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Options: data.Options, + Table: RAW_INCIDENTS_TABLE, + }, + InputRowType: reflect.TypeOf(models.Incident{}), + Input: cursor, + Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { + incident := inputRow.(*models.Incident) + + status := mapStatus(incident.Status) + if status == ticket.IN_PROGRESS && !isKnownStatus(incident.Status) { + logger.Warn(nil, "unknown rootly incident status: %s", incident.Status) + } + + leadTime, resolutionDate := computeLeadTime(incident.StartedDate, incident.ResolvedDate) + + domainIssueId := idGen.Generate(data.Options.ConnectionId, incident.Id) + + // Creator drives Issue.CreatorId / CreatorName and also + // Issue.AssigneeId / AssigneeName (the latter is a + // convenience denormalization — per-role fidelity lives + // on the IssueAssignee rows below). + var creatorDomainId, creatorName string + if incident.CreatorUserId != "" { + creatorDomainId = userIdGen.Generate(data.Options.ConnectionId, incident.CreatorUserId) + creatorName = userNames[incident.CreatorUserId] + } + + domainIssue := &ticket.Issue{ + DomainEntity: domainlayer.DomainEntity{ + Id: domainIssueId, + }, + Url: incident.Url, + IssueKey: issueKeyFor(incident), + Title: incident.Title, + Description: incident.Summary, + Type: ticket.INCIDENT, + Status: status, + OriginalStatus: incident.Status, + ResolutionDate: resolutionDate, + CreatedDate: &incident.StartedDate, + UpdatedDate: &incident.UpdatedDate, + LeadTimeMinutes: leadTime, + Priority: mapSeverityToPriority(incident.Severity), + Severity: incident.Severity, + Urgency: incident.Urgency, + CreatorId: creatorDomainId, + CreatorName: creatorName, + AssigneeId: creatorDomainId, + AssigneeName: creatorName, + } + + results := []interface{}{domainIssue} + + // Emit one IssueAssignee per distinct role-user on the + // incident. Deduping is local to this incident so that + // the same person in multiple roles (e.g. creator + + // resolver) produces a single assignee row. + seenAssignees := map[string]bool{} + roleUserIds := []string{ + incident.CreatorUserId, + incident.StartedByUserId, + incident.MitigatedByUserId, + incident.ResolvedByUserId, + incident.ClosedByUserId, + } + for _, toolUserId := range roleUserIds { + if toolUserId == "" || seenAssignees[toolUserId] { + continue + } + seenAssignees[toolUserId] = true + results = append(results, &ticket.IssueAssignee{ + IssueId: domainIssueId, + AssigneeId: userIdGen.Generate(data.Options.ConnectionId, toolUserId), + AssigneeName: userNames[toolUserId], + }) + } + + results = append(results, &ticket.BoardIssue{ + BoardId: boardId, + IssueId: domainIssueId, + }) + + return results, nil + }, + }) + if err != nil { + return err + } + return converter.Execute() +} + +// mapStatus translates a Rootly incident status into the domain-layer +// status enum. Unknown statuses fall through to IN_PROGRESS; callers +// that care whether a value was known should check via isKnownStatus +// to emit a warning log rather than a panic. +func mapStatus(status string) string { + switch status { + case "triage", "started": + return ticket.TODO + case "mitigated": + return ticket.IN_PROGRESS + case "resolved", "closed", "cancelled": + return ticket.DONE + default: + return ticket.IN_PROGRESS + } +} + +// isKnownStatus answers whether the given Rootly status value is one +// of the enum members we map explicitly. Used so the converter can +// warn about unknown values without re-running the switch. +func isKnownStatus(status string) bool { + switch status { + case "triage", "started", "mitigated", "resolved", "closed", "cancelled": + return true + default: + return false + } +} + +// mapSeverityToPriority translates a Rootly severity slug into the +// domain-layer priority string. Accepts case-insensitive inputs +// because Rootly has been observed returning SEV0, sev0, and Sev0 +// interchangeably. Unknown severities are passed through verbatim +// so operators can see the raw value rather than a collapsed +// default. +func mapSeverityToPriority(severity string) string { + switch strings.ToLower(severity) { + case "sev0": + return "CRITICAL" + case "sev1": + return "HIGH" + case "sev2": + return "MEDIUM" + case "sev3", "sev4": + return "LOW" + default: + return severity + } +} + +// computeLeadTime derives the DORA lead-time and resolution-date +// values from the incident's started_at and optional resolved_at +// timestamps. When resolved is nil both return values are nil — the +// incident is still ongoing and has no resolution to measure. When +// resolved equals started the lead time is zero minutes but still +// non-nil, so DORA math can distinguish "resolved instantly" from +// "not yet resolved". +func computeLeadTime(started time.Time, resolved *time.Time) (*uint, *time.Time) { + if resolved == nil { + return nil, nil + } + // Guard against clock skew or backfill anomalies that place the + // resolution before the start. A naive uint() cast on a negative + // duration produces wraparound garbage and silently corrupts MTTR. + if resolved.Before(started) { + return nil, nil + } + minutes := uint(resolved.Sub(started).Minutes()) + resolutionDate := *resolved + return &minutes, &resolutionDate +} + +// issueKeyFor picks the most human-readable identifier available: the +// Rootly sequential id (what operators see in the UI) when present, +// falling back to the internal slug id when the sequential id is +// missing or zero. +func issueKeyFor(incident *models.Incident) string { + if incident.Number > 0 { + return fmt.Sprintf("%d", incident.Number) + } + return incident.Id +} diff --git a/backend/plugins/rootly/tasks/incidents_converter_test.go b/backend/plugins/rootly/tasks/incidents_converter_test.go new file mode 100644 index 00000000000..1cd907a9c05 --- /dev/null +++ b/backend/plugins/rootly/tasks/incidents_converter_test.go @@ -0,0 +1,256 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/incubator-devlake/core/models/domainlayer/ticket" + "github.com/apache/incubator-devlake/plugins/rootly/models" +) + +// TestMapStatus exercises every branch of the Rootly-to-domain status +// mapping, including the deliberate divergence from PagerDuty: unknown +// statuses fall back to IN_PROGRESS with a warning rather than panic. +func TestMapStatus(t *testing.T) { + cases := []struct { + in string + expected string + }{ + {"triage", ticket.TODO}, + {"started", ticket.TODO}, + {"mitigated", ticket.IN_PROGRESS}, + {"resolved", ticket.DONE}, + {"closed", ticket.DONE}, + {"cancelled", ticket.DONE}, + {"wat", ticket.IN_PROGRESS}, + {"", ticket.IN_PROGRESS}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + assert.Equal(t, c.expected, mapStatus(c.in)) + }) + } +} + +// TestMapStatusDoesNotPanic pins the behavioral difference from the +// PagerDuty converter, which panics on unknown statuses. Rootly's +// enum is more volatile, so we fall back rather than crash. +func TestMapStatusDoesNotPanic(t *testing.T) { + assert.NotPanics(t, func() { + _ = mapStatus("brand-new-status-rootly-invented-yesterday") + }) +} + +// TestMapSeverityToPriority covers the severity table plus case +// variation (Rootly has been observed returning SEV0, sev0, and +// Sev0 interchangeably) and the pass-through behavior for unknown +// values. +func TestMapSeverityToPriority(t *testing.T) { + cases := []struct { + in string + expected string + }{ + {"sev0", "CRITICAL"}, + {"SEV0", "CRITICAL"}, + {"Sev0", "CRITICAL"}, + {"sev1", "HIGH"}, + {"SEV1", "HIGH"}, + {"sev2", "MEDIUM"}, + {"sev3", "LOW"}, + {"sev4", "LOW"}, + {"sev5", "sev5"}, + {"critical-ish", "critical-ish"}, + {"", ""}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + assert.Equal(t, c.expected, mapSeverityToPriority(c.in)) + }) + } +} + +// TestComputeLeadTime_Resolved verifies that a resolved incident yields +// a non-nil lead time in minutes and a non-nil resolution date pointer +// whose value matches the resolved timestamp. +func TestComputeLeadTime_Resolved(t *testing.T) { + started := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC) + resolved := time.Date(2026, 5, 10, 11, 30, 0, 0, time.UTC) + leadTime, resolutionDate := computeLeadTime(started, &resolved) + require.NotNil(t, leadTime) + require.NotNil(t, resolutionDate) + assert.Equal(t, uint(90), *leadTime) + assert.Equal(t, resolved, *resolutionDate) +} + +// TestComputeLeadTime_Unresolved verifies that an unresolved incident +// (resolved pointer is nil) yields nil, nil rather than a zero-time +// sentinel — downstream DORA math treats (nil) as "still ongoing" and +// a zero-time value would pollute mean-time-to-resolve. +func TestComputeLeadTime_Unresolved(t *testing.T) { + started := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC) + leadTime, resolutionDate := computeLeadTime(started, nil) + assert.Nil(t, leadTime) + assert.Nil(t, resolutionDate) +} + +// TestComputeLeadTime_ZeroDuration covers the edge case where an +// incident is resolved at the same instant it started. Lead time is +// zero but should still be non-nil, because DORA needs to distinguish +// "resolved instantly" from "not yet resolved". +func TestComputeLeadTime_ZeroDuration(t *testing.T) { + started := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC) + resolved := started + leadTime, resolutionDate := computeLeadTime(started, &resolved) + require.NotNil(t, leadTime) + require.NotNil(t, resolutionDate) + assert.Equal(t, uint(0), *leadTime) +} + +// TestComputeLeadTime_ResolvedBeforeStarted guards against clock skew +// or backfill anomalies where the resolution timestamp precedes the +// start. A naive uint() cast on a negative duration would produce +// wraparound garbage and silently corrupt MTTR. The helper treats +// these cases as if unresolved so bad data does not contaminate the +// domain layer. +func TestComputeLeadTime_ResolvedBeforeStarted(t *testing.T) { + started := time.Date(2026, 5, 10, 11, 0, 0, 0, time.UTC) + resolved := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC) + leadTime, resolutionDate := computeLeadTime(started, &resolved) + assert.Nil(t, leadTime) + assert.Nil(t, resolutionDate) +} + +// TestIssueKeyFor covers the human-readable-id preference: prefer the +// Rootly sequential id when positive, fall back to the internal slug id +// when missing, zero, or negative. The negative branch matters because +// Number is typed int and a "negative sequential id" would be a data +// bug we should surface as the slug rather than as a negative string. +func TestIssueKeyFor(t *testing.T) { + cases := []struct { + name string + incident models.Incident + expected string + }{ + {"positive sequential id", models.Incident{Number: 42, Id: "inc_abc"}, "42"}, + {"zero sequential id falls back to slug", models.Incident{Number: 0, Id: "inc_abc"}, "inc_abc"}, + {"negative sequential id falls back to slug", models.Incident{Number: -1, Id: "inc_abc"}, "inc_abc"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.expected, issueKeyFor(&c.incident)) + }) + } +} + +// TestAssigneeDedup covers the role-user dedup in ConvertIncidents by +// calling the dedup logic in isolation (inlined here to avoid wiring up +// a SubTaskContext in a unit test — e2e coverage lives in U6). The +// invariants are: (1) distinct user ids across roles produce distinct +// IssueAssignees, (2) duplicate user ids across roles produce exactly +// one IssueAssignee, (3) empty-string user ids are skipped. +func TestAssigneeDedup(t *testing.T) { + cases := []struct { + name string + incident models.Incident + expected []string + }{ + { + name: "all roles empty", + incident: models.Incident{}, + expected: []string{}, + }, + { + name: "single creator", + incident: models.Incident{CreatorUserId: "u1"}, + expected: []string{"u1"}, + }, + { + name: "same user in creator and resolver", + incident: models.Incident{ + CreatorUserId: "u1", + ResolvedByUserId: "u1", + }, + expected: []string{"u1"}, + }, + { + name: "distinct users across all roles", + incident: models.Incident{ + CreatorUserId: "u1", + StartedByUserId: "u2", + MitigatedByUserId: "u3", + ResolvedByUserId: "u4", + ClosedByUserId: "u5", + }, + expected: []string{"u1", "u2", "u3", "u4", "u5"}, + }, + { + name: "empty interleaved with populated", + incident: models.Incident{ + CreatorUserId: "u1", + StartedByUserId: "", + MitigatedByUserId: "u2", + ResolvedByUserId: "", + ClosedByUserId: "u1", + }, + expected: []string{"u1", "u2"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + seen := map[string]bool{} + var got []string + for _, uid := range []string{ + c.incident.CreatorUserId, + c.incident.StartedByUserId, + c.incident.MitigatedByUserId, + c.incident.ResolvedByUserId, + c.incident.ClosedByUserId, + } { + if uid == "" || seen[uid] { + continue + } + seen[uid] = true + got = append(got, uid) + } + if len(c.expected) == 0 { + assert.Empty(t, got) + } else { + assert.Equal(t, c.expected, got) + } + }) + } +} + +// TestMapStatus_MitigatedIsKnown pins the boundary between mapStatus +// and isKnownStatus: "mitigated" maps to IN_PROGRESS (the same bucket +// as the unknown-status fallback), but it is a KNOWN status and should +// not trigger the warning log. Without this test, a regression that +// deletes "mitigated" from isKnownStatus would silently fire unknown- +// status warnings on every mitigated incident. +func TestMapStatus_MitigatedIsKnown(t *testing.T) { + assert.Equal(t, ticket.IN_PROGRESS, mapStatus("mitigated")) + assert.True(t, isKnownStatus("mitigated")) + // And the contrapositive — the fallback case is indeed unknown. + assert.Equal(t, ticket.IN_PROGRESS, mapStatus("something-else")) + assert.False(t, isKnownStatus("something-else")) +} From 2a5138074d9567a1877c1b2830f9f4439e2816c8 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Tue, 12 May 2026 14:58:25 -0700 Subject: [PATCH 06/14] test(rootly): add e2e test for extract and convert pipeline Add an end-to-end test that drives the extract and convert subtasks over a crafted raw-incident fixture and verifies the tool-layer and domain-layer output against snapshot CSVs. Fixtures cover every branch of the status and severity mapping tables, the same-user-across-roles dedup path, the zero-user case, and the safety-net filter that drops incidents whose relationships point at a different service than the one the task was scoped to. --- backend/plugins/rootly/e2e/incident_test.go | 131 ++++++++++++++++++ .../e2e/raw_tables/_raw_rootly_incidents.csv | 7 + .../_tool_rootly_incidents.csv | 6 + .../snapshot_tables/_tool_rootly_services.csv | 2 + .../snapshot_tables/_tool_rootly_users.csv | 4 + .../e2e/snapshot_tables/board_issues.csv | 6 + .../rootly/e2e/snapshot_tables/boards.csv | 2 + .../e2e/snapshot_tables/issue_assignees.csv | 6 + .../rootly/e2e/snapshot_tables/issues.csv | 6 + 9 files changed, 170 insertions(+) create mode 100644 backend/plugins/rootly/e2e/incident_test.go create mode 100644 backend/plugins/rootly/e2e/raw_tables/_raw_rootly_incidents.csv create mode 100644 backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_incidents.csv create mode 100644 backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_services.csv create mode 100644 backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_users.csv create mode 100644 backend/plugins/rootly/e2e/snapshot_tables/board_issues.csv create mode 100644 backend/plugins/rootly/e2e/snapshot_tables/boards.csv create mode 100644 backend/plugins/rootly/e2e/snapshot_tables/issue_assignees.csv create mode 100644 backend/plugins/rootly/e2e/snapshot_tables/issues.csv diff --git a/backend/plugins/rootly/e2e/incident_test.go b/backend/plugins/rootly/e2e/incident_test.go new file mode 100644 index 00000000000..111efca0b79 --- /dev/null +++ b/backend/plugins/rootly/e2e/incident_test.go @@ -0,0 +1,131 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "fmt" + "testing" + + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/models/domainlayer/ticket" + "github.com/apache/incubator-devlake/helpers/e2ehelper" + "github.com/apache/incubator-devlake/plugins/rootly/impl" + "github.com/apache/incubator-devlake/plugins/rootly/models" + "github.com/apache/incubator-devlake/plugins/rootly/tasks" + "github.com/stretchr/testify/require" +) + +func TestIncidentDataFlow(t *testing.T) { + var plugin impl.Rootly + dataflowTester := e2ehelper.NewDataFlowTester(t, "rootly", plugin) + options := tasks.RootlyOptions{ + ConnectionId: 1, + ServiceId: "svc_01", + ServiceName: "Payments", + } + taskData := &tasks.RootlyTaskData{ + Options: &options, + } + + // Seed the service scope as a prereq: the service is the scope unit, + // not test data, so we populate _tool_rootly_services directly + // instead of running the services extractor. Mirrors the pagerduty + // e2e pattern. + dataflowTester.FlushTabler(&models.Service{}) + service := models.Service{ + Scope: common.Scope{ + ConnectionId: options.ConnectionId, + }, + Url: fmt.Sprintf("https://rootly.com/account/services/%s", options.ServiceId), + Id: options.ServiceId, + Name: options.ServiceName, + } + require.NoError(t, dataflowTester.Dal.CreateOrUpdate(&service)) + + // Import the raw incidents fixture that drives the extractor. + dataflowTester.ImportCsvIntoRawTable( + "./raw_tables/_raw_rootly_incidents.csv", + "_raw_rootly_incidents", + ) + + // Extract incidents. The extractor writes to _tool_rootly_incidents + // and _tool_rootly_users (inline users from nested attributes). + dataflowTester.FlushTabler(&models.Incident{}) + dataflowTester.FlushTabler(&models.User{}) + dataflowTester.Subtask(tasks.ExtractIncidentsMeta, taskData) + dataflowTester.VerifyTableWithOptions( + models.Service{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_rootly_services.csv", + IgnoreTypes: []any{common.Scope{}}, + }, + ) + dataflowTester.VerifyTableWithOptions( + models.Incident{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_rootly_incidents.csv", + IgnoreTypes: []any{common.NoPKModel{}}, + }, + ) + dataflowTester.VerifyTableWithOptions( + models.User{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_rootly_users.csv", + IgnoreTypes: []any{common.NoPKModel{}}, + }, + ) + + // Convert: services -> boards, incidents -> issues + assignees + board + // membership. + dataflowTester.FlushTabler(&ticket.Board{}) + dataflowTester.Subtask(tasks.ConvertServicesMeta, taskData) + dataflowTester.VerifyTableWithOptions( + ticket.Board{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/boards.csv", + IgnoreTypes: []any{common.NoPKModel{}}, + }, + ) + + dataflowTester.FlushTabler(&ticket.Issue{}) + dataflowTester.FlushTabler(&ticket.IssueAssignee{}) + dataflowTester.FlushTabler(&ticket.BoardIssue{}) + dataflowTester.Subtask(tasks.ConvertIncidentsMeta, taskData) + dataflowTester.VerifyTableWithOptions( + ticket.Issue{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/issues.csv", + IgnoreTypes: []any{common.NoPKModel{}}, + IgnoreFields: []string{"original_project"}, + }, + ) + dataflowTester.VerifyTableWithOptions( + ticket.IssueAssignee{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/issue_assignees.csv", + IgnoreTypes: []any{common.NoPKModel{}}, + }, + ) + dataflowTester.VerifyTableWithOptions( + ticket.BoardIssue{}, + e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/board_issues.csv", + IgnoreTypes: []any{common.NoPKModel{}}, + }, + ) +} diff --git a/backend/plugins/rootly/e2e/raw_tables/_raw_rootly_incidents.csv b/backend/plugins/rootly/e2e/raw_tables/_raw_rootly_incidents.csv new file mode 100644 index 00000000000..fe30c07deb6 --- /dev/null +++ b/backend/plugins/rootly/e2e/raw_tables/_raw_rootly_incidents.csv @@ -0,0 +1,7 @@ +id,params,data,url,input,created_at +1,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_01"",""type"":""incidents"",""attributes"":{""sequential_id"":101,""title"":""Payment processor outage"",""summary"":""Payments are timing out"",""url"":""https://rootly.com/account/incidents/inc_01"",""status"":""triage"",""severity"":""sev0"",""urgency"":""high"",""started_at"":""2026-05-01T10:00:00Z"",""updated_at"":""2026-05-01T10:05:00Z"",""user"":{""id"":""u1"",""email"":""alice@example.com"",""full_name"":""Alice""}},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-01T10:05:00.000+00:00 +2,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_02"",""type"":""incidents"",""attributes"":{""sequential_id"":102,""title"":""Latency spike"",""summary"":""p99 latency above SLO"",""url"":""https://rootly.com/account/incidents/inc_02"",""status"":""mitigated"",""severity"":""sev1"",""urgency"":""high"",""started_at"":""2026-05-02T09:00:00Z"",""mitigated_at"":""2026-05-02T09:45:00Z"",""updated_at"":""2026-05-02T09:45:00Z"",""user"":{""id"":""u1"",""email"":""alice@example.com"",""full_name"":""Alice""},""started_by"":{""id"":""u2"",""email"":""bob@example.com"",""full_name"":""Bob""},""mitigated_by"":{""id"":""u2"",""email"":""bob@example.com"",""full_name"":""Bob""}},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-02T09:45:00.000+00:00 +3,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_03"",""type"":""incidents"",""attributes"":{""sequential_id"":103,""title"":""Queue backlog"",""summary"":""Worker queue backed up"",""url"":""https://rootly.com/account/incidents/inc_03"",""status"":""resolved"",""severity"":""sev2"",""urgency"":""medium"",""started_at"":""2026-05-03T12:00:00Z"",""resolved_at"":""2026-05-03T13:30:00Z"",""updated_at"":""2026-05-03T13:30:00Z"",""user"":{""id"":""u1"",""email"":""alice@example.com"",""full_name"":""Alice""},""resolved_by"":{""id"":""u1"",""email"":""alice@example.com"",""full_name"":""Alice""}},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-03T13:30:00.000+00:00 +4,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_04"",""type"":""incidents"",""attributes"":{""sequential_id"":201,""title"":""Wrong-service incident"",""summary"":""This incident belongs to svc_02"",""url"":""https://rootly.com/account/incidents/inc_04"",""status"":""closed"",""severity"":""sev3"",""urgency"":""low"",""started_at"":""2026-05-04T08:00:00Z"",""resolved_at"":""2026-05-04T08:30:00Z"",""updated_at"":""2026-05-04T08:30:00Z"",""user"":{""id"":""u2"",""email"":""bob@example.com"",""full_name"":""Bob""}},""relationships"":{""services"":{""data"":[{""id"":""svc_02"",""type"":""services""}]}}}",,null,2026-05-04T08:30:00.000+00:00 +5,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_05"",""type"":""incidents"",""attributes"":{""sequential_id"":104,""title"":""False alarm"",""summary"":""Cancelled after triage"",""url"":""https://rootly.com/account/incidents/inc_05"",""status"":""cancelled"",""severity"":""sev4"",""urgency"":""low"",""started_at"":""2026-05-05T14:00:00Z"",""updated_at"":""2026-05-05T14:05:00Z""},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-05T14:05:00.000+00:00 +6,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_06"",""type"":""incidents"",""attributes"":{""sequential_id"":105,""title"":""Odd state"",""summary"":""Status from future Rootly version"",""url"":""https://rootly.com/account/incidents/inc_06"",""status"":""investigating"",""severity"":""blocker"",""urgency"":""high"",""started_at"":""2026-05-06T11:00:00Z"",""updated_at"":""2026-05-06T11:10:00Z"",""user"":{""id"":""u3"",""email"":""carol@example.com"",""full_name"":""Carol""}},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-06T11:10:00.000+00:00 diff --git a/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_incidents.csv b/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_incidents.csv new file mode 100644 index 00000000000..30210c1ccaa --- /dev/null +++ b/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_incidents.csv @@ -0,0 +1,6 @@ +connection_id,id,number,service_id,url,title,summary,status,severity,urgency,started_date,acknowledged_date,mitigated_date,resolved_date,updated_date,creator_user_id,started_by_user_id,mitigated_by_user_id,resolved_by_user_id,closed_by_user_id +1,inc_01,101,svc_01,https://rootly.com/account/incidents/inc_01,Payment processor outage,Payments are timing out,triage,sev0,high,2026-05-01T10:00:00.000+00:00,,,,2026-05-01T10:05:00.000+00:00,u1,,,, +1,inc_02,102,svc_01,https://rootly.com/account/incidents/inc_02,Latency spike,p99 latency above SLO,mitigated,sev1,high,2026-05-02T09:00:00.000+00:00,,2026-05-02T09:45:00.000+00:00,,2026-05-02T09:45:00.000+00:00,u1,u2,u2,, +1,inc_03,103,svc_01,https://rootly.com/account/incidents/inc_03,Queue backlog,Worker queue backed up,resolved,sev2,medium,2026-05-03T12:00:00.000+00:00,,,2026-05-03T13:30:00.000+00:00,2026-05-03T13:30:00.000+00:00,u1,,,u1, +1,inc_05,104,svc_01,https://rootly.com/account/incidents/inc_05,False alarm,Cancelled after triage,cancelled,sev4,low,2026-05-05T14:00:00.000+00:00,,,,2026-05-05T14:05:00.000+00:00,,,,, +1,inc_06,105,svc_01,https://rootly.com/account/incidents/inc_06,Odd state,Status from future Rootly version,investigating,blocker,high,2026-05-06T11:00:00.000+00:00,,,,2026-05-06T11:10:00.000+00:00,u3,,,, diff --git a/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_services.csv b/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_services.csv new file mode 100644 index 00000000000..e5029d1d698 --- /dev/null +++ b/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_services.csv @@ -0,0 +1,2 @@ +connection_id,id,url,name +1,svc_01,https://rootly.com/account/services/svc_01,Payments diff --git a/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_users.csv b/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_users.csv new file mode 100644 index 00000000000..759cc859347 --- /dev/null +++ b/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_users.csv @@ -0,0 +1,4 @@ +connection_id,id,email,name,url +1,u1,alice@example.com,Alice, +1,u2,bob@example.com,Bob, +1,u3,carol@example.com,Carol, diff --git a/backend/plugins/rootly/e2e/snapshot_tables/board_issues.csv b/backend/plugins/rootly/e2e/snapshot_tables/board_issues.csv new file mode 100644 index 00000000000..654e122979e --- /dev/null +++ b/backend/plugins/rootly/e2e/snapshot_tables/board_issues.csv @@ -0,0 +1,6 @@ +board_id,issue_id +rootly:Service:1:svc_01,rootly:Incident:1:inc_01 +rootly:Service:1:svc_01,rootly:Incident:1:inc_02 +rootly:Service:1:svc_01,rootly:Incident:1:inc_03 +rootly:Service:1:svc_01,rootly:Incident:1:inc_05 +rootly:Service:1:svc_01,rootly:Incident:1:inc_06 diff --git a/backend/plugins/rootly/e2e/snapshot_tables/boards.csv b/backend/plugins/rootly/e2e/snapshot_tables/boards.csv new file mode 100644 index 00000000000..20bc5a3e054 --- /dev/null +++ b/backend/plugins/rootly/e2e/snapshot_tables/boards.csv @@ -0,0 +1,2 @@ +id,name,description,url,created_date,type +rootly:Service:1:svc_01,Payments,,https://rootly.com/account/services/svc_01,, diff --git a/backend/plugins/rootly/e2e/snapshot_tables/issue_assignees.csv b/backend/plugins/rootly/e2e/snapshot_tables/issue_assignees.csv new file mode 100644 index 00000000000..6a4ecdb1f60 --- /dev/null +++ b/backend/plugins/rootly/e2e/snapshot_tables/issue_assignees.csv @@ -0,0 +1,6 @@ +issue_id,assignee_id,assignee_name +rootly:Incident:1:inc_01,rootly:User:1:u1,Alice +rootly:Incident:1:inc_02,rootly:User:1:u1,Alice +rootly:Incident:1:inc_02,rootly:User:1:u2,Bob +rootly:Incident:1:inc_03,rootly:User:1:u1,Alice +rootly:Incident:1:inc_06,rootly:User:1:u3,Carol diff --git a/backend/plugins/rootly/e2e/snapshot_tables/issues.csv b/backend/plugins/rootly/e2e/snapshot_tables/issues.csv new file mode 100644 index 00000000000..b61c690072e --- /dev/null +++ b/backend/plugins/rootly/e2e/snapshot_tables/issues.csv @@ -0,0 +1,6 @@ +id,url,icon_url,issue_key,title,description,epic_key,type,original_type,status,original_status,story_point,resolution_date,created_date,updated_date,lead_time_minutes,original_estimate_minutes,time_spent_minutes,time_remaining_minutes,creator_id,creator_name,assignee_id,assignee_name,parent_issue_id,priority,severity,urgency,component,is_subtask,due_date,fix_versions +rootly:Incident:1:inc_01,https://rootly.com/account/incidents/inc_01,,101,Payment processor outage,Payments are timing out,,INCIDENT,,TODO,triage,,,2026-05-01T10:00:00.000+00:00,2026-05-01T10:05:00.000+00:00,,,,,rootly:User:1:u1,Alice,rootly:User:1:u1,Alice,,CRITICAL,sev0,high,,0,, +rootly:Incident:1:inc_02,https://rootly.com/account/incidents/inc_02,,102,Latency spike,p99 latency above SLO,,INCIDENT,,IN_PROGRESS,mitigated,,,2026-05-02T09:00:00.000+00:00,2026-05-02T09:45:00.000+00:00,,,,,rootly:User:1:u1,Alice,rootly:User:1:u1,Alice,,HIGH,sev1,high,,0,, +rootly:Incident:1:inc_03,https://rootly.com/account/incidents/inc_03,,103,Queue backlog,Worker queue backed up,,INCIDENT,,DONE,resolved,,2026-05-03T13:30:00.000+00:00,2026-05-03T12:00:00.000+00:00,2026-05-03T13:30:00.000+00:00,90,,,,rootly:User:1:u1,Alice,rootly:User:1:u1,Alice,,MEDIUM,sev2,medium,,0,, +rootly:Incident:1:inc_05,https://rootly.com/account/incidents/inc_05,,104,False alarm,Cancelled after triage,,INCIDENT,,DONE,cancelled,,,2026-05-05T14:00:00.000+00:00,2026-05-05T14:05:00.000+00:00,,,,,,,,,,LOW,sev4,low,,0,, +rootly:Incident:1:inc_06,https://rootly.com/account/incidents/inc_06,,105,Odd state,Status from future Rootly version,,INCIDENT,,IN_PROGRESS,investigating,,,2026-05-06T11:00:00.000+00:00,2026-05-06T11:10:00.000+00:00,,,,,rootly:User:1:u3,Carol,rootly:User:1:u3,Carol,,blocker,blocker,high,,0,, From b3a451ef66c6f29d0f340ccc96dbd53c3909ed10 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Tue, 12 May 2026 16:16:10 -0700 Subject: [PATCH 07/14] feat(rootly): register plugin in backend and config-ui Add rootly to the backend plugin startup test and the table-info test so CI exercises it alongside the other plugins. Register RootlyConfig in the config-ui plugin list so operators can create connections, browse scopes, and run blueprints through the UI. The cloud endpoint default includes the /v1/ API version prefix, which is what Rootly's REST API actually expects; without it requests land on the marketing site and return 404. The DOC_URL entries point at a Configuration/Rootly docs page that still needs to be written. Use the Rootly wordmark glyph as the plugin icon, rewritten as a fill-aware (currentColor) SVG so the config-ui can recolor it for selected/unselected states the same way it does every other plugin icon. --- backend/plugins/table_info_test.go | 2 + .../test/e2e/services/server_startup_test.go | 2 + config-ui/src/plugins/register/index.ts | 2 + .../plugins/register/rootly/assets/icon.svg | 28 +++++++++ .../src/plugins/register/rootly/config.tsx | 57 +++++++++++++++++++ .../src/plugins/register/rootly/index.ts | 19 +++++++ config-ui/src/release/stable.ts | 4 ++ 7 files changed, 114 insertions(+) create mode 100644 config-ui/src/plugins/register/rootly/assets/icon.svg create mode 100644 config-ui/src/plugins/register/rootly/config.tsx create mode 100644 config-ui/src/plugins/register/rootly/index.ts diff --git a/backend/plugins/table_info_test.go b/backend/plugins/table_info_test.go index 5f81f319aea..bf187ee1df1 100644 --- a/backend/plugins/table_info_test.go +++ b/backend/plugins/table_info_test.go @@ -49,6 +49,7 @@ import ( pagerduty "github.com/apache/incubator-devlake/plugins/pagerduty/impl" q_dev "github.com/apache/incubator-devlake/plugins/q_dev/impl" refdiff "github.com/apache/incubator-devlake/plugins/refdiff/impl" + rootly "github.com/apache/incubator-devlake/plugins/rootly/impl" slack "github.com/apache/incubator-devlake/plugins/slack/impl" sonarqube "github.com/apache/incubator-devlake/plugins/sonarqube/impl" starrocks "github.com/apache/incubator-devlake/plugins/starrocks/impl" @@ -88,6 +89,7 @@ func Test_GetPluginTablesInfo(t *testing.T) { checker.FeedIn("org", org.Org{}.GetTablesInfo) checker.FeedIn("pagerduty/models", pagerduty.PagerDuty{}.GetTablesInfo) checker.FeedIn("refdiff/models", refdiff.RefDiff{}.GetTablesInfo) + checker.FeedIn("rootly/models", rootly.Rootly{}.GetTablesInfo) checker.FeedIn("slack/models", slack.Slack{}.GetTablesInfo) checker.FeedIn("sonarqube/models", sonarqube.Sonarqube{}.GetTablesInfo) checker.FeedIn("starrocks", starrocks.StarRocks{}.GetTablesInfo) diff --git a/backend/test/e2e/services/server_startup_test.go b/backend/test/e2e/services/server_startup_test.go index f66c48aaeea..9432f80c263 100644 --- a/backend/test/e2e/services/server_startup_test.go +++ b/backend/test/e2e/services/server_startup_test.go @@ -39,6 +39,7 @@ import ( org "github.com/apache/incubator-devlake/plugins/org/impl" pagerduty "github.com/apache/incubator-devlake/plugins/pagerduty/impl" refdiff "github.com/apache/incubator-devlake/plugins/refdiff/impl" + rootly "github.com/apache/incubator-devlake/plugins/rootly/impl" slack "github.com/apache/incubator-devlake/plugins/slack/impl" sonarqube "github.com/apache/incubator-devlake/plugins/sonarqube/impl" starrocks "github.com/apache/incubator-devlake/plugins/starrocks/impl" @@ -78,6 +79,7 @@ func loadGoPlugins() []plugin.PluginMeta { org.Org{}, pagerduty.PagerDuty{}, refdiff.RefDiff{}, + rootly.Rootly{}, slack.Slack{}, sonarqube.Sonarqube{}, starrocks.StarRocks{}, diff --git a/config-ui/src/plugins/register/index.ts b/config-ui/src/plugins/register/index.ts index 18baf06628c..fdc82a8778c 100644 --- a/config-ui/src/plugins/register/index.ts +++ b/config-ui/src/plugins/register/index.ts @@ -31,6 +31,7 @@ import { GitLabConfig } from './gitlab'; import { JenkinsConfig } from './jenkins'; import { JiraConfig } from './jira'; import { PagerDutyConfig } from './pagerduty'; +import { RootlyConfig } from './rootly'; import { SonarQubeConfig } from './sonarqube'; import { TAPDConfig } from './tapd'; import { WebhookConfig } from './webhook'; @@ -56,6 +57,7 @@ export const pluginConfigs: IPluginConfig[] = [ JenkinsConfig, JiraConfig, PagerDutyConfig, + RootlyConfig, SlackConfig, QDevConfig, SonarQubeConfig, diff --git a/config-ui/src/plugins/register/rootly/assets/icon.svg b/config-ui/src/plugins/register/rootly/assets/icon.svg new file mode 100644 index 00000000000..319bb6d007e --- /dev/null +++ b/config-ui/src/plugins/register/rootly/assets/icon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + diff --git a/config-ui/src/plugins/register/rootly/config.tsx b/config-ui/src/plugins/register/rootly/config.tsx new file mode 100644 index 00000000000..d33ac45bbd9 --- /dev/null +++ b/config-ui/src/plugins/register/rootly/config.tsx @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { DOC_URL } from '@/release'; +import { IPluginConfig } from '@/types'; + +import Icon from './assets/icon.svg?react'; + +export const RootlyConfig: IPluginConfig = { + plugin: 'rootly', + name: 'Rootly', + icon: ({ color }) => , + sort: 16, + connection: { + docLink: DOC_URL.PLUGIN.ROOTLY.BASIS, + initialValues: { + endpoint: 'https://api.rootly.com/v1/', + }, + fields: [ + 'name', + { + key: 'endpoint', + multipleVersions: { + cloud: 'https://api.rootly.com/v1/', + server: '', + }, + }, + 'token', + 'proxy', + { + key: 'rateLimitPerHour', + subLabel: + 'By default, DevLake uses 3,600 requests/hour for data collection for Rootly. But you can adjust the collection speed by setting up your desirable rate limit.', + learnMore: DOC_URL.PLUGIN.ROOTLY.RATE_LIMIT, + defaultValue: 3600, + }, + ], + }, + dataScope: { + title: 'Services', + }, +}; diff --git a/config-ui/src/plugins/register/rootly/index.ts b/config-ui/src/plugins/register/rootly/index.ts new file mode 100644 index 00000000000..de415db39ab --- /dev/null +++ b/config-ui/src/plugins/register/rootly/index.ts @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export * from './config'; diff --git a/config-ui/src/release/stable.ts b/config-ui/src/release/stable.ts index 45a7c3e3018..118bd544f58 100644 --- a/config-ui/src/release/stable.ts +++ b/config-ui/src/release/stable.ts @@ -107,6 +107,10 @@ const URLS = { BASIS: 'https://devlake.apache.org/docs/Configuration/PagerDuty', RATE_LIMIT: 'https://devlake.apache.org/docs/Configuration/PagerDuty/#custom-rate-limit-optional', }, + ROOTLY: { + BASIS: 'https://devlake.apache.org/docs/Configuration/Rootly', + RATE_LIMIT: 'https://devlake.apache.org/docs/Configuration/Rootly#fixed-rate-limit-optional', + }, SLACK: { BASIS: 'https://devlake.apache.org/docs/Configuration/Slack', RATE_LIMIT: 'https://devlake.apache.org/docs/Configuration/Slack#custom-rate-limit-optional', From 5d9b04cae405a89e89afc42e672c53aac32a15f0 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Wed, 13 May 2026 12:04:26 -0700 Subject: [PATCH 08/14] fix(rootly): align archived models with live model schemas The U1 init migration's archived Connection, Service, and ScopeConfig models were missing columns contributed by the live models' embedded helpers, so AutoMigrate produced tables the live models could not read or write. Each gap surfaced as an "Unknown column" error from MySQL the first time the table was touched: - Connection was missing endpoint, proxy, rate_limit_per_hour (contributed by helper.RestConnection on the live struct). - Service was missing scope_config_id (contributed by common.Scope). - ScopeConfig was missing connection_id and name (contributed by common.ScopeConfig, which the archived base type does not include). Fold the missing fields into the archived models so a single init migration produces the correct schema. Since rootly has not been deployed anywhere, keeping one init migration is cleaner than chaining follow-up ALTERs; a fresh migrate creates the correct tables in one pass. --- .../models/migrationscripts/archived/connection.go | 7 +++++-- .../migrationscripts/archived/scope_config.go | 8 ++++++++ .../models/migrationscripts/archived/service.go | 13 +++++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/backend/plugins/rootly/models/migrationscripts/archived/connection.go b/backend/plugins/rootly/models/migrationscripts/archived/connection.go index 212cfa5f1ab..5bfd1ad47a5 100644 --- a/backend/plugins/rootly/models/migrationscripts/archived/connection.go +++ b/backend/plugins/rootly/models/migrationscripts/archived/connection.go @@ -23,8 +23,11 @@ import ( type Connection struct { archived.Model - Name string `gorm:"type:varchar(100);uniqueIndex" json:"name" validate:"required"` - Token string `mapstructure:"token" env:"ROOTLY_AUTH" validate:"required" encrypt:"yes"` + Name string `gorm:"type:varchar(100);uniqueIndex" json:"name" validate:"required"` + Endpoint string `mapstructure:"endpoint" validate:"required" json:"endpoint"` + Proxy string `mapstructure:"proxy" json:"proxy"` + RateLimitPerHour int `comment:"api request rate limit per hour" json:"rateLimitPerHour"` + Token string `mapstructure:"token" env:"ROOTLY_AUTH" validate:"required" encrypt:"yes"` } func (Connection) TableName() string { diff --git a/backend/plugins/rootly/models/migrationscripts/archived/scope_config.go b/backend/plugins/rootly/models/migrationscripts/archived/scope_config.go index 91689beead9..f4a4b544f82 100644 --- a/backend/plugins/rootly/models/migrationscripts/archived/scope_config.go +++ b/backend/plugins/rootly/models/migrationscripts/archived/scope_config.go @@ -21,8 +21,16 @@ import ( "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" ) +// ScopeConfig mirrors the columns contributed by live +// `models.RootlyScopeConfig`'s embedded `common.ScopeConfig` +// (Model + Entities + ConnectionId + Name). The archived base +// `ScopeConfig` struct only carries Model + Entities, so we carry +// ConnectionId and Name explicitly here so the generated table has +// the columns the live model reads and writes. type ScopeConfig struct { archived.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` + ConnectionId uint64 `json:"connectionId" gorm:"index" validate:"required" mapstructure:"connectionId,omitempty"` + Name string `mapstructure:"name" json:"name" gorm:"type:varchar(255);uniqueIndex" validate:"required"` } func (ScopeConfig) TableName() string { diff --git a/backend/plugins/rootly/models/migrationscripts/archived/service.go b/backend/plugins/rootly/models/migrationscripts/archived/service.go index c322594eddd..744643c85c0 100644 --- a/backend/plugins/rootly/models/migrationscripts/archived/service.go +++ b/backend/plugins/rootly/models/migrationscripts/archived/service.go @@ -21,12 +21,17 @@ import ( "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" ) +// Service mirrors the columns contributed by live `models.Service`'s +// embedded `common.Scope` (NoPKModel + ConnectionId + ScopeConfigId) +// plus the Rootly-specific fields. Missing `scope_config_id` here +// would cause PUT /scopes to fail with "Unknown column 'scope_config_id'". type Service struct { archived.NoPKModel - ConnectionId uint64 `gorm:"primaryKey"` - Id string `gorm:"primaryKey;autoIncrement:false"` - Url string - Name string + ConnectionId uint64 `gorm:"primaryKey"` + ScopeConfigId uint64 `json:"scopeConfigId,omitempty" mapstructure:"scopeConfigId,omitempty"` + Id string `gorm:"primaryKey;autoIncrement:false"` + Url string + Name string } func (Service) TableName() string { From 54c4b87a518bc3bf5db74cd8baf81d37b251ce78 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Wed, 13 May 2026 12:07:10 -0700 Subject: [PATCH 09/14] fix(rootly): align API contract with real Rootly /v1 responses The original plugin shape was built from docs summaries rather than an actual response capture and diverged from the Rootly API in ways that silently produced zero incidents. Reconcile every code path with ground truth from the OpenAPI spec and a captured GET /v1/incidents response: - Connection test hits /users/me (Rootly's real "who am I" path); the original /users/current returns 404. - Incident list filter is filter[service_ids]=, not filter[services]=. The latter exists but accepts names and silently matches nothing for a UUID. - Role-bearing user fields (user, started_by, mitigated_by, resolved_by, closed_by) and severity are JSON:API response envelopes nested on attributes: {"data":{"id":...,"attributes":...}}. The previous flat NestedUser / SeverityAttrs shapes were reading the wrong paths, so those fields were always empty. - Service membership lives on the sibling relationships block as JSON:API id+type pointers, not on attributes. The safety-net scope-filter check now reads from the right place. - The incident resource does not have an urgency field. Drop the corresponding column from the model and archived schema. Also harden the collector: split the ResponseParser / next-page logic so pagination state is captured during parse (rather than re-reading the already-drained response body in GetNextPageCustomData), and add lightweight request/response diagnostics gated behind Debug logging. Verified end-to-end against a live Rootly tenant: 3 of 6 scoped services returned incidents, all 3 extracted and converted into ticket.Issue rows with creator assignees and board linkage. --- backend/plugins/rootly/api/connection_api.go | 2 +- backend/plugins/rootly/models/connection.go | 2 +- backend/plugins/rootly/models/incident.go | 1 - .../migrationscripts/archived/incident.go | 1 - backend/plugins/rootly/models/raw/incident.go | 149 +++++++++++------- .../rootly/tasks/incidents_collector.go | 67 +++++--- .../rootly/tasks/incidents_converter.go | 1 - .../rootly/tasks/incidents_extractor.go | 109 ++++++------- .../rootly/tasks/incidents_extractor_test.go | 123 +++++++-------- 9 files changed, 250 insertions(+), 205 deletions(-) diff --git a/backend/plugins/rootly/api/connection_api.go b/backend/plugins/rootly/api/connection_api.go index eba67ba2953..3fb36609ded 100644 --- a/backend/plugins/rootly/api/connection_api.go +++ b/backend/plugins/rootly/api/connection_api.go @@ -37,7 +37,7 @@ func testConnection(ctx context.Context, connection models.RootlyConn) (*plugin. if err != nil { return nil, err } - response, err := apiClient.Get("users/current", nil, nil) + response, err := apiClient.Get("users/me", nil, nil) if err != nil { return nil, err } diff --git a/backend/plugins/rootly/models/connection.go b/backend/plugins/rootly/models/connection.go index 13bf1cf388b..b89983a0565 100644 --- a/backend/plugins/rootly/models/connection.go +++ b/backend/plugins/rootly/models/connection.go @@ -66,7 +66,7 @@ type RootlyResponse struct { RootlyConnection } -// ApiUserResponse represents the Rootly /users/current response for token validation. +// ApiUserResponse represents the Rootly /users/me response for token validation. type ApiUserResponse struct { Id string Name string `json:"name"` diff --git a/backend/plugins/rootly/models/incident.go b/backend/plugins/rootly/models/incident.go index b638aa434b5..50adb6018d5 100644 --- a/backend/plugins/rootly/models/incident.go +++ b/backend/plugins/rootly/models/incident.go @@ -34,7 +34,6 @@ type Incident struct { Summary string Status string Severity string - Urgency string StartedDate time.Time AcknowledgedDate *time.Time MitigatedDate *time.Time diff --git a/backend/plugins/rootly/models/migrationscripts/archived/incident.go b/backend/plugins/rootly/models/migrationscripts/archived/incident.go index b6ca97529de..fd43fe99ec8 100644 --- a/backend/plugins/rootly/models/migrationscripts/archived/incident.go +++ b/backend/plugins/rootly/models/migrationscripts/archived/incident.go @@ -34,7 +34,6 @@ type Incident struct { Summary string Status string Severity string - Urgency string StartedDate time.Time AcknowledgedDate *time.Time MitigatedDate *time.Time diff --git a/backend/plugins/rootly/models/raw/incident.go b/backend/plugins/rootly/models/raw/incident.go index 0d733c1c0b0..017a705e1b6 100644 --- a/backend/plugins/rootly/models/raw/incident.go +++ b/backend/plugins/rootly/models/raw/incident.go @@ -18,81 +18,110 @@ limitations under the License. package raw import ( - "encoding/json" "time" ) // Incident is the JSON:API envelope for a Rootly incident as returned by -// GET /incidents. The top-level Id is the incident id; display fields live -// under Attributes, and Relationships carries cross-entity references -// (services, users) that we may or may not consult during extraction. +// GET /incidents. The top-level Id is the incident id; display fields +// live under Attributes. Role-bearing fields (user, *_by) and the +// severity field on Attributes are themselves JSON:API response +// envelopes nested on the incident's attributes. Service membership +// lives on the sibling Relationships block (JSON:API relationship +// data, id+type pointers only) and is used by the extractor's +// safety-net service-scope filter. type Incident struct { - Id string `json:"id"` - Type string `json:"type"` - Attributes IncidentAttributes `json:"attributes"` - Relationships json.RawMessage `json:"relationships"` + Id string `json:"id"` + Type string `json:"type"` + Attributes IncidentAttributes `json:"attributes"` + Relationships IncidentRelationships `json:"relationships"` +} + +// IncidentRelationships is a narrow view of the JSON:API relationships +// block used only for the safety-net service-scope check in the +// extractor. Every relationship type other than services is ignored. +type IncidentRelationships struct { + Services struct { + Data []struct { + Id string `json:"id"` + Type string `json:"type"` + } `json:"data"` + } `json:"services"` } // IncidentAttributes carries the display fields for a Rootly incident. +// Shapes here match an actual GET /v1/incidents response. Notable: // -// The severity response shape is defensive: Rootly may return severity -// as a flat slug string or as a nested object; we accept either. The -// extractor prefers SeverityObj.Slug when non-nil, otherwise falls back -// to SeveritySlug, otherwise empty string. -// -// Role-bearing user objects (User, StartedBy, MitigatedBy, ResolvedBy, -// ClosedBy) are nested user records inlined directly on the incident — -// NOT JSON:API-wrapped and NOT surfaced through a relationships -// `included` array. Any of them may be nil if the role was not filled. +// - `severity`, `user`, `started_by`, `mitigated_by`, `resolved_by`, +// `closed_by` are each nullable JSON:API-envelope objects — the +// inner record lives at `.data.id` / +// `.data.attributes.*`. +// - Service membership is NOT on attributes; it lives on the +// Incident.Relationships.Services block as JSON:API id+type +// pointers. Without `?include=services` the full service records +// are not returned — but the relationship pointers alone are +// enough for the extractor's safety-net scope filter. +// - No `urgency` field exists on the incident resource. type IncidentAttributes struct { - SequentialId *int `json:"sequential_id"` - Title string `json:"title"` - Summary *string `json:"summary"` - Url *string `json:"url"` - Status string `json:"status"` - SeveritySlug *string `json:"severity"` - SeverityObj *SeverityAttrs `json:"severity_attributes"` - Urgency *string `json:"urgency"` - StartedAt time.Time `json:"started_at"` - AcknowledgedAt *time.Time `json:"acknowledged_at"` - MitigatedAt *time.Time `json:"mitigated_at"` - ResolvedAt *time.Time `json:"resolved_at"` - UpdatedAt time.Time `json:"updated_at"` + SequentialId *int `json:"sequential_id"` + Title string `json:"title"` + Summary *string `json:"summary"` + Url *string `json:"url"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + AcknowledgedAt *time.Time `json:"acknowledged_at"` + MitigatedAt *time.Time `json:"mitigated_at"` + ResolvedAt *time.Time `json:"resolved_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Severity is a JSON:API-envelope nested object. Inner attributes + // include `slug` (e.g. sev0, sev1) and `severity` (the domain- + // normalized value: critical, high, medium, low). + Severity *SeverityEnvelope `json:"severity"` - // Role-bearing user objects. Each is a nullable nested user record. - // User is the incident creator; the others track lifecycle actors. - User *NestedUser `json:"user"` - StartedBy *NestedUser `json:"started_by"` - MitigatedBy *NestedUser `json:"mitigated_by"` - ResolvedBy *NestedUser `json:"resolved_by"` - ClosedBy *NestedUser `json:"closed_by"` + // Role-bearing users. Each is a JSON:API-envelope nested object, + // nullable. User is the incident creator. + User *UserEnvelope `json:"user"` + StartedBy *UserEnvelope `json:"started_by"` + MitigatedBy *UserEnvelope `json:"mitigated_by"` + ResolvedBy *UserEnvelope `json:"resolved_by"` + ClosedBy *UserEnvelope `json:"closed_by"` } -type SeverityAttrs struct { - Slug string `json:"slug"` - Name string `json:"name"` +// SeverityEnvelope is a JSON:API response envelope for a severity +// resource as it appears nested on an incident's attributes. +type SeverityEnvelope struct { + Data struct { + Id string `json:"id"` + Type string `json:"type"` + Attributes SeverityAttributes `json:"attributes"` + } `json:"data"` } -// NestedUser is the shape of a user object as inlined on an incident's -// attributes. Plain JSON — NOT a JSON:API envelope. Rootly exposes the -// display name under either `name` or `full_name` depending on endpoint -// shape; the extractor prefers FullName when non-empty. -type NestedUser struct { - Id string `json:"id"` - Email *string `json:"email"` - Name *string `json:"name"` - FullName *string `json:"full_name"` - Url *string `json:"url"` +// SeverityAttributes carries the inner severity display fields. +// `Slug` is the org-defined identifier (e.g. sev0, sev1). `Severity` +// is the domain-normalized bucket (critical, high, medium, low) that +// DevLake maps straight onto ticket.Issue.Priority. +type SeverityAttributes struct { + Slug string `json:"slug"` + Name string `json:"name"` + Severity string `json:"severity"` } -// IncidentRelationships is a narrow view of the JSON:API relationships -// envelope used only for the safety-net service-scope check. It ignores -// every relationship type except services. -type IncidentRelationships struct { - Services struct { - Data []struct { - Id string `json:"id"` - Type string `json:"type"` - } `json:"data"` - } `json:"services"` +// UserEnvelope is a JSON:API response envelope for a user resource as +// it appears nested on an incident's attributes. +type UserEnvelope struct { + Data struct { + Id string `json:"id"` + Type string `json:"type"` + Attributes UserAttributes `json:"attributes"` + } `json:"data"` } + +// UserAttributes is the subset of the user resource DevLake cares +// about for incident role tracking. +type UserAttributes struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Email string `json:"email"` +} + diff --git a/backend/plugins/rootly/tasks/incidents_collector.go b/backend/plugins/rootly/tasks/incidents_collector.go index ea17055a435..0bb2f80e18f 100644 --- a/backend/plugins/rootly/tasks/incidents_collector.go +++ b/backend/plugins/rootly/tasks/incidents_collector.go @@ -64,41 +64,41 @@ var CollectIncidentsMeta = plugin.SubTaskMeta{ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { data := taskCtx.GetData().(*RootlyTaskData) + logger := taskCtx.GetLogger() + logger.Info("[rootly] CollectIncidents: starting for serviceId=%s connectionId=%d", data.Options.ServiceId, data.Options.ConnectionId) args := api.RawDataSubTaskArgs{ Ctx: taskCtx, Options: data.Options, Table: RAW_INCIDENTS_TABLE, } + // lastPage captures the pagination signals from the most recent + // ResponseParser invocation so GetNextPageCustomData can decide + // whether to stop without re-reading prevPageResponse.Body, which + // has already been drained by ResponseParser (http.Response.Body + // is a single-read stream). + var lastPage *collectedListMeta + var lastLinksNext *string + var lastPageEmpty bool + collector, err := api.NewStatefulApiCollectorForFinalizableEntity(api.FinalizableApiCollectorArgs{ RawDataSubTaskArgs: args, ApiClient: data.Client, CollectNewRecordsByList: api.FinalizableApiCollectorListArgs{ PageSize: 100, - // GetNextPageCustomData terminates pagination by reading the - // JSON:API links.next / meta.current_page / meta.total_pages - // fields from the previous response. If either signal says - // "no more pages", return ErrFinishCollect. GetNextPageCustomData: func(prevReqData *api.RequestData, prevPageResponse *http.Response) (interface{}, errors.Error) { - if prevPageResponse == nil { + // The response body was already consumed by + // ResponseParser; rely on the closure-captured + // pagination state from that parse. + if lastLinksNext != nil && *lastLinksNext != "" { return nil, nil } - parsed := collectedIncidents{} - if perr := api.UnmarshalResponse(prevPageResponse, &parsed); perr != nil { - return nil, perr - } - if parsed.Links != nil && parsed.Links.Next != nil && *parsed.Links.Next != "" { - return nil, nil - } - if parsed.Meta != nil && parsed.Meta.CurrentPage != nil && parsed.Meta.TotalPages != nil { - if *parsed.Meta.CurrentPage >= *parsed.Meta.TotalPages { + if lastPage != nil && lastPage.CurrentPage != nil && lastPage.TotalPages != nil { + if *lastPage.CurrentPage >= *lastPage.TotalPages { return nil, api.ErrFinishCollect } return nil, nil } - // No signal either way — if the page came back empty, - // stop. Otherwise continue and let the next empty page - // terminate us. - if len(parsed.Data) == 0 { + if lastPageEmpty { return nil, api.ErrFinishCollect } return nil, nil @@ -107,7 +107,7 @@ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { UrlTemplate: "incidents", Query: func(reqData *api.RequestData, createdAfter *time.Time) (url.Values, errors.Error) { query := url.Values{} - query.Set("filter[services]", data.Options.ServiceId) + query.Set("filter[service_ids]", data.Options.ServiceId) query.Set("page[size]", fmt.Sprintf("%d", reqData.Pager.Size)) // Rootly's JSON:API pagination is 1-based. pageNumber := reqData.Pager.Skip/reqData.Pager.Size + 1 @@ -116,13 +116,35 @@ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { if createdAfter != nil { query.Set("filter[updated_at][gt]", createdAfter.UTC().Format(time.RFC3339)) } + logger.Debug("[rootly] incidents query: page=%d size=%d createdAfter=%v %s", pageNumber, reqData.Pager.Size, createdAfter, query.Encode()) return query, nil }, ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { rawResult := collectedIncidents{} if err := api.UnmarshalResponse(res, &rawResult); err != nil { + logger.Error(err, "[rootly] incidents ResponseParser: unmarshal failed") return nil, err } + metaStr := "nil" + if rawResult.Meta != nil { + metaStr = fmt.Sprintf("current=%s total_pages=%s total_count=%s", + derefIntStr(rawResult.Meta.CurrentPage), + derefIntStr(rawResult.Meta.TotalPages), + derefIntStr(rawResult.Meta.TotalCount)) + } + linksNextStr := "nil" + if rawResult.Links != nil && rawResult.Links.Next != nil { + linksNextStr = *rawResult.Links.Next + } + logger.Debug("[rootly] incidents response: status=%d count=%d meta=%s links.next=%s", + res.StatusCode, len(rawResult.Data), metaStr, linksNextStr) + lastPage = rawResult.Meta + if rawResult.Links != nil { + lastLinksNext = rawResult.Links.Next + } else { + lastLinksNext = nil + } + lastPageEmpty = len(rawResult.Data) == 0 return rawResult.Data, nil }, }, @@ -133,3 +155,10 @@ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { } return collector.Execute() } + +func derefIntStr(p *int) string { + if p == nil { + return "nil" + } + return fmt.Sprintf("%d", *p) +} diff --git a/backend/plugins/rootly/tasks/incidents_converter.go b/backend/plugins/rootly/tasks/incidents_converter.go index f10031724c1..2da1f20af02 100644 --- a/backend/plugins/rootly/tasks/incidents_converter.go +++ b/backend/plugins/rootly/tasks/incidents_converter.go @@ -135,7 +135,6 @@ func ConvertIncidents(taskCtx plugin.SubTaskContext) errors.Error { LeadTimeMinutes: leadTime, Priority: mapSeverityToPriority(incident.Severity), Severity: incident.Severity, - Urgency: incident.Urgency, CreatorId: creatorDomainId, CreatorName: creatorName, AssigneeId: creatorDomainId, diff --git a/backend/plugins/rootly/tasks/incidents_extractor.go b/backend/plugins/rootly/tasks/incidents_extractor.go index 3295c0520ff..ef9049ea577 100644 --- a/backend/plugins/rootly/tasks/incidents_extractor.go +++ b/backend/plugins/rootly/tasks/incidents_extractor.go @@ -62,38 +62,33 @@ func ExtractIncidents(taskCtx plugin.SubTaskContext) errors.Error { // // Output shape per incident: // - exactly one *models.Incident (always), with role-specific user-id -// fields populated from the nested attributes.user / started_by / -// mitigated_by / resolved_by / closed_by blocks +// fields populated from the nested attributes.{user,started_by, +// mitigated_by,resolved_by,closed_by} JSON:API-envelope blocks // - zero-to-N *models.User rows, one per distinct user id seen across // those role fields (deduplicated within a single incident — if the -// same user is both creator and resolver, only one User row is emitted) +// same user is both creator and resolver, only one User row is +// emitted) // -// The nested user objects are plain JSON on the incident's attributes, -// NOT JSON:API-wrapped and NOT surfaced through a relationships -// `included` array. That is the whole reason this extractor can emit -// users directly without a separate users-collector. +// The role fields are nested JSON:API response envelopes on the +// incident's attributes — inner record at `.data.attributes.*`. +// We pull users straight from those without needing a separate users +// collector or a JSON:API `included` sidecar parse. func extractRootlyIncident(rawData []byte, op *RootlyOptions) ([]interface{}, errors.Error) { rawIncident := &raw.Incident{} if err := errors.Convert(json.Unmarshal(rawData, rawIncident)); err != nil { return nil, err } - // Safety-net scope filter. The collector already sends - // filter[services]=, but if Rootly ever returns an - // incident that touches multiple services (or the filter regresses), - // dropping anything whose relationships.services.data does not - // include our scoped service keeps the tool table clean. When the - // envelope has no relationships at all we accept the incident — the - // API-side filter is the only scoping signal we have. - if len(rawIncident.Relationships) > 0 { - relationships := raw.IncidentRelationships{} - // Ignore unmarshal errors here: a malformed relationships block - // should not fail the entire row — fall through to accept. - if err := json.Unmarshal(rawIncident.Relationships, &relationships); err == nil { - if len(relationships.Services.Data) > 0 && !containsService(relationships.Services.Data, op.ServiceId) { - return nil, nil - } - } + // Safety-net scope filter. Rootly exposes service membership on the + // JSON:API relationships block (id+type pointers only, no embedded + // attributes unless we pass `?include=services`). The collector + // relies on `filter[service_ids]=` for scoping; this + // is defense in depth for multi-service incidents that would + // otherwise leak into a wrong scope. When the relationship is + // empty we accept the incident — API-side filtering is the only + // signal we have. + if services := rawIncident.Relationships.Services.Data; len(services) > 0 && !containsServiceId(services, op.ServiceId) { + return nil, nil } if rawIncident.Attributes.StartedAt.IsZero() { @@ -109,8 +104,7 @@ func extractRootlyIncident(rawData []byte, op *RootlyOptions) ([]interface{}, er Title: rawIncident.Attributes.Title, Summary: resolve(rawIncident.Attributes.Summary), Status: rawIncident.Attributes.Status, - Severity: resolveSeverity(rawIncident.Attributes), - Urgency: resolve(rawIncident.Attributes.Urgency), + Severity: resolveSeverity(rawIncident.Attributes.Severity), StartedDate: rawIncident.Attributes.StartedAt, AcknowledgedDate: rawIncident.Attributes.AcknowledgedAt, MitigatedDate: rawIncident.Attributes.MitigatedAt, @@ -120,21 +114,20 @@ func extractRootlyIncident(rawData []byte, op *RootlyOptions) ([]interface{}, er results := []interface{}{incident} seen := map[string]bool{} - addUser := func(u *raw.NestedUser, setRoleId func(string)) { - if u == nil || u.Id == "" { + addUser := func(u *raw.UserEnvelope, setRoleId func(string)) { + if u == nil || u.Data.Id == "" { return } - setRoleId(u.Id) - if seen[u.Id] { + setRoleId(u.Data.Id) + if seen[u.Data.Id] { return } - seen[u.Id] = true + seen[u.Data.Id] = true results = append(results, &models.User{ ConnectionId: op.ConnectionId, - Id: u.Id, - Email: resolve(u.Email), - Name: pickUserName(u), - Url: resolve(u.Url), + Id: u.Data.Id, + Email: u.Data.Attributes.Email, + Name: pickUserName(u.Data.Attributes), }) } addUser(rawIncident.Attributes.User, func(id string) { incident.CreatorUserId = id }) @@ -146,29 +139,27 @@ func extractRootlyIncident(rawData []byte, op *RootlyOptions) ([]interface{}, er return results, nil } -// pickUserName chooses the best display name for a nested user: -// FullName when set, otherwise Name, otherwise Email, otherwise empty. -// Email is a last-resort fallback so the User row is never nameless. -func pickUserName(u *raw.NestedUser) string { - if u.FullName != nil && *u.FullName != "" { - return *u.FullName - } - if u.Name != nil && *u.Name != "" { - return *u.Name +// pickUserName chooses the best display name: FullName when set, +// otherwise Name, otherwise Email, otherwise empty. Email is a +// last-resort fallback so the User row is never nameless. +func pickUserName(u raw.UserAttributes) string { + if u.FullName != "" { + return u.FullName } - if u.Email != nil { - return *u.Email + if u.Name != "" { + return u.Name } - return "" + return u.Email } -// containsService checks whether the given service id appears in the -// JSON:API relationships.services.data array. -func containsService(data []struct { +// containsServiceId checks whether the given service id appears in +// the incident's relationships.services.data array. Each entry is +// just a JSON:API pointer (id + type), not a full service record. +func containsServiceId(services []struct { Id string `json:"id"` Type string `json:"type"` }, serviceId string) bool { - for _, s := range data { + for _, s := range services { if s.Id == serviceId { return true } @@ -176,15 +167,17 @@ func containsService(data []struct { return false } -// resolveSeverity picks whichever severity shape Rootly returned: -// a nested severity_attributes.slug if present, else the flat -// `severity` field. See raw.IncidentAttributes for the shape -// decision deferred to implementation. -func resolveSeverity(attrs raw.IncidentAttributes) string { - if attrs.SeverityObj != nil && attrs.SeverityObj.Slug != "" { - return attrs.SeverityObj.Slug +// resolveSeverity extracts the slug from a Rootly severity envelope. +// Rootly returns severity as a JSON:API envelope with inner attributes +// `slug` (org-defined, e.g. "sev2") and `severity` (domain-normalized: +// critical/high/medium/low). We preserve the org's own slug in the tool +// layer — the converter applies the domain-normalized mapping at +// conversion time. +func resolveSeverity(s *raw.SeverityEnvelope) string { + if s == nil { + return "" } - return resolve(attrs.SeveritySlug) + return s.Data.Attributes.Slug } func resolve[T any](t *T) T { diff --git a/backend/plugins/rootly/tasks/incidents_extractor_test.go b/backend/plugins/rootly/tasks/incidents_extractor_test.go index 8378631dbc2..5c5efe47270 100644 --- a/backend/plugins/rootly/tasks/incidents_extractor_test.go +++ b/backend/plugins/rootly/tasks/incidents_extractor_test.go @@ -27,40 +27,37 @@ import ( "github.com/apache/incubator-devlake/plugins/rootly/models" ) -// buildRawIncident produces a minimally-valid JSON:API incident envelope -// so individual tests can override only the fields they exercise. When -// overrides is non-empty it is used verbatim as the raw payload. -func buildRawIncident(overrides string) []byte { - base := `{ - "id": "inc_01", - "type": "incidents", - "attributes": { - "sequential_id": 42, - "title": "db outage", - "summary": "replica lag blew past threshold", - "url": "https://rootly.example.com/incidents/inc_01", - "status": "started", - "severity": "sev1", - "urgency": "high", - "started_at": "2026-05-10T10:00:00Z", - "updated_at": "2026-05-10T10:05:00Z", - "user": { - "id": "usr_100", - "email": "reporter@example.com", - "full_name": "Reporter One" - } - }, - "relationships": { - "services": { - "data": [{"id": "svc_02", "type": "services"}] - } - } - }` - if overrides != "" { - return []byte(overrides) +// Fixture shapes match a real GET /v1/incidents response: +// - each incident is a JSON:API envelope {id, type, attributes, relationships} +// - role-bearing users (user, started_by, …) live on attributes as +// nested JSON:API envelopes: {"data": {"id": "…", "type": "users", +// "attributes": {"name", "full_name", "email"}}} +// - severity lives on attributes as a nested JSON:API envelope: +// {"data": {"id": "…", "type": "severities", "attributes": +// {"slug", "severity", "name"}}} +// - service membership lives on the sibling relationships block as +// plain JSON:API pointers: {"services": {"data": [{"id":"…","type":"services"}]}} +// (Full service records are only returned when the caller passes +// `?include=services`; we don't, so we only see pointer ids here.) + +const baseHappyPathActive = `{ + "id": "inc_01", + "type": "incidents", + "attributes": { + "sequential_id": 42, + "title": "db outage", + "summary": "replica lag blew past threshold", + "url": "https://rootly.example.com/incidents/inc_01", + "status": "started", + "severity": {"data": {"id": "sev-uuid-1", "type": "severities", "attributes": {"slug": "sev1", "name": "SEV1", "severity": "high"}}}, + "started_at": "2026-05-10T10:00:00Z", + "updated_at": "2026-05-10T10:05:00Z", + "user": {"data": {"id": "usr_100", "type": "users", "attributes": {"name": "Reporter One", "full_name": "Reporter One", "email": "reporter@example.com"}}} + }, + "relationships": { + "services": {"data": [{"id": "svc_02", "type": "services"}]} } - return []byte(base) -} +}` func newTestOptions() *RootlyOptions { return &RootlyOptions{ @@ -87,7 +84,7 @@ func collectUsers(results []interface{}) []*models.User { // Incident row (with CreatorUserId populated) and one User row. func TestExtractRootlyIncident_HappyPathActive(t *testing.T) { op := newTestOptions() - results, err := extractRootlyIncident(buildRawIncident(""), op) + results, err := extractRootlyIncident([]byte(baseHappyPathActive), op) require.NoError(t, err) require.Len(t, results, 2) @@ -102,7 +99,6 @@ func TestExtractRootlyIncident_HappyPathActive(t *testing.T) { assert.Equal(t, "https://rootly.example.com/incidents/inc_01", incident.Url) assert.Equal(t, "started", incident.Status) assert.Equal(t, "sev1", incident.Severity) - assert.Equal(t, "high", incident.Urgency) assert.Equal(t, time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC), incident.StartedDate) assert.Nil(t, incident.AcknowledgedDate) assert.Nil(t, incident.MitigatedDate) @@ -126,7 +122,7 @@ func TestExtractRootlyIncident_HappyPathActive(t *testing.T) { // TestExtractRootlyIncident_Resolved verifies that a resolved incident // populates AcknowledgedDate / MitigatedDate / ResolvedDate as non-nil // pointers AND populates CreatorUserId + ResolvedByUserId from the -// nested user objects. Both users are emitted as User rows. +// nested JSON:API user envelopes. Both users are emitted as User rows. func TestExtractRootlyIncident_Resolved(t *testing.T) { raw := []byte(`{ "id": "inc_02", @@ -135,14 +131,14 @@ func TestExtractRootlyIncident_Resolved(t *testing.T) { "sequential_id": 43, "title": "cache cleared", "status": "resolved", - "severity": "sev3", + "severity": {"data": {"id": "sev-uuid-3", "type": "severities", "attributes": {"slug": "sev3", "severity": "low"}}}, "started_at": "2026-05-09T08:00:00Z", "acknowledged_at": "2026-05-09T08:05:00Z", "mitigated_at": "2026-05-09T08:30:00Z", "resolved_at": "2026-05-09T09:00:00Z", "updated_at": "2026-05-09T09:01:00Z", - "user": {"id": "usr_100", "full_name": "Reporter One"}, - "resolved_by": {"id": "usr_200", "full_name": "Resolver Two"} + "user": {"data": {"id": "usr_100", "type": "users", "attributes": {"full_name": "Reporter One"}}}, + "resolved_by": {"data": {"id": "usr_200", "type": "users", "attributes": {"full_name": "Resolver Two"}}} }, "relationships": { "services": {"data": [{"id": "svc_02", "type": "services"}]} @@ -203,19 +199,18 @@ func TestExtractRootlyIncident_MissingOptionalTimestamps(t *testing.T) { assert.Nil(t, incident.AcknowledgedDate) } -// TestExtractRootlyIncident_SeverityObjectShape covers the defensive -// alternate response shape: when severity comes in as a nested -// severity_attributes object, the extractor prefers its Slug over -// the flat `severity` field. -func TestExtractRootlyIncident_SeverityObjectShape(t *testing.T) { +// TestExtractRootlyIncident_NullSeverity covers the common case where +// an incident has no severity set: the Severity field on the tool row +// is empty string, not a panic or a "null" literal. +func TestExtractRootlyIncident_NullSeverity(t *testing.T) { raw := []byte(`{ "id": "inc_04", "type": "incidents", "attributes": { "sequential_id": 45, - "title": "nested severity", + "title": "no sev yet", "status": "mitigated", - "severity_attributes": {"slug": "sev0", "name": "Critical"}, + "severity": null, "started_at": "2026-05-10T14:00:00Z", "updated_at": "2026-05-10T14:05:00Z" }, @@ -228,7 +223,7 @@ func TestExtractRootlyIncident_SeverityObjectShape(t *testing.T) { require.NoError(t, err) require.Len(t, results, 1) incident := results[0].(*models.Incident) - assert.Equal(t, "sev0", incident.Severity) + assert.Equal(t, "", incident.Severity) } // TestExtractRootlyIncident_NoRolesFilled verifies that an incident @@ -283,8 +278,8 @@ func TestExtractRootlyIncident_SameUserInMultipleRoles(t *testing.T) { "started_at": "2026-05-10T16:00:00Z", "resolved_at": "2026-05-10T16:30:00Z", "updated_at": "2026-05-10T16:31:00Z", - "user": {"id": "usr_100", "full_name": "Solo Operator"}, - "resolved_by": {"id": "usr_100", "full_name": "Solo Operator"} + "user": {"data": {"id": "usr_100", "type": "users", "attributes": {"full_name": "Solo Operator"}}}, + "resolved_by": {"data": {"id": "usr_100", "type": "users", "attributes": {"full_name": "Solo Operator"}}} }, "relationships": { "services": {"data": [{"id": "svc_02", "type": "services"}]} @@ -318,9 +313,9 @@ func TestExtractRootlyIncident_UserNamePreference(t *testing.T) { "status": "started", "started_at": "2026-05-10T17:00:00Z", "updated_at": "2026-05-10T17:05:00Z", - "user": {"id": "usr_full", "full_name": "Full Name", "name": "Ignored", "email": "ignored@example.com"}, - "started_by": {"id": "usr_short", "name": "Short Name", "email": "ignored@example.com"}, - "resolved_by": {"id": "usr_mail", "email": "fallback@example.com"} + "user": {"data": {"id": "usr_full", "type": "users", "attributes": {"full_name": "Full Name", "name": "Ignored", "email": "ignored@example.com"}}}, + "started_by": {"data": {"id": "usr_short", "type": "users", "attributes": {"name": "Short Name", "email": "ignored@example.com"}}}, + "resolved_by": {"data": {"id": "usr_mail", "type": "users", "attributes": {"email": "fallback@example.com"}}} }, "relationships": { "services": {"data": [{"id": "svc_02", "type": "services"}]} @@ -345,10 +340,11 @@ func TestExtractRootlyIncident_UserNamePreference(t *testing.T) { } // TestExtractRootlyIncident_WrongServiceSkipped asserts the safety-net -// scope filter: if the incident's relationships don't include the -// configured ServiceId, the extractor returns an empty slice and no -// error. This protects us from multi-service incidents leaking into -// the wrong scope even if the API-side filter[services] query failed. +// scope filter: if the incident's relationships.services.data doesn't +// include the configured ServiceId, the extractor returns an empty +// slice and no error. Defense in depth against multi-service +// incidents leaking into the wrong scope even if the API-side +// filter[service_ids] query failed. func TestExtractRootlyIncident_WrongServiceSkipped(t *testing.T) { raw := []byte(`{ "id": "inc_wrong_svc", @@ -370,17 +366,18 @@ func TestExtractRootlyIncident_WrongServiceSkipped(t *testing.T) { assert.Empty(t, results, "incident for unrelated service should produce no rows") } -// TestExtractRootlyIncident_NoRelationshipsAccepted covers the case -// where the API response omits relationships entirely. We cannot fail -// closed here — `filter[services]` already scoped the list — so the -// incident is accepted with incident.ServiceId = op.ServiceId. -func TestExtractRootlyIncident_NoRelationshipsAccepted(t *testing.T) { +// TestExtractRootlyIncident_EmptyServicesAccepted covers the case +// where an incident has no services relationship (empty array or +// missing block entirely). We accept the incident — the API-side +// filter[service_ids] query is the only scoping signal — and tag it +// with op.ServiceId. +func TestExtractRootlyIncident_EmptyServicesAccepted(t *testing.T) { raw := []byte(`{ - "id": "inc_no_rel", + "id": "inc_no_svc", "type": "incidents", "attributes": { "sequential_id": 50, - "title": "relationships omitted", + "title": "services omitted", "status": "started", "started_at": "2026-05-10T19:00:00Z", "updated_at": "2026-05-10T19:05:00Z" From 9b3f7faba6231144733b556bfb38d21bd23070b1 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Wed, 13 May 2026 12:39:16 -0700 Subject: [PATCH 10/14] style(rootly): trim narrative comments to match plugin conventions Match PagerDuty's comment density. Keep the few comments that flag non-obvious invariants (archived-base field overrides, 1-based pagination, deliberate divergence from PagerDuty's panic-on-unknown behavior, clock-skew guard). --- backend/plugins/rootly/api/remote_api.go | 2 - backend/plugins/rootly/e2e/incident_test.go | 13 ++--- backend/plugins/rootly/models/connection.go | 6 -- backend/plugins/rootly/models/incident.go | 6 +- .../migrationscripts/archived/scope_config.go | 9 +-- .../migrationscripts/archived/service.go | 6 +- backend/plugins/rootly/models/raw/incident.go | 46 +-------------- backend/plugins/rootly/models/raw/service.go | 3 - .../rootly/tasks/incidents_collector.go | 40 ++----------- .../rootly/tasks/incidents_converter.go | 55 ++---------------- .../rootly/tasks/incidents_converter_test.go | 45 -------------- .../rootly/tasks/incidents_extractor.go | 41 +------------ .../rootly/tasks/incidents_extractor_test.go | 58 ------------------- 13 files changed, 27 insertions(+), 303 deletions(-) diff --git a/backend/plugins/rootly/api/remote_api.go b/backend/plugins/rootly/api/remote_api.go index 1f627c3117b..93b5022e290 100644 --- a/backend/plugins/rootly/api/remote_api.go +++ b/backend/plugins/rootly/api/remote_api.go @@ -32,13 +32,11 @@ import ( "github.com/apache/incubator-devlake/plugins/rootly/models" ) -// RootlyRemotePagination holds JSON:API page-based pagination state. type RootlyRemotePagination struct { Page int `json:"page"` PerPage int `json:"per_page"` } -// ServiceResponse mirrors Rootly's JSON:API envelope for GET /services. type ServiceResponse struct { Data []struct { Id string `json:"id"` diff --git a/backend/plugins/rootly/e2e/incident_test.go b/backend/plugins/rootly/e2e/incident_test.go index 111efca0b79..15cc1d324a3 100644 --- a/backend/plugins/rootly/e2e/incident_test.go +++ b/backend/plugins/rootly/e2e/incident_test.go @@ -42,10 +42,7 @@ func TestIncidentDataFlow(t *testing.T) { Options: &options, } - // Seed the service scope as a prereq: the service is the scope unit, - // not test data, so we populate _tool_rootly_services directly - // instead of running the services extractor. Mirrors the pagerduty - // e2e pattern. + // scope dataflowTester.FlushTabler(&models.Service{}) service := models.Service{ Scope: common.Scope{ @@ -57,14 +54,13 @@ func TestIncidentDataFlow(t *testing.T) { } require.NoError(t, dataflowTester.Dal.CreateOrUpdate(&service)) - // Import the raw incidents fixture that drives the extractor. + // import raw data table dataflowTester.ImportCsvIntoRawTable( "./raw_tables/_raw_rootly_incidents.csv", "_raw_rootly_incidents", ) - // Extract incidents. The extractor writes to _tool_rootly_incidents - // and _tool_rootly_users (inline users from nested attributes). + // verify extraction dataflowTester.FlushTabler(&models.Incident{}) dataflowTester.FlushTabler(&models.User{}) dataflowTester.Subtask(tasks.ExtractIncidentsMeta, taskData) @@ -90,8 +86,7 @@ func TestIncidentDataFlow(t *testing.T) { }, ) - // Convert: services -> boards, incidents -> issues + assignees + board - // membership. + // verify conversion dataflowTester.FlushTabler(&ticket.Board{}) dataflowTester.Subtask(tasks.ConvertServicesMeta, taskData) dataflowTester.VerifyTableWithOptions( diff --git a/backend/plugins/rootly/models/connection.go b/backend/plugins/rootly/models/connection.go index b89983a0565..a60c0d74118 100644 --- a/backend/plugins/rootly/models/connection.go +++ b/backend/plugins/rootly/models/connection.go @@ -26,22 +26,18 @@ import ( helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" ) -// RootlyAccessToken implements HTTP Bearer Authentication with an access token type RootlyAccessToken helper.AccessToken -// SetupAuthentication sets up the request headers for authentication func (at *RootlyAccessToken) SetupAuthentication(request *http.Request) errors.Error { request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", at.Token)) return nil } -// RootlyConn holds the essential information to connect to the Rootly API type RootlyConn struct { helper.RestConnection `mapstructure:",squash"` RootlyAccessToken `mapstructure:",squash"` } -// RootlyConnection holds RootlyConn plus ID/Name for database storage type RootlyConnection struct { helper.BaseConnection `mapstructure:",squash"` RootlyConn `mapstructure:",squash"` @@ -59,14 +55,12 @@ func (connection *RootlyConnection) MergeFromRequest(target *RootlyConnection, b return nil } -// This object conforms to what the frontend currently expects. type RootlyResponse struct { Name string `json:"name"` ID int `json:"id"` RootlyConnection } -// ApiUserResponse represents the Rootly /users/me response for token validation. type ApiUserResponse struct { Id string Name string `json:"name"` diff --git a/backend/plugins/rootly/models/incident.go b/backend/plugins/rootly/models/incident.go index 50adb6018d5..4c46c5542fc 100644 --- a/backend/plugins/rootly/models/incident.go +++ b/backend/plugins/rootly/models/incident.go @@ -38,11 +38,7 @@ type Incident struct { AcknowledgedDate *time.Time MitigatedDate *time.Time ResolvedDate *time.Time - UpdatedDate time.Time - // Role-specific user ids, extracted from the nested user objects on - // attributes.user (creator), started_by, mitigated_by, resolved_by, - // and closed_by. Any may be empty if the role was not filled (e.g., - // an unresolved incident has no ResolvedByUserId). + UpdatedDate time.Time CreatorUserId string StartedByUserId string MitigatedByUserId string diff --git a/backend/plugins/rootly/models/migrationscripts/archived/scope_config.go b/backend/plugins/rootly/models/migrationscripts/archived/scope_config.go index f4a4b544f82..4438c38f480 100644 --- a/backend/plugins/rootly/models/migrationscripts/archived/scope_config.go +++ b/backend/plugins/rootly/models/migrationscripts/archived/scope_config.go @@ -21,12 +21,9 @@ import ( "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" ) -// ScopeConfig mirrors the columns contributed by live -// `models.RootlyScopeConfig`'s embedded `common.ScopeConfig` -// (Model + Entities + ConnectionId + Name). The archived base -// `ScopeConfig` struct only carries Model + Entities, so we carry -// ConnectionId and Name explicitly here so the generated table has -// the columns the live model reads and writes. +// ConnectionId and Name come from `common.ScopeConfig` on the live +// model; the archived `archived.ScopeConfig` base only carries +// Model + Entities, so declare them explicitly. type ScopeConfig struct { archived.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` ConnectionId uint64 `json:"connectionId" gorm:"index" validate:"required" mapstructure:"connectionId,omitempty"` diff --git a/backend/plugins/rootly/models/migrationscripts/archived/service.go b/backend/plugins/rootly/models/migrationscripts/archived/service.go index 744643c85c0..821b9ea2b01 100644 --- a/backend/plugins/rootly/models/migrationscripts/archived/service.go +++ b/backend/plugins/rootly/models/migrationscripts/archived/service.go @@ -21,10 +21,8 @@ import ( "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" ) -// Service mirrors the columns contributed by live `models.Service`'s -// embedded `common.Scope` (NoPKModel + ConnectionId + ScopeConfigId) -// plus the Rootly-specific fields. Missing `scope_config_id` here -// would cause PUT /scopes to fail with "Unknown column 'scope_config_id'". +// ScopeConfigId mirrors the column that live `models.Service` gets via +// embedded `common.Scope`; the archived `NoPKModel` doesn't include it. type Service struct { archived.NoPKModel ConnectionId uint64 `gorm:"primaryKey"` diff --git a/backend/plugins/rootly/models/raw/incident.go b/backend/plugins/rootly/models/raw/incident.go index 017a705e1b6..e08d135543f 100644 --- a/backend/plugins/rootly/models/raw/incident.go +++ b/backend/plugins/rootly/models/raw/incident.go @@ -21,14 +21,6 @@ import ( "time" ) -// Incident is the JSON:API envelope for a Rootly incident as returned by -// GET /incidents. The top-level Id is the incident id; display fields -// live under Attributes. Role-bearing fields (user, *_by) and the -// severity field on Attributes are themselves JSON:API response -// envelopes nested on the incident's attributes. Service membership -// lives on the sibling Relationships block (JSON:API relationship -// data, id+type pointers only) and is used by the extractor's -// safety-net service-scope filter. type Incident struct { Id string `json:"id"` Type string `json:"type"` @@ -36,9 +28,6 @@ type Incident struct { Relationships IncidentRelationships `json:"relationships"` } -// IncidentRelationships is a narrow view of the JSON:API relationships -// block used only for the safety-net service-scope check in the -// extractor. Every relationship type other than services is ignored. type IncidentRelationships struct { Services struct { Data []struct { @@ -48,19 +37,6 @@ type IncidentRelationships struct { } `json:"services"` } -// IncidentAttributes carries the display fields for a Rootly incident. -// Shapes here match an actual GET /v1/incidents response. Notable: -// -// - `severity`, `user`, `started_by`, `mitigated_by`, `resolved_by`, -// `closed_by` are each nullable JSON:API-envelope objects — the -// inner record lives at `.data.id` / -// `.data.attributes.*`. -// - Service membership is NOT on attributes; it lives on the -// Incident.Relationships.Services block as JSON:API id+type -// pointers. Without `?include=services` the full service records -// are not returned — but the relationship pointers alone are -// enough for the extractor's safety-net scope filter. -// - No `urgency` field exists on the incident resource. type IncidentAttributes struct { SequentialId *int `json:"sequential_id"` Title string `json:"title"` @@ -73,13 +49,8 @@ type IncidentAttributes struct { ResolvedAt *time.Time `json:"resolved_at"` UpdatedAt time.Time `json:"updated_at"` - // Severity is a JSON:API-envelope nested object. Inner attributes - // include `slug` (e.g. sev0, sev1) and `severity` (the domain- - // normalized value: critical, high, medium, low). Severity *SeverityEnvelope `json:"severity"` - // Role-bearing users. Each is a JSON:API-envelope nested object, - // nullable. User is the incident creator. User *UserEnvelope `json:"user"` StartedBy *UserEnvelope `json:"started_by"` MitigatedBy *UserEnvelope `json:"mitigated_by"` @@ -87,8 +58,6 @@ type IncidentAttributes struct { ClosedBy *UserEnvelope `json:"closed_by"` } -// SeverityEnvelope is a JSON:API response envelope for a severity -// resource as it appears nested on an incident's attributes. type SeverityEnvelope struct { Data struct { Id string `json:"id"` @@ -97,18 +66,12 @@ type SeverityEnvelope struct { } `json:"data"` } -// SeverityAttributes carries the inner severity display fields. -// `Slug` is the org-defined identifier (e.g. sev0, sev1). `Severity` -// is the domain-normalized bucket (critical, high, medium, low) that -// DevLake maps straight onto ticket.Issue.Priority. type SeverityAttributes struct { - Slug string `json:"slug"` - Name string `json:"name"` - Severity string `json:"severity"` + Slug string `json:"slug"` // org-defined (sev0, sev1, ...) + Name string `json:"name"` // display name + Severity string `json:"severity"` // critical, high, medium, low } -// UserEnvelope is a JSON:API response envelope for a user resource as -// it appears nested on an incident's attributes. type UserEnvelope struct { Data struct { Id string `json:"id"` @@ -117,11 +80,8 @@ type UserEnvelope struct { } `json:"data"` } -// UserAttributes is the subset of the user resource DevLake cares -// about for incident role tracking. type UserAttributes struct { Name string `json:"name"` FullName string `json:"full_name"` Email string `json:"email"` } - diff --git a/backend/plugins/rootly/models/raw/service.go b/backend/plugins/rootly/models/raw/service.go index 55f40393096..d9971796274 100644 --- a/backend/plugins/rootly/models/raw/service.go +++ b/backend/plugins/rootly/models/raw/service.go @@ -19,9 +19,6 @@ package raw import "time" -// Service is the JSON:API shape of a single Rootly service as returned -// by GET /services/{id}. The top-level Id lives on the JSON:API envelope; -// all display fields are nested under Attributes. type Service struct { Id string `json:"id"` Type string `json:"type"` diff --git a/backend/plugins/rootly/tasks/incidents_collector.go b/backend/plugins/rootly/tasks/incidents_collector.go index 0bb2f80e18f..e2e4ebb2c4a 100644 --- a/backend/plugins/rootly/tasks/incidents_collector.go +++ b/backend/plugins/rootly/tasks/incidents_collector.go @@ -33,10 +33,6 @@ const RAW_INCIDENTS_TABLE = "rootly_incidents" var _ plugin.SubTaskEntryPoint = CollectIncidents -// collectedIncidents is the JSON:API envelope for a paginated list of -// incidents. `data` is an array of raw resource objects (id, type, -// attributes, relationships) — one per incident — and `meta`/`links` -// drive pagination termination. type collectedIncidents struct { Data []json.RawMessage `json:"data"` Meta *collectedListMeta `json:"meta"` @@ -65,17 +61,15 @@ var CollectIncidentsMeta = plugin.SubTaskMeta{ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { data := taskCtx.GetData().(*RootlyTaskData) logger := taskCtx.GetLogger() - logger.Info("[rootly] CollectIncidents: starting for serviceId=%s connectionId=%d", data.Options.ServiceId, data.Options.ConnectionId) args := api.RawDataSubTaskArgs{ Ctx: taskCtx, Options: data.Options, Table: RAW_INCIDENTS_TABLE, } - // lastPage captures the pagination signals from the most recent - // ResponseParser invocation so GetNextPageCustomData can decide - // whether to stop without re-reading prevPageResponse.Body, which - // has already been drained by ResponseParser (http.Response.Body - // is a single-read stream). + // Pagination state captured during ResponseParser and consulted in + // GetNextPageCustomData. Required because prevPageResponse.Body is + // a single-read stream and is already drained by the time the + // next-page hook fires. var lastPage *collectedListMeta var lastLinksNext *string var lastPageEmpty bool @@ -86,9 +80,6 @@ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { CollectNewRecordsByList: api.FinalizableApiCollectorListArgs{ PageSize: 100, GetNextPageCustomData: func(prevReqData *api.RequestData, prevPageResponse *http.Response) (interface{}, errors.Error) { - // The response body was already consumed by - // ResponseParser; rely on the closure-captured - // pagination state from that parse. if lastLinksNext != nil && *lastLinksNext != "" { return nil, nil } @@ -116,28 +107,14 @@ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { if createdAfter != nil { query.Set("filter[updated_at][gt]", createdAfter.UTC().Format(time.RFC3339)) } - logger.Debug("[rootly] incidents query: page=%d size=%d createdAfter=%v %s", pageNumber, reqData.Pager.Size, createdAfter, query.Encode()) return query, nil }, ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { rawResult := collectedIncidents{} if err := api.UnmarshalResponse(res, &rawResult); err != nil { - logger.Error(err, "[rootly] incidents ResponseParser: unmarshal failed") + logger.Error(err, "rootly incidents response unmarshal failed") return nil, err } - metaStr := "nil" - if rawResult.Meta != nil { - metaStr = fmt.Sprintf("current=%s total_pages=%s total_count=%s", - derefIntStr(rawResult.Meta.CurrentPage), - derefIntStr(rawResult.Meta.TotalPages), - derefIntStr(rawResult.Meta.TotalCount)) - } - linksNextStr := "nil" - if rawResult.Links != nil && rawResult.Links.Next != nil { - linksNextStr = *rawResult.Links.Next - } - logger.Debug("[rootly] incidents response: status=%d count=%d meta=%s links.next=%s", - res.StatusCode, len(rawResult.Data), metaStr, linksNextStr) lastPage = rawResult.Meta if rawResult.Links != nil { lastLinksNext = rawResult.Links.Next @@ -155,10 +132,3 @@ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { } return collector.Execute() } - -func derefIntStr(p *int) string { - if p == nil { - return "nil" - } - return fmt.Sprintf("%d", *p) -} diff --git a/backend/plugins/rootly/tasks/incidents_converter.go b/backend/plugins/rootly/tasks/incidents_converter.go index 2da1f20af02..e55706ec21d 100644 --- a/backend/plugins/rootly/tasks/incidents_converter.go +++ b/backend/plugins/rootly/tasks/incidents_converter.go @@ -43,25 +43,11 @@ var ConvertIncidentsMeta = plugin.SubTaskMeta{ DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, } -// ConvertIncidents turns _tool_rootly_incidents rows into the domain -// TICKET trio: ticket.Issue (one per incident), ticket.IssueAssignee -// (one per distinct role user referenced by the incident), and -// ticket.BoardIssue (linking the incident to its service board). -// -// Unlike PagerDuty's converter, there is no assignments join — U4 -// reshaped the tool layer so incidents carry role-specific user-id -// columns directly. The only auxiliary lookup is a per-connection map -// of user display names, built once before the cursor opens. func ConvertIncidents(taskCtx plugin.SubTaskContext) errors.Error { db := taskCtx.GetDal() data := taskCtx.GetData().(*RootlyTaskData) logger := taskCtx.GetLogger() - // Load all users for this connection up-front so the per-row - // Convert closure can resolve role user ids to display names - // without re-querying. This is cheaper than a per-incident join - // when the user table is small (Rootly users, not incidents), and - // incidents outnumber users. var userRows []models.User if err := db.All( &userRows, @@ -108,10 +94,6 @@ func ConvertIncidents(taskCtx plugin.SubTaskContext) errors.Error { domainIssueId := idGen.Generate(data.Options.ConnectionId, incident.Id) - // Creator drives Issue.CreatorId / CreatorName and also - // Issue.AssigneeId / AssigneeName (the latter is a - // convenience denormalization — per-role fidelity lives - // on the IssueAssignee rows below). var creatorDomainId, creatorName string if incident.CreatorUserId != "" { creatorDomainId = userIdGen.Generate(data.Options.ConnectionId, incident.CreatorUserId) @@ -143,10 +125,6 @@ func ConvertIncidents(taskCtx plugin.SubTaskContext) errors.Error { results := []interface{}{domainIssue} - // Emit one IssueAssignee per distinct role-user on the - // incident. Deduping is local to this incident so that - // the same person in multiple roles (e.g. creator + - // resolver) produces a single assignee row. seenAssignees := map[string]bool{} roleUserIds := []string{ incident.CreatorUserId, @@ -181,10 +159,9 @@ func ConvertIncidents(taskCtx plugin.SubTaskContext) errors.Error { return converter.Execute() } -// mapStatus translates a Rootly incident status into the domain-layer -// status enum. Unknown statuses fall through to IN_PROGRESS; callers -// that care whether a value was known should check via isKnownStatus -// to emit a warning log rather than a panic. +// Unknown statuses fall through to IN_PROGRESS rather than panicking +// (PagerDuty panics). Rootly's status enum is more volatile, so a new +// value from upstream shouldn't crash a production pipeline. func mapStatus(status string) string { switch status { case "triage", "started": @@ -198,9 +175,6 @@ func mapStatus(status string) string { } } -// isKnownStatus answers whether the given Rootly status value is one -// of the enum members we map explicitly. Used so the converter can -// warn about unknown values without re-running the switch. func isKnownStatus(status string) bool { switch status { case "triage", "started", "mitigated", "resolved", "closed", "cancelled": @@ -210,12 +184,6 @@ func isKnownStatus(status string) bool { } } -// mapSeverityToPriority translates a Rootly severity slug into the -// domain-layer priority string. Accepts case-insensitive inputs -// because Rootly has been observed returning SEV0, sev0, and Sev0 -// interchangeably. Unknown severities are passed through verbatim -// so operators can see the raw value rather than a collapsed -// default. func mapSeverityToPriority(severity string) string { switch strings.ToLower(severity) { case "sev0": @@ -231,20 +199,13 @@ func mapSeverityToPriority(severity string) string { } } -// computeLeadTime derives the DORA lead-time and resolution-date -// values from the incident's started_at and optional resolved_at -// timestamps. When resolved is nil both return values are nil — the -// incident is still ongoing and has no resolution to measure. When -// resolved equals started the lead time is zero minutes but still -// non-nil, so DORA math can distinguish "resolved instantly" from -// "not yet resolved". func computeLeadTime(started time.Time, resolved *time.Time) (*uint, *time.Time) { if resolved == nil { return nil, nil } - // Guard against clock skew or backfill anomalies that place the - // resolution before the start. A naive uint() cast on a negative - // duration produces wraparound garbage and silently corrupts MTTR. + // Clock skew / backfill can place resolved before started. A naive + // uint() cast on a negative duration wraps to huge garbage and + // silently corrupts MTTR; treat as unresolved instead. if resolved.Before(started) { return nil, nil } @@ -253,10 +214,6 @@ func computeLeadTime(started time.Time, resolved *time.Time) (*uint, *time.Time) return &minutes, &resolutionDate } -// issueKeyFor picks the most human-readable identifier available: the -// Rootly sequential id (what operators see in the UI) when present, -// falling back to the internal slug id when the sequential id is -// missing or zero. func issueKeyFor(incident *models.Incident) string { if incident.Number > 0 { return fmt.Sprintf("%d", incident.Number) diff --git a/backend/plugins/rootly/tasks/incidents_converter_test.go b/backend/plugins/rootly/tasks/incidents_converter_test.go index 1cd907a9c05..1c71fd5eacd 100644 --- a/backend/plugins/rootly/tasks/incidents_converter_test.go +++ b/backend/plugins/rootly/tasks/incidents_converter_test.go @@ -28,9 +28,6 @@ import ( "github.com/apache/incubator-devlake/plugins/rootly/models" ) -// TestMapStatus exercises every branch of the Rootly-to-domain status -// mapping, including the deliberate divergence from PagerDuty: unknown -// statuses fall back to IN_PROGRESS with a warning rather than panic. func TestMapStatus(t *testing.T) { cases := []struct { in string @@ -52,19 +49,12 @@ func TestMapStatus(t *testing.T) { } } -// TestMapStatusDoesNotPanic pins the behavioral difference from the -// PagerDuty converter, which panics on unknown statuses. Rootly's -// enum is more volatile, so we fall back rather than crash. func TestMapStatusDoesNotPanic(t *testing.T) { assert.NotPanics(t, func() { _ = mapStatus("brand-new-status-rootly-invented-yesterday") }) } -// TestMapSeverityToPriority covers the severity table plus case -// variation (Rootly has been observed returning SEV0, sev0, and -// Sev0 interchangeably) and the pass-through behavior for unknown -// values. func TestMapSeverityToPriority(t *testing.T) { cases := []struct { in string @@ -89,9 +79,6 @@ func TestMapSeverityToPriority(t *testing.T) { } } -// TestComputeLeadTime_Resolved verifies that a resolved incident yields -// a non-nil lead time in minutes and a non-nil resolution date pointer -// whose value matches the resolved timestamp. func TestComputeLeadTime_Resolved(t *testing.T) { started := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC) resolved := time.Date(2026, 5, 10, 11, 30, 0, 0, time.UTC) @@ -102,10 +89,6 @@ func TestComputeLeadTime_Resolved(t *testing.T) { assert.Equal(t, resolved, *resolutionDate) } -// TestComputeLeadTime_Unresolved verifies that an unresolved incident -// (resolved pointer is nil) yields nil, nil rather than a zero-time -// sentinel — downstream DORA math treats (nil) as "still ongoing" and -// a zero-time value would pollute mean-time-to-resolve. func TestComputeLeadTime_Unresolved(t *testing.T) { started := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC) leadTime, resolutionDate := computeLeadTime(started, nil) @@ -113,10 +96,6 @@ func TestComputeLeadTime_Unresolved(t *testing.T) { assert.Nil(t, resolutionDate) } -// TestComputeLeadTime_ZeroDuration covers the edge case where an -// incident is resolved at the same instant it started. Lead time is -// zero but should still be non-nil, because DORA needs to distinguish -// "resolved instantly" from "not yet resolved". func TestComputeLeadTime_ZeroDuration(t *testing.T) { started := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC) resolved := started @@ -126,12 +105,6 @@ func TestComputeLeadTime_ZeroDuration(t *testing.T) { assert.Equal(t, uint(0), *leadTime) } -// TestComputeLeadTime_ResolvedBeforeStarted guards against clock skew -// or backfill anomalies where the resolution timestamp precedes the -// start. A naive uint() cast on a negative duration would produce -// wraparound garbage and silently corrupt MTTR. The helper treats -// these cases as if unresolved so bad data does not contaminate the -// domain layer. func TestComputeLeadTime_ResolvedBeforeStarted(t *testing.T) { started := time.Date(2026, 5, 10, 11, 0, 0, 0, time.UTC) resolved := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC) @@ -140,11 +113,6 @@ func TestComputeLeadTime_ResolvedBeforeStarted(t *testing.T) { assert.Nil(t, resolutionDate) } -// TestIssueKeyFor covers the human-readable-id preference: prefer the -// Rootly sequential id when positive, fall back to the internal slug id -// when missing, zero, or negative. The negative branch matters because -// Number is typed int and a "negative sequential id" would be a data -// bug we should surface as the slug rather than as a negative string. func TestIssueKeyFor(t *testing.T) { cases := []struct { name string @@ -162,12 +130,6 @@ func TestIssueKeyFor(t *testing.T) { } } -// TestAssigneeDedup covers the role-user dedup in ConvertIncidents by -// calling the dedup logic in isolation (inlined here to avoid wiring up -// a SubTaskContext in a unit test — e2e coverage lives in U6). The -// invariants are: (1) distinct user ids across roles produce distinct -// IssueAssignees, (2) duplicate user ids across roles produce exactly -// one IssueAssignee, (3) empty-string user ids are skipped. func TestAssigneeDedup(t *testing.T) { cases := []struct { name string @@ -241,16 +203,9 @@ func TestAssigneeDedup(t *testing.T) { } } -// TestMapStatus_MitigatedIsKnown pins the boundary between mapStatus -// and isKnownStatus: "mitigated" maps to IN_PROGRESS (the same bucket -// as the unknown-status fallback), but it is a KNOWN status and should -// not trigger the warning log. Without this test, a regression that -// deletes "mitigated" from isKnownStatus would silently fire unknown- -// status warnings on every mitigated incident. func TestMapStatus_MitigatedIsKnown(t *testing.T) { assert.Equal(t, ticket.IN_PROGRESS, mapStatus("mitigated")) assert.True(t, isKnownStatus("mitigated")) - // And the contrapositive — the fallback case is indeed unknown. assert.Equal(t, ticket.IN_PROGRESS, mapStatus("something-else")) assert.False(t, isKnownStatus("something-else")) } diff --git a/backend/plugins/rootly/tasks/incidents_extractor.go b/backend/plugins/rootly/tasks/incidents_extractor.go index ef9049ea577..b5880924e6b 100644 --- a/backend/plugins/rootly/tasks/incidents_extractor.go +++ b/backend/plugins/rootly/tasks/incidents_extractor.go @@ -55,38 +55,15 @@ func ExtractIncidents(taskCtx plugin.SubTaskContext) errors.Error { return extractor.Execute() } -// extractRootlyIncident is the pure-function core of the extractor: -// take a raw JSON:API incident envelope + the task options, return the -// tool-layer rows to persist. Factored out of the closure so it can be -// unit-tested without a SubTaskContext. -// -// Output shape per incident: -// - exactly one *models.Incident (always), with role-specific user-id -// fields populated from the nested attributes.{user,started_by, -// mitigated_by,resolved_by,closed_by} JSON:API-envelope blocks -// - zero-to-N *models.User rows, one per distinct user id seen across -// those role fields (deduplicated within a single incident — if the -// same user is both creator and resolver, only one User row is -// emitted) -// -// The role fields are nested JSON:API response envelopes on the -// incident's attributes — inner record at `.data.attributes.*`. -// We pull users straight from those without needing a separate users -// collector or a JSON:API `included` sidecar parse. func extractRootlyIncident(rawData []byte, op *RootlyOptions) ([]interface{}, errors.Error) { rawIncident := &raw.Incident{} if err := errors.Convert(json.Unmarshal(rawData, rawIncident)); err != nil { return nil, err } - // Safety-net scope filter. Rootly exposes service membership on the - // JSON:API relationships block (id+type pointers only, no embedded - // attributes unless we pass `?include=services`). The collector - // relies on `filter[service_ids]=` for scoping; this - // is defense in depth for multi-service incidents that would - // otherwise leak into a wrong scope. When the relationship is - // empty we accept the incident — API-side filtering is the only - // signal we have. + // Safety net: filter[service_ids] in the collector is the primary + // scope filter, but a regression there would let multi-service + // incidents leak into a wrong scope's tool table. if services := rawIncident.Relationships.Services.Data; len(services) > 0 && !containsServiceId(services, op.ServiceId) { return nil, nil } @@ -139,9 +116,6 @@ func extractRootlyIncident(rawData []byte, op *RootlyOptions) ([]interface{}, er return results, nil } -// pickUserName chooses the best display name: FullName when set, -// otherwise Name, otherwise Email, otherwise empty. Email is a -// last-resort fallback so the User row is never nameless. func pickUserName(u raw.UserAttributes) string { if u.FullName != "" { return u.FullName @@ -152,9 +126,6 @@ func pickUserName(u raw.UserAttributes) string { return u.Email } -// containsServiceId checks whether the given service id appears in -// the incident's relationships.services.data array. Each entry is -// just a JSON:API pointer (id + type), not a full service record. func containsServiceId(services []struct { Id string `json:"id"` Type string `json:"type"` @@ -167,12 +138,6 @@ func containsServiceId(services []struct { return false } -// resolveSeverity extracts the slug from a Rootly severity envelope. -// Rootly returns severity as a JSON:API envelope with inner attributes -// `slug` (org-defined, e.g. "sev2") and `severity` (domain-normalized: -// critical/high/medium/low). We preserve the org's own slug in the tool -// layer — the converter applies the domain-normalized mapping at -// conversion time. func resolveSeverity(s *raw.SeverityEnvelope) string { if s == nil { return "" diff --git a/backend/plugins/rootly/tasks/incidents_extractor_test.go b/backend/plugins/rootly/tasks/incidents_extractor_test.go index 5c5efe47270..cba3410116e 100644 --- a/backend/plugins/rootly/tasks/incidents_extractor_test.go +++ b/backend/plugins/rootly/tasks/incidents_extractor_test.go @@ -27,19 +27,6 @@ import ( "github.com/apache/incubator-devlake/plugins/rootly/models" ) -// Fixture shapes match a real GET /v1/incidents response: -// - each incident is a JSON:API envelope {id, type, attributes, relationships} -// - role-bearing users (user, started_by, …) live on attributes as -// nested JSON:API envelopes: {"data": {"id": "…", "type": "users", -// "attributes": {"name", "full_name", "email"}}} -// - severity lives on attributes as a nested JSON:API envelope: -// {"data": {"id": "…", "type": "severities", "attributes": -// {"slug", "severity", "name"}}} -// - service membership lives on the sibling relationships block as -// plain JSON:API pointers: {"services": {"data": [{"id":"…","type":"services"}]}} -// (Full service records are only returned when the caller passes -// `?include=services`; we don't, so we only see pointer ids here.) - const baseHappyPathActive = `{ "id": "inc_01", "type": "incidents", @@ -66,9 +53,6 @@ func newTestOptions() *RootlyOptions { } } -// collectUsers pulls the *models.User rows out of a heterogeneous result -// slice so individual tests can make assertions without worrying about -// the incident row's ordering. func collectUsers(results []interface{}) []*models.User { users := []*models.User{} for _, r := range results { @@ -79,9 +63,6 @@ func collectUsers(results []interface{}) []*models.User { return users } -// TestExtractRootlyIncident_HappyPathActive covers the base case: a -// started incident with a creator user in attributes.user produces one -// Incident row (with CreatorUserId populated) and one User row. func TestExtractRootlyIncident_HappyPathActive(t *testing.T) { op := newTestOptions() results, err := extractRootlyIncident([]byte(baseHappyPathActive), op) @@ -119,10 +100,6 @@ func TestExtractRootlyIncident_HappyPathActive(t *testing.T) { assert.Equal(t, "reporter@example.com", users[0].Email) } -// TestExtractRootlyIncident_Resolved verifies that a resolved incident -// populates AcknowledgedDate / MitigatedDate / ResolvedDate as non-nil -// pointers AND populates CreatorUserId + ResolvedByUserId from the -// nested JSON:API user envelopes. Both users are emitted as User rows. func TestExtractRootlyIncident_Resolved(t *testing.T) { raw := []byte(`{ "id": "inc_02", @@ -171,9 +148,6 @@ func TestExtractRootlyIncident_Resolved(t *testing.T) { assert.Equal(t, "Resolver Two", ids["usr_200"]) } -// TestExtractRootlyIncident_MissingOptionalTimestamps asserts that -// missing mitigated_at and resolved_at yield nil pointers rather than -// zero-time values (which would pollute downstream DORA math). func TestExtractRootlyIncident_MissingOptionalTimestamps(t *testing.T) { raw := []byte(`{ "id": "inc_03", @@ -199,9 +173,6 @@ func TestExtractRootlyIncident_MissingOptionalTimestamps(t *testing.T) { assert.Nil(t, incident.AcknowledgedDate) } -// TestExtractRootlyIncident_NullSeverity covers the common case where -// an incident has no severity set: the Severity field on the tool row -// is empty string, not a panic or a "null" literal. func TestExtractRootlyIncident_NullSeverity(t *testing.T) { raw := []byte(`{ "id": "inc_04", @@ -226,10 +197,6 @@ func TestExtractRootlyIncident_NullSeverity(t *testing.T) { assert.Equal(t, "", incident.Severity) } -// TestExtractRootlyIncident_NoRolesFilled verifies that an incident -// with every role-bearing user field null produces exactly one result -// (the incident row) with all role user-id fields empty and zero User -// rows. func TestExtractRootlyIncident_NoRolesFilled(t *testing.T) { raw := []byte(`{ "id": "inc_05", @@ -263,10 +230,6 @@ func TestExtractRootlyIncident_NoRolesFilled(t *testing.T) { assert.Empty(t, collectUsers(results)) } -// TestExtractRootlyIncident_SameUserInMultipleRoles verifies the -// dedupe invariant: if one person is both the creator and the -// resolver, only one User row is emitted but BOTH role id fields on -// the incident point to that user. func TestExtractRootlyIncident_SameUserInMultipleRoles(t *testing.T) { raw := []byte(`{ "id": "inc_dup", @@ -300,9 +263,6 @@ func TestExtractRootlyIncident_SameUserInMultipleRoles(t *testing.T) { assert.Equal(t, "Solo Operator", users[0].Name) } -// TestExtractRootlyIncident_UserNamePreference verifies the name -// preference order: FullName > Name > Email > empty string. Three -// users exercise the three fallbacks in a single incident. func TestExtractRootlyIncident_UserNamePreference(t *testing.T) { raw := []byte(`{ "id": "inc_names", @@ -339,12 +299,6 @@ func TestExtractRootlyIncident_UserNamePreference(t *testing.T) { assert.Equal(t, "fallback@example.com", byId["usr_mail"].Name) } -// TestExtractRootlyIncident_WrongServiceSkipped asserts the safety-net -// scope filter: if the incident's relationships.services.data doesn't -// include the configured ServiceId, the extractor returns an empty -// slice and no error. Defense in depth against multi-service -// incidents leaking into the wrong scope even if the API-side -// filter[service_ids] query failed. func TestExtractRootlyIncident_WrongServiceSkipped(t *testing.T) { raw := []byte(`{ "id": "inc_wrong_svc", @@ -366,11 +320,6 @@ func TestExtractRootlyIncident_WrongServiceSkipped(t *testing.T) { assert.Empty(t, results, "incident for unrelated service should produce no rows") } -// TestExtractRootlyIncident_EmptyServicesAccepted covers the case -// where an incident has no services relationship (empty array or -// missing block entirely). We accept the incident — the API-side -// filter[service_ids] query is the only scoping signal — and tag it -// with op.ServiceId. func TestExtractRootlyIncident_EmptyServicesAccepted(t *testing.T) { raw := []byte(`{ "id": "inc_no_svc", @@ -391,9 +340,6 @@ func TestExtractRootlyIncident_EmptyServicesAccepted(t *testing.T) { assert.Equal(t, "svc_02", incident.ServiceId) } -// TestExtractRootlyIncident_MissingStartedAtReturnsError covers the -// single required-field validation. A missing started_at would write -// a zero-time row, breaking downstream MTTR math silently. Fail loud. func TestExtractRootlyIncident_MissingStartedAtReturnsError(t *testing.T) { raw := []byte(`{ "id": "inc_bad", @@ -413,10 +359,6 @@ func TestExtractRootlyIncident_MissingStartedAtReturnsError(t *testing.T) { assert.Error(t, err) } -// TestExtractRootlyIncident_MissingSequentialId verifies graceful -// degradation when the Rootly response omits the incident number. -// We want the row to still land in the tool table so downstream -// conversion can fall back to the string id. func TestExtractRootlyIncident_MissingSequentialId(t *testing.T) { raw := []byte(`{ "id": "inc_no_num", From 25773828c001ef95d2fac427e2b4666cc76282c4 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Wed, 13 May 2026 15:45:32 -0700 Subject: [PATCH 11/14] refactor(rootly): address code review feedback Apply fixes from a multi-lens code review pass: - API: rename swagger {serviceId} to {scopeId} to match registered route; remove dead Proxy handler. - Models: add Sanitize() on RootlyConn; add RoleUserIds() helper on Incident; index ServiceId; drop unused Url field on User; remove dead RootlyResponse/ApiUserResponse types. - Migrations: mirror live schema in archived models (index on service_id; drop user.url). - Collector: switch pagination to reqData.Pager.Page (avoids divide-by-zero), cap at 10000 pages, extract buildIncidentsQuery as a pure helper, drop unreachable lastPageEmpty branch and unused TotalCount, remove diagnostic logs; add unit test pinning the filter[service_ids] param literal as a regression guard. - Services: preserve ScopeConfigId across re-collections; declare ProductTables on collector and extractor metas. - Extractor: skip emitting User rows with neither name nor email so sibling scope tasks can fill in fuller data; use generic resolve() for SequentialId; type ServiceRef as a named struct. - Converter: consolidate mapStatus to return (mapped, known); use Incident.RoleUserIds() instead of an inline slice. - impl.go: comment justifying services-before-incidents subtask ordering. - e2e: rewrite raw incident fixtures to JSON:API envelope shape; regenerate snapshots (drop urgency column). --- backend/plugins/rootly/api/remote_api.go | 9 ---- backend/plugins/rootly/api/scope_api.go | 12 ++--- .../e2e/raw_tables/_raw_rootly_incidents.csv | 12 ++--- .../_tool_rootly_incidents.csv | 12 ++--- .../rootly/e2e/snapshot_tables/issues.csv | 10 ++-- backend/plugins/rootly/impl/impl.go | 4 ++ backend/plugins/rootly/models/connection.go | 20 ++++---- backend/plugins/rootly/models/incident.go | 15 +++++- .../migrationscripts/archived/incident.go | 2 +- .../models/migrationscripts/archived/user.go | 1 - backend/plugins/rootly/models/raw/incident.go | 14 +++--- backend/plugins/rootly/models/user.go | 1 - .../rootly/tasks/incidents_collector.go | 44 ++++++++++-------- .../rootly/tasks/incidents_collector_test.go | 46 +++++++++++++++++++ .../rootly/tasks/incidents_converter.go | 32 ++++--------- .../rootly/tasks/incidents_converter_test.go | 45 +++++++++--------- .../rootly/tasks/incidents_extractor.go | 23 ++++------ .../rootly/tasks/services_collector.go | 1 + .../rootly/tasks/services_extractor.go | 8 ++++ backend/plugins/rootly/tasks/task_data.go | 9 ---- 20 files changed, 177 insertions(+), 143 deletions(-) create mode 100644 backend/plugins/rootly/tasks/incidents_collector_test.go diff --git a/backend/plugins/rootly/api/remote_api.go b/backend/plugins/rootly/api/remote_api.go index 93b5022e290..0271e66e2e9 100644 --- a/backend/plugins/rootly/api/remote_api.go +++ b/backend/plugins/rootly/api/remote_api.go @@ -188,12 +188,3 @@ func SearchRemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutp return raScopeSearch.Get(input) } -// @Summary Remote server API proxy -// @Description Forward API requests to the specified remote server -// @Param connectionId path int true "connection ID" -// @Param path path string true "path to a API endpoint" -// @Tags plugins/rootly -// @Router /plugins/rootly/connections/{connectionId}/proxy/{path} [GET] -func Proxy(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { - return raProxy.Proxy(input) -} diff --git a/backend/plugins/rootly/api/scope_api.go b/backend/plugins/rootly/api/scope_api.go index 7624f1bbba8..ef8633423f6 100644 --- a/backend/plugins/rootly/api/scope_api.go +++ b/backend/plugins/rootly/api/scope_api.go @@ -48,12 +48,12 @@ func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, error // @Tags plugins/rootly // @Accept application/json // @Param connectionId path int true "connection ID" -// @Param serviceId path string true "service ID" +// @Param scopeId path string true "scope ID" // @Param scope body models.Service true "json" // @Success 200 {object} models.Service // @Failure 400 {object} shared.ApiBody "Bad Request" // @Failure 500 {object} shared.ApiBody "Internal Error" -// @Router /plugins/rootly/connections/{connectionId}/scopes/{serviceId} [PATCH] +// @Router /plugins/rootly/connections/{connectionId}/scopes/{scopeId} [PATCH] func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { return dsHelper.ScopeApi.Patch(input) } @@ -80,12 +80,12 @@ func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, er // @Description get one Rootly service // @Tags plugins/rootly // @Param connectionId path int true "connection ID" -// @Param serviceId path string true "service ID" +// @Param scopeId path string true "scope ID" // @Param blueprints query bool false "also return blueprints using this scope as part of the payload" // @Success 200 {object} ScopeDetail // @Failure 400 {object} shared.ApiBody "Bad Request" // @Failure 500 {object} shared.ApiBody "Internal Error" -// @Router /plugins/rootly/connections/{connectionId}/scopes/{serviceId} [GET] +// @Router /plugins/rootly/connections/{connectionId}/scopes/{scopeId} [GET] func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { return dsHelper.ScopeApi.GetScopeDetail(input) } @@ -95,13 +95,13 @@ func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors // @Description delete data associated with plugin scope // @Tags plugins/rootly // @Param connectionId path int true "connection ID" -// @Param serviceId path string true "service ID" +// @Param scopeId path string true "scope ID" // @Param delete_data_only query bool false "Only delete the scope data, not the scope itself" // @Success 200 // @Failure 400 {object} shared.ApiBody "Bad Request" // @Failure 409 {object} api.ScopeRefDoc "References exist to this scope" // @Failure 500 {object} shared.ApiBody "Internal Error" -// @Router /plugins/rootly/connections/{connectionId}/scopes/{serviceId} [DELETE] +// @Router /plugins/rootly/connections/{connectionId}/scopes/{scopeId} [DELETE] func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { return dsHelper.ScopeApi.Delete(input) } diff --git a/backend/plugins/rootly/e2e/raw_tables/_raw_rootly_incidents.csv b/backend/plugins/rootly/e2e/raw_tables/_raw_rootly_incidents.csv index fe30c07deb6..cccd06ffc30 100644 --- a/backend/plugins/rootly/e2e/raw_tables/_raw_rootly_incidents.csv +++ b/backend/plugins/rootly/e2e/raw_tables/_raw_rootly_incidents.csv @@ -1,7 +1,7 @@ id,params,data,url,input,created_at -1,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_01"",""type"":""incidents"",""attributes"":{""sequential_id"":101,""title"":""Payment processor outage"",""summary"":""Payments are timing out"",""url"":""https://rootly.com/account/incidents/inc_01"",""status"":""triage"",""severity"":""sev0"",""urgency"":""high"",""started_at"":""2026-05-01T10:00:00Z"",""updated_at"":""2026-05-01T10:05:00Z"",""user"":{""id"":""u1"",""email"":""alice@example.com"",""full_name"":""Alice""}},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-01T10:05:00.000+00:00 -2,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_02"",""type"":""incidents"",""attributes"":{""sequential_id"":102,""title"":""Latency spike"",""summary"":""p99 latency above SLO"",""url"":""https://rootly.com/account/incidents/inc_02"",""status"":""mitigated"",""severity"":""sev1"",""urgency"":""high"",""started_at"":""2026-05-02T09:00:00Z"",""mitigated_at"":""2026-05-02T09:45:00Z"",""updated_at"":""2026-05-02T09:45:00Z"",""user"":{""id"":""u1"",""email"":""alice@example.com"",""full_name"":""Alice""},""started_by"":{""id"":""u2"",""email"":""bob@example.com"",""full_name"":""Bob""},""mitigated_by"":{""id"":""u2"",""email"":""bob@example.com"",""full_name"":""Bob""}},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-02T09:45:00.000+00:00 -3,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_03"",""type"":""incidents"",""attributes"":{""sequential_id"":103,""title"":""Queue backlog"",""summary"":""Worker queue backed up"",""url"":""https://rootly.com/account/incidents/inc_03"",""status"":""resolved"",""severity"":""sev2"",""urgency"":""medium"",""started_at"":""2026-05-03T12:00:00Z"",""resolved_at"":""2026-05-03T13:30:00Z"",""updated_at"":""2026-05-03T13:30:00Z"",""user"":{""id"":""u1"",""email"":""alice@example.com"",""full_name"":""Alice""},""resolved_by"":{""id"":""u1"",""email"":""alice@example.com"",""full_name"":""Alice""}},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-03T13:30:00.000+00:00 -4,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_04"",""type"":""incidents"",""attributes"":{""sequential_id"":201,""title"":""Wrong-service incident"",""summary"":""This incident belongs to svc_02"",""url"":""https://rootly.com/account/incidents/inc_04"",""status"":""closed"",""severity"":""sev3"",""urgency"":""low"",""started_at"":""2026-05-04T08:00:00Z"",""resolved_at"":""2026-05-04T08:30:00Z"",""updated_at"":""2026-05-04T08:30:00Z"",""user"":{""id"":""u2"",""email"":""bob@example.com"",""full_name"":""Bob""}},""relationships"":{""services"":{""data"":[{""id"":""svc_02"",""type"":""services""}]}}}",,null,2026-05-04T08:30:00.000+00:00 -5,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_05"",""type"":""incidents"",""attributes"":{""sequential_id"":104,""title"":""False alarm"",""summary"":""Cancelled after triage"",""url"":""https://rootly.com/account/incidents/inc_05"",""status"":""cancelled"",""severity"":""sev4"",""urgency"":""low"",""started_at"":""2026-05-05T14:00:00Z"",""updated_at"":""2026-05-05T14:05:00Z""},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-05T14:05:00.000+00:00 -6,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_06"",""type"":""incidents"",""attributes"":{""sequential_id"":105,""title"":""Odd state"",""summary"":""Status from future Rootly version"",""url"":""https://rootly.com/account/incidents/inc_06"",""status"":""investigating"",""severity"":""blocker"",""urgency"":""high"",""started_at"":""2026-05-06T11:00:00Z"",""updated_at"":""2026-05-06T11:10:00Z"",""user"":{""id"":""u3"",""email"":""carol@example.com"",""full_name"":""Carol""}},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-06T11:10:00.000+00:00 +1,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_01"",""type"":""incidents"",""attributes"":{""sequential_id"":101,""title"":""Payment processor outage"",""summary"":""Payments are timing out"",""url"":""https://rootly.com/account/incidents/inc_01"",""status"":""triage"",""severity"":{""data"":{""id"":""sev-uuid-0"",""type"":""severities"",""attributes"":{""slug"":""sev0"",""name"":""SEV0"",""severity"":""critical""}}},""started_at"":""2026-05-01T10:00:00Z"",""updated_at"":""2026-05-01T10:05:00Z"",""user"":{""data"":{""id"":""u1"",""type"":""users"",""attributes"":{""email"":""alice@example.com"",""full_name"":""Alice""}}}},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-01T10:05:00.000+00:00 +2,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_02"",""type"":""incidents"",""attributes"":{""sequential_id"":102,""title"":""Latency spike"",""summary"":""p99 latency above SLO"",""url"":""https://rootly.com/account/incidents/inc_02"",""status"":""mitigated"",""severity"":{""data"":{""id"":""sev-uuid-1"",""type"":""severities"",""attributes"":{""slug"":""sev1"",""name"":""SEV1"",""severity"":""high""}}},""started_at"":""2026-05-02T09:00:00Z"",""mitigated_at"":""2026-05-02T09:45:00Z"",""updated_at"":""2026-05-02T09:45:00Z"",""user"":{""data"":{""id"":""u1"",""type"":""users"",""attributes"":{""email"":""alice@example.com"",""full_name"":""Alice""}}},""started_by"":{""data"":{""id"":""u2"",""type"":""users"",""attributes"":{""email"":""bob@example.com"",""full_name"":""Bob""}}},""mitigated_by"":{""data"":{""id"":""u2"",""type"":""users"",""attributes"":{""email"":""bob@example.com"",""full_name"":""Bob""}}}},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-02T09:45:00.000+00:00 +3,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_03"",""type"":""incidents"",""attributes"":{""sequential_id"":103,""title"":""Queue backlog"",""summary"":""Worker queue backed up"",""url"":""https://rootly.com/account/incidents/inc_03"",""status"":""resolved"",""severity"":{""data"":{""id"":""sev-uuid-2"",""type"":""severities"",""attributes"":{""slug"":""sev2"",""name"":""SEV2"",""severity"":""medium""}}},""started_at"":""2026-05-03T12:00:00Z"",""resolved_at"":""2026-05-03T13:30:00Z"",""updated_at"":""2026-05-03T13:30:00Z"",""user"":{""data"":{""id"":""u1"",""type"":""users"",""attributes"":{""email"":""alice@example.com"",""full_name"":""Alice""}}},""resolved_by"":{""data"":{""id"":""u1"",""type"":""users"",""attributes"":{""email"":""alice@example.com"",""full_name"":""Alice""}}}},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-03T13:30:00.000+00:00 +4,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_04"",""type"":""incidents"",""attributes"":{""sequential_id"":201,""title"":""Wrong-service incident"",""summary"":""This incident belongs to svc_02"",""url"":""https://rootly.com/account/incidents/inc_04"",""status"":""closed"",""severity"":{""data"":{""id"":""sev-uuid-3"",""type"":""severities"",""attributes"":{""slug"":""sev3"",""name"":""SEV3"",""severity"":""low""}}},""started_at"":""2026-05-04T08:00:00Z"",""resolved_at"":""2026-05-04T08:30:00Z"",""updated_at"":""2026-05-04T08:30:00Z"",""user"":{""data"":{""id"":""u2"",""type"":""users"",""attributes"":{""email"":""bob@example.com"",""full_name"":""Bob""}}}},""relationships"":{""services"":{""data"":[{""id"":""svc_02"",""type"":""services""}]}}}",,null,2026-05-04T08:30:00.000+00:00 +5,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_05"",""type"":""incidents"",""attributes"":{""sequential_id"":104,""title"":""False alarm"",""summary"":""Cancelled after triage"",""url"":""https://rootly.com/account/incidents/inc_05"",""status"":""cancelled"",""severity"":{""data"":{""id"":""sev-uuid-4"",""type"":""severities"",""attributes"":{""slug"":""sev4"",""name"":""SEV4"",""severity"":""low""}}},""started_at"":""2026-05-05T14:00:00Z"",""updated_at"":""2026-05-05T14:05:00Z""},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-05T14:05:00.000+00:00 +6,"{""ConnectionId"":1,""ScopeId"":""svc_01""}","{""id"":""inc_06"",""type"":""incidents"",""attributes"":{""sequential_id"":105,""title"":""Odd state"",""summary"":""Status from future Rootly version"",""url"":""https://rootly.com/account/incidents/inc_06"",""status"":""investigating"",""severity"":{""data"":{""id"":""sev-uuid-x"",""type"":""severities"",""attributes"":{""slug"":""blocker"",""name"":""Blocker"",""severity"":""critical""}}},""started_at"":""2026-05-06T11:00:00Z"",""updated_at"":""2026-05-06T11:10:00Z"",""user"":{""data"":{""id"":""u3"",""type"":""users"",""attributes"":{""email"":""carol@example.com"",""full_name"":""Carol""}}}},""relationships"":{""services"":{""data"":[{""id"":""svc_01"",""type"":""services""}]}}}",,null,2026-05-06T11:10:00.000+00:00 diff --git a/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_incidents.csv b/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_incidents.csv index 30210c1ccaa..7a180306867 100644 --- a/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_incidents.csv +++ b/backend/plugins/rootly/e2e/snapshot_tables/_tool_rootly_incidents.csv @@ -1,6 +1,6 @@ -connection_id,id,number,service_id,url,title,summary,status,severity,urgency,started_date,acknowledged_date,mitigated_date,resolved_date,updated_date,creator_user_id,started_by_user_id,mitigated_by_user_id,resolved_by_user_id,closed_by_user_id -1,inc_01,101,svc_01,https://rootly.com/account/incidents/inc_01,Payment processor outage,Payments are timing out,triage,sev0,high,2026-05-01T10:00:00.000+00:00,,,,2026-05-01T10:05:00.000+00:00,u1,,,, -1,inc_02,102,svc_01,https://rootly.com/account/incidents/inc_02,Latency spike,p99 latency above SLO,mitigated,sev1,high,2026-05-02T09:00:00.000+00:00,,2026-05-02T09:45:00.000+00:00,,2026-05-02T09:45:00.000+00:00,u1,u2,u2,, -1,inc_03,103,svc_01,https://rootly.com/account/incidents/inc_03,Queue backlog,Worker queue backed up,resolved,sev2,medium,2026-05-03T12:00:00.000+00:00,,,2026-05-03T13:30:00.000+00:00,2026-05-03T13:30:00.000+00:00,u1,,,u1, -1,inc_05,104,svc_01,https://rootly.com/account/incidents/inc_05,False alarm,Cancelled after triage,cancelled,sev4,low,2026-05-05T14:00:00.000+00:00,,,,2026-05-05T14:05:00.000+00:00,,,,, -1,inc_06,105,svc_01,https://rootly.com/account/incidents/inc_06,Odd state,Status from future Rootly version,investigating,blocker,high,2026-05-06T11:00:00.000+00:00,,,,2026-05-06T11:10:00.000+00:00,u3,,,, +connection_id,id,number,service_id,url,title,summary,status,severity,started_date,acknowledged_date,mitigated_date,resolved_date,updated_date,creator_user_id,started_by_user_id,mitigated_by_user_id,resolved_by_user_id,closed_by_user_id +1,inc_01,101,svc_01,https://rootly.com/account/incidents/inc_01,Payment processor outage,Payments are timing out,triage,sev0,2026-05-01T10:00:00.000+00:00,,,,2026-05-01T10:05:00.000+00:00,u1,,,, +1,inc_02,102,svc_01,https://rootly.com/account/incidents/inc_02,Latency spike,p99 latency above SLO,mitigated,sev1,2026-05-02T09:00:00.000+00:00,,2026-05-02T09:45:00.000+00:00,,2026-05-02T09:45:00.000+00:00,u1,u2,u2,, +1,inc_03,103,svc_01,https://rootly.com/account/incidents/inc_03,Queue backlog,Worker queue backed up,resolved,sev2,2026-05-03T12:00:00.000+00:00,,,2026-05-03T13:30:00.000+00:00,2026-05-03T13:30:00.000+00:00,u1,,,u1, +1,inc_05,104,svc_01,https://rootly.com/account/incidents/inc_05,False alarm,Cancelled after triage,cancelled,sev4,2026-05-05T14:00:00.000+00:00,,,,2026-05-05T14:05:00.000+00:00,,,,, +1,inc_06,105,svc_01,https://rootly.com/account/incidents/inc_06,Odd state,Status from future Rootly version,investigating,blocker,2026-05-06T11:00:00.000+00:00,,,,2026-05-06T11:10:00.000+00:00,u3,,,, diff --git a/backend/plugins/rootly/e2e/snapshot_tables/issues.csv b/backend/plugins/rootly/e2e/snapshot_tables/issues.csv index b61c690072e..43c50aa8e3a 100644 --- a/backend/plugins/rootly/e2e/snapshot_tables/issues.csv +++ b/backend/plugins/rootly/e2e/snapshot_tables/issues.csv @@ -1,6 +1,6 @@ id,url,icon_url,issue_key,title,description,epic_key,type,original_type,status,original_status,story_point,resolution_date,created_date,updated_date,lead_time_minutes,original_estimate_minutes,time_spent_minutes,time_remaining_minutes,creator_id,creator_name,assignee_id,assignee_name,parent_issue_id,priority,severity,urgency,component,is_subtask,due_date,fix_versions -rootly:Incident:1:inc_01,https://rootly.com/account/incidents/inc_01,,101,Payment processor outage,Payments are timing out,,INCIDENT,,TODO,triage,,,2026-05-01T10:00:00.000+00:00,2026-05-01T10:05:00.000+00:00,,,,,rootly:User:1:u1,Alice,rootly:User:1:u1,Alice,,CRITICAL,sev0,high,,0,, -rootly:Incident:1:inc_02,https://rootly.com/account/incidents/inc_02,,102,Latency spike,p99 latency above SLO,,INCIDENT,,IN_PROGRESS,mitigated,,,2026-05-02T09:00:00.000+00:00,2026-05-02T09:45:00.000+00:00,,,,,rootly:User:1:u1,Alice,rootly:User:1:u1,Alice,,HIGH,sev1,high,,0,, -rootly:Incident:1:inc_03,https://rootly.com/account/incidents/inc_03,,103,Queue backlog,Worker queue backed up,,INCIDENT,,DONE,resolved,,2026-05-03T13:30:00.000+00:00,2026-05-03T12:00:00.000+00:00,2026-05-03T13:30:00.000+00:00,90,,,,rootly:User:1:u1,Alice,rootly:User:1:u1,Alice,,MEDIUM,sev2,medium,,0,, -rootly:Incident:1:inc_05,https://rootly.com/account/incidents/inc_05,,104,False alarm,Cancelled after triage,,INCIDENT,,DONE,cancelled,,,2026-05-05T14:00:00.000+00:00,2026-05-05T14:05:00.000+00:00,,,,,,,,,,LOW,sev4,low,,0,, -rootly:Incident:1:inc_06,https://rootly.com/account/incidents/inc_06,,105,Odd state,Status from future Rootly version,,INCIDENT,,IN_PROGRESS,investigating,,,2026-05-06T11:00:00.000+00:00,2026-05-06T11:10:00.000+00:00,,,,,rootly:User:1:u3,Carol,rootly:User:1:u3,Carol,,blocker,blocker,high,,0,, +rootly:Incident:1:inc_01,https://rootly.com/account/incidents/inc_01,,101,Payment processor outage,Payments are timing out,,INCIDENT,,TODO,triage,,,2026-05-01T10:00:00.000+00:00,2026-05-01T10:05:00.000+00:00,,,,,rootly:User:1:u1,Alice,rootly:User:1:u1,Alice,,CRITICAL,sev0,,,0,, +rootly:Incident:1:inc_02,https://rootly.com/account/incidents/inc_02,,102,Latency spike,p99 latency above SLO,,INCIDENT,,IN_PROGRESS,mitigated,,,2026-05-02T09:00:00.000+00:00,2026-05-02T09:45:00.000+00:00,,,,,rootly:User:1:u1,Alice,rootly:User:1:u1,Alice,,HIGH,sev1,,,0,, +rootly:Incident:1:inc_03,https://rootly.com/account/incidents/inc_03,,103,Queue backlog,Worker queue backed up,,INCIDENT,,DONE,resolved,,2026-05-03T13:30:00.000+00:00,2026-05-03T12:00:00.000+00:00,2026-05-03T13:30:00.000+00:00,90,,,,rootly:User:1:u1,Alice,rootly:User:1:u1,Alice,,MEDIUM,sev2,,,0,, +rootly:Incident:1:inc_05,https://rootly.com/account/incidents/inc_05,,104,False alarm,Cancelled after triage,,INCIDENT,,DONE,cancelled,,,2026-05-05T14:00:00.000+00:00,2026-05-05T14:05:00.000+00:00,,,,,,,,,,LOW,sev4,,,0,, +rootly:Incident:1:inc_06,https://rootly.com/account/incidents/inc_06,,105,Odd state,Status from future Rootly version,,INCIDENT,,IN_PROGRESS,investigating,,,2026-05-06T11:00:00.000+00:00,2026-05-06T11:10:00.000+00:00,,,,,rootly:User:1:u3,Carol,rootly:User:1:u3,Carol,,blocker,blocker,,,0,, diff --git a/backend/plugins/rootly/impl/impl.go b/backend/plugins/rootly/impl/impl.go index 066a9dd2ca6..296bd6e6594 100644 --- a/backend/plugins/rootly/impl/impl.go +++ b/backend/plugins/rootly/impl/impl.go @@ -73,6 +73,10 @@ func (p Rootly) ScopeConfig() dal.Tabler { } func (p Rootly) SubTaskMetas() []plugin.SubTaskMeta { + // Convert services before incidents so the domain Board row exists + // before the BoardIssue rows that reference it; the opposite order + // (PagerDuty / Opsgenie convention) works too because BoardIssue + // has no FK enforcement, but ours is explicit about the dependency. return []plugin.SubTaskMeta{ tasks.CollectServicesMeta, tasks.ExtractServicesMeta, diff --git a/backend/plugins/rootly/models/connection.go b/backend/plugins/rootly/models/connection.go index a60c0d74118..898d1542198 100644 --- a/backend/plugins/rootly/models/connection.go +++ b/backend/plugins/rootly/models/connection.go @@ -38,11 +38,20 @@ type RootlyConn struct { RootlyAccessToken `mapstructure:",squash"` } +func (connection RootlyConn) Sanitize() RootlyConn { + connection.Token = utils.SanitizeString(connection.Token) + return connection +} + type RootlyConnection struct { helper.BaseConnection `mapstructure:",squash"` RootlyConn `mapstructure:",squash"` } +// MergeFromRequest preserves the existing token when an incoming PATCH +// body omits it or echoes the sanitized form. The config-UI sends the +// sanitized token back on every PATCH to avoid round-tripping the +// secret; this guard is what makes that pattern safe. func (connection *RootlyConnection) MergeFromRequest(target *RootlyConnection, body map[string]interface{}) error { token := target.Token if err := helper.DecodeMapStruct(body, target, true); err != nil { @@ -55,17 +64,6 @@ func (connection *RootlyConnection) MergeFromRequest(target *RootlyConnection, b return nil } -type RootlyResponse struct { - Name string `json:"name"` - ID int `json:"id"` - RootlyConnection -} - -type ApiUserResponse struct { - Id string - Name string `json:"name"` -} - func (RootlyConnection) TableName() string { return "_tool_rootly_connections" } diff --git a/backend/plugins/rootly/models/incident.go b/backend/plugins/rootly/models/incident.go index 4c46c5542fc..1cd8defd937 100644 --- a/backend/plugins/rootly/models/incident.go +++ b/backend/plugins/rootly/models/incident.go @@ -28,7 +28,7 @@ type Incident struct { ConnectionId uint64 `gorm:"primaryKey"` Id string `gorm:"primaryKey;autoIncrement:false"` Number int - ServiceId string + ServiceId string `gorm:"index"` Url string Title string Summary string @@ -47,3 +47,16 @@ type Incident struct { } func (Incident) TableName() string { return "_tool_rootly_incidents" } + +// RoleUserIds returns the five role-bearing user ids on the incident +// in lifecycle order (creator, started_by, mitigated_by, resolved_by, +// closed_by). Empty strings are included; callers filter or dedup. +func (i *Incident) RoleUserIds() []string { + return []string{ + i.CreatorUserId, + i.StartedByUserId, + i.MitigatedByUserId, + i.ResolvedByUserId, + i.ClosedByUserId, + } +} diff --git a/backend/plugins/rootly/models/migrationscripts/archived/incident.go b/backend/plugins/rootly/models/migrationscripts/archived/incident.go index fd43fe99ec8..b787b8c81ff 100644 --- a/backend/plugins/rootly/models/migrationscripts/archived/incident.go +++ b/backend/plugins/rootly/models/migrationscripts/archived/incident.go @@ -28,7 +28,7 @@ type Incident struct { ConnectionId uint64 `gorm:"primaryKey"` Id string `gorm:"primaryKey;autoIncrement:false"` Number int - ServiceId string + ServiceId string `gorm:"index"` Url string Title string Summary string diff --git a/backend/plugins/rootly/models/migrationscripts/archived/user.go b/backend/plugins/rootly/models/migrationscripts/archived/user.go index 13cde223dde..d16851d3249 100644 --- a/backend/plugins/rootly/models/migrationscripts/archived/user.go +++ b/backend/plugins/rootly/models/migrationscripts/archived/user.go @@ -25,7 +25,6 @@ type User struct { Id string `gorm:"primaryKey;autoIncrement:false"` Email string Name string - Url string } func (User) TableName() string { diff --git a/backend/plugins/rootly/models/raw/incident.go b/backend/plugins/rootly/models/raw/incident.go index e08d135543f..541fb477e5c 100644 --- a/backend/plugins/rootly/models/raw/incident.go +++ b/backend/plugins/rootly/models/raw/incident.go @@ -30,13 +30,15 @@ type Incident struct { type IncidentRelationships struct { Services struct { - Data []struct { - Id string `json:"id"` - Type string `json:"type"` - } `json:"data"` + Data []ServiceRef `json:"data"` } `json:"services"` } +type ServiceRef struct { + Id string `json:"id"` + Type string `json:"type"` +} + type IncidentAttributes struct { SequentialId *int `json:"sequential_id"` Title string `json:"title"` @@ -67,9 +69,7 @@ type SeverityEnvelope struct { } type SeverityAttributes struct { - Slug string `json:"slug"` // org-defined (sev0, sev1, ...) - Name string `json:"name"` // display name - Severity string `json:"severity"` // critical, high, medium, low + Slug string `json:"slug"` } type UserEnvelope struct { diff --git a/backend/plugins/rootly/models/user.go b/backend/plugins/rootly/models/user.go index 03569d107a1..4bf25434501 100644 --- a/backend/plugins/rootly/models/user.go +++ b/backend/plugins/rootly/models/user.go @@ -25,7 +25,6 @@ type User struct { Id string `gorm:"primaryKey;autoIncrement:false"` Email string Name string - Url string } func (User) TableName() string { return "_tool_rootly_users" } diff --git a/backend/plugins/rootly/tasks/incidents_collector.go b/backend/plugins/rootly/tasks/incidents_collector.go index e2e4ebb2c4a..e89bb89fcd7 100644 --- a/backend/plugins/rootly/tasks/incidents_collector.go +++ b/backend/plugins/rootly/tasks/incidents_collector.go @@ -42,7 +42,6 @@ type collectedIncidents struct { type collectedListMeta struct { CurrentPage *int `json:"current_page"` TotalPages *int `json:"total_pages"` - TotalCount *int `json:"total_count"` } type collectedListLinks struct { @@ -60,7 +59,6 @@ var CollectIncidentsMeta = plugin.SubTaskMeta{ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { data := taskCtx.GetData().(*RootlyTaskData) - logger := taskCtx.GetLogger() args := api.RawDataSubTaskArgs{ Ctx: taskCtx, Options: data.Options, @@ -72,7 +70,6 @@ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { // next-page hook fires. var lastPage *collectedListMeta var lastLinksNext *string - var lastPageEmpty bool collector, err := api.NewStatefulApiCollectorForFinalizableEntity(api.FinalizableApiCollectorArgs{ RawDataSubTaskArgs: args, @@ -80,6 +77,12 @@ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { CollectNewRecordsByList: api.FinalizableApiCollectorListArgs{ PageSize: 100, GetNextPageCustomData: func(prevReqData *api.RequestData, prevPageResponse *http.Response) (interface{}, errors.Error) { + // Safety cap against an upstream that returns full pages forever + // without populating either meta.total_pages or links.next. + const maxPages = 10000 + if prevReqData.Pager.Page >= maxPages { + return nil, api.ErrFinishCollect + } if lastLinksNext != nil && *lastLinksNext != "" { return nil, nil } @@ -87,32 +90,17 @@ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { if *lastPage.CurrentPage >= *lastPage.TotalPages { return nil, api.ErrFinishCollect } - return nil, nil - } - if lastPageEmpty { - return nil, api.ErrFinishCollect } return nil, nil }, FinalizableApiCollectorCommonArgs: api.FinalizableApiCollectorCommonArgs{ UrlTemplate: "incidents", Query: func(reqData *api.RequestData, createdAfter *time.Time) (url.Values, errors.Error) { - query := url.Values{} - query.Set("filter[service_ids]", data.Options.ServiceId) - query.Set("page[size]", fmt.Sprintf("%d", reqData.Pager.Size)) - // Rootly's JSON:API pagination is 1-based. - pageNumber := reqData.Pager.Skip/reqData.Pager.Size + 1 - query.Set("page[number]", fmt.Sprintf("%d", pageNumber)) - query.Set("sort", "-updated_at") - if createdAfter != nil { - query.Set("filter[updated_at][gt]", createdAfter.UTC().Format(time.RFC3339)) - } - return query, nil + return buildIncidentsQuery(data.Options.ServiceId, reqData.Pager.Size, reqData.Pager.Page, createdAfter), nil }, ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { rawResult := collectedIncidents{} if err := api.UnmarshalResponse(res, &rawResult); err != nil { - logger.Error(err, "rootly incidents response unmarshal failed") return nil, err } lastPage = rawResult.Meta @@ -121,7 +109,6 @@ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { } else { lastLinksNext = nil } - lastPageEmpty = len(rawResult.Data) == 0 return rawResult.Data, nil }, }, @@ -132,3 +119,20 @@ func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error { } return collector.Execute() } + +// buildIncidentsQuery is the pure-function core of the Query closure +// above so a regression in the filter parameter name (we shipped with +// `filter[services]` once and got 0 results back; the correct param is +// `filter[service_ids]`) is caught by a unit test. +func buildIncidentsQuery(serviceId string, pageSize, pageNumber int, createdAfter *time.Time) url.Values { + query := url.Values{} + query.Set("filter[service_ids]", serviceId) + query.Set("page[size]", fmt.Sprintf("%d", pageSize)) + // Rootly's JSON:API pagination is 1-based. + query.Set("page[number]", fmt.Sprintf("%d", pageNumber)) + query.Set("sort", "-updated_at") + if createdAfter != nil { + query.Set("filter[updated_at][gt]", createdAfter.UTC().Format(time.RFC3339)) + } + return query +} diff --git a/backend/plugins/rootly/tasks/incidents_collector_test.go b/backend/plugins/rootly/tasks/incidents_collector_test.go new file mode 100644 index 00000000000..eb3c0241581 --- /dev/null +++ b/backend/plugins/rootly/tasks/incidents_collector_test.go @@ -0,0 +1,46 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestBuildIncidentsQuery_FirstPageNoSince(t *testing.T) { + q := buildIncidentsQuery("svc_42", 100, 1, nil) + assert.Equal(t, "svc_42", q.Get("filter[service_ids]")) + assert.Equal(t, "100", q.Get("page[size]")) + assert.Equal(t, "1", q.Get("page[number]")) + assert.Equal(t, "-updated_at", q.Get("sort")) + assert.Equal(t, "", q.Get("filter[updated_at][gt]")) + assert.Equal(t, "", q.Get("filter[services]"), "regression guard: must be filter[service_ids], not filter[services]") +} + +func TestBuildIncidentsQuery_SubsequentPage(t *testing.T) { + q := buildIncidentsQuery("svc_42", 100, 3, nil) + assert.Equal(t, "3", q.Get("page[number]")) +} + +func TestBuildIncidentsQuery_WithSince(t *testing.T) { + since := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + q := buildIncidentsQuery("svc_42", 100, 1, &since) + assert.Equal(t, "2026-05-01T12:00:00Z", q.Get("filter[updated_at][gt]")) +} diff --git a/backend/plugins/rootly/tasks/incidents_converter.go b/backend/plugins/rootly/tasks/incidents_converter.go index e55706ec21d..856575d7866 100644 --- a/backend/plugins/rootly/tasks/incidents_converter.go +++ b/backend/plugins/rootly/tasks/incidents_converter.go @@ -85,8 +85,8 @@ func ConvertIncidents(taskCtx plugin.SubTaskContext) errors.Error { Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { incident := inputRow.(*models.Incident) - status := mapStatus(incident.Status) - if status == ticket.IN_PROGRESS && !isKnownStatus(incident.Status) { + status, known := mapStatus(incident.Status) + if !known { logger.Warn(nil, "unknown rootly incident status: %s", incident.Status) } @@ -126,14 +126,7 @@ func ConvertIncidents(taskCtx plugin.SubTaskContext) errors.Error { results := []interface{}{domainIssue} seenAssignees := map[string]bool{} - roleUserIds := []string{ - incident.CreatorUserId, - incident.StartedByUserId, - incident.MitigatedByUserId, - incident.ResolvedByUserId, - incident.ClosedByUserId, - } - for _, toolUserId := range roleUserIds { + for _, toolUserId := range incident.RoleUserIds() { if toolUserId == "" || seenAssignees[toolUserId] { continue } @@ -162,25 +155,16 @@ func ConvertIncidents(taskCtx plugin.SubTaskContext) errors.Error { // Unknown statuses fall through to IN_PROGRESS rather than panicking // (PagerDuty panics). Rootly's status enum is more volatile, so a new // value from upstream shouldn't crash a production pipeline. -func mapStatus(status string) string { +func mapStatus(status string) (mapped string, known bool) { switch status { case "triage", "started": - return ticket.TODO + return ticket.TODO, true case "mitigated": - return ticket.IN_PROGRESS + return ticket.IN_PROGRESS, true case "resolved", "closed", "cancelled": - return ticket.DONE - default: - return ticket.IN_PROGRESS - } -} - -func isKnownStatus(status string) bool { - switch status { - case "triage", "started", "mitigated", "resolved", "closed", "cancelled": - return true + return ticket.DONE, true default: - return false + return ticket.IN_PROGRESS, false } } diff --git a/backend/plugins/rootly/tasks/incidents_converter_test.go b/backend/plugins/rootly/tasks/incidents_converter_test.go index 1c71fd5eacd..6ea99bd2dfe 100644 --- a/backend/plugins/rootly/tasks/incidents_converter_test.go +++ b/backend/plugins/rootly/tasks/incidents_converter_test.go @@ -30,28 +30,31 @@ import ( func TestMapStatus(t *testing.T) { cases := []struct { - in string - expected string + in string + expectMapped string + expectedKnown bool }{ - {"triage", ticket.TODO}, - {"started", ticket.TODO}, - {"mitigated", ticket.IN_PROGRESS}, - {"resolved", ticket.DONE}, - {"closed", ticket.DONE}, - {"cancelled", ticket.DONE}, - {"wat", ticket.IN_PROGRESS}, - {"", ticket.IN_PROGRESS}, + {"triage", ticket.TODO, true}, + {"started", ticket.TODO, true}, + {"mitigated", ticket.IN_PROGRESS, true}, + {"resolved", ticket.DONE, true}, + {"closed", ticket.DONE, true}, + {"cancelled", ticket.DONE, true}, + {"wat", ticket.IN_PROGRESS, false}, + {"", ticket.IN_PROGRESS, false}, } for _, c := range cases { t.Run(c.in, func(t *testing.T) { - assert.Equal(t, c.expected, mapStatus(c.in)) + mapped, known := mapStatus(c.in) + assert.Equal(t, c.expectMapped, mapped) + assert.Equal(t, c.expectedKnown, known) }) } } func TestMapStatusDoesNotPanic(t *testing.T) { assert.NotPanics(t, func() { - _ = mapStatus("brand-new-status-rootly-invented-yesterday") + _, _ = mapStatus("brand-new-status-rootly-invented-yesterday") }) } @@ -181,13 +184,7 @@ func TestAssigneeDedup(t *testing.T) { t.Run(c.name, func(t *testing.T) { seen := map[string]bool{} var got []string - for _, uid := range []string{ - c.incident.CreatorUserId, - c.incident.StartedByUserId, - c.incident.MitigatedByUserId, - c.incident.ResolvedByUserId, - c.incident.ClosedByUserId, - } { + for _, uid := range c.incident.RoleUserIds() { if uid == "" || seen[uid] { continue } @@ -204,8 +201,10 @@ func TestAssigneeDedup(t *testing.T) { } func TestMapStatus_MitigatedIsKnown(t *testing.T) { - assert.Equal(t, ticket.IN_PROGRESS, mapStatus("mitigated")) - assert.True(t, isKnownStatus("mitigated")) - assert.Equal(t, ticket.IN_PROGRESS, mapStatus("something-else")) - assert.False(t, isKnownStatus("something-else")) + mapped, known := mapStatus("mitigated") + assert.Equal(t, ticket.IN_PROGRESS, mapped) + assert.True(t, known) + mapped, known = mapStatus("something-else") + assert.Equal(t, ticket.IN_PROGRESS, mapped) + assert.False(t, known) } diff --git a/backend/plugins/rootly/tasks/incidents_extractor.go b/backend/plugins/rootly/tasks/incidents_extractor.go index b5880924e6b..99aca0946db 100644 --- a/backend/plugins/rootly/tasks/incidents_extractor.go +++ b/backend/plugins/rootly/tasks/incidents_extractor.go @@ -35,6 +35,7 @@ var ExtractIncidentsMeta = plugin.SubTaskMeta{ EnabledByDefault: true, Description: "Extract Rootly incidents", DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, + ProductTables: []string{models.Incident{}.TableName(), models.User{}.TableName()}, } func ExtractIncidents(taskCtx plugin.SubTaskContext) errors.Error { @@ -75,7 +76,7 @@ func extractRootlyIncident(rawData []byte, op *RootlyOptions) ([]interface{}, er incident := &models.Incident{ ConnectionId: op.ConnectionId, Id: rawIncident.Id, - Number: resolveInt(rawIncident.Attributes.SequentialId), + Number: resolve(rawIncident.Attributes.SequentialId), ServiceId: op.ServiceId, Url: resolve(rawIncident.Attributes.Url), Title: rawIncident.Attributes.Title, @@ -100,11 +101,17 @@ func extractRootlyIncident(rawData []byte, op *RootlyOptions) ([]interface{}, er return } seen[u.Data.Id] = true + name := pickUserName(u.Data.Attributes) + // Skip rows with no useful data so a sibling scope task that has + // fuller data for the same user doesn't get overwritten with blanks. + if name == "" && u.Data.Attributes.Email == "" { + return + } results = append(results, &models.User{ ConnectionId: op.ConnectionId, Id: u.Data.Id, Email: u.Data.Attributes.Email, - Name: pickUserName(u.Data.Attributes), + Name: name, }) } addUser(rawIncident.Attributes.User, func(id string) { incident.CreatorUserId = id }) @@ -126,10 +133,7 @@ func pickUserName(u raw.UserAttributes) string { return u.Email } -func containsServiceId(services []struct { - Id string `json:"id"` - Type string `json:"type"` -}, serviceId string) bool { +func containsServiceId(services []raw.ServiceRef, serviceId string) bool { for _, s := range services { if s.Id == serviceId { return true @@ -151,10 +155,3 @@ func resolve[T any](t *T) T { } return *t } - -func resolveInt(i *int) int { - if i == nil { - return 0 - } - return *i -} diff --git a/backend/plugins/rootly/tasks/services_collector.go b/backend/plugins/rootly/tasks/services_collector.go index 140fc7e464e..9800b47e6b4 100644 --- a/backend/plugins/rootly/tasks/services_collector.go +++ b/backend/plugins/rootly/tasks/services_collector.go @@ -43,6 +43,7 @@ var CollectServicesMeta = plugin.SubTaskMeta{ EnabledByDefault: true, Description: "Collect Rootly services", DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, + ProductTables: []string{RAW_SERVICES_TABLE}, } func CollectServices(taskCtx plugin.SubTaskContext) errors.Error { diff --git a/backend/plugins/rootly/tasks/services_extractor.go b/backend/plugins/rootly/tasks/services_extractor.go index 326eff92d77..bc5cea0d230 100644 --- a/backend/plugins/rootly/tasks/services_extractor.go +++ b/backend/plugins/rootly/tasks/services_extractor.go @@ -20,6 +20,7 @@ package tasks import ( "encoding/json" + "github.com/apache/incubator-devlake/core/dal" "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/helpers/pluginhelper/api" @@ -35,10 +36,12 @@ var ExtractServicesMeta = plugin.SubTaskMeta{ EnabledByDefault: true, Description: "Extract Rootly services", DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, + ProductTables: []string{models.Service{}.TableName()}, } func ExtractServices(taskCtx plugin.SubTaskContext) errors.Error { data := taskCtx.GetData().(*RootlyTaskData) + db := taskCtx.GetDal() extractor, err := api.NewApiExtractor(api.ApiExtractorArgs{ RawDataSubTaskArgs: api.RawDataSubTaskArgs{ Ctx: taskCtx, @@ -60,6 +63,11 @@ func ExtractServices(taskCtx plugin.SubTaskContext) errors.Error { Url: url, } service.ConnectionId = data.Options.ConnectionId + // Preserve operator-set ScopeConfigId across re-collections. + existing := &models.Service{} + if err := db.First(existing, dal.Where("connection_id = ? AND id = ?", data.Options.ConnectionId, rawService.Id)); err == nil { + service.ScopeConfigId = existing.ScopeConfigId + } return []interface{}{service}, nil }, }) diff --git a/backend/plugins/rootly/tasks/task_data.go b/backend/plugins/rootly/tasks/task_data.go index a0a881bc0d7..334483bbe28 100644 --- a/backend/plugins/rootly/tasks/task_data.go +++ b/backend/plugins/rootly/tasks/task_data.go @@ -64,15 +64,6 @@ func DecodeTaskOptions(options map[string]interface{}) (*RootlyOptions, errors.E return &op, nil } -func EncodeTaskOptions(op *RootlyOptions) (map[string]interface{}, errors.Error) { - var result map[string]interface{} - err := api.Decode(op, &result, nil) - if err != nil { - return nil, err - } - return result, nil -} - func ValidateTaskOptions(op *RootlyOptions) errors.Error { if op.ServiceId == "" { return errors.BadInput.New("not enough info for Rootly execution") From 533c3e79bce1e58ca844f5c3c02ab3681e2ddf68 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Wed, 13 May 2026 16:23:19 -0700 Subject: [PATCH 12/14] feat(rootly): add rootly dashboard --- grafana/dashboards/Rootly.json | 1295 ++++++++++++++++++++++++++++++++ 1 file changed, 1295 insertions(+) create mode 100644 grafana/dashboards/Rootly.json diff --git a/grafana/dashboards/Rootly.json b/grafana/dashboards/Rootly.json new file mode 100644 index 00000000000..de7cf18f7ad --- /dev/null +++ b/grafana/dashboards/Rootly.json @@ -0,0 +1,1295 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 20, + "links": [ + { + "asDropdown": false, + "icon": "bolt", + "includeVars": false, + "keepTime": true, + "tags": [], + "targetBlank": false, + "title": "Homepage", + "tooltip": "", + "type": "link", + "url": "/grafana/d/Lv1XbLHnk/data-specific-dashboards-homepage" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": true, + "tags": [ + "Data Source Specific Dashboard" + ], + "targetBlank": false, + "title": "Metric dashboards", + "tooltip": "", + "type": "dashboards", + "url": "" + } + ], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 3, + "w": 13, + "x": 0, + "y": 0 + }, + "id": 128, + "links": [ + { + "targetBlank": true, + "title": "Rootly", + "url": "https://devlake.apache.org/docs/Configuration/Rootly" + } + ], + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "- Use Cases: This dashboard shows the incident data from Rootly.\n- Data Source Required: Rootly", + "mode": "markdown" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Dashboard Introduction", + "type": "text" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 3 + }, + "id": 126, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "1. Incident Resolution Status", + "type": "row" + }, + { + "datasource": "mysql", + "description": "1. Total number of incidents created.\n2. The requirements being calculated are filtered by \"requirement creation time\" (time filter at the upper-right corner) and \"Jira board\" (\"Choose Board\" filter at the upper-left corner)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 0, + "y": 4 + }, + "id": 114, + "links": [ + { + "targetBlank": true, + "title": "Requirement Count", + "url": "https://devlake.apache.org/docs/Metrics/RequirementCount" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "select \r\n count(distinct i.id) as value\r\nfrom issues i\r\n join board_issues bi on i.id = bi.issue_id\r\nwhere \r\n $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Number of Incidents [Created in Selected Time Range]", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 4, + "y": 4 + }, + "id": 116, + "links": [ + { + "targetBlank": true, + "title": "Requirement Count", + "url": "https://devlake.apache.org/docs/Metrics/RequirementCount" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "select \r\n count(distinct i.id) as value\r\nfrom issues i\r\n join board_issues bi on i.id = bi.issue_id\r\nwhere \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Number of Resolved Incidents [Created in Selected Time Range]", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "1. Total number of incidents created.\n2. The requirements being calculated are filtered by \"requirement creation time\" (time filter at the upper-right corner) and \"Jira board\" (\"Choose Board\" filter at the upper-left corner)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "custom.filterable", + "value": true + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 8, + "y": 4 + }, + "id": 131, + "links": [ + { + "targetBlank": true, + "title": "Requirement Count", + "url": "https://devlake.apache.org/docs/Metrics/RequirementCount" + } + ], + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "select \r\n b.name as service,\r\n i.issue_key,\r\n i.description,\r\n i.original_status,\r\n i.priority,\r\n i.created_date,\r\n i.updated_date,\r\n round((i.lead_time_minutes/1440),1) as lead_time_days,\r\n i.url\r\nfrom issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n join boards b on bi.board_id = b.id\r\nwhere \r\n $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "List of Incidents [Created in Selected Time Range]", + "type": "table" + }, + { + "datasource": "mysql", + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0.8 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 117, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "time_series", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "with _requirements as(\r\n select\r\n count(distinct i.id) as total_count,\r\n count(distinct case when i.original_status = 'resolved' then i.id else null end) as resolved_count\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})\r\n)\r\n\r\nselect \r\n now() as time,\r\n 1.0 * resolved_count/total_count as requirement_delivery_rate\r\nfrom _requirements", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Incident Resolution Rate [Incidents created in the selected time range]", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Resolution Rate(%)", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 12, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0.8 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 8, + "y": 10 + }, + "id": 121, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "8.0.6", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "time_series", + "group": [], + "metricColumn": "none", + "queryType": "randomWalk", + "rawQuery": true, + "rawSql": "with _requirements as(\r\n select\r\n DATE_ADD(date(i.created_date), INTERVAL -DAYOFMONTH(date(i.created_date))+1 DAY) as time,\r\n 1.0 * count(distinct case when i.original_status = 'resolved' then i.id else null end)/count(distinct i.id) as resolved_rate\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n $__timeFilter(i.created_date)\r\n and bi.board_id in (${board_id})\r\n group by 1\r\n)\r\n\r\nselect\r\n time,\r\n resolved_rate\r\nfrom _requirements\r\norder by time", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "timeColumn": "time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Incident Resolution Rate over Time [Incidents Created in Selected Time Range]", + "type": "timeseries" + }, + { + "collapsed": false, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 110, + "panels": [], + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "2. Mean Time to Resolve (MTTR)", + "type": "row" + }, + { + "datasource": "mysql", + "description": "", + "fieldConfig": { + "defaults": { + "decimals": 1, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "d" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 0, + "y": 17 + }, + "id": 12, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "/^value$/", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "select \r\n avg(lead_time_minutes/1440) as value\r\nfrom issues i\r\n join board_issues bi on i.id = bi.issue_id\r\nwhere \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.resolution_date)\r\n and bi.board_id in (${board_id})", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "MTTR [Incidents Resolved in Select Time Range]", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "d" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 4, + "y": 17 + }, + "id": 13, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "with _ranks as(\r\n select \r\n i.lead_time_minutes,\r\n percent_rank() over (order by lead_time_minutes asc) as ranks\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.resolution_date)\r\n and bi.board_id in (${board_id})\r\n)\r\n\r\nselect\r\n max(lead_time_minutes/1440) as value\r\nfrom _ranks\r\nwhere \r\n ranks <= 0.8", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "80% Incidents' MTTR are less than # [Incidents Resolved in Select Time Range]", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Incident Age(days)", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 8, + "y": 17 + }, + "id": 17, + "interval": "", + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "options": { + "barRadius": 0, + "barWidth": 0.5, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "text": { + "valueSize": 12 + }, + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "8.0.6", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "with _requirements as(\r\n select \r\n DATE_ADD(date(i.resolution_date), INTERVAL -DAYOFMONTH(date(i.resolution_date))+1 DAY) as time,\r\n avg(lead_time_minutes/1440) as mean_incident_age\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.resolution_date)\r\n and bi.board_id in (${board_id})\r\n group by 1\r\n)\r\n\r\nselect \r\n date_format(time,'%M %Y') as month,\r\n mean_incident_age\r\nfrom _requirements\r\norder by time asc", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Mean MTTR [Incidents Resolved in Select Time Range]", + "type": "barchart" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "mysql", + "description": "1. The cumulative distribution of MTTR\n2. Each point refers to the percent rank of a distinct duration to resolve incidents.", + "fill": 0, + "fillGradient": 4, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 23 + }, + "hiddenSeries": false, + "id": 15, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 8, + "links": [ + { + "targetBlank": true, + "title": "Incident Age", + "url": "https://devlake.apache.org/docs/Metrics/IncidentAge" + } + ], + "nullPointMode": "null", + "options": { + "alertThreshold": false + }, + "percentage": false, + "pluginVersion": "9.5.15", + "pointradius": 0.5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "time_series", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "with _ranks as(\r\n select \r\n round(i.lead_time_minutes/1440) as lead_time_day\r\n from issues i\r\n join board_issues bi on i.id = bi.issue_id\r\n where \r\n i.original_status = 'resolved'\r\n and $__timeFilter(i.resolution_date)\r\n and bi.board_id in (${board_id})\r\n order by lead_time_day asc\r\n)\r\n\r\nselect \r\n now() as time,\r\n lpad(concat(lead_time_day,'d'), 4, ' ') as metric,\r\n percent_rank() over (order by lead_time_day asc) as value\r\nfrom _ranks\r\norder by lead_time_day asc", + "refId": "A", + "select": [ + [ + { + "params": [ + "progress" + ], + "type": "column" + } + ] + ], + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "ca_analysis", + "timeColumn": "create_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "thresholds": [ + { + "$$hashKey": "object:469", + "colorMode": "ok", + "fill": true, + "line": true, + "op": "lt", + "value": 0.8, + "yaxis": "right" + } + ], + "timeRegions": [], + "title": "Cumulative Distribution of MTTR [Incidents Resolved in Select Time Range]", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "transformations": [], + "type": "graph", + "xaxis": { + "mode": "series", + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "$$hashKey": "object:76", + "format": "percentunit", + "label": "Percent Rank (%)", + "logBase": 1, + "max": "1.2", + "show": true + }, + { + "$$hashKey": "object:77", + "format": "short", + "logBase": 1, + "show": false + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 29 + }, + "id": 130, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "
\n\nThis dashboard is created based on this [data schema](https://devlake.apache.org/docs/DataModels/DevLakeDomainLayerSchema). Want to add more metrics? Please follow the [guide](https://devlake.apache.org/docs/Configuration/Dashboards/GrafanaUserGuide).", + "mode": "markdown" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "type": "text" + } + ], + "refresh": "", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "Data Source Dashboard", + "Stable Data Sources" + ], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": "mysql", + "definition": "select concat(name, '--', id) from boards where id like 'rootly%'", + "hide": 0, + "includeAll": true, + "label": "Choose Board", + "multi": true, + "name": "board_id", + "options": [], + "query": "select concat(name, '--', id) from boards where id like 'rootly%'", + "refresh": 1, + "regex": "/^(?.*)--(?.*)$/", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6M", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "Rootly", + "uid": "rootly-dashboard", + "version": 2, + "weekStart": "" +} \ No newline at end of file From ce813c5794da06dfeb348ab22dbd1ff640a2b577 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Wed, 13 May 2026 16:54:26 -0700 Subject: [PATCH 13/14] chore: run gofmt --- backend/plugins/rootly/api/remote_api.go | 1 - backend/plugins/rootly/models/incident.go | 26 +++++++++++------------ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/backend/plugins/rootly/api/remote_api.go b/backend/plugins/rootly/api/remote_api.go index 0271e66e2e9..0bb81ce219d 100644 --- a/backend/plugins/rootly/api/remote_api.go +++ b/backend/plugins/rootly/api/remote_api.go @@ -187,4 +187,3 @@ func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, er func SearchRemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { return raScopeSearch.Get(input) } - diff --git a/backend/plugins/rootly/models/incident.go b/backend/plugins/rootly/models/incident.go index 1cd8defd937..804ed645798 100644 --- a/backend/plugins/rootly/models/incident.go +++ b/backend/plugins/rootly/models/incident.go @@ -25,19 +25,19 @@ import ( type Incident struct { common.NoPKModel - ConnectionId uint64 `gorm:"primaryKey"` - Id string `gorm:"primaryKey;autoIncrement:false"` - Number int - ServiceId string `gorm:"index"` - Url string - Title string - Summary string - Status string - Severity string - StartedDate time.Time - AcknowledgedDate *time.Time - MitigatedDate *time.Time - ResolvedDate *time.Time + ConnectionId uint64 `gorm:"primaryKey"` + Id string `gorm:"primaryKey;autoIncrement:false"` + Number int + ServiceId string `gorm:"index"` + Url string + Title string + Summary string + Status string + Severity string + StartedDate time.Time + AcknowledgedDate *time.Time + MitigatedDate *time.Time + ResolvedDate *time.Time UpdatedDate time.Time CreatorUserId string StartedByUserId string From fa2d3c303bf3f6bd5ba2aa760b17caea9270e2f4 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Wed, 13 May 2026 17:17:56 -0700 Subject: [PATCH 14/14] chore: add isBeta flag to rootly plugin --- config-ui/src/plugins/register/rootly/config.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/config-ui/src/plugins/register/rootly/config.tsx b/config-ui/src/plugins/register/rootly/config.tsx index d33ac45bbd9..163852d04cd 100644 --- a/config-ui/src/plugins/register/rootly/config.tsx +++ b/config-ui/src/plugins/register/rootly/config.tsx @@ -26,6 +26,7 @@ export const RootlyConfig: IPluginConfig = { name: 'Rootly', icon: ({ color }) => , sort: 16, + isBeta: true, connection: { docLink: DOC_URL.PLUGIN.ROOTLY.BASIS, initialValues: {