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..3fb36609ded
--- /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/me", 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..0bb81ce219d
--- /dev/null
+++ b/backend/plugins/rootly/api/remote_api.go
@@ -0,0 +1,189 @@
+/*
+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"
+)
+
+type RootlyRemotePagination struct {
+ Page int `json:"page"`
+ PerPage int `json:"per_page"`
+}
+
+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 page.Page < 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)
+}
diff --git a/backend/plugins/rootly/api/scope_api.go b/backend/plugins/rootly/api/scope_api.go
new file mode 100644
index 00000000000..ef8633423f6
--- /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 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/{scopeId} [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 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/{scopeId} [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 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/{scopeId} [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/e2e/incident_test.go b/backend/plugins/rootly/e2e/incident_test.go
new file mode 100644
index 00000000000..15cc1d324a3
--- /dev/null
+++ b/backend/plugins/rootly/e2e/incident_test.go
@@ -0,0 +1,126 @@
+/*
+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,
+ }
+
+ // scope
+ 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 raw data table
+ dataflowTester.ImportCsvIntoRawTable(
+ "./raw_tables/_raw_rootly_incidents.csv",
+ "_raw_rootly_incidents",
+ )
+
+ // verify extraction
+ 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{}},
+ },
+ )
+
+ // verify conversion
+ 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..cccd06ffc30
--- /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"":{""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
new file mode 100644
index 00000000000..7a180306867
--- /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,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/_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..43c50aa8e3a
--- /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,,,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
new file mode 100644
index 00000000000..296bd6e6594
--- /dev/null
+++ b/backend/plugins/rootly/impl/impl.go
@@ -0,0 +1,190 @@
+/*
+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"
+ 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"
+)
+
+// make sure interface is implemented
+
+var _ interface {
+ plugin.PluginMeta
+ plugin.PluginInit
+ plugin.PluginTask
+ plugin.PluginApi
+ plugin.PluginModel
+ plugin.DataSourcePluginBlueprintV200
+ 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 {
+ api.Init(basicRes, p)
+ 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 {
+ // 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,
+ tasks.CollectIncidentsMeta,
+ tasks.ExtractIncidentsMeta,
+ tasks.ConvertServicesMeta,
+ tasks.ConvertIncidentsMeta,
+ }
+}
+
+func (p Rootly) GetTablesInfo() []dal.Tabler {
+ return []dal.Tabler{
+ &models.Service{},
+ &models.Incident{},
+ &models.User{},
+ &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{
+ "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 {
+ _, 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/connection.go b/backend/plugins/rootly/models/connection.go
new file mode 100644
index 00000000000..898d1542198
--- /dev/null
+++ b/backend/plugins/rootly/models/connection.go
@@ -0,0 +1,74 @@
+/*
+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"
+)
+
+type RootlyAccessToken helper.AccessToken
+
+func (at *RootlyAccessToken) SetupAuthentication(request *http.Request) errors.Error {
+ request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", at.Token))
+ return nil
+}
+
+type RootlyConn struct {
+ helper.RestConnection `mapstructure:",squash"`
+ 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 {
+ return err
+ }
+ modifiedToken := target.Token
+ if modifiedToken == "" || modifiedToken == utils.SanitizeString(token) {
+ target.Token = token
+ }
+ return nil
+}
+
+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..804ed645798
--- /dev/null
+++ b/backend/plugins/rootly/models/incident.go
@@ -0,0 +1,62 @@
+/*
+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 (
+ "time"
+
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+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
+ UpdatedDate time.Time
+ CreatorUserId string
+ StartedByUserId string
+ MitigatedByUserId string
+ ResolvedByUserId string
+ ClosedByUserId string
+}
+
+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/20260512_add_init_tables.go b/backend/plugins/rootly/models/migrationscripts/20260512_add_init_tables.go
new file mode 100644
index 00000000000..ec4ee5fcad6
--- /dev/null
+++ b/backend/plugins/rootly/models/migrationscripts/20260512_add_init_tables.go
@@ -0,0 +1,45 @@
+/*
+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.ScopeConfig{},
+ )
+}
+
+func (*addInitTables) Version() uint64 {
+ return 20260512000001
+}
+
+func (*addInitTables) Name() string {
+ return "Rootly init schemas"
+}
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..5bfd1ad47a5
--- /dev/null
+++ b/backend/plugins/rootly/models/migrationscripts/archived/connection.go
@@ -0,0 +1,35 @@
+/*
+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"`
+ 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 {
+ 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..b787b8c81ff
--- /dev/null
+++ b/backend/plugins/rootly/models/migrationscripts/archived/incident.go
@@ -0,0 +1,51 @@
+/*
+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 `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
+ MitigatedByUserId string
+ ResolvedByUserId string
+ ClosedByUserId string
+}
+
+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..4438c38f480
--- /dev/null
+++ b/backend/plugins/rootly/models/migrationscripts/archived/scope_config.go
@@ -0,0 +1,35 @@
+/*
+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"
+)
+
+// 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"`
+ Name string `mapstructure:"name" json:"name" gorm:"type:varchar(255);uniqueIndex" validate:"required"`
+}
+
+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..821b9ea2b01
--- /dev/null
+++ b/backend/plugins/rootly/models/migrationscripts/archived/service.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 (
+ "github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+)
+
+// 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"`
+ ScopeConfigId uint64 `json:"scopeConfigId,omitempty" mapstructure:"scopeConfigId,omitempty"`
+ 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..d16851d3249
--- /dev/null
+++ b/backend/plugins/rootly/models/migrationscripts/archived/user.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 User struct {
+ archived.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ Id string `gorm:"primaryKey;autoIncrement:false"`
+ Email string
+ Name 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/raw/incident.go b/backend/plugins/rootly/models/raw/incident.go
new file mode 100644
index 00000000000..541fb477e5c
--- /dev/null
+++ b/backend/plugins/rootly/models/raw/incident.go
@@ -0,0 +1,87 @@
+/*
+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"
+)
+
+type Incident struct {
+ Id string `json:"id"`
+ Type string `json:"type"`
+ Attributes IncidentAttributes `json:"attributes"`
+ Relationships IncidentRelationships `json:"relationships"`
+}
+
+type IncidentRelationships struct {
+ Services struct {
+ 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"`
+ 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 *SeverityEnvelope `json:"severity"`
+
+ 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 SeverityEnvelope struct {
+ Data struct {
+ Id string `json:"id"`
+ Type string `json:"type"`
+ Attributes SeverityAttributes `json:"attributes"`
+ } `json:"data"`
+}
+
+type SeverityAttributes struct {
+ Slug string `json:"slug"`
+}
+
+type UserEnvelope struct {
+ Data struct {
+ Id string `json:"id"`
+ Type string `json:"type"`
+ Attributes UserAttributes `json:"attributes"`
+ } `json:"data"`
+}
+
+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
new file mode 100644
index 00000000000..d9971796274
--- /dev/null
+++ b/backend/plugins/rootly/models/raw/service.go
@@ -0,0 +1,35 @@
+/*
+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"
+
+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/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..4bf25434501
--- /dev/null
+++ b/backend/plugins/rootly/models/user.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 User struct {
+ common.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ Id string `gorm:"primaryKey;autoIncrement:false"`
+ Email string
+ Name string
+}
+
+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/incidents_collector.go b/backend/plugins/rootly/tasks/incidents_collector.go
new file mode 100644
index 00000000000..e89bb89fcd7
--- /dev/null
+++ b/backend/plugins/rootly/tasks/incidents_collector.go
@@ -0,0 +1,138 @@
+/*
+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
+
+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"`
+}
+
+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,
+ }
+ // 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
+
+ collector, err := api.NewStatefulApiCollectorForFinalizableEntity(api.FinalizableApiCollectorArgs{
+ RawDataSubTaskArgs: args,
+ ApiClient: data.Client,
+ 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
+ }
+ if lastPage != nil && lastPage.CurrentPage != nil && lastPage.TotalPages != nil {
+ if *lastPage.CurrentPage >= *lastPage.TotalPages {
+ return nil, api.ErrFinishCollect
+ }
+ }
+ return nil, nil
+ },
+ FinalizableApiCollectorCommonArgs: api.FinalizableApiCollectorCommonArgs{
+ UrlTemplate: "incidents",
+ Query: func(reqData *api.RequestData, createdAfter *time.Time) (url.Values, errors.Error) {
+ 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 {
+ return nil, err
+ }
+ lastPage = rawResult.Meta
+ if rawResult.Links != nil {
+ lastLinksNext = rawResult.Links.Next
+ } else {
+ lastLinksNext = nil
+ }
+ return rawResult.Data, nil
+ },
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+ 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
new file mode 100644
index 00000000000..856575d7866
--- /dev/null
+++ b/backend/plugins/rootly/tasks/incidents_converter.go
@@ -0,0 +1,206 @@
+/*
+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},
+}
+
+func ConvertIncidents(taskCtx plugin.SubTaskContext) errors.Error {
+ db := taskCtx.GetDal()
+ data := taskCtx.GetData().(*RootlyTaskData)
+ logger := taskCtx.GetLogger()
+
+ 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, known := mapStatus(incident.Status)
+ if !known {
+ 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)
+
+ 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,
+ CreatorId: creatorDomainId,
+ CreatorName: creatorName,
+ AssigneeId: creatorDomainId,
+ AssigneeName: creatorName,
+ }
+
+ results := []interface{}{domainIssue}
+
+ seenAssignees := map[string]bool{}
+ for _, toolUserId := range incident.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()
+}
+
+// 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) (mapped string, known bool) {
+ switch status {
+ case "triage", "started":
+ return ticket.TODO, true
+ case "mitigated":
+ return ticket.IN_PROGRESS, true
+ case "resolved", "closed", "cancelled":
+ return ticket.DONE, true
+ default:
+ return ticket.IN_PROGRESS, false
+ }
+}
+
+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
+ }
+}
+
+func computeLeadTime(started time.Time, resolved *time.Time) (*uint, *time.Time) {
+ if resolved == nil {
+ return nil, nil
+ }
+ // 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
+ }
+ minutes := uint(resolved.Sub(started).Minutes())
+ resolutionDate := *resolved
+ return &minutes, &resolutionDate
+}
+
+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..6ea99bd2dfe
--- /dev/null
+++ b/backend/plugins/rootly/tasks/incidents_converter_test.go
@@ -0,0 +1,210 @@
+/*
+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"
+)
+
+func TestMapStatus(t *testing.T) {
+ cases := []struct {
+ in string
+ expectMapped string
+ expectedKnown bool
+ }{
+ {"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) {
+ 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")
+ })
+}
+
+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))
+ })
+ }
+}
+
+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)
+}
+
+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)
+}
+
+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)
+}
+
+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)
+}
+
+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))
+ })
+ }
+}
+
+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 c.incident.RoleUserIds() {
+ 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)
+ }
+ })
+ }
+}
+
+func TestMapStatus_MitigatedIsKnown(t *testing.T) {
+ 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
new file mode 100644
index 00000000000..99aca0946db
--- /dev/null
+++ b/backend/plugins/rootly/tasks/incidents_extractor.go
@@ -0,0 +1,157 @@
+/*
+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},
+ ProductTables: []string{models.Incident{}.TableName(), models.User{}.TableName()},
+}
+
+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()
+}
+
+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: 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
+ }
+
+ 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: resolve(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.Severity),
+ 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.UserEnvelope, setRoleId func(string)) {
+ if u == nil || u.Data.Id == "" {
+ return
+ }
+ setRoleId(u.Data.Id)
+ if seen[u.Data.Id] {
+ 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: name,
+ })
+ }
+ 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
+}
+
+func pickUserName(u raw.UserAttributes) string {
+ if u.FullName != "" {
+ return u.FullName
+ }
+ if u.Name != "" {
+ return u.Name
+ }
+ return u.Email
+}
+
+func containsServiceId(services []raw.ServiceRef, serviceId string) bool {
+ for _, s := range services {
+ if s.Id == serviceId {
+ return true
+ }
+ }
+ return false
+}
+
+func resolveSeverity(s *raw.SeverityEnvelope) string {
+ if s == nil {
+ return ""
+ }
+ return s.Data.Attributes.Slug
+}
+
+func resolve[T any](t *T) T {
+ if t == nil {
+ return *new(T)
+ }
+ return *t
+}
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..cba3410116e
--- /dev/null
+++ b/backend/plugins/rootly/tasks/incidents_extractor_test.go
@@ -0,0 +1,382 @@
+/*
+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"
+)
+
+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"}]}
+ }
+}`
+
+func newTestOptions() *RootlyOptions {
+ return &RootlyOptions{
+ ConnectionId: 7,
+ ServiceId: "svc_02",
+ }
+}
+
+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
+}
+
+func TestExtractRootlyIncident_HappyPathActive(t *testing.T) {
+ op := newTestOptions()
+ results, err := extractRootlyIncident([]byte(baseHappyPathActive), 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, 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)
+}
+
+func TestExtractRootlyIncident_Resolved(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_02",
+ "type": "incidents",
+ "attributes": {
+ "sequential_id": 43,
+ "title": "cache cleared",
+ "status": "resolved",
+ "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": {"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"}]}
+ }
+ }`)
+ 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"])
+}
+
+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)
+}
+
+func TestExtractRootlyIncident_NullSeverity(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_04",
+ "type": "incidents",
+ "attributes": {
+ "sequential_id": 45,
+ "title": "no sev yet",
+ "status": "mitigated",
+ "severity": null,
+ "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, "", incident.Severity)
+}
+
+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))
+}
+
+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": {"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"}]}
+ }
+ }`)
+ 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)
+}
+
+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": {"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"}]}
+ }
+ }`)
+ 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)
+}
+
+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")
+}
+
+func TestExtractRootlyIncident_EmptyServicesAccepted(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_no_svc",
+ "type": "incidents",
+ "attributes": {
+ "sequential_id": 50,
+ "title": "services 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)
+}
+
+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)
+}
+
+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)
+}
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..9800b47e6b4
--- /dev/null
+++ b/backend/plugins/rootly/tasks/services_collector.go
@@ -0,0 +1,76 @@
+/*
+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},
+ ProductTables: []string{RAW_SERVICES_TABLE},
+}
+
+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..bc5cea0d230
--- /dev/null
+++ b/backend/plugins/rootly/tasks/services_extractor.go
@@ -0,0 +1,78 @@
+/*
+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/dal"
+ "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},
+ 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,
+ 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
+ // 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
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return extractor.Execute()
+}
diff --git a/backend/plugins/rootly/tasks/task_data.go b/backend/plugins/rootly/tasks/task_data.go
new file mode 100644
index 00000000000..334483bbe28
--- /dev/null
+++ b/backend/plugins/rootly/tasks/task_data.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 (
+ "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 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
+}
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..163852d04cd
--- /dev/null
+++ b/config-ui/src/plugins/register/rootly/config.tsx
@@ -0,0 +1,58 @@
+/*
+ * 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,
+ isBeta: true,
+ 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',
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