diff --git a/backend/plugins/incidentio/api/blueprint_v200.go b/backend/plugins/incidentio/api/blueprint_v200.go
new file mode 100644
index 00000000000..81b3bee0c75
--- /dev/null
+++ b/backend/plugins/incidentio/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/incidentio/models"
+ "github.com/apache/incubator-devlake/plugins/incidentio/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.IncidentType, models.IncidentioScopeConfig],
+ connection *models.IncidentioConnection,
+) (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(
+ "incidentio",
+ subtaskMetas,
+ scopeConfig.Entities,
+ tasks.IncidentioOptions{
+ ConnectionId: connection.ID,
+ IncidentTypeId: scope.Id,
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+ stage = append(stage, task)
+ plan[i] = stage
+ }
+
+ return plan, nil
+}
+
+func makeScopesV200(
+ scopeDetails []*srvhelper.ScopeDetail[models.IncidentType, models.IncidentioScopeConfig],
+ connection *models.IncidentioConnection,
+) ([]plugin.Scope, errors.Error) {
+ scopes := make([]plugin.Scope, 0, len(scopeDetails))
+
+ idgen := didgen.NewDomainIdGenerator(&models.IncidentType{})
+ 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/incidentio/api/connection_api.go b/backend/plugins/incidentio/api/connection_api.go
new file mode 100644
index 00000000000..c7c77bad0d5
--- /dev/null
+++ b/backend/plugins/incidentio/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/incidentio/models"
+)
+
+func testConnection(ctx context.Context, connection models.IncidentioConn) (*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("v1/incident_types", 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 incidentio connection
+// @Summary test incidentio connection
+// @Description Test incident.io Connection
+// @Tags plugins/incidentio
+// @Param body body models.IncidentioConn 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/incidentio/test [POST]
+func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ var connection models.IncidentioConn
+ 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 incidentio connection
+// @Summary test incidentio connection
+// @Description Test incident.io Connection
+// @Tags plugins/incidentio
+// @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/incidentio/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.IncidentioConn)
+ if testConnectionErr != nil {
+ return nil, plugin.WrapTestConnectionErrResp(basicRes, testConnectionErr)
+ }
+ return testConnectionResult, nil
+}
+
+// @Summary create incidentio connection
+// @Description Create incident.io connection
+// @Tags plugins/incidentio
+// @Param body body models.IncidentioConnection true "json body"
+// @Success 200 {object} models.IncidentioConnection
+// @Failure 400 {string} errcode.Error "Bad Request"
+// @Failure 500 {string} errcode.Error "Internal Error"
+// @Router /plugins/incidentio/connections [POST]
+func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ConnApi.Post(input)
+}
+
+// @Summary patch incidentio connection
+// @Description Patch incident.io connection
+// @Tags plugins/incidentio
+// @Param body body models.IncidentioConnection true "json body"
+// @Success 200 {object} models.IncidentioConnection
+// @Failure 400 {string} errcode.Error "Bad Request"
+// @Failure 500 {string} errcode.Error "Internal Error"
+// @Router /plugins/incidentio/connections/{connectionId} [PATCH]
+func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ConnApi.Patch(input)
+}
+
+// @Summary delete incidentio connection
+// @Description Delete incident.io connection
+// @Tags plugins/incidentio
+// @Success 200 {object} models.IncidentioConnection
+// @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/incidentio/connections/{connectionId} [DELETE]
+func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ConnApi.Delete(input)
+}
+
+// @Summary list incidentio connections
+// @Description List incident.io connections
+// @Tags plugins/incidentio
+// @Success 200 {object} models.IncidentioConnection
+// @Failure 400 {string} errcode.Error "Bad Request"
+// @Failure 500 {string} errcode.Error "Internal Error"
+// @Router /plugins/incidentio/connections [GET]
+func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ConnApi.GetAll(input)
+}
+
+// @Summary get incidentio connection
+// @Description Get incident.io connection
+// @Tags plugins/incidentio
+// @Success 200 {object} models.IncidentioConnection
+// @Failure 400 {string} errcode.Error "Bad Request"
+// @Failure 500 {string} errcode.Error "Internal Error"
+// @Router /plugins/incidentio/connections/{connectionId} [GET]
+func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ConnApi.GetDetail(input)
+}
diff --git a/backend/plugins/incidentio/api/init.go b/backend/plugins/incidentio/api/init.go
new file mode 100644
index 00000000000..36a9345ed70
--- /dev/null
+++ b/backend/plugins/incidentio/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/incidentio/models"
+ "github.com/go-playground/validator/v10"
+)
+
+var vld *validator.Validate
+var basicRes context.BasicRes
+
+var dsHelper *api.DsHelper[models.IncidentioConnection, models.IncidentType, models.IncidentioScopeConfig]
+var raProxy *api.DsRemoteApiProxyHelper[models.IncidentioConnection]
+var raScopeList *api.DsRemoteApiScopeListHelper[models.IncidentioConnection, models.IncidentType, IncidentioRemotePagination]
+
+var raScopeSearch *api.DsRemoteApiScopeSearchHelper[models.IncidentioConnection, models.IncidentType]
+
+func Init(br context.BasicRes, p plugin.PluginMeta) {
+ vld = validator.New()
+ basicRes = br
+ dsHelper = api.NewDataSourceHelper[
+ models.IncidentioConnection, models.IncidentType, models.IncidentioScopeConfig,
+ ](
+ br,
+ p.Name(),
+ []string{"name"},
+ func(c models.IncidentioConnection) models.IncidentioConnection {
+ return c.Sanitize()
+ },
+ nil,
+ nil,
+ )
+ raProxy = api.NewDsRemoteApiProxyHelper[models.IncidentioConnection](dsHelper.ConnApi.ModelApiHelper)
+ raScopeList = api.NewDsRemoteApiScopeListHelper[models.IncidentioConnection, models.IncidentType, IncidentioRemotePagination](raProxy, listIncidentioRemoteScopes)
+ raScopeSearch = api.NewDsRemoteApiScopeSearchHelper[models.IncidentioConnection, models.IncidentType](raProxy, searchIncidentioRemoteScopes)
+}
diff --git a/backend/plugins/incidentio/api/remote_api.go b/backend/plugins/incidentio/api/remote_api.go
new file mode 100644
index 00000000000..6c8ca5318b0
--- /dev/null
+++ b/backend/plugins/incidentio/api/remote_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 (
+ "net/http"
+ "strings"
+ "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/incidentio/models"
+)
+
+type IncidentioRemotePagination struct {
+ Page int `json:"page"`
+ PerPage int `json:"per_page"`
+}
+
+type IncidentTypesResponse struct {
+ IncidentTypes []struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Description *string `json:"description"`
+ CreatedAt *time.Time `json:"created_at"`
+ } `json:"incident_types"`
+}
+
+// queryIncidentioRemoteScopes lists incident types as scopes. The
+// endpoint is not paginated and returns the full list, so search is
+// applied client-side and there is never a next page.
+func queryIncidentioRemoteScopes(
+ apiClient plugin.ApiClient,
+ _ string,
+ page IncidentioRemotePagination,
+ search string,
+) (
+ children []dsmodels.DsRemoteApiScopeListEntry[models.IncidentType],
+ nextPage *IncidentioRemotePagination,
+ err errors.Error,
+) {
+ var res *http.Response
+ res, err = apiClient.Get("v1/incident_types", nil, nil)
+ if err != nil {
+ return
+ }
+ response := &IncidentTypesResponse{}
+ err = api.UnmarshalResponse(res, response)
+ if err != nil {
+ return
+ }
+ for _, item := range response.IncidentTypes {
+ if search != "" && !strings.Contains(strings.ToLower(item.Name), strings.ToLower(search)) {
+ continue
+ }
+ entry := dsmodels.DsRemoteApiScopeListEntry[models.IncidentType]{
+ Type: api.RAS_ENTRY_TYPE_SCOPE,
+ Id: item.Id,
+ Name: item.Name,
+ FullName: item.Name,
+ Data: &models.IncidentType{
+ Id: item.Id,
+ Name: item.Name,
+ Scope: common.Scope{
+ NoPKModel: common.NoPKModel{},
+ },
+ },
+ }
+ if item.CreatedAt != nil {
+ entry.Data.Scope.NoPKModel.CreatedAt = *item.CreatedAt
+ }
+ children = append(children, entry)
+ }
+
+ return
+}
+
+func listIncidentioRemoteScopes(
+ connection *models.IncidentioConnection,
+ apiClient plugin.ApiClient,
+ groupId string,
+ page IncidentioRemotePagination,
+) (
+ []dsmodels.DsRemoteApiScopeListEntry[models.IncidentType],
+ *IncidentioRemotePagination,
+ errors.Error,
+) {
+ return queryIncidentioRemoteScopes(apiClient, groupId, page, "")
+}
+
+func searchIncidentioRemoteScopes(
+ apiClient plugin.ApiClient,
+ params *dsmodels.DsRemoteApiScopeSearchParams,
+) (
+ children []dsmodels.DsRemoteApiScopeListEntry[models.IncidentType],
+ err errors.Error,
+) {
+ children, _, err = queryIncidentioRemoteScopes(apiClient, "", IncidentioRemotePagination{
+ Page: params.Page,
+ PerPage: params.PageSize,
+ }, params.Search)
+ return
+}
+
+// RemoteScopes list all available scopes (incident types) for this connection
+// @Summary list all available scopes (incident types) for this connection
+// @Description list all available scopes (incident types) for this connection
+// @Tags plugins/incidentio
+// @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/incidentio/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/incidentio
+// @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/incidentio/connections/{connectionId}/search-remote-scopes [GET]
+func SearchRemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return raScopeSearch.Get(input)
+}
diff --git a/backend/plugins/incidentio/api/scope_api.go b/backend/plugins/incidentio/api/scope_api.go
new file mode 100644
index 00000000000..1d237812930
--- /dev/null
+++ b/backend/plugins/incidentio/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/incidentio/models"
+)
+
+type PutScopesReqBody api.PutScopesReqBody[models.IncidentType]
+type ScopeDetail api.ScopeDetail[models.IncidentType, models.IncidentioScopeConfig]
+
+// PutScopes create or update incidentio incident type
+// @Summary create or update incidentio incident type
+// @Description Create or update incidentio incident type
+// @Tags plugins/incidentio
+// @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/incidentio/connections/{connectionId}/scopes [PUT]
+func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeApi.PutMultiple(input)
+}
+
+// PatchScope patch to incidentio incident type
+// @Summary patch to incidentio incident type
+// @Description patch to incidentio incident type
+// @Tags plugins/incidentio
+// @Accept application/json
+// @Param connectionId path int true "connection ID"
+// @Param scopeId path string true "scope ID"
+// @Param scope body models.IncidentType true "json"
+// @Success 200 {object} models.IncidentType
+// @Failure 400 {object} shared.ApiBody "Bad Request"
+// @Failure 500 {object} shared.ApiBody "Internal Error"
+// @Router /plugins/incidentio/connections/{connectionId}/scopes/{scopeId} [PATCH]
+func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeApi.Patch(input)
+}
+
+// GetScopeList get incidentio incident types
+// @Summary get incidentio incident types
+// @Description get incidentio incident types
+// @Tags plugins/incidentio
+// @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/incidentio/connections/{connectionId}/scopes/ [GET]
+func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeApi.GetPage(input)
+}
+
+// GetScope get one incidentio incident type
+// @Summary get one incidentio incident type
+// @Description get one incidentio incident type
+// @Tags plugins/incidentio
+// @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/incidentio/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/incidentio
+// @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/incidentio/connections/{connectionId}/scopes/{scopeId} [DELETE]
+func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeApi.Delete(input)
+}
diff --git a/backend/plugins/incidentio/api/scope_state_api.go b/backend/plugins/incidentio/api/scope_state_api.go
new file mode 100644
index 00000000000..b8c6cb6c509
--- /dev/null
+++ b/backend/plugins/incidentio/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 incidentio incident type's latest sync state
+// @Summary get one incidentio incident type's latest sync state
+// @Description get one incidentio incident type's latest sync state
+// @Tags plugins/incidentio
+// @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/incidentio/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/incidentio/api/swagger.go b/backend/plugins/incidentio/api/swagger.go
new file mode 100644
index 00000000000..91691513c69
--- /dev/null
+++ b/backend/plugins/incidentio/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/incidentio/tasks"
+)
+
+type IncidentioTaskOptions tasks.IncidentioOptions
+
+// @Summary incidentio task options for pipelines
+// @Description This is a dummy API to demonstrate the available task options for incidentio pipelines
+// @Tags plugins/incidentio
+// @Accept application/json
+// @Param pipeline body IncidentioTaskOptions true "json"
+// @Router /pipelines/incidentio/pipeline-task [post]
+func _() {}
diff --git a/backend/plugins/incidentio/e2e/incident_test.go b/backend/plugins/incidentio/e2e/incident_test.go
new file mode 100644
index 00000000000..7f19a34aab9
--- /dev/null
+++ b/backend/plugins/incidentio/e2e/incident_test.go
@@ -0,0 +1,108 @@
+/*
+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 (
+ "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/incidentio/impl"
+ "github.com/apache/incubator-devlake/plugins/incidentio/models"
+ "github.com/apache/incubator-devlake/plugins/incidentio/tasks"
+ "github.com/stretchr/testify/require"
+)
+
+func TestIncidentDataFlow(t *testing.T) {
+ var plugin impl.Incidentio
+ dataflowTester := e2ehelper.NewDataFlowTester(t, "incidentio", plugin)
+ options := tasks.IncidentioOptions{
+ ConnectionId: 1,
+ IncidentTypeId: "type_01",
+ IncidentTypeName: "Default",
+ }
+ taskData := &tasks.IncidentioTaskData{
+ Options: &options,
+ }
+
+ // scope
+ dataflowTester.FlushTabler(&models.IncidentType{})
+ incidentType := models.IncidentType{
+ Scope: common.Scope{
+ ConnectionId: options.ConnectionId,
+ },
+ Id: options.IncidentTypeId,
+ Name: options.IncidentTypeName,
+ }
+ require.NoError(t, dataflowTester.Dal.CreateOrUpdate(&incidentType))
+
+ // import raw data table
+ dataflowTester.ImportCsvIntoRawTable(
+ "./raw_tables/_raw_incidentio_incidents.csv",
+ "_raw_incidentio_incidents",
+ )
+
+ // verify extraction
+ dataflowTester.FlushTabler(&models.Incident{})
+ dataflowTester.Subtask(tasks.ExtractIncidentsMeta, taskData)
+ dataflowTester.VerifyTableWithOptions(
+ models.IncidentType{},
+ e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/_tool_incidentio_incident_types.csv",
+ IgnoreTypes: []any{common.Scope{}},
+ },
+ )
+ dataflowTester.VerifyTableWithOptions(
+ models.Incident{},
+ e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/_tool_incidentio_incidents.csv",
+ IgnoreTypes: []any{common.NoPKModel{}},
+ },
+ )
+
+ // verify conversion
+ dataflowTester.FlushTabler(&ticket.Board{})
+ dataflowTester.Subtask(tasks.ConvertIncidentTypesMeta, taskData)
+ dataflowTester.VerifyTableWithOptions(
+ ticket.Board{},
+ e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/boards.csv",
+ IgnoreTypes: []any{common.NoPKModel{}},
+ },
+ )
+
+ dataflowTester.FlushTabler(&ticket.Issue{})
+ 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.BoardIssue{},
+ e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/board_issues.csv",
+ IgnoreTypes: []any{common.NoPKModel{}},
+ },
+ )
+}
diff --git a/backend/plugins/incidentio/e2e/raw_tables/_raw_incidentio_incidents.csv b/backend/plugins/incidentio/e2e/raw_tables/_raw_incidentio_incidents.csv
new file mode 100644
index 00000000000..79882c03aa5
--- /dev/null
+++ b/backend/plugins/incidentio/e2e/raw_tables/_raw_incidentio_incidents.csv
@@ -0,0 +1,6 @@
+id,params,data,url,input,created_at
+1,"{""ConnectionId"":1,""ScopeId"":""type_01""}","{""id"":""inc_01"",""reference"":""INC-101"",""name"":""Payment processor outage"",""summary"":""Payments are timing out"",""permalink"":""https://app.incident.io/example/incidents/101"",""mode"":""standard"",""created_at"":""2026-05-01T09:55:00Z"",""updated_at"":""2026-05-01T11:31:00Z"",""incident_status"":{""name"":""Closed"",""category"":""closed""},""severity"":{""id"":""sev_3"",""name"":""Critical"",""rank"":3},""incident_type"":{""id"":""type_01"",""name"":""Default""},""creator"":{""user"":{""id"":""u1"",""name"":""Alice"",""email"":""alice@example.com""}},""incident_timestamp_values"":[{""incident_timestamp"":{""id"":""ts_1"",""name"":""Reported at""},""value"":{""value"":""2026-05-01T09:55:00Z""}},{""incident_timestamp"":{""id"":""ts_2"",""name"":""Declared at""},""value"":{""value"":""2026-05-01T10:00:00Z""}},{""incident_timestamp"":{""id"":""ts_3"",""name"":""Resolved at""},""value"":{""value"":""2026-05-01T11:30:00Z""}},{""incident_timestamp"":{""id"":""ts_4"",""name"":""Closed at""},""value"":{""value"":""2026-05-01T11:31:00Z""}}]}",,null,2026-05-01T11:31:00.000+00:00
+2,"{""ConnectionId"":1,""ScopeId"":""type_01""}","{""id"":""inc_02"",""reference"":""INC-102"",""name"":""Latency spike"",""summary"":""p99 latency above SLO"",""permalink"":""https://app.incident.io/example/incidents/102"",""mode"":""retrospective"",""created_at"":""2026-05-02T09:00:00Z"",""updated_at"":""2026-05-02T09:10:00Z"",""incident_status"":{""name"":""Closed"",""category"":""closed""},""severity"":{""id"":""sev_2"",""name"":""Major"",""rank"":2},""incident_type"":{""id"":""type_01"",""name"":""Default""},""creator"":{""user"":{""id"":""u2"",""name"":""Bob"",""email"":""bob@example.com""}},""incident_timestamp_values"":[{""incident_timestamp"":{""id"":""ts_2"",""name"":""Declared at""},""value"":{""value"":""2026-05-02T09:05:00Z""}},{""incident_timestamp"":{""id"":""ts_3"",""name"":""Resolved at""},""value"":{""value"":""2026-05-01T18:00:00Z""}},{""incident_timestamp"":{""id"":""ts_4"",""name"":""Closed at""},""value"":{""value"":""2026-05-02T09:10:00Z""}}]}",,null,2026-05-02T09:10:00.000+00:00
+3,"{""ConnectionId"":1,""ScopeId"":""type_01""}","{""id"":""inc_03"",""reference"":""INC-103"",""name"":""Practice run"",""summary"":""Game day exercise"",""permalink"":""https://app.incident.io/example/incidents/103"",""mode"":""test"",""created_at"":""2026-05-03T12:00:00Z"",""updated_at"":""2026-05-03T12:30:00Z"",""incident_status"":{""name"":""Closed"",""category"":""closed""},""severity"":{""id"":""sev_1"",""name"":""Minor"",""rank"":1},""incident_type"":{""id"":""type_01"",""name"":""Default""},""incident_timestamp_values"":[{""incident_timestamp"":{""id"":""ts_2"",""name"":""Declared at""},""value"":{""value"":""2026-05-03T12:00:00Z""}}]}",,null,2026-05-03T12:30:00.000+00:00
+4,"{""ConnectionId"":1,""ScopeId"":""type_01""}","{""id"":""inc_04"",""reference"":""INC-104"",""name"":""Queue backlog"",""summary"":""Worker queue backed up"",""permalink"":""https://app.incident.io/example/incidents/104"",""mode"":""standard"",""created_at"":""2026-05-04T08:00:00Z"",""updated_at"":""2026-05-04T08:31:00Z"",""incident_status"":{""name"":""Closed"",""category"":""closed""},""severity"":{""id"":""sev_1"",""name"":""Minor"",""rank"":1},""incident_type"":{""id"":""type_01"",""name"":""Default""},""creator"":{""user"":{""id"":""u1"",""name"":""Alice"",""email"":""alice@example.com""}},""incident_timestamp_values"":[{""incident_timestamp"":{""id"":""ts_2"",""name"":""Declared at""},""value"":{""value"":""2026-05-04T08:00:00Z""}},{""incident_timestamp"":{""id"":""ts_3"",""name"":""Resolved at""},""value"":null},{""incident_timestamp"":{""id"":""ts_4"",""name"":""Closed at""},""value"":{""value"":""2026-05-04T08:30:00Z""}}]}",,null,2026-05-04T08:31:00.000+00:00
+5,"{""ConnectionId"":1,""ScopeId"":""type_01""}","{""id"":""inc_05"",""reference"":""INC-105"",""name"":""Security review"",""summary"":""Belongs to the Security incident type"",""permalink"":""https://app.incident.io/example/incidents/105"",""mode"":""standard"",""created_at"":""2026-05-05T14:00:00Z"",""updated_at"":""2026-05-05T14:05:00Z"",""incident_status"":{""name"":""Investigating"",""category"":""active""},""severity"":{""id"":""sev_2"",""name"":""Major"",""rank"":2},""incident_type"":{""id"":""type_99"",""name"":""Security""},""incident_timestamp_values"":[{""incident_timestamp"":{""id"":""ts_2"",""name"":""Declared at""},""value"":{""value"":""2026-05-05T14:00:00Z""}}]}",,null,2026-05-05T14:05:00.000+00:00
diff --git a/backend/plugins/incidentio/e2e/snapshot_tables/_tool_incidentio_incident_types.csv b/backend/plugins/incidentio/e2e/snapshot_tables/_tool_incidentio_incident_types.csv
new file mode 100644
index 00000000000..513ef7885c9
--- /dev/null
+++ b/backend/plugins/incidentio/e2e/snapshot_tables/_tool_incidentio_incident_types.csv
@@ -0,0 +1,2 @@
+connection_id,id,name
+1,type_01,Default
diff --git a/backend/plugins/incidentio/e2e/snapshot_tables/_tool_incidentio_incidents.csv b/backend/plugins/incidentio/e2e/snapshot_tables/_tool_incidentio_incidents.csv
new file mode 100644
index 00000000000..74185e7851d
--- /dev/null
+++ b/backend/plugins/incidentio/e2e/snapshot_tables/_tool_incidentio_incidents.csv
@@ -0,0 +1,4 @@
+connection_id,id,reference,name,summary,url,mode,status_name,status_category,severity_name,severity_rank,incident_type_id,created_date,updated_date,declared_date,resolved_date
+1,inc_01,INC-101,Payment processor outage,Payments are timing out,https://app.incident.io/example/incidents/101,standard,Closed,closed,Critical,3,type_01,2026-05-01T09:55:00.000+00:00,2026-05-01T11:31:00.000+00:00,2026-05-01T10:00:00.000+00:00,2026-05-01T11:30:00.000+00:00
+1,inc_02,INC-102,Latency spike,p99 latency above SLO,https://app.incident.io/example/incidents/102,retrospective,Closed,closed,Major,2,type_01,2026-05-02T09:00:00.000+00:00,2026-05-02T09:10:00.000+00:00,2026-05-02T09:05:00.000+00:00,2026-05-01T18:00:00.000+00:00
+1,inc_04,INC-104,Queue backlog,Worker queue backed up,https://app.incident.io/example/incidents/104,standard,Closed,closed,Minor,1,type_01,2026-05-04T08:00:00.000+00:00,2026-05-04T08:31:00.000+00:00,2026-05-04T08:00:00.000+00:00,2026-05-04T08:30:00.000+00:00
diff --git a/backend/plugins/incidentio/e2e/snapshot_tables/board_issues.csv b/backend/plugins/incidentio/e2e/snapshot_tables/board_issues.csv
new file mode 100644
index 00000000000..9d6a8d037bf
--- /dev/null
+++ b/backend/plugins/incidentio/e2e/snapshot_tables/board_issues.csv
@@ -0,0 +1,4 @@
+board_id,issue_id
+incidentio:IncidentType:1:type_01,incidentio:Incident:1:inc_01
+incidentio:IncidentType:1:type_01,incidentio:Incident:1:inc_02
+incidentio:IncidentType:1:type_01,incidentio:Incident:1:inc_04
diff --git a/backend/plugins/incidentio/e2e/snapshot_tables/boards.csv b/backend/plugins/incidentio/e2e/snapshot_tables/boards.csv
new file mode 100644
index 00000000000..4026a61c54d
--- /dev/null
+++ b/backend/plugins/incidentio/e2e/snapshot_tables/boards.csv
@@ -0,0 +1,2 @@
+id,name,description,url,created_date,type
+incidentio:IncidentType:1:type_01,Default,,,,
diff --git a/backend/plugins/incidentio/e2e/snapshot_tables/issues.csv b/backend/plugins/incidentio/e2e/snapshot_tables/issues.csv
new file mode 100644
index 00000000000..7833d9cc63f
--- /dev/null
+++ b/backend/plugins/incidentio/e2e/snapshot_tables/issues.csv
@@ -0,0 +1,4 @@
+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
+incidentio:Incident:1:inc_01,https://app.incident.io/example/incidents/101,,INC-101,Payment processor outage,Payments are timing out,,INCIDENT,,DONE,Closed,,2026-05-01T11:30:00.000+00:00,2026-05-01T10:00:00.000+00:00,2026-05-01T11:31:00.000+00:00,90,,,,,,,,,,Critical,,,0,,
+incidentio:Incident:1:inc_02,https://app.incident.io/example/incidents/102,,INC-102,Latency spike,p99 latency above SLO,,INCIDENT,,DONE,Closed,,2026-05-01T18:00:00.000+00:00,2026-05-02T09:05:00.000+00:00,2026-05-02T09:10:00.000+00:00,,,,,,,,,,,Major,,,0,,
+incidentio:Incident:1:inc_04,https://app.incident.io/example/incidents/104,,INC-104,Queue backlog,Worker queue backed up,,INCIDENT,,DONE,Closed,,2026-05-04T08:30:00.000+00:00,2026-05-04T08:00:00.000+00:00,2026-05-04T08:31:00.000+00:00,30,,,,,,,,,,Minor,,,0,,
diff --git a/backend/plugins/incidentio/impl/impl.go b/backend/plugins/incidentio/impl/impl.go
new file mode 100644
index 00000000000..3b36e6b3f6e
--- /dev/null
+++ b/backend/plugins/incidentio/impl/impl.go
@@ -0,0 +1,187 @@
+/*
+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/incidentio/api"
+ "github.com/apache/incubator-devlake/plugins/incidentio/models"
+ "github.com/apache/incubator-devlake/plugins/incidentio/models/migrationscripts"
+ "github.com/apache/incubator-devlake/plugins/incidentio/tasks"
+)
+
+// make sure interface is implemented
+
+var _ interface {
+ plugin.PluginMeta
+ plugin.PluginInit
+ plugin.PluginTask
+ plugin.PluginApi
+ plugin.PluginModel
+ plugin.DataSourcePluginBlueprintV200
+ plugin.CloseablePluginTask
+ plugin.PluginSource
+} = (*Incidentio)(nil)
+
+type Incidentio struct{}
+
+func (p Incidentio) Description() string {
+ return "collect incident.io incident data"
+}
+
+func (p Incidentio) Name() string {
+ return "incidentio"
+}
+
+func (p Incidentio) Init(basicRes context.BasicRes) errors.Error {
+ api.Init(basicRes, p)
+ return nil
+}
+
+func (p Incidentio) Connection() dal.Tabler {
+ return &models.IncidentioConnection{}
+}
+
+func (p Incidentio) Scope() plugin.ToolLayerScope {
+ return &models.IncidentType{}
+}
+
+func (p Incidentio) ScopeConfig() dal.Tabler {
+ return &models.IncidentioScopeConfig{}
+}
+
+func (p Incidentio) SubTaskMetas() []plugin.SubTaskMeta {
+ // Convert incident types before incidents so the domain Board row
+ // exists before the BoardIssue rows that reference it.
+ return []plugin.SubTaskMeta{
+ tasks.CollectIncidentTypesMeta,
+ tasks.ExtractIncidentTypesMeta,
+ tasks.CollectIncidentsMeta,
+ tasks.ExtractIncidentsMeta,
+ tasks.ConvertIncidentTypesMeta,
+ tasks.ConvertIncidentsMeta,
+ }
+}
+
+func (p Incidentio) GetTablesInfo() []dal.Tabler {
+ return []dal.Tabler{
+ &models.IncidentType{},
+ &models.Incident{},
+ &models.IncidentioConnection{},
+ &models.IncidentioScopeConfig{},
+ }
+}
+
+func (p Incidentio) 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.IncidentioConnection{}
+ err = connectionHelper.FirstById(connection, op.ConnectionId)
+ if err != nil {
+ return nil, errors.Default.Wrap(err, "unable to get incident.io 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.IncidentioTaskData{
+ Options: op,
+ Client: asyncClient,
+ }, nil
+}
+
+// RootPkgPath information lost when compiled as plugin(.so)
+func (p Incidentio) RootPkgPath() string {
+ return "github.com/apache/incubator-devlake/plugins/incidentio"
+}
+
+func (p Incidentio) MigrationScripts() []plugin.MigrationScript {
+ return migrationscripts.All()
+}
+
+func (p Incidentio) 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 Incidentio) MakeDataSourcePipelinePlanV200(
+ connectionId uint64,
+ scopes []*coreModels.BlueprintScope,
+) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) {
+ return api.MakeDataSourcePipelinePlanV200(p.SubTaskMetas(), connectionId, scopes)
+}
+
+func (p Incidentio) Close(taskCtx plugin.TaskContext) errors.Error {
+ _, ok := taskCtx.GetData().(*tasks.IncidentioTaskData)
+ if !ok {
+ return errors.Default.New(fmt.Sprintf("GetData failed when try to close %+v", taskCtx))
+ }
+ return nil
+}
diff --git a/backend/plugins/incidentio/incidentio.go b/backend/plugins/incidentio/incidentio.go
new file mode 100644
index 00000000000..e24d12d20f6
--- /dev/null
+++ b/backend/plugins/incidentio/incidentio.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/incidentio/impl"
+ "github.com/spf13/cobra"
+)
+
+// PluginEntry Export a variable named PluginEntry for Framework to search and load
+var PluginEntry impl.Incidentio //nolint
+
+// standalone mode for debugging
+func main() {
+ cmd := &cobra.Command{Use: "incidentio"}
+ 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/incidentio/models/connection.go b/backend/plugins/incidentio/models/connection.go
new file mode 100644
index 00000000000..333bb509917
--- /dev/null
+++ b/backend/plugins/incidentio/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 IncidentioAccessToken helper.AccessToken
+
+func (at *IncidentioAccessToken) SetupAuthentication(request *http.Request) errors.Error {
+ request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", at.Token))
+ return nil
+}
+
+type IncidentioConn struct {
+ helper.RestConnection `mapstructure:",squash"`
+ IncidentioAccessToken `mapstructure:",squash"`
+}
+
+func (connection IncidentioConn) Sanitize() IncidentioConn {
+ connection.Token = utils.SanitizeString(connection.Token)
+ return connection
+}
+
+type IncidentioConnection struct {
+ helper.BaseConnection `mapstructure:",squash"`
+ IncidentioConn `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 *IncidentioConnection) MergeFromRequest(target *IncidentioConnection, 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 (IncidentioConnection) TableName() string {
+ return "_tool_incidentio_connections"
+}
+
+func (connection IncidentioConnection) Sanitize() IncidentioConnection {
+ connection.Token = utils.SanitizeString(connection.Token)
+ return connection
+}
diff --git a/backend/plugins/incidentio/models/incident.go b/backend/plugins/incidentio/models/incident.go
new file mode 100644
index 00000000000..7a8d42d2908
--- /dev/null
+++ b/backend/plugins/incidentio/models/incident.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 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"`
+ Reference string
+ Name string
+ Summary string
+ Url string
+ Mode string
+ StatusName string
+ StatusCategory string
+ SeverityName string
+ SeverityRank int64
+ IncidentTypeId string `gorm:"index"`
+ CreatedDate time.Time
+ UpdatedDate time.Time
+ DeclaredDate time.Time
+ ResolvedDate *time.Time
+}
+
+func (Incident) TableName() string { return "_tool_incidentio_incidents" }
diff --git a/backend/plugins/incidentio/models/incident_type.go b/backend/plugins/incidentio/models/incident_type.go
new file mode 100644
index 00000000000..3aa3075b8f4
--- /dev/null
+++ b/backend/plugins/incidentio/models/incident_type.go
@@ -0,0 +1,59 @@
+/*
+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 IncidentioParams struct {
+ ConnectionId uint64
+ ScopeId string
+}
+
+type IncidentType struct {
+ common.Scope `mapstructure:",squash"`
+ Id string `json:"id" mapstructure:"id" gorm:"primaryKey;autoIncrement:false" `
+ Name string `json:"name" mapstructure:"name"`
+}
+
+func (t IncidentType) ScopeId() string {
+ return t.Id
+}
+
+func (t IncidentType) ScopeName() string {
+ return t.Name
+}
+
+func (t IncidentType) ScopeFullName() string {
+ return t.Name
+}
+
+func (t IncidentType) ScopeParams() interface{} {
+ return &IncidentioParams{
+ ConnectionId: t.ConnectionId,
+ ScopeId: t.Id,
+ }
+}
+
+func (t IncidentType) TableName() string {
+ return "_tool_incidentio_incident_types"
+}
+
+var _ plugin.ToolLayerScope = (*IncidentType)(nil)
diff --git a/backend/plugins/incidentio/models/migrationscripts/20260729_add_init_tables.go b/backend/plugins/incidentio/models/migrationscripts/20260729_add_init_tables.go
new file mode 100644
index 00000000000..fbdeb314d04
--- /dev/null
+++ b/backend/plugins/incidentio/models/migrationscripts/20260729_add_init_tables.go
@@ -0,0 +1,44 @@
+/*
+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/incidentio/models/migrationscripts/archived"
+)
+
+type addInitTables struct{}
+
+func (*addInitTables) Up(baseRes context.BasicRes) errors.Error {
+ return migrationhelper.AutoMigrateTables(baseRes,
+ &archived.Connection{},
+ &archived.IncidentType{},
+ &archived.Incident{},
+ &archived.ScopeConfig{},
+ )
+}
+
+func (*addInitTables) Version() uint64 {
+ return 20260729000001
+}
+
+func (*addInitTables) Name() string {
+ return "incident.io init schemas"
+}
diff --git a/backend/plugins/incidentio/models/migrationscripts/archived/connection.go b/backend/plugins/incidentio/models/migrationscripts/archived/connection.go
new file mode 100644
index 00000000000..7f176fc46b0
--- /dev/null
+++ b/backend/plugins/incidentio/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:"INCIDENTIO_AUTH" validate:"required" encrypt:"yes"`
+}
+
+func (Connection) TableName() string {
+ return "_tool_incidentio_connections"
+}
diff --git a/backend/plugins/incidentio/models/migrationscripts/archived/incident.go b/backend/plugins/incidentio/models/migrationscripts/archived/incident.go
new file mode 100644
index 00000000000..f665fec613a
--- /dev/null
+++ b/backend/plugins/incidentio/models/migrationscripts/archived/incident.go
@@ -0,0 +1,48 @@
+/*
+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"`
+ Reference string
+ Name string
+ Summary string
+ Url string
+ Mode string
+ StatusName string
+ StatusCategory string
+ SeverityName string
+ SeverityRank int64
+ IncidentTypeId string `gorm:"index"`
+ CreatedDate time.Time
+ UpdatedDate time.Time
+ DeclaredDate time.Time
+ ResolvedDate *time.Time
+}
+
+func (Incident) TableName() string {
+ return "_tool_incidentio_incidents"
+}
diff --git a/backend/plugins/incidentio/models/migrationscripts/archived/incident_type.go b/backend/plugins/incidentio/models/migrationscripts/archived/incident_type.go
new file mode 100644
index 00000000000..8dca3393fd4
--- /dev/null
+++ b/backend/plugins/incidentio/models/migrationscripts/archived/incident_type.go
@@ -0,0 +1,36 @@
+/*
+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.IncidentType` gets
+// via embedded `common.Scope`; the archived `NoPKModel` doesn't include it.
+type IncidentType struct {
+ archived.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeConfigId uint64 `json:"scopeConfigId,omitempty" mapstructure:"scopeConfigId,omitempty"`
+ Id string `gorm:"primaryKey;autoIncrement:false"`
+ Name string
+}
+
+func (IncidentType) TableName() string {
+ return "_tool_incidentio_incident_types"
+}
diff --git a/backend/plugins/incidentio/models/migrationscripts/archived/scope_config.go b/backend/plugins/incidentio/models/migrationscripts/archived/scope_config.go
new file mode 100644
index 00000000000..8b6174ef23b
--- /dev/null
+++ b/backend/plugins/incidentio/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_incidentio_scope_configs"
+}
diff --git a/backend/plugins/incidentio/models/migrationscripts/register.go b/backend/plugins/incidentio/models/migrationscripts/register.go
new file mode 100644
index 00000000000..16c72e29273
--- /dev/null
+++ b/backend/plugins/incidentio/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 incidentio plugin
+func All() []plugin.MigrationScript {
+ return []plugin.MigrationScript{
+ new(addInitTables),
+ }
+}
diff --git a/backend/plugins/incidentio/models/raw/incident.go b/backend/plugins/incidentio/models/raw/incident.go
new file mode 100644
index 00000000000..4bdde7ce38e
--- /dev/null
+++ b/backend/plugins/incidentio/models/raw/incident.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 raw
+
+import (
+ "time"
+)
+
+type Incident struct {
+ Id string `json:"id"`
+ Reference string `json:"reference"`
+ Name string `json:"name"`
+ Summary *string `json:"summary"`
+ Permalink *string `json:"permalink"`
+ Mode string `json:"mode"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ IncidentStatus *IncidentStatus `json:"incident_status"`
+ Severity *Severity `json:"severity"`
+ IncidentType *IncidentTypeRef `json:"incident_type"`
+ Creator *Creator `json:"creator"`
+ IncidentTimestampValues []IncidentTimestampValue `json:"incident_timestamp_values"`
+}
+
+type IncidentStatus struct {
+ Name string `json:"name"`
+ Category string `json:"category"`
+}
+
+type Severity struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Rank int64 `json:"rank"`
+}
+
+type IncidentTypeRef struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+}
+
+type Creator struct {
+ User *CreatorUser `json:"user"`
+}
+
+type CreatorUser struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+}
+
+type IncidentTimestampValue struct {
+ IncidentTimestamp IncidentTimestamp `json:"incident_timestamp"`
+ Value *TimestampValue `json:"value"`
+}
+
+type IncidentTimestamp struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+}
+
+type TimestampValue struct {
+ Value time.Time `json:"value"`
+}
diff --git a/backend/plugins/incidentio/models/raw/incident_type.go b/backend/plugins/incidentio/models/raw/incident_type.go
new file mode 100644
index 00000000000..baef2ca3404
--- /dev/null
+++ b/backend/plugins/incidentio/models/raw/incident_type.go
@@ -0,0 +1,28 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package raw
+
+import "time"
+
+type IncidentType struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Description *string `json:"description"`
+ CreatedAt *time.Time `json:"created_at"`
+ UpdatedAt *time.Time `json:"updated_at"`
+}
diff --git a/backend/plugins/incidentio/models/scope_config.go b/backend/plugins/incidentio/models/scope_config.go
new file mode 100644
index 00000000000..f3220e1fa4f
--- /dev/null
+++ b/backend/plugins/incidentio/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 IncidentioScopeConfig struct {
+ common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"`
+}
+
+func (IncidentioScopeConfig) TableName() string {
+ return "_tool_incidentio_scope_configs"
+}
diff --git a/backend/plugins/incidentio/tasks/incident_type_converter.go b/backend/plugins/incidentio/tasks/incident_type_converter.go
new file mode 100644
index 00000000000..6744b461068
--- /dev/null
+++ b/backend/plugins/incidentio/tasks/incident_type_converter.go
@@ -0,0 +1,81 @@
+/*
+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/incidentio/models"
+)
+
+var ConvertIncidentTypesMeta = plugin.SubTaskMeta{
+ Name: "convertIncidentTypes",
+ EntryPoint: ConvertIncidentTypes,
+ EnabledByDefault: true,
+ Description: "Convert incident.io incident types",
+ DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET},
+}
+
+func ConvertIncidentTypes(taskCtx plugin.SubTaskContext) errors.Error {
+ db := taskCtx.GetDal()
+ data := taskCtx.GetData().(*IncidentioTaskData)
+ rawDataSubTaskArgs := &helper.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Options: data.Options,
+ Table: RAW_INCIDENT_TYPES_TABLE,
+ }
+ clauses := []dal.Clause{
+ dal.Select("incident_types.*"),
+ dal.From("_tool_incidentio_incident_types incident_types"),
+ dal.Where("id = ? and connection_id = ?", data.Options.IncidentTypeId, 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.IncidentType{}),
+ Input: cursor,
+ Convert: func(inputRow interface{}) ([]interface{}, errors.Error) {
+ incidentType := inputRow.(*models.IncidentType)
+ domainBoard := &ticket.Board{
+ DomainEntity: domainlayer.DomainEntity{
+ Id: didgen.NewDomainIdGenerator(incidentType).Generate(incidentType.ConnectionId, incidentType.Id),
+ },
+ Name: incidentType.Name,
+ }
+ return []interface{}{
+ domainBoard,
+ }, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return converter.Execute()
+}
diff --git a/backend/plugins/incidentio/tasks/incident_types_collector.go b/backend/plugins/incidentio/tasks/incident_types_collector.go
new file mode 100644
index 00000000000..d440ed00923
--- /dev/null
+++ b/backend/plugins/incidentio/tasks/incident_types_collector.go
@@ -0,0 +1,72 @@
+/*
+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_INCIDENT_TYPES_TABLE = "incidentio_incident_types"
+
+// incidentTypesResponse is the envelope returned by
+// GET /v1/incident_types. The endpoint is not paginated.
+type incidentTypesResponse struct {
+ IncidentTypes []json.RawMessage `json:"incident_types"`
+}
+
+var _ plugin.SubTaskEntryPoint = CollectIncidentTypes
+
+var CollectIncidentTypesMeta = plugin.SubTaskMeta{
+ Name: "collectIncidentTypes",
+ EntryPoint: CollectIncidentTypes,
+ EnabledByDefault: true,
+ Description: "Collect incident.io incident types",
+ DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET},
+ ProductTables: []string{RAW_INCIDENT_TYPES_TABLE},
+}
+
+func CollectIncidentTypes(taskCtx plugin.SubTaskContext) errors.Error {
+ data := taskCtx.GetData().(*IncidentioTaskData)
+ collector, err := api.NewApiCollector(api.ApiCollectorArgs{
+ RawDataSubTaskArgs: api.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Options: data.Options,
+ Table: RAW_INCIDENT_TYPES_TABLE,
+ },
+ ApiClient: data.Client,
+ UrlTemplate: "v1/incident_types",
+ Query: nil,
+ ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) {
+ rawResult := incidentTypesResponse{}
+ err := api.UnmarshalResponse(res, &rawResult)
+ if err != nil {
+ return nil, err
+ }
+ return rawResult.IncidentTypes, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return collector.Execute()
+}
diff --git a/backend/plugins/incidentio/tasks/incident_types_extractor.go b/backend/plugins/incidentio/tasks/incident_types_extractor.go
new file mode 100644
index 00000000000..a5fb1bcb1fe
--- /dev/null
+++ b/backend/plugins/incidentio/tasks/incident_types_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/incidentio/models"
+ "github.com/apache/incubator-devlake/plugins/incidentio/models/raw"
+)
+
+var _ plugin.SubTaskEntryPoint = ExtractIncidentTypes
+
+var ExtractIncidentTypesMeta = plugin.SubTaskMeta{
+ Name: "extractIncidentTypes",
+ EntryPoint: ExtractIncidentTypes,
+ EnabledByDefault: true,
+ Description: "Extract incident.io incident types",
+ DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET},
+ ProductTables: []string{models.IncidentType{}.TableName()},
+}
+
+func ExtractIncidentTypes(taskCtx plugin.SubTaskContext) errors.Error {
+ data := taskCtx.GetData().(*IncidentioTaskData)
+ db := taskCtx.GetDal()
+ extractor, err := api.NewApiExtractor(api.ApiExtractorArgs{
+ RawDataSubTaskArgs: api.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Options: data.Options,
+ Table: RAW_INCIDENT_TYPES_TABLE,
+ },
+ Extract: func(row *api.RawData) ([]interface{}, errors.Error) {
+ rawIncidentType := &raw.IncidentType{}
+ if err := errors.Convert(json.Unmarshal(row.Data, rawIncidentType)); err != nil {
+ return nil, err
+ }
+ // The collector fetches the full incident type list; keep only
+ // the type this scope is bound to.
+ if data.Options.IncidentTypeId != "" && rawIncidentType.Id != data.Options.IncidentTypeId {
+ return nil, nil
+ }
+ incidentType := &models.IncidentType{
+ Id: rawIncidentType.Id,
+ Name: rawIncidentType.Name,
+ }
+ incidentType.ConnectionId = data.Options.ConnectionId
+ // Preserve operator-set ScopeConfigId across re-collections.
+ existing := &models.IncidentType{}
+ if err := db.First(existing, dal.Where("connection_id = ? AND id = ?", data.Options.ConnectionId, rawIncidentType.Id)); err == nil {
+ incidentType.ScopeConfigId = existing.ScopeConfigId
+ }
+ return []interface{}{incidentType}, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return extractor.Execute()
+}
diff --git a/backend/plugins/incidentio/tasks/incidents_collector.go b/backend/plugins/incidentio/tasks/incidents_collector.go
new file mode 100644
index 00000000000..de4f730e190
--- /dev/null
+++ b/backend/plugins/incidentio/tasks/incidents_collector.go
@@ -0,0 +1,129 @@
+/*
+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 = "incidentio_incidents"
+
+var _ plugin.SubTaskEntryPoint = CollectIncidents
+
+type collectedIncidents struct {
+ Incidents []json.RawMessage `json:"incidents"`
+ PaginationMeta *collectedPaginationMeta `json:"pagination_meta"`
+}
+
+type collectedPaginationMeta struct {
+ After *string `json:"after"`
+ PageSize int `json:"page_size"`
+}
+
+var CollectIncidentsMeta = plugin.SubTaskMeta{
+ Name: "collectIncidents",
+ EntryPoint: CollectIncidents,
+ EnabledByDefault: true,
+ Description: "Collect incident.io incidents",
+ DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET},
+ ProductTables: []string{RAW_INCIDENTS_TABLE},
+}
+
+func CollectIncidents(taskCtx plugin.SubTaskContext) errors.Error {
+ data := taskCtx.GetData().(*IncidentioTaskData)
+ 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 lastAfter *string
+
+ collector, err := api.NewStatefulApiCollectorForFinalizableEntity(api.FinalizableApiCollectorArgs{
+ RawDataSubTaskArgs: args,
+ ApiClient: data.Client,
+ CollectNewRecordsByList: api.FinalizableApiCollectorListArgs{
+ PageSize: 250,
+ GetNextPageCustomData: func(prevReqData *api.RequestData, prevPageResponse *http.Response) (interface{}, errors.Error) {
+ // Safety cap against an upstream that returns full pages forever
+ // while echoing a non-empty `after` cursor on every page.
+ const maxPages = 10000
+ if prevReqData.Pager.Page >= maxPages {
+ return nil, api.ErrFinishCollect
+ }
+ if lastAfter == nil || *lastAfter == "" {
+ return nil, api.ErrFinishCollect
+ }
+ return *lastAfter, nil
+ },
+ FinalizableApiCollectorCommonArgs: api.FinalizableApiCollectorCommonArgs{
+ UrlTemplate: "v2/incidents",
+ // incident.io does not support server-side filtering by incident
+ // type or update time on this endpoint, so every scope collects
+ // all incidents and the extractor filters; createdAfter is
+ // intentionally ignored.
+ Query: func(reqData *api.RequestData, createdAfter *time.Time) (url.Values, errors.Error) {
+ after := ""
+ if cursor, ok := reqData.CustomData.(string); ok {
+ after = cursor
+ }
+ return buildIncidentsQuery(reqData.Pager.Size, after), nil
+ },
+ ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) {
+ rawResult := collectedIncidents{}
+ if err := api.UnmarshalResponse(res, &rawResult); err != nil {
+ return nil, err
+ }
+ if rawResult.PaginationMeta != nil {
+ lastAfter = rawResult.PaginationMeta.After
+ } else {
+ lastAfter = nil
+ }
+ return rawResult.Incidents, nil
+ },
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return collector.Execute()
+}
+
+// buildIncidentsQuery is the pure-function core of the Query closure
+// above. incident.io paginates with an opaque `after` cursor taken from
+// the previous response's pagination_meta; the first page sends no cursor.
+func buildIncidentsQuery(pageSize int, after string) url.Values {
+ query := url.Values{}
+ query.Set("page_size", fmt.Sprintf("%d", pageSize))
+ if after != "" {
+ query.Set("after", after)
+ }
+ return query
+}
diff --git a/backend/plugins/incidentio/tasks/incidents_collector_test.go b/backend/plugins/incidentio/tasks/incidents_collector_test.go
new file mode 100644
index 00000000000..f9dea868412
--- /dev/null
+++ b/backend/plugins/incidentio/tasks/incidents_collector_test.go
@@ -0,0 +1,64 @@
+/*
+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"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildIncidentsQuery_FirstPage(t *testing.T) {
+ q := buildIncidentsQuery(250, "")
+ assert.Equal(t, "250", q.Get("page_size"))
+ assert.False(t, q.Has("after"), "first page must not send an after cursor")
+}
+
+func TestBuildIncidentsQuery_SubsequentPage(t *testing.T) {
+ q := buildIncidentsQuery(250, "01FCNDV6P870EA6S7TK1DSYDG0")
+ assert.Equal(t, "250", q.Get("page_size"))
+ assert.Equal(t, "01FCNDV6P870EA6S7TK1DSYDG0", q.Get("after"))
+}
+
+func TestCollectedIncidentsEnvelope(t *testing.T) {
+ body := []byte(`{
+ "incidents": [{"id": "inc_1"}, {"id": "inc_2"}],
+ "pagination_meta": {"after": "cursor-abc", "page_size": 2}
+ }`)
+ rawResult := collectedIncidents{}
+ require.NoError(t, json.Unmarshal(body, &rawResult))
+ require.Len(t, rawResult.Incidents, 2)
+ require.NotNil(t, rawResult.PaginationMeta)
+ require.NotNil(t, rawResult.PaginationMeta.After)
+ assert.Equal(t, "cursor-abc", *rawResult.PaginationMeta.After)
+}
+
+func TestCollectedIncidentsEnvelope_LastPage(t *testing.T) {
+ // The final page omits `after` entirely.
+ body := []byte(`{
+ "incidents": [{"id": "inc_3"}],
+ "pagination_meta": {"page_size": 250}
+ }`)
+ rawResult := collectedIncidents{}
+ require.NoError(t, json.Unmarshal(body, &rawResult))
+ require.Len(t, rawResult.Incidents, 1)
+ require.NotNil(t, rawResult.PaginationMeta)
+ assert.Nil(t, rawResult.PaginationMeta.After)
+}
diff --git a/backend/plugins/incidentio/tasks/incidents_converter.go b/backend/plugins/incidentio/tasks/incidents_converter.go
new file mode 100644
index 00000000000..90247c44d0c
--- /dev/null
+++ b/backend/plugins/incidentio/tasks/incidents_converter.go
@@ -0,0 +1,153 @@
+/*
+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"
+ "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/incidentio/models"
+)
+
+var _ plugin.SubTaskEntryPoint = ConvertIncidents
+
+var ConvertIncidentsMeta = plugin.SubTaskMeta{
+ Name: "convertIncidents",
+ EntryPoint: ConvertIncidents,
+ EnabledByDefault: true,
+ Description: "Convert incident.io 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().(*IncidentioTaskData)
+ logger := taskCtx.GetLogger()
+
+ cursor, err := db.Cursor(
+ dal.From(&models.Incident{}),
+ dal.Where("connection_id = ? AND incident_type_id = ?", data.Options.ConnectionId, data.Options.IncidentTypeId),
+ )
+ if err != nil {
+ return err
+ }
+ defer cursor.Close()
+
+ idGen := didgen.NewDomainIdGenerator(&models.Incident{})
+ incidentTypeIdGen := didgen.NewDomainIdGenerator(&models.IncidentType{})
+ boardId := incidentTypeIdGen.Generate(data.Options.ConnectionId, data.Options.IncidentTypeId)
+
+ 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 := mapStatusCategory(incident.StatusCategory)
+ if !known {
+ logger.Warn(nil, "unknown incident.io status category: %s", incident.StatusCategory)
+ }
+
+ leadTime, resolutionDate := computeLeadTime(incident.DeclaredDate, incident.ResolvedDate)
+
+ domainIssueId := idGen.Generate(data.Options.ConnectionId, incident.Id)
+
+ domainIssue := &ticket.Issue{
+ DomainEntity: domainlayer.DomainEntity{
+ Id: domainIssueId,
+ },
+ Url: incident.Url,
+ IssueKey: issueKeyFor(incident),
+ Title: incident.Name,
+ Description: incident.Summary,
+ Type: ticket.INCIDENT,
+ Status: status,
+ OriginalStatus: incident.StatusName,
+ ResolutionDate: resolutionDate,
+ CreatedDate: &incident.DeclaredDate,
+ UpdatedDate: &incident.UpdatedDate,
+ LeadTimeMinutes: leadTime,
+ Severity: incident.SeverityName,
+ }
+
+ return []interface{}{
+ domainIssue,
+ &ticket.BoardIssue{
+ BoardId: boardId,
+ IssueId: domainIssueId,
+ },
+ }, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return converter.Execute()
+}
+
+// Unknown categories fall through to IN_PROGRESS rather than
+// panicking; incident.io statuses are operator-defined, but their
+// categories form a small fixed enum, so anything new from upstream
+// shouldn't crash a production pipeline.
+func mapStatusCategory(category string) (mapped string, known bool) {
+ switch category {
+ case "triage", "declared", "active", "paused", "post-incident":
+ return ticket.IN_PROGRESS, true
+ case "closed", "resolved":
+ return ticket.DONE, true
+ default:
+ return ticket.IN_PROGRESS, false
+ }
+}
+
+func computeLeadTime(declared time.Time, resolved *time.Time) (*uint, *time.Time) {
+ if resolved == nil {
+ return nil, nil
+ }
+ // Retrospective incidents are declared after the fact, so resolved
+ // legitimately precedes declared. The resolution date is still real;
+ // only the declared→resolved duration is meaningless (and a naive
+ // uint() cast on a negative duration would wrap to huge garbage and
+ // silently corrupt MTTR), so keep the date and drop the lead time.
+ if resolved.Before(declared) {
+ resolutionDate := *resolved
+ return nil, &resolutionDate
+ }
+ minutes := uint(resolved.Sub(declared).Minutes())
+ resolutionDate := *resolved
+ return &minutes, &resolutionDate
+}
+
+func issueKeyFor(incident *models.Incident) string {
+ if incident.Reference != "" {
+ return incident.Reference
+ }
+ return incident.Id
+}
diff --git a/backend/plugins/incidentio/tasks/incidents_converter_test.go b/backend/plugins/incidentio/tasks/incidents_converter_test.go
new file mode 100644
index 00000000000..dc9924a93a9
--- /dev/null
+++ b/backend/plugins/incidentio/tasks/incidents_converter_test.go
@@ -0,0 +1,114 @@
+/*
+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/incidentio/models"
+)
+
+func TestMapStatusCategory(t *testing.T) {
+ cases := []struct {
+ in string
+ expectMapped string
+ expectedKnown bool
+ }{
+ {"triage", ticket.IN_PROGRESS, true},
+ {"declared", ticket.IN_PROGRESS, true},
+ {"active", ticket.IN_PROGRESS, true},
+ {"paused", ticket.IN_PROGRESS, true},
+ {"post-incident", ticket.IN_PROGRESS, true},
+ {"closed", ticket.DONE, true},
+ {"resolved", 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 := mapStatusCategory(c.in)
+ assert.Equal(t, c.expectMapped, mapped)
+ assert.Equal(t, c.expectedKnown, known)
+ })
+ }
+}
+
+func TestMapStatusCategoryDoesNotPanic(t *testing.T) {
+ assert.NotPanics(t, func() {
+ _, _ = mapStatusCategory("brand-new-category-incidentio-invented-yesterday")
+ })
+}
+
+func TestComputeLeadTime_Resolved(t *testing.T) {
+ declared := 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(declared, &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) {
+ declared := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC)
+ leadTime, resolutionDate := computeLeadTime(declared, nil)
+ assert.Nil(t, leadTime)
+ assert.Nil(t, resolutionDate)
+}
+
+func TestComputeLeadTime_ZeroDuration(t *testing.T) {
+ declared := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC)
+ resolved := declared
+ leadTime, resolutionDate := computeLeadTime(declared, &resolved)
+ require.NotNil(t, leadTime)
+ require.NotNil(t, resolutionDate)
+ assert.Equal(t, uint(0), *leadTime)
+}
+
+// Retrospective incidents are declared after resolution: the resolution
+// date must survive even though the declared→resolved duration is
+// meaningless and the lead time is dropped.
+func TestComputeLeadTime_ResolvedBeforeDeclared(t *testing.T) {
+ declared := 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(declared, &resolved)
+ assert.Nil(t, leadTime)
+ require.NotNil(t, resolutionDate)
+ assert.Equal(t, resolved, *resolutionDate)
+}
+
+func TestIssueKeyFor(t *testing.T) {
+ cases := []struct {
+ name string
+ incident models.Incident
+ expected string
+ }{
+ {"reference present", models.Incident{Reference: "INC-61", Id: "inc_abc"}, "INC-61"},
+ {"missing reference falls back to id", models.Incident{Reference: "", 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))
+ })
+ }
+}
diff --git a/backend/plugins/incidentio/tasks/incidents_extractor.go b/backend/plugins/incidentio/tasks/incidents_extractor.go
new file mode 100644
index 00000000000..6bfd9bdbcff
--- /dev/null
+++ b/backend/plugins/incidentio/tasks/incidents_extractor.go
@@ -0,0 +1,141 @@
+/*
+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"
+ "time"
+
+ "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/incidentio/models"
+ "github.com/apache/incubator-devlake/plugins/incidentio/models/raw"
+)
+
+var _ plugin.SubTaskEntryPoint = ExtractIncidents
+
+var ExtractIncidentsMeta = plugin.SubTaskMeta{
+ Name: "extractIncidents",
+ EntryPoint: ExtractIncidents,
+ EnabledByDefault: true,
+ Description: "Extract incident.io incidents",
+ DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET},
+ ProductTables: []string{models.Incident{}.TableName()},
+}
+
+func ExtractIncidents(taskCtx plugin.SubTaskContext) errors.Error {
+ data := taskCtx.GetData().(*IncidentioTaskData)
+ 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 extractIncidentioIncident(row.Data, data.Options)
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return extractor.Execute()
+}
+
+func extractIncidentioIncident(rawData []byte, op *IncidentioOptions) ([]interface{}, errors.Error) {
+ rawIncident := &raw.Incident{}
+ if err := errors.Convert(json.Unmarshal(rawData, rawIncident)); err != nil {
+ return nil, err
+ }
+
+ // "test" and "tutorial" incidents are practice data; keep only
+ // "standard" and "retrospective" (and future real modes).
+ if rawIncident.Mode == "test" || rawIncident.Mode == "tutorial" {
+ return nil, nil
+ }
+
+ // The collector fetches all incidents (no server-side filtering), so
+ // the scope filter lives here. When IncidentTypeId is empty we are
+ // collecting all incidents globally, so skip this check.
+ if op.IncidentTypeId != "" {
+ if rawIncident.IncidentType == nil || rawIncident.IncidentType.Id != op.IncidentTypeId {
+ return nil, nil
+ }
+ }
+
+ if rawIncident.CreatedAt.IsZero() {
+ return nil, errors.Default.New("incident.io incident missing created_at")
+ }
+
+ declaredDate := rawIncident.CreatedAt
+ if declared := timestampValueByName(rawIncident.IncidentTimestampValues, "Declared at"); declared != nil {
+ declaredDate = *declared
+ }
+ resolvedDate := timestampValueByName(rawIncident.IncidentTimestampValues, "Resolved at")
+ if resolvedDate == nil {
+ resolvedDate = timestampValueByName(rawIncident.IncidentTimestampValues, "Closed at")
+ }
+
+ incident := &models.Incident{
+ ConnectionId: op.ConnectionId,
+ Id: rawIncident.Id,
+ Reference: rawIncident.Reference,
+ Name: rawIncident.Name,
+ Summary: resolve(rawIncident.Summary),
+ Url: resolve(rawIncident.Permalink),
+ Mode: rawIncident.Mode,
+ CreatedDate: rawIncident.CreatedAt,
+ UpdatedDate: rawIncident.UpdatedAt,
+ DeclaredDate: declaredDate,
+ ResolvedDate: resolvedDate,
+ }
+ if rawIncident.IncidentStatus != nil {
+ incident.StatusName = rawIncident.IncidentStatus.Name
+ incident.StatusCategory = rawIncident.IncidentStatus.Category
+ }
+ if rawIncident.Severity != nil {
+ incident.SeverityName = rawIncident.Severity.Name
+ incident.SeverityRank = rawIncident.Severity.Rank
+ }
+ if rawIncident.IncidentType != nil {
+ incident.IncidentTypeId = rawIncident.IncidentType.Id
+ }
+
+ return []interface{}{incident}, nil
+}
+
+func timestampValueByName(values []raw.IncidentTimestampValue, name string) *time.Time {
+ for _, v := range values {
+ if v.IncidentTimestamp.Name != name {
+ continue
+ }
+ if v.Value == nil || v.Value.Value.IsZero() {
+ return nil
+ }
+ value := v.Value.Value
+ return &value
+ }
+ return nil
+}
+
+func resolve[T any](t *T) T {
+ if t == nil {
+ return *new(T)
+ }
+ return *t
+}
diff --git a/backend/plugins/incidentio/tasks/incidents_extractor_test.go b/backend/plugins/incidentio/tasks/incidents_extractor_test.go
new file mode 100644
index 00000000000..0a0f08e8cca
--- /dev/null
+++ b/backend/plugins/incidentio/tasks/incidents_extractor_test.go
@@ -0,0 +1,289 @@
+/*
+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/incidentio/models"
+)
+
+const baseHappyPathActive = `{
+ "id": "01FCNDV6P870EA6S7TK1DSYDG0",
+ "reference": "INC-61",
+ "name": "db outage",
+ "summary": "replica lag blew past threshold",
+ "permalink": "https://app.incident.io/example/incidents/61",
+ "mode": "standard",
+ "created_at": "2026-05-10T09:55:00Z",
+ "updated_at": "2026-05-10T10:05:00Z",
+ "incident_status": {"name": "Investigating", "category": "active"},
+ "severity": {"id": "sev-uuid-1", "name": "Major", "rank": 2},
+ "incident_type": {"id": "type_01", "name": "Default"},
+ "creator": {"user": {"id": "usr_100", "name": "Reporter One", "email": "reporter@example.com"}},
+ "incident_timestamp_values": [
+ {"incident_timestamp": {"id": "ts_01", "name": "Reported at"}, "value": {"value": "2026-05-10T09:55:00Z"}},
+ {"incident_timestamp": {"id": "ts_02", "name": "Declared at"}, "value": {"value": "2026-05-10T10:00:00Z"}}
+ ]
+}`
+
+func newTestOptions() *IncidentioOptions {
+ return &IncidentioOptions{
+ ConnectionId: 7,
+ IncidentTypeId: "type_01",
+ }
+}
+
+func TestExtractIncidentioIncident_HappyPathActive(t *testing.T) {
+ op := newTestOptions()
+ results, err := extractIncidentioIncident([]byte(baseHappyPathActive), op)
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+
+ 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, "01FCNDV6P870EA6S7TK1DSYDG0", incident.Id)
+ assert.Equal(t, "INC-61", incident.Reference)
+ assert.Equal(t, "db outage", incident.Name)
+ assert.Equal(t, "replica lag blew past threshold", incident.Summary)
+ assert.Equal(t, "https://app.incident.io/example/incidents/61", incident.Url)
+ assert.Equal(t, "standard", incident.Mode)
+ assert.Equal(t, "Investigating", incident.StatusName)
+ assert.Equal(t, "active", incident.StatusCategory)
+ assert.Equal(t, "Major", incident.SeverityName)
+ assert.Equal(t, int64(2), incident.SeverityRank)
+ assert.Equal(t, "type_01", incident.IncidentTypeId)
+ assert.Equal(t, time.Date(2026, 5, 10, 9, 55, 0, 0, time.UTC), incident.CreatedDate)
+ assert.Equal(t, time.Date(2026, 5, 10, 10, 5, 0, 0, time.UTC), incident.UpdatedDate)
+ assert.Equal(t, time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC), incident.DeclaredDate)
+ assert.Nil(t, incident.ResolvedDate)
+}
+
+func TestExtractIncidentioIncident_Resolved(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_02",
+ "reference": "INC-62",
+ "name": "cache cleared",
+ "mode": "standard",
+ "created_at": "2026-05-09T07:55:00Z",
+ "updated_at": "2026-05-09T09:01:00Z",
+ "incident_status": {"name": "Closed", "category": "closed"},
+ "severity": {"id": "sev-uuid-3", "name": "Minor", "rank": 1},
+ "incident_type": {"id": "type_01", "name": "Default"},
+ "incident_timestamp_values": [
+ {"incident_timestamp": {"id": "ts_02", "name": "Declared at"}, "value": {"value": "2026-05-09T08:00:00Z"}},
+ {"incident_timestamp": {"id": "ts_03", "name": "Fixed at"}, "value": {"value": "2026-05-09T08:30:00Z"}},
+ {"incident_timestamp": {"id": "ts_04", "name": "Resolved at"}, "value": {"value": "2026-05-09T09:00:00Z"}},
+ {"incident_timestamp": {"id": "ts_05", "name": "Closed at"}, "value": {"value": "2026-05-09T09:01:00Z"}}
+ ]
+ }`)
+ op := newTestOptions()
+ results, err := extractIncidentioIncident(raw, op)
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+
+ incident := results[0].(*models.Incident)
+ assert.Equal(t, "Closed", incident.StatusName)
+ assert.Equal(t, "closed", incident.StatusCategory)
+ assert.Equal(t, time.Date(2026, 5, 9, 8, 0, 0, 0, time.UTC), incident.DeclaredDate)
+ require.NotNil(t, incident.ResolvedDate)
+ assert.Equal(t, time.Date(2026, 5, 9, 9, 0, 0, 0, time.UTC), *incident.ResolvedDate)
+}
+
+func TestExtractIncidentioIncident_ClosedAtFallback(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_03",
+ "reference": "INC-63",
+ "name": "closed without resolved timestamp",
+ "mode": "standard",
+ "created_at": "2026-05-09T07:55:00Z",
+ "updated_at": "2026-05-09T09:01:00Z",
+ "incident_status": {"name": "Closed", "category": "closed"},
+ "incident_type": {"id": "type_01", "name": "Default"},
+ "incident_timestamp_values": [
+ {"incident_timestamp": {"id": "ts_04", "name": "Resolved at"}, "value": null},
+ {"incident_timestamp": {"id": "ts_05", "name": "Closed at"}, "value": {"value": "2026-05-09T09:01:00Z"}}
+ ]
+ }`)
+ op := newTestOptions()
+ results, err := extractIncidentioIncident(raw, op)
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+
+ incident := results[0].(*models.Incident)
+ require.NotNil(t, incident.ResolvedDate)
+ assert.Equal(t, time.Date(2026, 5, 9, 9, 1, 0, 0, time.UTC), *incident.ResolvedDate)
+}
+
+func TestExtractIncidentioIncident_DeclaredAtFallsBackToCreatedAt(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_04",
+ "reference": "INC-64",
+ "name": "no declared timestamp",
+ "mode": "standard",
+ "created_at": "2026-05-10T12:00:00Z",
+ "updated_at": "2026-05-10T12:05:00Z",
+ "incident_status": {"name": "Investigating", "category": "active"},
+ "incident_type": {"id": "type_01", "name": "Default"}
+ }`)
+ op := newTestOptions()
+ results, err := extractIncidentioIncident(raw, op)
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+ incident := results[0].(*models.Incident)
+ assert.Equal(t, time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC), incident.DeclaredDate)
+ assert.Nil(t, incident.ResolvedDate)
+}
+
+func TestExtractIncidentioIncident_NullSeverity(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_05",
+ "reference": "INC-65",
+ "name": "no sev yet",
+ "mode": "standard",
+ "created_at": "2026-05-10T14:00:00Z",
+ "updated_at": "2026-05-10T14:05:00Z",
+ "incident_status": {"name": "Triage", "category": "triage"},
+ "severity": null,
+ "incident_type": {"id": "type_01", "name": "Default"}
+ }`)
+ op := newTestOptions()
+ results, err := extractIncidentioIncident(raw, op)
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+ incident := results[0].(*models.Incident)
+ assert.Equal(t, "", incident.SeverityName)
+ assert.Equal(t, int64(0), incident.SeverityRank)
+}
+
+func TestExtractIncidentioIncident_TestModeSkipped(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_test",
+ "reference": "INC-66",
+ "name": "practice run",
+ "mode": "test",
+ "created_at": "2026-05-10T15:00:00Z",
+ "updated_at": "2026-05-10T15:05:00Z",
+ "incident_type": {"id": "type_01", "name": "Default"}
+ }`)
+ op := newTestOptions()
+ results, err := extractIncidentioIncident(raw, op)
+ require.NoError(t, err)
+ assert.Empty(t, results, "test-mode incident should produce no rows")
+}
+
+func TestExtractIncidentioIncident_TutorialModeSkipped(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_tutorial",
+ "reference": "INC-67",
+ "name": "onboarding walkthrough",
+ "mode": "tutorial",
+ "created_at": "2026-05-10T15:00:00Z",
+ "updated_at": "2026-05-10T15:05:00Z",
+ "incident_type": {"id": "type_01", "name": "Default"}
+ }`)
+ op := newTestOptions()
+ results, err := extractIncidentioIncident(raw, op)
+ require.NoError(t, err)
+ assert.Empty(t, results, "tutorial-mode incident should produce no rows")
+}
+
+func TestExtractIncidentioIncident_RetrospectiveModeKept(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_retro",
+ "reference": "INC-68",
+ "name": "backfilled incident",
+ "mode": "retrospective",
+ "created_at": "2026-05-10T16:00:00Z",
+ "updated_at": "2026-05-10T16:05:00Z",
+ "incident_status": {"name": "Closed", "category": "closed"},
+ "incident_type": {"id": "type_01", "name": "Default"}
+ }`)
+ op := newTestOptions()
+ results, err := extractIncidentioIncident(raw, op)
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+ incident := results[0].(*models.Incident)
+ assert.Equal(t, "retrospective", incident.Mode)
+}
+
+func TestExtractIncidentioIncident_WrongIncidentTypeSkipped(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_wrong_type",
+ "reference": "INC-69",
+ "name": "other type",
+ "mode": "standard",
+ "created_at": "2026-05-10T18:00:00Z",
+ "updated_at": "2026-05-10T18:05:00Z",
+ "incident_type": {"id": "type_99", "name": "Security"}
+ }`)
+ op := newTestOptions()
+ results, err := extractIncidentioIncident(raw, op)
+ require.NoError(t, err)
+ assert.Empty(t, results, "incident for unrelated incident type should produce no rows")
+}
+
+func TestExtractIncidentioIncident_MissingIncidentTypeSkippedWhenScoped(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_no_type",
+ "reference": "INC-70",
+ "name": "type omitted",
+ "mode": "standard",
+ "created_at": "2026-05-10T19:00:00Z",
+ "updated_at": "2026-05-10T19:05:00Z"
+ }`)
+ op := newTestOptions()
+ results, err := extractIncidentioIncident(raw, op)
+ require.NoError(t, err)
+ assert.Empty(t, results)
+}
+
+func TestExtractIncidentioIncident_GlobalCollectionKeepsAllTypes(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_any_type",
+ "reference": "INC-71",
+ "name": "any type",
+ "mode": "standard",
+ "created_at": "2026-05-10T20:00:00Z",
+ "updated_at": "2026-05-10T20:05:00Z",
+ "incident_type": {"id": "type_99", "name": "Security"}
+ }`)
+ op := &IncidentioOptions{ConnectionId: 7}
+ results, err := extractIncidentioIncident(raw, op)
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+}
+
+func TestExtractIncidentioIncident_MissingCreatedAtReturnsError(t *testing.T) {
+ raw := []byte(`{
+ "id": "inc_bad",
+ "reference": "INC-72",
+ "name": "bad row",
+ "mode": "standard",
+ "updated_at": "2026-05-10T20:05:00Z",
+ "incident_type": {"id": "type_01", "name": "Default"}
+ }`)
+ op := newTestOptions()
+ _, err := extractIncidentioIncident(raw, op)
+ assert.Error(t, err)
+}
diff --git a/backend/plugins/incidentio/tasks/task_data.go b/backend/plugins/incidentio/tasks/task_data.go
new file mode 100644
index 00000000000..9659d39ebc3
--- /dev/null
+++ b/backend/plugins/incidentio/tasks/task_data.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 (
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/incidentio/models"
+)
+
+type IncidentioOptions struct {
+ ConnectionId uint64 `json:"connectionId" mapstructure:"connectionId,omitempty"`
+ IncidentTypeId string `json:"incidentTypeId,omitempty" mapstructure:"incidentTypeId,omitempty"`
+ IncidentTypeName string `json:"incidentTypeName,omitempty" mapstructure:"incidentTypeName,omitempty"`
+ ScopeConfigId uint64 `json:"scopeConfigId,omitempty" mapstructure:"scopeConfigId,omitempty"`
+ ScopeConfig *models.IncidentioScopeConfig `json:"scopeConfig,omitempty" mapstructure:"scopeConfig,omitempty"`
+}
+
+type IncidentioTaskData struct {
+ Options *IncidentioOptions
+ Client api.RateLimitedApiClient
+}
+
+func (p *IncidentioOptions) GetParams() any {
+ scopeId := p.IncidentTypeId
+ if scopeId == "" {
+ scopeId = "all"
+ }
+ return models.IncidentioParams{
+ ConnectionId: p.ConnectionId,
+ ScopeId: scopeId,
+ }
+}
+
+func DecodeAndValidateTaskOptions(options map[string]interface{}) (*IncidentioOptions, 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{}) (*IncidentioOptions, errors.Error) {
+ var op IncidentioOptions
+ err := api.Decode(options, &op, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &op, nil
+}
+
+func ValidateTaskOptions(op *IncidentioOptions) errors.Error {
+ 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 0d4482ca6c9..68e94332568 100644
--- a/backend/plugins/table_info_test.go
+++ b/backend/plugins/table_info_test.go
@@ -41,6 +41,7 @@ import (
githubGraphql "github.com/apache/incubator-devlake/plugins/github_graphql/impl"
gitlab "github.com/apache/incubator-devlake/plugins/gitlab/impl"
icla "github.com/apache/incubator-devlake/plugins/icla/impl"
+ incidentio "github.com/apache/incubator-devlake/plugins/incidentio/impl"
issueTrace "github.com/apache/incubator-devlake/plugins/issue_trace/impl"
jenkins "github.com/apache/incubator-devlake/plugins/jenkins/impl"
jira "github.com/apache/incubator-devlake/plugins/jira/impl"
@@ -87,6 +88,7 @@ func Test_GetPluginTablesInfo(t *testing.T) {
checker.FeedIn("github_graphql", githubGraphql.GithubGraphql{}.GetTablesInfo)
checker.FeedIn("gitlab/models", gitlab.Gitlab{}.GetTablesInfo)
checker.FeedIn("icla/models", icla.Icla{}.GetTablesInfo)
+ checker.FeedIn("incidentio/models", incidentio.Incidentio{}.GetTablesInfo)
checker.FeedIn("jenkins/models", jenkins.Jenkins{}.GetTablesInfo)
checker.FeedIn("jira/models", jira.Jira{}.GetTablesInfo)
checker.FeedIn("linear/models", linear.Linear{}.GetTablesInfo)
diff --git a/backend/test/e2e/services/server_startup_test.go b/backend/test/e2e/services/server_startup_test.go
index 9432f80c263..f52dafeb038 100644
--- a/backend/test/e2e/services/server_startup_test.go
+++ b/backend/test/e2e/services/server_startup_test.go
@@ -34,6 +34,7 @@ import (
githubGraphql "github.com/apache/incubator-devlake/plugins/github_graphql/impl"
gitlab "github.com/apache/incubator-devlake/plugins/gitlab/impl"
icla "github.com/apache/incubator-devlake/plugins/icla/impl"
+ incidentio "github.com/apache/incubator-devlake/plugins/incidentio/impl"
jenkins "github.com/apache/incubator-devlake/plugins/jenkins/impl"
jira "github.com/apache/incubator-devlake/plugins/jira/impl"
org "github.com/apache/incubator-devlake/plugins/org/impl"
@@ -74,6 +75,7 @@ func loadGoPlugins() []plugin.PluginMeta {
githubGraphql.GithubGraphql{},
gitlab.Gitlab{},
icla.Icla{},
+ incidentio.Incidentio{},
jenkins.Jenkins{},
jira.Jira{},
org.Org{},
diff --git a/config-ui/src/plugins/register/incidentio/assets/icon.svg b/config-ui/src/plugins/register/incidentio/assets/icon.svg
new file mode 100644
index 00000000000..dc41f767624
--- /dev/null
+++ b/config-ui/src/plugins/register/incidentio/assets/icon.svg
@@ -0,0 +1,23 @@
+
+
diff --git a/config-ui/src/plugins/register/incidentio/config.tsx b/config-ui/src/plugins/register/incidentio/config.tsx
new file mode 100644
index 00000000000..be2b43e73f4
--- /dev/null
+++ b/config-ui/src/plugins/register/incidentio/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 IncidentioConfig: IPluginConfig = {
+ plugin: 'incidentio',
+ name: 'incident.io',
+ icon: ({ color }) => ,
+ sort: 19,
+ isBeta: true,
+ connection: {
+ docLink: DOC_URL.PLUGIN.INCIDENTIO.BASIS,
+ initialValues: {
+ endpoint: 'https://api.incident.io/',
+ },
+ fields: [
+ 'name',
+ {
+ key: 'endpoint',
+ multipleVersions: {
+ cloud: 'https://api.incident.io/',
+ server: '',
+ },
+ },
+ 'token',
+ 'proxy',
+ {
+ key: 'rateLimitPerHour',
+ subLabel:
+ 'By default, DevLake uses 3,600 requests/hour for data collection for incident.io. But you can adjust the collection speed by setting up your desirable rate limit.',
+ learnMore: DOC_URL.PLUGIN.INCIDENTIO.RATE_LIMIT,
+ defaultValue: 3600,
+ },
+ ],
+ },
+ dataScope: {
+ title: 'Incident Types',
+ },
+};
diff --git a/config-ui/src/plugins/register/incidentio/index.ts b/config-ui/src/plugins/register/incidentio/index.ts
new file mode 100644
index 00000000000..de415db39ab
--- /dev/null
+++ b/config-ui/src/plugins/register/incidentio/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/plugins/register/index.ts b/config-ui/src/plugins/register/index.ts
index d1fff9c79e2..7cbeed1416c 100644
--- a/config-ui/src/plugins/register/index.ts
+++ b/config-ui/src/plugins/register/index.ts
@@ -29,6 +29,7 @@ import { ClaudeCodeConfig } from './claude-code';
import { GitHubConfig } from './github';
import { GhCopilotConfig } from './gh-copilot';
import { GitLabConfig } from './gitlab';
+import { IncidentioConfig } from './incidentio';
import { JenkinsConfig } from './jenkins';
import { JiraConfig } from './jira';
import { LinearConfig } from './linear';
@@ -58,6 +59,7 @@ export const pluginConfigs: IPluginConfig[] = [
GitHubConfig,
GhCopilotConfig,
GitLabConfig,
+ IncidentioConfig,
JenkinsConfig,
JiraConfig,
LinearConfig,
diff --git a/config-ui/src/release/stable.ts b/config-ui/src/release/stable.ts
index 118bd544f58..5aa6c5a91fc 100644
--- a/config-ui/src/release/stable.ts
+++ b/config-ui/src/release/stable.ts
@@ -84,6 +84,10 @@ const URLS = {
TRANSFORMATION:
'https://devlake.apache.org/docs/Configuration/GitLab#step-3---adding-transformation-rules-optional',
},
+ INCIDENTIO: {
+ BASIS: 'https://devlake.apache.org/docs/Configuration/Incidentio',
+ RATE_LIMIT: 'https://devlake.apache.org/docs/Configuration/Incidentio#fixed-rate-limit-optional',
+ },
JENKINS: {
BASIS: 'https://devlake.apache.org/docs/Configuration/Jenkins',
RATE_LIMIT: 'https://devlake.apache.org/docs/Configuration/Jenkins#fixed-rate-limit-optional',
diff --git a/grafana/dashboards/Incidentio.json b/grafana/dashboards/Incidentio.json
new file mode 100644
index 00000000000..86923eb3e8c
--- /dev/null
+++ b/grafana/dashboards/Incidentio.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": null,
+ "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": "incident.io",
+ "url": "https://devlake.apache.org/docs/Configuration/Incidentio"
+ }
+ ],
+ "options": {
+ "code": {
+ "language": "plaintext",
+ "showLineNumbers": false,
+ "showMiniMap": false
+ },
+ "content": "- Use Cases: This dashboard shows the incident data from incident.io.\n- Data Source Required: incident.io",
+ "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.status = 'DONE'\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 incident_type,\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.status = 'DONE' 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.status = 'DONE' 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.status = 'DONE'\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.status = 'DONE'\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.status = 'DONE'\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.status = 'DONE'\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 'incidentio%'",
+ "hide": 0,
+ "includeAll": true,
+ "label": "Choose Board",
+ "multi": true,
+ "name": "board_id",
+ "options": [],
+ "query": "select concat(name, '--', id) from boards where id like 'incidentio%'",
+ "refresh": 1,
+ "regex": "/^(?.*)--(?.*)$/",
+ "skipUrlSync": false,
+ "sort": 0,
+ "type": "query"
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6M",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "utc",
+ "title": "incident.io",
+ "uid": "incidentio-dashboard",
+ "version": 2,
+ "weekStart": ""
+}