From 2a4f6d352656234b44fe54b5607f2a5d1c492675 Mon Sep 17 00:00:00 2001 From: Sebastian Machuca Date: Thu, 30 Jul 2026 11:58:39 +1000 Subject: [PATCH] feat(incidentio): add incident.io data source plugin Adds a datasource plugin for incident.io (https://incident.io), an incident management platform, following the structure of the rootly plugin: - Connection with Bearer-token auth against https://api.incident.io/ - Scopes are incident types (v1/incident_types); remote-scope listing included - Collects incidents (v2/incidents, cursor pagination), extracts to _tool_incidentio_* tables (test/tutorial-mode incidents excluded), converts to ticket domain issues with type INCIDENT for DORA CFR/MTTR correlation - Timestamps mapped from incident_timestamp_values: 'Declared at' drives the created date, 'Resolved at' (falling back to 'Closed at') the resolution date; retrospective incidents keep their resolution date even when it precedes the declaration - Unit tests, e2e snapshot tests, config-ui registration, Grafana dashboard Validated end-to-end against a production incident.io workspace via the in-memory test server (50 incidents, field-level match with reference data). Closes #9018 Co-Authored-By: Claude Fable 5 --- .../plugins/incidentio/api/blueprint_v200.go | 103 ++ .../plugins/incidentio/api/connection_api.go | 155 ++ backend/plugins/incidentio/api/init.go | 55 + backend/plugins/incidentio/api/remote_api.go | 155 ++ backend/plugins/incidentio/api/scope_api.go | 107 ++ .../plugins/incidentio/api/scope_state_api.go | 37 + backend/plugins/incidentio/api/swagger.go | 32 + .../plugins/incidentio/e2e/incident_test.go | 108 ++ .../raw_tables/_raw_incidentio_incidents.csv | 6 + .../_tool_incidentio_incident_types.csv | 2 + .../_tool_incidentio_incidents.csv | 4 + .../e2e/snapshot_tables/board_issues.csv | 4 + .../incidentio/e2e/snapshot_tables/boards.csv | 2 + .../incidentio/e2e/snapshot_tables/issues.csv | 4 + backend/plugins/incidentio/impl/impl.go | 187 +++ backend/plugins/incidentio/incidentio.go | 38 + .../plugins/incidentio/models/connection.go | 74 + backend/plugins/incidentio/models/incident.go | 46 + .../incidentio/models/incident_type.go | 59 + .../20260729_add_init_tables.go | 44 + .../migrationscripts/archived/connection.go | 35 + .../migrationscripts/archived/incident.go | 48 + .../archived/incident_type.go | 36 + .../migrationscripts/archived/scope_config.go | 35 + .../models/migrationscripts/register.go | 29 + .../plugins/incidentio/models/raw/incident.go | 78 + .../incidentio/models/raw/incident_type.go | 28 + .../plugins/incidentio/models/scope_config.go | 30 + .../tasks/incident_type_converter.go | 81 ++ .../tasks/incident_types_collector.go | 72 + .../tasks/incident_types_extractor.go | 78 + .../incidentio/tasks/incidents_collector.go | 129 ++ .../tasks/incidents_collector_test.go | 64 + .../incidentio/tasks/incidents_converter.go | 153 ++ .../tasks/incidents_converter_test.go | 114 ++ .../incidentio/tasks/incidents_extractor.go | 141 ++ .../tasks/incidents_extractor_test.go | 289 ++++ backend/plugins/incidentio/tasks/task_data.go | 76 + backend/plugins/table_info_test.go | 2 + .../test/e2e/services/server_startup_test.go | 2 + .../register/incidentio/assets/icon.svg | 23 + .../plugins/register/incidentio/config.tsx | 58 + .../src/plugins/register/incidentio/index.ts | 19 + config-ui/src/plugins/register/index.ts | 2 + config-ui/src/release/stable.ts | 4 + grafana/dashboards/Incidentio.json | 1295 +++++++++++++++++ 46 files changed, 4143 insertions(+) create mode 100644 backend/plugins/incidentio/api/blueprint_v200.go create mode 100644 backend/plugins/incidentio/api/connection_api.go create mode 100644 backend/plugins/incidentio/api/init.go create mode 100644 backend/plugins/incidentio/api/remote_api.go create mode 100644 backend/plugins/incidentio/api/scope_api.go create mode 100644 backend/plugins/incidentio/api/scope_state_api.go create mode 100644 backend/plugins/incidentio/api/swagger.go create mode 100644 backend/plugins/incidentio/e2e/incident_test.go create mode 100644 backend/plugins/incidentio/e2e/raw_tables/_raw_incidentio_incidents.csv create mode 100644 backend/plugins/incidentio/e2e/snapshot_tables/_tool_incidentio_incident_types.csv create mode 100644 backend/plugins/incidentio/e2e/snapshot_tables/_tool_incidentio_incidents.csv create mode 100644 backend/plugins/incidentio/e2e/snapshot_tables/board_issues.csv create mode 100644 backend/plugins/incidentio/e2e/snapshot_tables/boards.csv create mode 100644 backend/plugins/incidentio/e2e/snapshot_tables/issues.csv create mode 100644 backend/plugins/incidentio/impl/impl.go create mode 100644 backend/plugins/incidentio/incidentio.go create mode 100644 backend/plugins/incidentio/models/connection.go create mode 100644 backend/plugins/incidentio/models/incident.go create mode 100644 backend/plugins/incidentio/models/incident_type.go create mode 100644 backend/plugins/incidentio/models/migrationscripts/20260729_add_init_tables.go create mode 100644 backend/plugins/incidentio/models/migrationscripts/archived/connection.go create mode 100644 backend/plugins/incidentio/models/migrationscripts/archived/incident.go create mode 100644 backend/plugins/incidentio/models/migrationscripts/archived/incident_type.go create mode 100644 backend/plugins/incidentio/models/migrationscripts/archived/scope_config.go create mode 100644 backend/plugins/incidentio/models/migrationscripts/register.go create mode 100644 backend/plugins/incidentio/models/raw/incident.go create mode 100644 backend/plugins/incidentio/models/raw/incident_type.go create mode 100644 backend/plugins/incidentio/models/scope_config.go create mode 100644 backend/plugins/incidentio/tasks/incident_type_converter.go create mode 100644 backend/plugins/incidentio/tasks/incident_types_collector.go create mode 100644 backend/plugins/incidentio/tasks/incident_types_extractor.go create mode 100644 backend/plugins/incidentio/tasks/incidents_collector.go create mode 100644 backend/plugins/incidentio/tasks/incidents_collector_test.go create mode 100644 backend/plugins/incidentio/tasks/incidents_converter.go create mode 100644 backend/plugins/incidentio/tasks/incidents_converter_test.go create mode 100644 backend/plugins/incidentio/tasks/incidents_extractor.go create mode 100644 backend/plugins/incidentio/tasks/incidents_extractor_test.go create mode 100644 backend/plugins/incidentio/tasks/task_data.go create mode 100644 config-ui/src/plugins/register/incidentio/assets/icon.svg create mode 100644 config-ui/src/plugins/register/incidentio/config.tsx create mode 100644 config-ui/src/plugins/register/incidentio/index.ts create mode 100644 grafana/dashboards/Incidentio.json 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": "" +}