This is an automated email from the ASF dual-hosted git repository.

abeizn pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 2464f2c50 refactor: unify connection/scope/scopeConfig helpers (#6146)
2464f2c50 is described below

commit 2464f2c5069988bc5769daa63b853a7e15a446a9
Author: Klesh Wong <[email protected]>
AuthorDate: Thu Sep 28 14:22:10 2023 +0800

    refactor: unify connection/scope/scopeConfig helpers (#6146)
    
    * refactor: unify connection/scope/scopeConfig helpers
    
    * fix: linting
    
    * feat: python pick up VIRTUAL_ENV automatically
    
    * fix: e2e test
    
    * fix: createWithMap failed with nil clause ptr
    
    * fix: select may or may not targeting the table
    
    * fix: integration test
    
    * fix: conflict error checking should be nested in the non-nil condition
    
    * fix: remove debugging code
    
    * fix: client side should not be initlializing plugins
    
    * fix: typo and incorect comments
    
    * fix: scope detail api should output scopeConfig and blueprints as well
    
    * fix: make ParsePagination private
    
    * fix: toDsRefs should not imply deletion
    
    * feat: validate pagination
---
 backend/core/runner/run_task.go                    |   7 +-
 .../helpers/pluginhelper/api/connection_auths.go   |   5 +
 .../pluginhelper/api/ds_connection_api_helper.go   |  75 ++++++
 backend/helpers/pluginhelper/api/ds_helper.go      |  49 ++++
 .../pluginhelper/api/ds_scope_api_helper.go        | 119 +++++++++
 .../pluginhelper/api/ds_scope_config_api_helper.go |  92 +++++++
 .../helpers/pluginhelper/api/model_api_helper.go   | 184 ++++++++++++++
 .../helpers/srvhelper/connection_service_helper.go |  82 ++++++
 backend/helpers/srvhelper/dsrefs.go                |  45 ++++
 backend/helpers/srvhelper/model_service_helper.go  | 215 ++++++++++++++++
 backend/helpers/srvhelper/pagination.go            |  42 ++++
 .../srvhelper/scope_config_service_helper.go       |  66 +++++
 backend/helpers/srvhelper/scope_service_helper.go  | 276 +++++++++++++++++++++
 backend/helpers/utils/mapstructure.go              |  20 +-
 backend/helpers/utils/mapstructure_test.go         |  17 ++
 backend/impls/dalgorm/dalgorm.go                   |  32 ++-
 backend/plugins/github/api/blueprint_v200.go       |   9 +-
 .../api/{connection.go => connection_api.go}       |  31 +--
 backend/plugins/github/api/init.go                 |  59 ++---
 .../plugins/github/api/{scope.go => scope_api.go}  |  37 ++-
 .../api/{scope_config.go => scope_config_api.go}   |  18 +-
 backend/plugins/github/impl/impl.go                |  12 +-
 backend/python/test/fakeplugin/run.sh              |   3 +
 backend/test/helper/client.go                      |   8 -
 24 files changed, 1381 insertions(+), 122 deletions(-)

diff --git a/backend/core/runner/run_task.go b/backend/core/runner/run_task.go
index 4cc5eb251..a04e65fe6 100644
--- a/backend/core/runner/run_task.go
+++ b/backend/core/runner/run_task.go
@@ -107,14 +107,11 @@ func RunTask(
                        }
                }
                // update finishedTasks
-               dbe := db.UpdateColumn(
+               errors.Must(db.UpdateColumn(
                        &models.Pipeline{},
                        "finished_tasks", dal.Expr("finished_tasks + 1"),
                        dal.Where("id=?", task.PipelineId),
-               )
-               if dbe != nil {
-                       logger.Error(dbe, "update pipeline state failed")
-               }
+               ))
                // not return err if the `SkipOnFail` is true and the error is 
not canceled
                if dbPipeline.SkipOnFail && !errors.Is(err, gocontext.Canceled) 
{
                        err = nil
diff --git a/backend/helpers/pluginhelper/api/connection_auths.go 
b/backend/helpers/pluginhelper/api/connection_auths.go
index 6219b9a5d..09116415b 100644
--- a/backend/helpers/pluginhelper/api/connection_auths.go
+++ b/backend/helpers/pluginhelper/api/connection_auths.go
@@ -144,6 +144,11 @@ func (ma *MultiAuth) 
SetupAuthenticationForConnection(connection plugin.ApiConne
        return apiAuthenticator.SetupAuthentication(req)
 }
 
+func (ma *MultiAuth) CustomValidate(connection interface{}, v 
*validator.Validate) errors.Error {
+       return ma.ValidateConnection(connection, v)
+}
+
+// TODO: deprecated, rename to CustomValidate instead
 func (ma *MultiAuth) ValidateConnection(connection interface{}, v 
*validator.Validate) errors.Error {
        // the idea is to filtered out errors from unselected Authentication 
struct
        validationErrors := v.Struct(connection).(validator.ValidationErrors)
diff --git a/backend/helpers/pluginhelper/api/ds_connection_api_helper.go 
b/backend/helpers/pluginhelper/api/ds_connection_api_helper.go
new file mode 100644
index 000000000..8d7bf5a55
--- /dev/null
+++ b/backend/helpers/pluginhelper/api/ds_connection_api_helper.go
@@ -0,0 +1,75 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+       "strconv"
+
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/plugin"
+       "github.com/apache/incubator-devlake/helpers/srvhelper"
+       "github.com/apache/incubator-devlake/server/api/shared"
+)
+
+// DsConnectionApiHelper
+type DsConnectionApiHelper[C plugin.ToolLayerConnection, S 
plugin.ToolLayerScope, SC plugin.ToolLayerScopeConfig] struct {
+       *ModelApiHelper[C]
+       *srvhelper.ConnectionSrvHelper[C, S, SC]
+}
+
+func NewDsConnectionApiHelper[C plugin.ToolLayerConnection, S 
plugin.ToolLayerScope, SC plugin.ToolLayerScopeConfig](
+       basicRes context.BasicRes,
+       connSrvHelper *srvhelper.ConnectionSrvHelper[C, S, SC],
+) *DsConnectionApiHelper[C, S, SC] {
+       return &DsConnectionApiHelper[C, S, SC]{
+               ModelApiHelper:      NewModelApiHelper[C](basicRes, 
connSrvHelper.ModelSrvHelper, []string{"connectionId"}),
+               ConnectionSrvHelper: connSrvHelper,
+       }
+}
+
+func (connApi *DsConnectionApiHelper[C, S, SC]) Delete(input 
*plugin.ApiResourceInput) (out *plugin.ApiResourceOutput, err errors.Error) {
+       var conn *C
+       conn, err = connApi.FindByPk(input)
+       if err != nil {
+               return nil, err
+       }
+       refs, err := connApi.ConnectionSrvHelper.DeleteConnection(conn)
+       if err != nil {
+               return &plugin.ApiResourceOutput{Body: &shared.ApiBody{
+                       Success: false,
+                       Message: err.Error(),
+                       Data:    refs,
+               }, Status: err.GetType().GetHttpCode()}, nil
+       }
+       return &plugin.ApiResourceOutput{
+               Body: conn,
+       }, nil
+}
+
+func extractConnectionId(input *plugin.ApiResourceInput) (uint64, 
errors.Error) {
+       connectionId, ok := input.Params["connectionId"]
+       if !ok {
+               return 0, errors.BadInput.New("connectionId is required")
+       }
+       id, err := strconv.ParseUint(connectionId, 10, 64)
+       if err != nil {
+               return 0, errors.BadInput.Wrap(err, "connectionId must be a 
number")
+       }
+       return id, nil
+}
diff --git a/backend/helpers/pluginhelper/api/ds_helper.go 
b/backend/helpers/pluginhelper/api/ds_helper.go
new file mode 100644
index 000000000..bb0aa019f
--- /dev/null
+++ b/backend/helpers/pluginhelper/api/ds_helper.go
@@ -0,0 +1,49 @@
+/*
+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/srvhelper"
+)
+
+func NewDataSourceHelpers[
+       C plugin.ToolLayerConnection,
+       S plugin.ToolLayerScope,
+       SC plugin.ToolLayerScopeConfig,
+](
+       basicRes context.BasicRes,
+       pluginName string,
+       scopeSearchColumns []string,
+) (
+       *srvhelper.ConnectionSrvHelper[C, S, SC],
+       *DsConnectionApiHelper[C, S, SC],
+       *srvhelper.ScopeSrvHelper[C, S, SC],
+       *DsScopeApiHelper[C, S, SC],
+       *srvhelper.ScopeConfigSrvHelper[C, S, SC],
+       *DsScopeConfigApiHelper[C, S, SC],
+) {
+       connSrv := srvhelper.NewConnectionSrvHelper[C, S, SC](basicRes, 
pluginName)
+       connApi := NewDsConnectionApiHelper[C, S, SC](basicRes, connSrv)
+       scopeSrv := srvhelper.NewScopeSrvHelper[C, S, SC](basicRes, pluginName, 
scopeSearchColumns)
+       scopeApi := NewDsScopeApiHelper[C, S, SC](basicRes, scopeSrv)
+       scSrv := srvhelper.NewScopeConfigSrvHelper[C, S, SC](basicRes)
+       scApi := NewDsScopeConfigApiHelper[C, S, SC](basicRes, scSrv)
+       return connSrv, connApi, scopeSrv, scopeApi, scSrv, scApi
+}
diff --git a/backend/helpers/pluginhelper/api/ds_scope_api_helper.go 
b/backend/helpers/pluginhelper/api/ds_scope_api_helper.go
new file mode 100644
index 000000000..279d9b1ae
--- /dev/null
+++ b/backend/helpers/pluginhelper/api/ds_scope_api_helper.go
@@ -0,0 +1,119 @@
+/*
+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/errors"
+       "github.com/apache/incubator-devlake/core/plugin"
+       "github.com/apache/incubator-devlake/helpers/srvhelper"
+       "github.com/apache/incubator-devlake/server/api/shared"
+)
+
+type PutScopesReqBody[T any] struct {
+       Data []*T `json:"data"`
+}
+
+type ScopeDetail[S plugin.ToolLayerScope, SC plugin.ToolLayerScopeConfig] 
srvhelper.ScopeDetail[S, SC]
+
+type DsScopeApiHelper[C plugin.ToolLayerConnection, S plugin.ToolLayerScope, 
SC plugin.ToolLayerScopeConfig] struct {
+       *ModelApiHelper[S]
+       *srvhelper.ScopeSrvHelper[C, S, SC]
+}
+
+func NewDsScopeApiHelper[C plugin.ToolLayerConnection, S 
plugin.ToolLayerScope, SC plugin.ToolLayerScopeConfig](
+       basicRes context.BasicRes,
+       srvHelper *srvhelper.ScopeSrvHelper[C, S, SC],
+) *DsScopeApiHelper[C, S, SC] {
+       return &DsScopeApiHelper[C, S, SC]{
+               ModelApiHelper: NewModelApiHelper[S](basicRes, 
srvHelper.ModelSrvHelper, []string{"connectionId", "scopeId"}),
+               ScopeSrvHelper: srvHelper,
+       }
+}
+
+func (scopeApi *DsScopeApiHelper[C, S, SC]) GetPage(input 
*plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+       pagination, err := parsePagination[srvhelper.ScopePagination](input)
+       if err != nil {
+               return nil, errors.BadInput.Wrap(err, "failed to decode 
pathvars into pagination")
+       }
+       scopes, count, err := scopeApi.ScopeSrvHelper.GetScopesPage(pagination)
+       if err != nil {
+               return nil, err
+       }
+       return &plugin.ApiResourceOutput{
+               Body: map[string]interface{}{
+                       "count":  count,
+                       "scopes": scopes,
+               },
+       }, nil
+}
+
+func (scopeApi *DsScopeApiHelper[C, S, SC]) GetScopeDetail(input 
*plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+       pkv, err := scopeApi.ExtractPkValues(input)
+       if err != nil {
+               return nil, err
+       }
+       scopeDetail, err := 
scopeApi.ScopeSrvHelper.GetScopeDetail(input.Query.Get("blueprints") == "true", 
pkv...)
+       if err != nil {
+               return nil, err
+       }
+       return &plugin.ApiResourceOutput{
+               Body: scopeDetail,
+       }, nil
+}
+
+func (scopeApi *DsScopeApiHelper[C, S, SC]) PutMultiple(input 
*plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+       // fix data[].connectionId
+       connectionId, err := extractConnectionId(input)
+       if err != nil {
+               return nil, err
+       }
+       data, ok := input.Body["data"].([]interface{})
+       if !ok {
+               return nil, errors.BadInput.New("invalid data")
+       }
+       for _, row := range data {
+               dict, ok := row.(map[string]interface{})
+               if !ok {
+                       return nil, errors.BadInput.New("invalid data row")
+               }
+               dict["connectionId"] = connectionId
+       }
+       return scopeApi.ModelApiHelper.PutMultiple(input)
+}
+
+func (scopeApi *DsScopeApiHelper[C, S, SC]) Delete(input 
*plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+       var scope *S
+       scope, err := scopeApi.FindByPk(input)
+       if err != nil {
+               return nil, err
+       }
+       // time.Sleep(1 * time.Minute) # uncomment this line if you were to 
verify pipelines get blocked while deleting data
+       // check referencing blueprints
+       refs, err := scopeApi.ScopeSrvHelper.DeleteScope(scope, 
input.Query.Get("delete_data_only") == "true")
+       if err != nil {
+               return &plugin.ApiResourceOutput{Body: &shared.ApiBody{
+                       Success: false,
+                       Message: err.Error(),
+                       Data:    refs,
+               }, Status: err.GetType().GetHttpCode()}, nil
+       }
+       return &plugin.ApiResourceOutput{
+               Body: scope,
+       }, nil
+}
diff --git a/backend/helpers/pluginhelper/api/ds_scope_config_api_helper.go 
b/backend/helpers/pluginhelper/api/ds_scope_config_api_helper.go
new file mode 100644
index 000000000..6a3e7cd19
--- /dev/null
+++ b/backend/helpers/pluginhelper/api/ds_scope_config_api_helper.go
@@ -0,0 +1,92 @@
+/*
+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/errors"
+       "github.com/apache/incubator-devlake/core/plugin"
+       "github.com/apache/incubator-devlake/helpers/srvhelper"
+       "github.com/apache/incubator-devlake/server/api/shared"
+)
+
+// DsScopeConfigApiHelper
+type DsScopeConfigApiHelper[C plugin.ToolLayerConnection, S 
plugin.ToolLayerScope, SC plugin.ToolLayerScopeConfig] struct {
+       *ModelApiHelper[SC]
+       *srvhelper.ScopeConfigSrvHelper[C, S, SC]
+}
+
+func NewDsScopeConfigApiHelper[C plugin.ToolLayerConnection, S 
plugin.ToolLayerScope, SC plugin.ToolLayerScopeConfig](
+       basicRes context.BasicRes,
+       dalHelper *srvhelper.ScopeConfigSrvHelper[C, S, SC],
+) *DsScopeConfigApiHelper[C, S, SC] {
+       return &DsScopeConfigApiHelper[C, S, SC]{
+               ModelApiHelper:       NewModelApiHelper[SC](basicRes, 
dalHelper.ModelSrvHelper, []string{"scopeConfigId"}),
+               ScopeConfigSrvHelper: dalHelper,
+       }
+}
+
+func (connApi *DsScopeConfigApiHelper[C, S, SC]) GetAll(input 
*plugin.ApiResourceInput) (out *plugin.ApiResourceOutput, err errors.Error) {
+       connectionId, err := extractConnectionId(input)
+       if err != nil {
+               return nil, err
+       }
+       scopeConfigs := 
errors.Must1(connApi.ScopeConfigSrvHelper.GetAllByConnectionId(connectionId))
+       return &plugin.ApiResourceOutput{
+               Body: scopeConfigs,
+       }, nil
+}
+
+func (connApi *DsScopeConfigApiHelper[C, S, SC]) Post(input 
*plugin.ApiResourceInput) (out *plugin.ApiResourceOutput, err errors.Error) {
+       // fix connectionId
+       connectionId, err := extractConnectionId(input)
+       if err != nil {
+               return nil, err
+       }
+       input.Body["connectionId"] = connectionId
+       return connApi.ModelApiHelper.Post(input)
+}
+
+func (connApi *DsScopeConfigApiHelper[C, S, SC]) Patch(input 
*plugin.ApiResourceInput) (out *plugin.ApiResourceOutput, err errors.Error) {
+       // fix connectionId
+       connectionId, err := extractConnectionId(input)
+       if err != nil {
+               return nil, err
+       }
+       input.Body["connectionId"] = connectionId
+       return connApi.ModelApiHelper.Patch(input)
+}
+
+func (connApi *DsScopeConfigApiHelper[C, S, SC]) Delete(input 
*plugin.ApiResourceInput) (out *plugin.ApiResourceOutput, err errors.Error) {
+       var scopeConfig *SC
+       scopeConfig, err = connApi.FindByPk(input)
+       if err != nil {
+               return nil, err
+       }
+       refs, err := connApi.ScopeConfigSrvHelper.DeleteScopeConfig(scopeConfig)
+       if err != nil {
+               return &plugin.ApiResourceOutput{Body: &shared.ApiBody{
+                       Success: false,
+                       Message: err.Error(),
+                       Data:    refs,
+               }, Status: err.GetType().GetHttpCode()}, nil
+       }
+       return &plugin.ApiResourceOutput{
+               Body: scopeConfig,
+       }, nil
+}
diff --git a/backend/helpers/pluginhelper/api/model_api_helper.go 
b/backend/helpers/pluginhelper/api/model_api_helper.go
new file mode 100644
index 000000000..ce4d18be5
--- /dev/null
+++ b/backend/helpers/pluginhelper/api/model_api_helper.go
@@ -0,0 +1,184 @@
+/*
+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 (
+       "fmt"
+       "net/http"
+
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/dal"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/log"
+       "github.com/apache/incubator-devlake/core/plugin"
+       "github.com/apache/incubator-devlake/helpers/srvhelper"
+       "github.com/apache/incubator-devlake/helpers/utils"
+       "github.com/go-playground/validator/v10"
+)
+
+var vld = validator.New()
+
+type ModelApiHelper[M dal.Tabler] struct {
+       basicRes       context.BasicRes
+       dalHelper      *srvhelper.ModelSrvHelper[M]
+       log            log.Logger
+       modelName      string
+       pkPathVarNames []string
+}
+
+func NewModelApiHelper[M dal.Tabler](
+       basicRes context.BasicRes,
+       dalHelper *srvhelper.ModelSrvHelper[M],
+       pkPathVarNames []string, // path variable names of primary key
+) *ModelApiHelper[M] {
+       m := new(M)
+       modelName := fmt.Sprintf("%T", m)
+       return &ModelApiHelper[M]{
+               basicRes:       basicRes,
+               dalHelper:      dalHelper,
+               log:            
basicRes.GetLogger().Nested(fmt.Sprintf("%s_dal", modelName)),
+               modelName:      modelName,
+               pkPathVarNames: pkPathVarNames,
+       }
+}
+
+func (self *ModelApiHelper[M]) Post(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
+       model := new(M)
+       err := utils.DecodeMapStruct(input.Body, model, false)
+       if err != nil {
+               return nil, err
+       }
+       err = self.dalHelper.Create(model)
+       if err != nil {
+               return nil, err
+       }
+       return &plugin.ApiResourceOutput{
+               Status: http.StatusCreated,
+               Body:   model,
+       }, nil
+}
+
+func (self *ModelApiHelper[M]) ExtractPkValues(input *plugin.ApiResourceInput) 
([]interface{}, errors.Error) {
+       pkv := make([]interface{}, len(self.pkPathVarNames))
+       for i, pkn := range self.pkPathVarNames {
+               var ok bool
+               pkv[i], ok = input.Params[pkn]
+               if !ok {
+                       return nil, errors.BadInput.New(fmt.Sprintf("missing 
path variable %s", pkn))
+               }
+       }
+       return pkv, nil
+}
+
+func (self *ModelApiHelper[M]) FindByPk(input *plugin.ApiResourceInput) (*M, 
errors.Error) {
+       pkv, err := self.ExtractPkValues(input)
+       if err != nil {
+               return nil, err
+       }
+       return self.dalHelper.FindByPk(pkv...)
+}
+
+func (self *ModelApiHelper[M]) GetDetail(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
+       model, err := self.FindByPk(input)
+       if err != nil {
+               return nil, err
+       }
+       return &plugin.ApiResourceOutput{
+               Body: model,
+       }, nil
+}
+
+func (self *ModelApiHelper[M]) Patch(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
+       model, err := self.FindByPk(input)
+       if err != nil {
+               return nil, err
+       }
+       err = utils.DecodeMapStruct(input.Body, model, true)
+       if err != nil {
+               return nil, errors.BadInput.Wrap(err, fmt.Sprintf("faled to 
patch %s", self.modelName))
+       }
+       err = self.dalHelper.Update(model)
+       if err != nil {
+               return nil, err
+       }
+       return &plugin.ApiResourceOutput{
+               Body: model,
+       }, nil
+}
+
+func (self *ModelApiHelper[M]) Delete(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
+       model, err := self.FindByPk(input)
+       if err != nil {
+               return nil, err
+       }
+       err = self.dalHelper.DeleteModel(model)
+       if err != nil {
+               return nil, err
+       }
+       return &plugin.ApiResourceOutput{
+               Body: model,
+       }, nil
+}
+
+func (self *ModelApiHelper[M]) GetAll(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
+       all, err := self.dalHelper.GetAll()
+       return &plugin.ApiResourceOutput{
+               Body: all,
+       }, err
+}
+
+func (self *ModelApiHelper[M]) PutMultiple(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
+       var req struct {
+               Data []M `json:"data"`
+       }
+       err := utils.DecodeMapStruct(input.Body, &req, false)
+       if err != nil {
+               return nil, err
+       }
+       for i, item := range req.Data {
+               err := self.dalHelper.CreateOrUpdate(&item)
+               if err != nil {
+                       return nil, errors.BadInput.Wrap(err, 
fmt.Sprintf("failed to save item %d", i))
+               }
+       }
+       return &plugin.ApiResourceOutput{
+               Body: req.Data,
+       }, nil
+}
+
+func parsePagination[P any](input *plugin.ApiResourceInput, query 
...dal.Clause) (*P, errors.Error) {
+       if !input.Query.Has("page") {
+               input.Query.Set("page", "1")
+       }
+       if !input.Query.Has("pageSize") {
+               input.Query.Set("pageSize", "100")
+       }
+       pagination := new(P)
+       err := utils.DecodeMapStruct(input.Query, pagination, false)
+       if err != nil {
+               return nil, errors.BadInput.Wrap(err, "faild to decode 
pagination from query string")
+       }
+       err = utils.DecodeMapStruct(input.Params, pagination, false)
+       if err != nil {
+               return nil, errors.BadInput.Wrap(err, "faild to decode 
pagination from path variables")
+       }
+       if e := vld.Struct(pagination); e != nil {
+               return nil, errors.BadInput.Wrap(e, "invalid pagination 
parameters")
+       }
+       return pagination, nil
+}
diff --git a/backend/helpers/srvhelper/connection_service_helper.go 
b/backend/helpers/srvhelper/connection_service_helper.go
new file mode 100644
index 000000000..a62650314
--- /dev/null
+++ b/backend/helpers/srvhelper/connection_service_helper.go
@@ -0,0 +1,82 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package srvhelper
+
+import (
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/dal"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/models"
+       "github.com/apache/incubator-devlake/core/plugin"
+)
+
+// ConnectionSrvHelper
+type ConnectionSrvHelper[C plugin.ToolLayerConnection, S 
plugin.ToolLayerScope, SC plugin.ToolLayerScopeConfig] struct {
+       *ModelSrvHelper[C]
+       pluginName string
+}
+
+// NewConnectionSrvHelper creates a ConnectionDalHelper for connection 
management
+func NewConnectionSrvHelper[
+       C plugin.ToolLayerConnection,
+       S plugin.ToolLayerScope,
+       SC plugin.ToolLayerScopeConfig,
+](
+       basicRes context.BasicRes,
+       pluginName string,
+) *ConnectionSrvHelper[C, S, SC] {
+       return &ConnectionSrvHelper[C, S, SC]{
+               ModelSrvHelper: NewModelSrvHelper[C](basicRes),
+               pluginName:     pluginName,
+       }
+}
+
+func (connSrv *ConnectionSrvHelper[C, S, SC]) DeleteConnection(connection *C) 
(refs *DsRefs, err errors.Error) {
+       err = connSrv.ModelSrvHelper.NoRunningPipeline(func(tx dal.Transaction) 
errors.Error {
+               // make sure no blueprint is using the connection
+               connectionId := (*connection).ConnectionId()
+               refs = 
toDsRefs(connSrv.getAllBlueprinsByConnection(connectionId))
+               if refs != nil {
+                       return errors.Conflict.New("Cannot delete the scope 
because it is referenced by blueprints")
+               }
+               scopeCount := errors.Must1(connSrv.db.Count(dal.From(new(S)), 
dal.Where("connection_id = ?", connectionId)))
+               if scopeCount > 0 {
+                       return errors.Conflict.New("Please delete all data 
scope(s) before you delete this Data Connection.")
+               }
+               errors.Must(tx.Delete(connection))
+               errors.Must(connSrv.db.Delete(new(SC), dal.Where("connection_id 
= ?", connectionId)))
+               return nil
+       })
+       return
+}
+
+func (connSrv *ConnectionSrvHelper[C, S, SC]) 
getAllBlueprinsByConnection(connectionId uint64) []*models.Blueprint {
+       blueprints := make([]*models.Blueprint, 0)
+       errors.Must(connSrv.db.All(
+               &blueprints,
+               dal.From("_devlake_blueprints bp"),
+               dal.Join("JOIN _devlake_blueprint_connections cn ON 
cn.blueprint_id = bp.id"),
+               dal.Where(
+                       "mode = ? AND cn.connection_id = ? AND cn.plugin_name = 
?",
+                       "NORMAL",
+                       connectionId,
+                       connSrv.pluginName,
+               ),
+       ))
+       return blueprints
+}
diff --git a/backend/helpers/srvhelper/dsrefs.go 
b/backend/helpers/srvhelper/dsrefs.go
new file mode 100644
index 000000000..afb7bd450
--- /dev/null
+++ b/backend/helpers/srvhelper/dsrefs.go
@@ -0,0 +1,45 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package srvhelper
+
+import (
+       "github.com/apache/incubator-devlake/core/models"
+)
+
+type DsRefs struct {
+       Blueprints []string `json:"blueprints"`
+       Projects   []string `json:"projects"`
+}
+
+func toDsRefs(blueprints []*models.Blueprint) *DsRefs {
+       if len(blueprints) > 0 {
+               blueprintNames := make([]string, 0, len(blueprints))
+               projectNames := make([]string, 0, len(blueprints))
+               for _, bp := range blueprints {
+                       blueprintNames = append(blueprintNames, bp.Name)
+                       if bp.ProjectName != "" {
+                               projectNames = append(projectNames, 
bp.ProjectName)
+                       }
+               }
+               return &DsRefs{
+                       Blueprints: blueprintNames,
+                       Projects:   projectNames,
+               }
+       }
+       return nil
+}
diff --git a/backend/helpers/srvhelper/model_service_helper.go 
b/backend/helpers/srvhelper/model_service_helper.go
new file mode 100644
index 000000000..7acb87c96
--- /dev/null
+++ b/backend/helpers/srvhelper/model_service_helper.go
@@ -0,0 +1,215 @@
+/*
+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 srvhelper
+
+import (
+       "fmt"
+       "time"
+
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/dal"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/log"
+       "github.com/apache/incubator-devlake/core/models"
+       "github.com/apache/incubator-devlake/helpers/dbhelper"
+       "github.com/go-playground/validator/v10"
+)
+
+type CustomValidator interface {
+       CustomValidate(entity interface{}, validate *validator.Validate) 
errors.Error
+}
+
+type ModelSrvHelper[M dal.Tabler] struct {
+       basicRes      context.BasicRes
+       log           log.Logger
+       db            dal.Dal
+       validator     *validator.Validate
+       modelName     string
+       pk            []dal.ColumnMeta
+       pkWhere       string
+       pkCount       int
+       searchColumns []string
+}
+
+func NewModelSrvHelper[M dal.Tabler](basicRes context.BasicRes) 
*ModelSrvHelper[M] {
+       m := new(M)
+       modelName := fmt.Sprintf("%T", m)
+       db := basicRes.GetDal()
+       if db == nil {
+               db = basicRes.GetDal()
+       }
+       pk := errors.Must1(dal.GetPrimarykeyColumns(db, *m))
+       pkWhere := ""
+       for _, col := range pk {
+               if pkWhere != "" {
+                       pkWhere += " AND "
+               }
+               pkWhere += fmt.Sprintf("%s = ? ", col.Name())
+       }
+       return &ModelSrvHelper[M]{
+               basicRes:  basicRes,
+               log:       basicRes.GetLogger().Nested(fmt.Sprintf("%s_dal", 
modelName)),
+               db:        db,
+               validator: validator.New(),
+               modelName: modelName,
+               pk:        pk,
+               pkWhere:   pkWhere,
+               pkCount:   len(pk),
+       }
+}
+
+func (srv *ModelSrvHelper[M]) NewTx(tx dal.Transaction) *ModelSrvHelper[M] {
+       helper := new(ModelSrvHelper[M])
+       *helper = *srv
+       helper.db = tx
+       return helper
+}
+
+func (srv *ModelSrvHelper[M]) ValidateModel(model *M) errors.Error {
+       // the model can validate itself
+       if customValidator, ok := (interface{}(model)).(CustomValidator); ok {
+               return customValidator.CustomValidate(model, srv.validator)
+       }
+       // basic validator
+       if e := srv.validator.Struct(model); e != nil {
+               return errors.BadInput.Wrap(e, "validation faild")
+       }
+       return nil
+}
+
+// Create validates given model and insert it into database if validation 
passed
+func (srv *ModelSrvHelper[M]) Create(model *M) errors.Error {
+       println("create model")
+       err := srv.ValidateModel(model)
+       if err != nil {
+               return err
+       }
+       err = srv.db.Create(model)
+       if err != nil {
+               if srv.db.IsDuplicationError(err) {
+                       return errors.Conflict.Wrap(err, fmt.Sprintf("%s 
already exists", srv.modelName))
+               }
+               return err
+       }
+       return err
+}
+
+// Update validates given model and update it into database if validation 
passed
+func (srv *ModelSrvHelper[M]) Update(model *M) errors.Error {
+       err := srv.ValidateModel(model)
+       if err != nil {
+               if srv.db.IsDuplicationError(err) {
+                       return errors.Conflict.Wrap(err, fmt.Sprintf("%s 
already exists", srv.modelName))
+               }
+               return err
+       }
+       return srv.db.Update(model)
+}
+
+// CreateOrUpdate validates given model and insert or update it into database 
if validation passed
+func (srv *ModelSrvHelper[M]) CreateOrUpdate(model *M) errors.Error {
+       err := srv.ValidateModel(model)
+       if err != nil {
+               return err
+       }
+       return srv.db.CreateOrUpdate(model)
+}
+
+// DeleteModel deletes given model from database
+func (srv *ModelSrvHelper[M]) DeleteModel(model *M) errors.Error {
+       return srv.db.Delete(model)
+}
+
+// FindByPk returns model with given primary key from database
+func (srv *ModelSrvHelper[M]) FindByPk(pk ...interface{}) (*M, errors.Error) {
+       if len(pk) != srv.pkCount {
+               return nil, errors.BadInput.New("invalid primary key")
+       }
+       model := new(M)
+       err := srv.db.First(model, dal.Where(srv.pkWhere, pk...))
+       if err != nil {
+               if srv.db.IsErrorNotFound(err) {
+                       return nil, errors.NotFound.Wrap(err, fmt.Sprintf("%s 
not found", srv.modelName))
+               }
+               return nil, err
+       }
+       return model, nil
+}
+
+// GetAll returns all models from database
+func (srv *ModelSrvHelper[M]) GetAll() ([]*M, errors.Error) {
+       array := make([]*M, 0)
+       return array, srv.db.All(&array)
+}
+
+func (srv *ModelSrvHelper[M]) GetPage(pagination *Pagination, query 
...dal.Clause) ([]*M, int64, errors.Error) {
+       query = append(query, dal.From(new(M)))
+       // process keyword
+       searchTerm := pagination.SearchTerm
+       if searchTerm != "" && len(srv.searchColumns) > 0 {
+               sql := ""
+               value := "%" + searchTerm + "%"
+               values := make([]interface{}, len(srv.searchColumns))
+               for i, field := range srv.searchColumns {
+                       if sql != "" {
+                               sql += " OR "
+                       }
+                       sql += fmt.Sprintf("%s LIKE ?", field)
+                       values[i] = value
+               }
+               sql = fmt.Sprintf("(%s)", sql)
+               query = append(query,
+                       dal.Where(sql, values...),
+               )
+       }
+       count, err := srv.db.Count(query...)
+       if err != nil {
+               return nil, 0, err
+       }
+       query = append(query, dal.Limit(pagination.GetLimit()), 
dal.Offset(pagination.GetOffset()))
+       var scopes []*M
+       return scopes, count, srv.db.All(&scopes, query...)
+}
+
+func (srv *ModelSrvHelper[M]) NoRunningPipeline(fn func(tx dal.Transaction) 
errors.Error, tablesToLock ...*dal.LockTable) (err errors.Error) {
+       // make sure no pipeline is running
+       tablesToLock = append(tablesToLock, &dal.LockTable{Table: 
"_devlake_pipelines", Exclusive: true})
+       txHelper := dbhelper.NewTxHelper(srv.basicRes, &err)
+       defer txHelper.End()
+       tx := txHelper.Begin()
+       err = txHelper.LockTablesTimeout(2*time.Second, tablesToLock)
+       if err != nil {
+               err = errors.Conflict.Wrap(err, "lock pipelines table timedout")
+               return
+       }
+       count := errors.Must1(tx.Count(
+               dal.From("_devlake_pipelines"),
+               dal.Where("status = ?", models.TASK_RUNNING),
+       ))
+       if count > 0 {
+               err = errors.Conflict.New("at least one pipeline is running")
+               return
+       }
+       // time.Sleep(1 * time.Minute) # uncomment this line if you were to 
verify pipelines get blocked while deleting data
+       // creating a nested transaction to avoid mysql complaining about 
table(s) NOT being locked
+       nextedTxHelper := dbhelper.NewTxHelper(srv.basicRes, &err)
+       defer nextedTxHelper.End()
+       nestedTX := nextedTxHelper.Begin()
+       err = fn(nestedTX)
+       return
+}
diff --git a/backend/helpers/srvhelper/pagination.go 
b/backend/helpers/srvhelper/pagination.go
new file mode 100644
index 000000000..8b6b96300
--- /dev/null
+++ b/backend/helpers/srvhelper/pagination.go
@@ -0,0 +1,42 @@
+/*
+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 srvhelper
+
+type Pagination struct {
+       Page       int    `json:"page" mapstructure:"page" validate:"min=1"`
+       PageSize   int    `json:"pageSize" mapstructure:"pageSize" 
validate:"min=1,max=1000"`
+       SearchTerm string `json:"searchTerm" mapstructure:"searchTerm"`
+}
+
+func (pagination *Pagination) GetPage() int {
+       if pagination.Page > 0 {
+               return pagination.Page
+       }
+       return 1
+}
+
+func (pagination *Pagination) GetLimit() int {
+       if pagination.PageSize > 0 {
+               return pagination.PageSize
+       }
+       return 100
+}
+
+func (pagination *Pagination) GetOffset() int {
+       return (pagination.GetPage() - 1) * pagination.GetLimit()
+}
diff --git a/backend/helpers/srvhelper/scope_config_service_helper.go 
b/backend/helpers/srvhelper/scope_config_service_helper.go
new file mode 100644
index 000000000..3ce1cad9a
--- /dev/null
+++ b/backend/helpers/srvhelper/scope_config_service_helper.go
@@ -0,0 +1,66 @@
+/*
+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 srvhelper
+
+import (
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/dal"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/plugin"
+)
+
+// ScopeConfigSrvHelper
+type ScopeConfigSrvHelper[C plugin.ToolLayerConnection, S 
plugin.ToolLayerScope, SC plugin.ToolLayerScopeConfig] struct {
+       *ModelSrvHelper[SC]
+}
+
+func NewScopeConfigSrvHelper[
+       C plugin.ToolLayerConnection,
+       S plugin.ToolLayerScope,
+       SC plugin.ToolLayerScopeConfig,
+](basicRes context.BasicRes) *ScopeConfigSrvHelper[C, S, SC] {
+       return &ScopeConfigSrvHelper[C, S, SC]{
+               ModelSrvHelper: NewModelSrvHelper[SC](basicRes),
+       }
+}
+
+func (scopeConfigSrv *ScopeConfigSrvHelper[C, S, SC]) 
GetAllByConnectionId(connectionId uint64) ([]*SC, errors.Error) {
+       var scopeConfigs []*SC
+       err := scopeConfigSrv.db.All(&scopeConfigs,
+               dal.Where("connection_id = ?", connectionId),
+               dal.Orderby("id DESC"),
+       )
+       return scopeConfigs, err
+}
+
+func (scopeConfigSrv *ScopeConfigSrvHelper[C, S, SC]) 
DeleteScopeConfig(scopeConfig *SC) (refs []*S, err errors.Error) {
+       err = scopeConfigSrv.ModelSrvHelper.NoRunningPipeline(func(tx 
dal.Transaction) errors.Error {
+               // make sure no scope is using the scopeConfig
+               sc := *scopeConfig
+               errors.Must(tx.All(
+                       &refs,
+                       dal.Where("connection_id = ? AND scope_config_id = ?", 
sc.ScopeConfigConnectionId(), sc.ScopeConfigId()),
+               ))
+               if len(refs) > 0 {
+                       return errors.Conflict.New("Please delete all data 
scope(s) before you delete this ScopeConfig.")
+               }
+               errors.Must(tx.Delete(scopeConfig))
+               return nil
+       })
+       return
+}
diff --git a/backend/helpers/srvhelper/scope_service_helper.go 
b/backend/helpers/srvhelper/scope_service_helper.go
new file mode 100644
index 000000000..f4fab4ce6
--- /dev/null
+++ b/backend/helpers/srvhelper/scope_service_helper.go
@@ -0,0 +1,276 @@
+/*
+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 srvhelper
+
+import (
+       "fmt"
+       "reflect"
+       "strings"
+
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/dal"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/models"
+       "github.com/apache/incubator-devlake/core/models/domainlayer/domaininfo"
+       "github.com/apache/incubator-devlake/core/plugin"
+)
+
+type ScopePagination struct {
+       Pagination   `mapstructure:",squash"`
+       ConnectionId uint64 `json:"connectionId" mapstructure:"connectionId" 
validate:"required"`
+       Blueprints   bool   `json:"blueprints" mapstructure:"blueprints"`
+}
+
+type ScopeDetail[S plugin.ToolLayerScope, SC plugin.ToolLayerScopeConfig] 
struct {
+       Scope       S                   `json:"scope"`
+       ScopeConfig *SC                 `json:"scopeConfig,omitempty"`
+       Blueprints  []*models.Blueprint `json:"blueprints,omitempty"`
+}
+
+type ScopeSrvHelper[C plugin.ToolLayerConnection, S plugin.ToolLayerScope, SC 
plugin.ToolLayerScopeConfig] struct {
+       *ModelSrvHelper[S]
+       pluginName    string
+       searchColumns []string
+}
+
+// NewScopeSrvHelper creates a ScopeDalHelper for scope management
+func NewScopeSrvHelper[
+       C plugin.ToolLayerConnection,
+       S plugin.ToolLayerScope,
+       SC plugin.ToolLayerScopeConfig,
+](
+       basicRes context.BasicRes,
+       pluginName string,
+       searchColumns []string,
+) *ScopeSrvHelper[C, S, SC] {
+       return &ScopeSrvHelper[C, S, SC]{
+               ModelSrvHelper: NewModelSrvHelper[S](basicRes),
+               pluginName:     pluginName,
+               searchColumns:  searchColumns,
+       }
+}
+
+func (scopeSrv *ScopeSrvHelper[C, S, SC]) Validate(scope *S) errors.Error {
+       connectionId := (*scope).ScopeConnectionId()
+       connectionCount := errors.Must1(scopeSrv.db.Count(dal.From(new(SC)), 
dal.Where("id = ?", connectionId)))
+       if connectionCount == 0 {
+               return errors.BadInput.New("connectionId is invalid")
+       }
+       scopeConfigId := (*scope).ScopeScopeConfigId()
+       scopeConfigCount := errors.Must1(scopeSrv.db.Count(dal.From(new(SC)), 
dal.Where("id = ?", scopeConfigId)))
+       if scopeConfigCount == 0 {
+               return errors.BadInput.New("scopeConfigId is invalid")
+       }
+       return nil
+}
+
+func (scopeSrv *ScopeSrvHelper[C, S, SC]) GetScopeDetail(includeBlueprints 
bool, pkv ...interface{}) (*ScopeDetail[S, SC], errors.Error) {
+       scope, err := scopeSrv.ModelSrvHelper.FindByPk(pkv...)
+       if err != nil {
+               return nil, err
+       }
+       s := *scope
+       scopeDetail := &ScopeDetail[S, SC]{
+               Scope:       s,
+               ScopeConfig: scopeSrv.getScopeConfig(s.ScopeScopeConfigId()),
+       }
+       if includeBlueprints {
+               scopeDetail.Blueprints = 
scopeSrv.getAllBlueprinsByScope(s.ScopeConnectionId(), s.ScopeId())
+       }
+       return scopeDetail, nil
+}
+
+func (scopeSrv *ScopeSrvHelper[C, S, SC]) GetScopesPage(pagination 
*ScopePagination) ([]*ScopeDetail[S, SC], int64, errors.Error) {
+       if pagination.ConnectionId < 1 {
+               return nil, 0, errors.BadInput.New("connectionId is required")
+       }
+       scopes, count, err := scopeSrv.ModelSrvHelper.GetPage(
+               &pagination.Pagination,
+               dal.Where("connection_id = ?", pagination.ConnectionId),
+       )
+       if err != nil {
+               return nil, 0, err
+       }
+
+       data := make([]*ScopeDetail[S, SC], len(scopes))
+       for i, s := range scopes {
+               // load blueprints
+               scope := (*s)
+               scopeDetail := &ScopeDetail[S, SC]{
+                       Scope:       scope,
+                       ScopeConfig: 
scopeSrv.getScopeConfig(scope.ScopeScopeConfigId()),
+               }
+               if pagination.Blueprints {
+                       scopeDetail.Blueprints = 
scopeSrv.getAllBlueprinsByScope(scope.ScopeConnectionId(), scope.ScopeId())
+               }
+               data[i] = scopeDetail
+       }
+       return data, count, nil
+}
+
+func (scopeSrv *ScopeSrvHelper[C, S, SC]) DeleteScope(scope *S, dataOnly bool) 
(refs *DsRefs, err errors.Error) {
+       err = scopeSrv.ModelSrvHelper.NoRunningPipeline(func(tx 
dal.Transaction) errors.Error {
+               s := (*scope)
+               // check referencing blueprints
+               if !dataOnly {
+                       refs = 
toDsRefs(scopeSrv.getAllBlueprinsByScope(s.ScopeConnectionId(), s.ScopeId()))
+                       if refs != nil {
+                               return errors.Conflict.New("Cannot delete the 
scope because it is referenced by blueprints")
+                       }
+                       errors.Must(tx.Delete(scope))
+               }
+               // delete data
+               scopeSrv.deleteScopeData(s, tx)
+               return nil
+       })
+       return
+}
+
+func (scopeSrv *ScopeSrvHelper[C, S, SC]) getScopeConfig(scopeConfigId uint64) 
*SC {
+       if scopeConfigId < 1 {
+               return nil
+       }
+       scopeConfig := new(SC)
+       errors.Must(scopeSrv.db.First(
+               scopeConfig,
+               dal.Where(
+                       "id = ?",
+                       scopeConfigId,
+               ),
+       ))
+       return scopeConfig
+}
+
+func (scopeSrv *ScopeSrvHelper[C, S, SC]) getAllBlueprinsByScope(connectionId 
uint64, scopeId string) []*models.Blueprint {
+       blueprints := make([]*models.Blueprint, 0)
+       errors.Must(scopeSrv.db.All(
+               &blueprints,
+               dal.From("_devlake_blueprints bp"),
+               dal.Join("JOIN _devlake_blueprint_scopes sc ON sc.blueprint_id 
= bp.id"),
+               dal.Where(
+                       "mode = ? AND sc.connection_id = ? AND sc.plugin_name = 
? AND sc.scope_id = ?",
+                       "NORMAL",
+                       connectionId,
+                       scopeSrv.pluginName,
+                       scopeId,
+               ),
+       ))
+       return blueprints
+}
+
+func (scopeSrv *ScopeSrvHelper[C, S, SC]) deleteScopeData(scope 
plugin.ToolLayerScope, tx dal.Transaction) {
+       rawDataParams := plugin.MarshalScopeParams(scope.ScopeParams())
+       generateWhereClause := func(table string) (string, []any) {
+               var where string
+               var params []interface{}
+               if strings.HasPrefix(table, "_raw_") {
+                       // raw table: should check connection and scope
+                       where = "params = ?"
+                       params = []interface{}{rawDataParams}
+               } else if strings.HasPrefix(table, "_tool_") {
+                       // tool layer table: should check connection and scope
+                       where = "_raw_data_params = ?"
+                       params = []interface{}{rawDataParams}
+               } else {
+                       // framework tables: should check plugin, connection 
and scope
+                       if table == (models.CollectorLatestState{}.TableName()) 
{
+                               // diff sync state
+                               where = "raw_data_table LIKE ? AND 
raw_data_params = ?"
+                       } else {
+                               // domain layer table
+                               where = "_raw_data_table LIKE ? AND 
_raw_data_params = ?"
+                       }
+                       rawDataTablePrefix := fmt.Sprintf("_raw_%s%%", 
scopeSrv.pluginName)
+                       params = []interface{}{rawDataTablePrefix, 
rawDataParams}
+               }
+               return where, params
+       }
+       tables := errors.Must1(scopeSrv.getAffectedTables())
+       for _, table := range tables {
+               where, params := generateWhereClause(table)
+               scopeSrv.log.Info("deleting data from table %s with WHERE 
\"%s\" and params: \"%v\"", table, where, params)
+               sql := fmt.Sprintf("DELETE FROM %s WHERE %s", table, where)
+               errors.Must(tx.Exec(sql, params...))
+       }
+}
+
+func (scopeSrv *ScopeSrvHelper[C, S, SC]) getAffectedTables() ([]string, 
errors.Error) {
+       var tables []string
+       meta, err := plugin.GetPlugin(scopeSrv.pluginName)
+       if err != nil {
+               return nil, err
+       }
+       if pluginModel, ok := meta.(plugin.PluginModel); !ok {
+               panic(errors.Default.New(fmt.Sprintf("plugin \"%s\" does not 
implement listing its tables", scopeSrv.pluginName)))
+       } else {
+               // Unfortunately, can't cache the tables because Python creates 
some tables on a per-demand basis, so such a cache would possibly get outdated.
+               // It's a rare scenario in practice, but might as well play it 
safe and sacrifice some performance here
+               var allTables []string
+               if allTables, err = scopeSrv.db.AllTables(); err != nil {
+                       return nil, err
+               }
+               // collect raw tables
+               for _, table := range allTables {
+                       if strings.HasPrefix(table, 
"_raw_"+scopeSrv.pluginName) {
+                               tables = append(tables, table)
+                       }
+               }
+               // collect tool tables
+               toolModels := pluginModel.GetTablesInfo()
+               for _, toolModel := range toolModels {
+                       if !isScopeModel(toolModel) && hasField(toolModel, 
"RawDataParams") {
+                               tables = append(tables, toolModel.TableName())
+                       }
+               }
+               // collect domain tables
+               for _, domainModel := range domaininfo.GetDomainTablesInfo() {
+                       // we only care about tables with RawOrigin
+                       ok = hasField(domainModel, "RawDataParams")
+                       if ok {
+                               tables = append(tables, domainModel.TableName())
+                       }
+               }
+               // additional tables
+               tables = append(tables, 
models.CollectorLatestState{}.TableName())
+       }
+       scopeSrv.log.Debug("Discovered %d tables used by plugin \"%s\": %v", 
len(tables), scopeSrv.pluginName, tables)
+       return tables, nil
+}
+
+// TODO: sort out the follow functions
+func isScopeModel(obj dal.Tabler) bool {
+       _, ok := obj.(plugin.ToolLayerScope)
+       return ok
+}
+
+func hasField(obj any, fieldName string) bool {
+       obj = models.UnwrapObject(obj)
+       _, ok := reflectType(obj).FieldByName(fieldName)
+       return ok
+}
+
+func reflectType(obj any) reflect.Type {
+       obj = models.UnwrapObject(obj)
+       typ := reflect.TypeOf(obj)
+       kind := typ.Kind()
+       for kind == reflect.Ptr {
+               typ = typ.Elem()
+               kind = typ.Kind()
+       }
+       return typ
+}
diff --git a/backend/helpers/utils/mapstructure.go 
b/backend/helpers/utils/mapstructure.go
index d7a109ebb..22ea85949 100644
--- a/backend/helpers/utils/mapstructure.go
+++ b/backend/helpers/utils/mapstructure.go
@@ -38,6 +38,17 @@ func DecodeHook(f reflect.Type, t reflect.Type, data 
interface{}) (interface{},
        if t == reflect.TypeOf(json.RawMessage{}) {
                return json.Marshal(data)
        }
+       // to support decoding url.Values (query string) to non-array variables
+       if t.Kind() != reflect.Slice && t.Kind() != reflect.Array &&
+               (f.Kind() == reflect.Slice || f.Kind() == reflect.Array) {
+               v := reflect.ValueOf(data)
+               if v.Len() == 1 {
+                       data = v.Index(0).Interface()
+                       var result interface{}
+                       err := DecodeMapStruct(data, &result, true)
+                       return result, err
+               }
+       }
 
        if t != reflect.TypeOf(common.Iso8601Time{}) && t != 
reflect.TypeOf(time.Time{}) {
                return data, nil
@@ -65,12 +76,13 @@ func DecodeHook(f reflect.Type, t reflect.Type, data 
interface{}) (interface{},
 }
 
 // DecodeMapStruct with time.Time and Iso8601Time support
-func DecodeMapStruct(input map[string]interface{}, result interface{}, 
zeroFields bool) errors.Error {
+func DecodeMapStruct(input interface{}, result interface{}, zeroFields bool) 
errors.Error {
        result = models.UnwrapObject(result)
        decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
-               ZeroFields: zeroFields,
-               DecodeHook: mapstructure.ComposeDecodeHookFunc(DecodeHook),
-               Result:     result,
+               ZeroFields:       zeroFields,
+               DecodeHook:       
mapstructure.ComposeDecodeHookFunc(DecodeHook),
+               Result:           result,
+               WeaklyTypedInput: true,
        })
        if err != nil {
                return errors.Convert(err)
diff --git a/backend/helpers/utils/mapstructure_test.go 
b/backend/helpers/utils/mapstructure_test.go
index 3758632d1..755dea4d2 100644
--- a/backend/helpers/utils/mapstructure_test.go
+++ b/backend/helpers/utils/mapstructure_test.go
@@ -20,6 +20,7 @@ package utils
 import (
        "encoding/json"
        "fmt"
+       "net/url"
        "testing"
        "time"
 
@@ -138,3 +139,19 @@ func TestIso8601Time(t *testing.T) {
                assert.Equal(t, expected, record4.Created.UTC())
        }
 }
+
+func TestDecodeMapStructUrlVales(t *testing.T) {
+       query := &url.Values{}
+       query.Set("page", "1")
+       query.Set("pageSize", "100")
+
+       var pagination struct {
+               Page     int `mapstructure:"page"`
+               PageSize int `mapstructure:"pageSize"`
+       }
+
+       err := DecodeMapStruct(query, &pagination, true)
+       assert.Nil(t, err)
+       assert.Equal(t, 1, pagination.Page)
+       assert.Equal(t, 100, pagination.PageSize)
+}
diff --git a/backend/impls/dalgorm/dalgorm.go b/backend/impls/dalgorm/dalgorm.go
index 21c0f5bdc..c5518a889 100644
--- a/backend/impls/dalgorm/dalgorm.go
+++ b/backend/impls/dalgorm/dalgorm.go
@@ -25,6 +25,7 @@ import (
 
        "github.com/apache/incubator-devlake/core/dal"
        "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/models"
        "github.com/apache/incubator-devlake/core/utils"
 
        "gorm.io/gorm"
@@ -105,6 +106,8 @@ func buildTx(tx *gorm.DB, clauses []dal.Clause) *gorm.DB {
                                        Alias: dd.Alias,
                                        Raw:   dd.Raw,
                                })
+                       case models.DynamicTabler:
+                               tx = tx.Table(dd.TableName())
                        default:
                                tx = tx.Model(d)
                        }
@@ -138,8 +141,26 @@ func (d *Dalgorm) Exec(query string, params 
...interface{}) errors.Error {
        return d.convertGormError(d.db.Exec(query, 
transformParams(params)...).Error)
 }
 
+func (d *Dalgorm) unwrapDynamic(entityPtr *interface{}, clausesPtr 
*[]dal.Clause) {
+       if dynamic, ok := (*entityPtr).(models.DynamicTabler); ok {
+               if clausesPtr != nil {
+                       *clausesPtr = append(*clausesPtr, 
dal.From(dynamic.TableName()))
+               }
+               *entityPtr = dynamic.Unwrap()
+       } else if clausesPtr != nil {
+               // try to add dal.From if it does not exist
+               for _, c := range *clausesPtr {
+                       if c.Type == dal.FromClause {
+                               return
+                       }
+               }
+               *clausesPtr = append(*clausesPtr, dal.From(*entityPtr))
+       }
+}
+
 // AutoMigrate runs auto migration for given models
 func (d *Dalgorm) AutoMigrate(entity interface{}, clauses ...dal.Clause) 
errors.Error {
+       d.unwrapDynamic(&entity, &clauses)
        err := buildTx(d.db, clauses).AutoMigrate(entity)
        if err == nil {
                // fix pg cache plan error
@@ -170,11 +191,13 @@ func (d *Dalgorm) Fetch(cursor dal.Rows, dst interface{}) 
errors.Error {
 
 // All loads matched rows from database to `dst`, USE IT WITH COUTIOUS!!
 func (d *Dalgorm) All(dst interface{}, clauses ...dal.Clause) errors.Error {
+       d.unwrapDynamic(&dst, &clauses)
        return d.convertGormError(buildTx(d.db, clauses).Find(dst).Error)
 }
 
 // First loads first matched row from database to `dst`, error will be 
returned if no records were found
 func (d *Dalgorm) First(dst interface{}, clauses ...dal.Clause) errors.Error {
+       d.unwrapDynamic(&dst, &clauses)
        return d.convertGormError(buildTx(d.db, clauses).First(dst).Error)
 }
 
@@ -192,45 +215,52 @@ func (d *Dalgorm) Pluck(column string, dest interface{}, 
clauses ...dal.Clause)
 
 // Create insert record to database
 func (d *Dalgorm) Create(entity interface{}, clauses ...dal.Clause) 
errors.Error {
+       d.unwrapDynamic(&entity, &clauses)
        return d.convertGormError(buildTx(d.db, clauses).Create(entity).Error)
 }
 
 // CreateWithMap insert record to database
 func (d *Dalgorm) CreateWithMap(entity interface{}, record 
map[string]interface{}) errors.Error {
+       d.unwrapDynamic(&entity, nil)
        return d.convertGormError(buildTx(d.db, 
nil).Model(entity).Clauses(clause.OnConflict{UpdateAll: 
true}).Create(record).Error)
 }
 
 // Update updates record
 func (d *Dalgorm) Update(entity interface{}, clauses ...dal.Clause) 
errors.Error {
+       d.unwrapDynamic(&entity, &clauses)
        return d.convertGormError(buildTx(d.db, clauses).Save(entity).Error)
 }
 
 // CreateOrUpdate tries to create the record, or fallback to update all if 
failed
 func (d *Dalgorm) CreateOrUpdate(entity interface{}, clauses ...dal.Clause) 
errors.Error {
+       d.unwrapDynamic(&entity, &clauses)
        return d.convertGormError(buildTx(d.db, 
clauses).Clauses(clause.OnConflict{UpdateAll: true}).Create(entity).Error)
 }
 
 // CreateIfNotExist tries to create the record if not exist
 func (d *Dalgorm) CreateIfNotExist(entity interface{}, clauses ...dal.Clause) 
errors.Error {
+       d.unwrapDynamic(&entity, &clauses)
        return d.convertGormError(buildTx(d.db, 
clauses).Clauses(clause.OnConflict{DoNothing: true}).Create(entity).Error)
 }
 
 // Delete records from database
 func (d *Dalgorm) Delete(entity interface{}, clauses ...dal.Clause) 
errors.Error {
+       d.unwrapDynamic(&entity, &clauses)
        return d.convertGormError(buildTx(d.db, clauses).Delete(entity).Error)
 }
 
 // UpdateColumn allows you to update mulitple records
 func (d *Dalgorm) UpdateColumn(entityOrTable interface{}, columnName string, 
value interface{}, clauses ...dal.Clause) errors.Error {
+       d.unwrapDynamic(&entityOrTable, &clauses)
        if expr, ok := value.(dal.DalClause); ok {
                value = gorm.Expr(expr.Expr, transformParams(expr.Params)...)
        }
-       clauses = append(clauses, dal.From(entityOrTable))
        return d.convertGormError(buildTx(d.db, clauses).Update(columnName, 
value).Error)
 }
 
 // UpdateColumns allows you to update multiple columns of mulitple records
 func (d *Dalgorm) UpdateColumns(entityOrTable interface{}, set []dal.DalSet, 
clauses ...dal.Clause) errors.Error {
+       d.unwrapDynamic(&entityOrTable, &clauses)
        updatesSet := make(map[string]interface{})
 
        for _, s := range set {
diff --git a/backend/plugins/github/api/blueprint_v200.go 
b/backend/plugins/github/api/blueprint_v200.go
index b6b508930..0ab128e4b 100644
--- a/backend/plugins/github/api/blueprint_v200.go
+++ b/backend/plugins/github/api/blueprint_v200.go
@@ -86,10 +86,17 @@ func makeDataSourcePipelinePlanV200(
                        stage = coreModels.PipelineStage{}
                }
                // get repo and scope config from db
-               githubRepo, scopeConfig, err := 
scopeHelper.DbHelper().GetScopeAndConfig(connection.ID, bpScope.ScopeId)
+               githubRepo, err := scopeSrv.FindByPk(connection.ID, 
bpScope.ScopeId)
                if err != nil {
                        return nil, err
                }
+               var scopeConfig *models.GithubScopeConfig
+               if githubRepo.ScopeConfigId != 0 {
+                       scopeConfig, err = 
scSrv.FindByPk(githubRepo.ScopeConfigId)
+                       if err != nil {
+                               return nil, err
+                       }
+               }
                // refdiff
                if scopeConfig != nil && scopeConfig.Refdiff != nil {
                        // add a new task to next stage
diff --git a/backend/plugins/github/api/connection.go 
b/backend/plugins/github/api/connection_api.go
similarity index 91%
rename from backend/plugins/github/api/connection.go
rename to backend/plugins/github/api/connection_api.go
index cb508ad58..618d39fe0 100644
--- a/backend/plugins/github/api/connection.go
+++ b/backend/plugins/github/api/connection_api.go
@@ -212,12 +212,7 @@ func TestConnection(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput,
 // @Failure 500  {string} errcode.Error "Internal Error"
 // @Router /plugins/github/connections [POST]
 func PostConnections(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
-       connection := &models.GithubConnection{}
-       err := connectionHelper.Create(connection, input)
-       if err != nil {
-               return nil, err
-       }
-       return &plugin.ApiResourceOutput{Body: connection, Status: 
http.StatusOK}, nil
+       return connApi.Post(input)
 }
 
 // @Summary patch github connection
@@ -229,12 +224,7 @@ func PostConnections(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput,
 // @Failure 500  {string} errcode.Error "Internal Error"
 // @Router /plugins/github/connections/{connectionId} [PATCH]
 func PatchConnection(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
-       connection := &models.GithubConnection{}
-       err := connectionHelper.Patch(connection, input)
-       if err != nil {
-               return nil, err
-       }
-       return &plugin.ApiResourceOutput{Body: connection, Status: 
http.StatusOK}, nil
+       return connApi.Patch(input)
 }
 
 // @Summary delete a github connection
@@ -246,7 +236,7 @@ func PatchConnection(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput,
 // @Failure 500  {string} errcode.Error "Internal Error"
 // @Router /plugins/github/connections/{connectionId} [DELETE]
 func DeleteConnection(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
-       return connectionHelper.Delete(&models.GithubConnection{}, input)
+       return connApi.Delete(input)
 }
 
 // @Summary get all github connections
@@ -257,13 +247,7 @@ func DeleteConnection(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput
 // @Failure 500  {string} errcode.Error "Internal Error"
 // @Router /plugins/github/connections [GET]
 func ListConnections(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
-       var connections []models.GithubConnection
-       err := connectionHelper.List(&connections)
-       if err != nil {
-               return nil, err
-       }
-
-       return &plugin.ApiResourceOutput{Body: connections}, nil
+       return connApi.GetAll(input)
 }
 
 // @Summary get github connection detail
@@ -274,10 +258,5 @@ func ListConnections(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput,
 // @Failure 500  {string} errcode.Error "Internal Error"
 // @Router /plugins/github/connections/{connectionId} [GET]
 func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, 
errors.Error) {
-       connection := &models.GithubConnection{}
-       err := connectionHelper.First(connection, input.Params)
-       if err != nil {
-               return nil, err
-       }
-       return &plugin.ApiResourceOutput{Body: connection}, nil
+       return connApi.GetDetail(input)
 }
diff --git a/backend/plugins/github/api/init.go 
b/backend/plugins/github/api/init.go
index 2459b86d0..a976677d9 100644
--- a/backend/plugins/github/api/init.go
+++ b/backend/plugins/github/api/init.go
@@ -18,65 +18,40 @@ limitations under the License.
 package api
 
 import (
-       "strconv"
-
        "github.com/apache/incubator-devlake/core/plugin"
 
        "github.com/apache/incubator-devlake/core/context"
-       "github.com/apache/incubator-devlake/core/dal"
-       "github.com/apache/incubator-devlake/core/errors"
        "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+       "github.com/apache/incubator-devlake/helpers/srvhelper"
        "github.com/apache/incubator-devlake/plugins/github/models"
        "github.com/go-playground/validator/v10"
 )
 
 var vld *validator.Validate
+
+// var connSrv *srvhelper.ConnectionSrvHelper[models.GithubConnection, 
models.GithubRepo, models.GithubScopeConfig]
+var connApi *api.DsConnectionApiHelper[models.GithubConnection, 
models.GithubRepo, models.GithubScopeConfig]
+var scopeSrv *srvhelper.ScopeSrvHelper[models.GithubConnection, 
models.GithubRepo, models.GithubScopeConfig]
+var scopeApi *api.DsScopeApiHelper[models.GithubConnection, models.GithubRepo, 
models.GithubScopeConfig]
+var scSrv *srvhelper.ScopeConfigSrvHelper[models.GithubConnection, 
models.GithubRepo, models.GithubScopeConfig]
+var scApi *api.DsScopeConfigApiHelper[models.GithubConnection, 
models.GithubRepo, models.GithubScopeConfig]
 var connectionHelper *api.ConnectionApiHelper
-var scopeHelper *api.ScopeApiHelper[models.GithubConnection, 
models.GithubRepo, models.GithubScopeConfig]
 var basicRes context.BasicRes
-var scHelper *api.ScopeConfigHelper[models.GithubScopeConfig]
 var remoteHelper *api.RemoteApiHelper[models.GithubConnection, 
models.GithubRepo, repo, plugin.ApiGroup]
 
 func Init(br context.BasicRes, p plugin.PluginMeta) {
-
        basicRes = br
-       vld = validator.New()
-       connectionHelper = api.NewConnectionHelper(
-               basicRes,
-               vld,
+       _, connApi, scopeSrv, scopeApi, scSrv, scApi = api.NewDataSourceHelpers[
+               models.GithubConnection,
+               models.GithubRepo, models.GithubScopeConfig,
+       ](
+               br,
                p.Name(),
+               []string{"full_name"},
        )
-       params := &api.ReflectionParameters{
-               ScopeIdFieldName:     "GithubId",
-               ScopeIdColumnName:    "github_id",
-               RawScopeParamName:    "Name",
-               SearchScopeParamName: "name",
-       }
-       scopeHelper = api.NewScopeHelper[models.GithubConnection, 
models.GithubRepo, models.GithubScopeConfig](
-               basicRes,
-               vld,
-               connectionHelper,
-               api.NewScopeDatabaseHelperImpl[models.GithubConnection, 
models.GithubRepo, models.GithubScopeConfig](
-                       basicRes, connectionHelper, params),
-               params,
-               &api.ScopeHelperOptions{
-                       GetScopeParamValue: func(db dal.Dal, scopeId string) 
(string, errors.Error) {
-                               id, err := 
errors.Convert01(strconv.ParseInt(scopeId, 10, 64))
-                               if err != nil {
-                                       return "", err
-                               }
-                               repo := models.GithubRepo{
-                                       GithubId: int(id),
-                               }
-                               err = db.First(&repo)
-                               if err != nil {
-                                       return "", err
-                               }
-                               return repo.FullName, nil
-                       },
-               },
-       )
-       scHelper = api.NewScopeConfigHelper[models.GithubScopeConfig](
+       // TODO: refactor remoteHelper
+       vld = validator.New()
+       connectionHelper = api.NewConnectionHelper(
                basicRes,
                vld,
                p.Name(),
diff --git a/backend/plugins/github/api/scope.go 
b/backend/plugins/github/api/scope_api.go
similarity index 80%
rename from backend/plugins/github/api/scope.go
rename to backend/plugins/github/api/scope_api.go
index 8df469f7d..902069416 100644
--- a/backend/plugins/github/api/scope.go
+++ b/backend/plugins/github/api/scope_api.go
@@ -24,12 +24,8 @@ import (
        "github.com/apache/incubator-devlake/plugins/github/models"
 )
 
-type ScopeRes struct {
-       models.GithubRepo
-       api.ScopeResDoc[models.GithubScopeConfig]
-}
-
-type ScopeReq api.ScopeReq[models.GithubRepo]
+type PutScopesReqBody api.PutScopesReqBody[models.GithubRepo]
+type ScopeDetail api.ScopeDetail[models.GithubRepo, models.GithubScopeConfig]
 
 // PutScope create or update github repo
 // @Summary create or update github repo
@@ -37,13 +33,13 @@ type ScopeReq api.ScopeReq[models.GithubRepo]
 // @Tags plugins/github
 // @Accept application/json
 // @Param connectionId path int true "connection ID"
-// @Param scope body ScopeReq true "json"
-// @Success 200  {object} []ScopeRes
+// @Param scope body PutScopesReqBody true "json"
+// @Success 200  {object} []models.GithubRepo
 // @Failure 400  {object} shared.ApiBody "Bad Request"
 // @Failure 500  {object} shared.ApiBody "Internal Error"
 // @Router /plugins/github/connections/{connectionId}/scopes [PUT]
-func PutScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, 
errors.Error) {
-       return scopeHelper.Put(input)
+func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, 
errors.Error) {
+       return scopeApi.PutMultiple(input)
 }
 
 // UpdateScope patch to github repo
@@ -54,12 +50,12 @@ func PutScope(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors
 // @Param connectionId path int true "connection ID"
 // @Param scopeId path int true "scope ID"
 // @Param scope body models.GithubRepo true "json"
-// @Success 200  {object} ScopeRes
+// @Success 200  {object} models.GithubRepo
 // @Failure 400  {object} shared.ApiBody "Bad Request"
 // @Failure 500  {object} shared.ApiBody "Internal Error"
 // @Router /plugins/github/connections/{connectionId}/scopes/{scopeId} [PATCH]
-func UpdateScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, 
errors.Error) {
-       return scopeHelper.Update(input)
+func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, 
errors.Error) {
+       return scopeApi.Patch(input)
 }
 
 // GetScopeList get Github repos
@@ -71,12 +67,12 @@ func UpdateScope(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, err
 // @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} []ScopeRes
+// @Success 200  {object} []ScopeDetail
 // @Failure 400  {object} shared.ApiBody "Bad Request"
 // @Failure 500  {object} shared.ApiBody "Internal Error"
 // @Router /plugins/github/connections/{connectionId}/scopes [GET]
-func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, 
errors.Error) {
-       return scopeHelper.GetScopeList(input)
+func GetScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, 
errors.Error) {
+       return scopeApi.GetPage(input)
 }
 
 // GetScope get one Github repo
@@ -85,12 +81,13 @@ func GetScopeList(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, er
 // @Tags plugins/github
 // @Param connectionId path int true "connection ID"
 // @Param scopeId path int true "scope ID"
-// @Success 200  {object} ScopeRes
+// @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/github/connections/{connectionId}/scopes/{scopeId} [GET]
 func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, 
errors.Error) {
-       return scopeHelper.GetScope(input)
+       return scopeApi.GetScopeDetail(input)
 }
 
 // DeleteScope delete plugin data associated with the scope and optionally the 
scope itself
@@ -100,11 +97,11 @@ func GetScope(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors
 // @Param connectionId path int true "connection ID"
 // @Param scopeId path int true "scope ID"
 // @Param delete_data_only query bool false "Only delete the scope data, not 
the scope itself"
-// @Success 200
+// @Success 200  {object} models.GithubRepo
 // @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/github/connections/{connectionId}/scopes/{scopeId} [DELETE]
 func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, 
errors.Error) {
-       return scopeHelper.Delete(input)
+       return scopeApi.Delete(input)
 }
diff --git a/backend/plugins/github/api/scope_config.go 
b/backend/plugins/github/api/scope_config_api.go
similarity index 89%
rename from backend/plugins/github/api/scope_config.go
rename to backend/plugins/github/api/scope_config_api.go
index ee1ee8f8e..351eeb24b 100644
--- a/backend/plugins/github/api/scope_config.go
+++ b/backend/plugins/github/api/scope_config_api.go
@@ -22,7 +22,7 @@ import (
        "github.com/apache/incubator-devlake/core/plugin"
 )
 
-// CreateScopeConfig create scope config for Github
+// PostScopeConfig create scope config for Github
 // @Summary create scope config for Github
 // @Description create scope config for Github
 // @Tags plugins/github
@@ -33,11 +33,11 @@ import (
 // @Failure 400  {object} shared.ApiBody "Bad Request"
 // @Failure 500  {object} shared.ApiBody "Internal Error"
 // @Router /plugins/github/connections/{connectionId}/scope-configs [POST]
-func CreateScopeConfig(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
-       return scHelper.Create(input)
+func PostScopeConfig(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
+       return scApi.Post(input)
 }
 
-// UpdateScopeConfig update scope config for Github
+// PatchScopeConfig update scope config for Github
 // @Summary update scope config for Github
 // @Description update scope config for Github
 // @Tags plugins/github
@@ -49,8 +49,8 @@ func CreateScopeConfig(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutpu
 // @Failure 400  {object} shared.ApiBody "Bad Request"
 // @Failure 500  {object} shared.ApiBody "Internal Error"
 // @Router /plugins/github/connections/{connectionId}/scope-configs/{id} 
[PATCH]
-func UpdateScopeConfig(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
-       return scHelper.Update(input)
+func PatchScopeConfig(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
+       return scApi.Patch(input)
 }
 
 // GetScopeConfig return one scope config
@@ -64,7 +64,7 @@ func UpdateScopeConfig(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutpu
 // @Failure 500  {object} shared.ApiBody "Internal Error"
 // @Router /plugins/github/connections/{connectionId}/scope-configs/{id} [GET]
 func GetScopeConfig(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
-       return scHelper.Get(input)
+       return scApi.GetDetail(input)
 }
 
 // GetScopeConfigList return all scope configs
@@ -79,7 +79,7 @@ func GetScopeConfig(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput,
 // @Failure 500  {object} shared.ApiBody "Internal Error"
 // @Router /plugins/github/connections/{connectionId}/scope-configs [GET]
 func GetScopeConfigList(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
-       return scHelper.List(input)
+       return scApi.GetAll(input)
 }
 
 // DeleteScopeConfig delete a scope config
@@ -93,5 +93,5 @@ func GetScopeConfigList(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutp
 // @Failure 500  {object} shared.ApiBody "Internal Error"
 // @Router /plugins/github/connections/{connectionId}/scope-configs/{id} 
[DELETE]
 func DeleteScopeConfig(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
-       return scHelper.Delete(input)
+       return scApi.Delete(input)
 }
diff --git a/backend/plugins/github/impl/impl.go 
b/backend/plugins/github/impl/impl.go
index 2a2d0d4ed..f87dcc63c 100644
--- a/backend/plugins/github/impl/impl.go
+++ b/backend/plugins/github/impl/impl.go
@@ -185,19 +185,19 @@ func (p Github) ApiResources() 
map[string]map[string]plugin.ApiResourceHandler {
                },
                "connections/:connectionId/scopes/:scopeId": {
                        "GET":    api.GetScope,
-                       "PATCH":  api.UpdateScope,
+                       "PATCH":  api.PatchScope,
                        "DELETE": api.DeleteScope,
                },
                "connections/:connectionId/scopes": {
-                       "GET": api.GetScopeList,
-                       "PUT": api.PutScope,
+                       "GET": api.GetScopes,
+                       "PUT": api.PutScopes,
                },
                "connections/:connectionId/scope-configs": {
-                       "POST": api.CreateScopeConfig,
+                       "POST": api.PostScopeConfig,
                        "GET":  api.GetScopeConfigList,
                },
-               "connections/:connectionId/scope-configs/:id": {
-                       "PATCH":  api.UpdateScopeConfig,
+               "connections/:connectionId/scope-configs/:scopeConfigId": {
+                       "PATCH":  api.PatchScopeConfig,
                        "GET":    api.GetScopeConfig,
                        "DELETE": api.DeleteScopeConfig,
                },
diff --git a/backend/python/test/fakeplugin/run.sh 
b/backend/python/test/fakeplugin/run.sh
index 6d287d6ba..e02a7ca2f 100755
--- a/backend/python/test/fakeplugin/run.sh
+++ b/backend/python/test/fakeplugin/run.sh
@@ -16,5 +16,8 @@
 # limitations under the License.
 #
 
+echo sys path $PATH >&2
+[ -n "$VIRTUAL_ENV" ] && echo "Using virtualenv: $VIRTUAL_ENV" >&2 && . 
"$VIRTUAL_ENV/bin/activate"
+
 cd "$(dirname "$0")"
 poetry run python fakeplugin/main.py "$@"
diff --git a/backend/test/helper/client.go b/backend/test/helper/client.go
index 446ea6d1f..0970db916 100644
--- a/backend/test/helper/client.go
+++ b/backend/test/helper/client.go
@@ -36,7 +36,6 @@ import (
        dora "github.com/apache/incubator-devlake/plugins/dora/impl"
        org "github.com/apache/incubator-devlake/plugins/org/impl"
        refdiff "github.com/apache/incubator-devlake/plugins/refdiff/impl"
-       remotePlugin 
"github.com/apache/incubator-devlake/server/services/remote/plugin"
 
        "github.com/apache/incubator-devlake/core/config"
        corectx "github.com/apache/incubator-devlake/core/context"
@@ -289,13 +288,6 @@ func (d *DevlakeClient) initPlugins(cfg 
*LocalClientConfig) {
        for _, p := range cfg.Plugins {
                require.NoError(d.testCtx, plugin.RegisterPlugin(p.Name(), p))
        }
-       for _, p := range plugin.AllPlugins() {
-               if pi, ok := p.(plugin.PluginInit); ok {
-                       err := pi.Init(d.basicRes)
-                       require.NoError(d.testCtx, err)
-               }
-       }
-       remotePlugin.Init(d.basicRes)
 }
 
 func (d *DevlakeClient) prepareDB(cfg *LocalClientConfig) {

Reply via email to