You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@devlake.apache.org by li...@apache.org on 2022/11/09 10:22:19 UTC

[incubator-devlake] branch main updated: feat: added MetricPluginBlueprintV200 interface (#3677)

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

likyh 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 2a593b92 feat: added MetricPluginBlueprintV200 interface (#3677)
2a593b92 is described below

commit 2a593b92a8f6d95b911a1ca810ea944929312a27
Author: Klesh Wong <zh...@merico.dev>
AuthorDate: Wed Nov 9 18:22:06 2022 +0800

    feat: added MetricPluginBlueprintV200 interface (#3677)
    
    * feat: added MetricPluginBlueprintV200 interface
    
    * fix: method names conflict / add projectName
    
    * fix: move `Make` to the beginning
    
    * feat: added unit test for blueprint_makeplan_v200
    
    * docs: update comments for blueprint_makeplan_v200_test
    
    * fix: remove useless interface
    
    * feat: save project/scopes to project_mapping table
    
    * refactor: rename GeneratePlanJson to MakePlan
    
      GeneratePlanJson was a pure function. but in blueprint protocol
      v2.0.0, this is no longer ture, because it requires project_mapping
      to be updated before blueprint being saved. Better to rename it to
      avoid misconception
---
 plugins/core/plugin_blueprint.go         |  70 +++++++++++++-----
 services/blueprint.go                    | 107 +++++++++------------------
 services/blueprint_makeplan_v100.go      |  78 ++++++++++++++++++++
 services/blueprint_makeplan_v200.go      | 122 +++++++++++++++++++++++++++++++
 services/blueprint_makeplan_v200_test.go |  97 ++++++++++++++++++++++++
 services/blueprint_test.go               |  18 ++---
 6 files changed, 393 insertions(+), 99 deletions(-)

diff --git a/plugins/core/plugin_blueprint.go b/plugins/core/plugin_blueprint.go
index 38daebf9..cb1d8f7e 100644
--- a/plugins/core/plugin_blueprint.go
+++ b/plugins/core/plugin_blueprint.go
@@ -68,9 +68,10 @@ PluginBlueprintV200 for project support
 step 1: blueprint.settings like
 	{
 		"version": "2.0.0",
-		"scopes": [
+		"connections": [
 			{
 				"plugin": "github",
+				"connectionId": 123,
 				"scopes": [
 					{ "id": null, "name": "apache/incubator-devlake" }
 				]
@@ -101,29 +102,64 @@ step 3: framework should maintain the project_mapping table based on the []Scope
 	]
 */
 
-// Scope represents the top level entity for a data source, i.e. github repo, gitlab project, jira board.
-// They turn into repo, board in Domain Layer.
-// In Apache Devlake, a Project is essentially a set of these top level entities, for the framework to
-// maintain these relationships dynamically and automatically, all Domain Layer Top Level Entities should
-// implement this interface
+// Scope represents the top level entity for a data source, i.e. github repo,
+// gitlab project, jira board. They turn into repo, board in Domain Layer. In
+// Apache Devlake, a Project is essentially a set of these top level entities,
+// for the framework to maintain these relationships dynamically and
+// automatically, all Domain Layer Top Level Entities should implement this
+// interface
 type Scope interface {
 	ScopeId() string
 	ScopeName() string
 	TableName() string
 }
 
-// PluginBlueprintV200 extends the V100 to provide support for Project to support complex metrics
-// like DORA
-type PluginBlueprintV200 interface {
-	MakePipelinePlan(scopes []*BlueprintScopeV200) (PipelinePlan, []Scope, errors.Error)
+// DataSourcePluginBlueprintV200 extends the V100 to provide support for
+// Project, so that complex metrics like DORA can be implemented based on a set
+// of Data Scopes
+type DataSourcePluginBlueprintV200 interface {
+	MakeDataSourcePipelinePlanV200(connectionId uint64, scopes []*BlueprintScopeV200) (PipelinePlan, []Scope, errors.Error)
 }
 
-// BlueprintScopeV200 contains the Plugin name and related ScopeIds, connectionId and transformationRuleId should be
-// deduced by the ScopeId
+// BlueprintConnectionV200 contains the pluginName/connectionId  and related Scopes,
+type BlueprintConnectionV200 struct {
+	Plugin       string                `json:"plugin" validate:"required"`
+	ConnectionId uint64                `json:"connectionId" validate:"required"`
+	Scopes       []*BlueprintScopeV200 `json:"scopes" validate:"required"`
+}
+
+// BlueprintScopeV200 contains the `id` and `name` for a specific scope
+// transformationRuleId should be deduced by the ScopeId
 type BlueprintScopeV200 struct {
-	Plugin string `json:"plugin" validate:"required"`
-	Scopes []struct {
-		Id   string
-		Name string
-	}
+	Id   string `json:"id"`
+	Name string `json:"name"`
+}
+
+// MetricPluginBlueprintV200 is similar to the DataSourcePluginBlueprintV200
+// but for Metric Plugin, take dora as an example, it doens't have any scope,
+// nor does it produce any, however, it does require other plugin to be
+// executed beforehand, like calcuating refdiff before it can connect PR to the
+// right Deployment keep in mind it would be called IFF the plugin was enabled
+// for the project.
+type MetricPluginBlueprintV200 interface {
+	MakeMetricPluginPipelinePlanV200(projectName string, options json.RawMessage) (PipelinePlan, errors.Error)
+}
+
+// CompositeDataSourcePluginBlueprintV200 is for unit test
+type CompositeDataSourcePluginBlueprintV200 interface {
+	PluginMeta
+	DataSourcePluginBlueprintV200
+}
+
+// CompositeMetricPluginBlueprintV200 is for unit test
+type CompositeMetricPluginBlueprintV200 interface {
+	PluginMeta
+	MetricPluginBlueprintV200
+}
+
+// CompositeMetricPluginBlueprintV200 is for unit test
+type CompositePluginBlueprintV200 interface {
+	PluginMeta
+	DataSourcePluginBlueprintV200
+	MetricPluginBlueprintV200
 }
diff --git a/services/blueprint.go b/services/blueprint.go
index edb4a0e3..5c59b6fc 100644
--- a/services/blueprint.go
+++ b/services/blueprint.go
@@ -47,7 +47,7 @@ var (
 
 // CreateBlueprint accepts a Blueprint instance and insert it to database
 func CreateBlueprint(blueprint *models.Blueprint) errors.Error {
-	err := validateBlueprint(blueprint)
+	err := validateBlueprintAndMakePlan(blueprint)
 	if err != nil {
 		return err
 	}
@@ -102,7 +102,7 @@ func GetBlueprint(blueprintId uint64) (*models.Blueprint, errors.Error) {
 	return blueprint, nil
 }
 
-func validateBlueprint(blueprint *models.Blueprint) errors.Error {
+func validateBlueprintAndMakePlan(blueprint *models.Blueprint) errors.Error {
 	// validation
 	err := vld.Struct(blueprint)
 	if err != nil {
@@ -130,10 +130,14 @@ func validateBlueprint(blueprint *models.Blueprint) errors.Error {
 			return errors.Default.New("empty plan")
 		}
 	} else if blueprint.Mode == models.BLUEPRINT_MODE_NORMAL {
-		blueprint.Plan, err = GeneratePlanJson(blueprint.Settings)
+		plan, err := MakePlanForBlueprint(blueprint)
 		if err != nil {
 			return errors.Default.Wrap(err, "invalid plan")
 		}
+		blueprint.Plan, err = errors.Convert01(json.Marshal(plan))
+		if err != nil {
+			return errors.Default.Wrap(err, "failed to markshal plan")
+		}
 	}
 
 	return nil
@@ -157,7 +161,7 @@ func PatchBlueprint(id uint64, body map[string]interface{}) (*models.Blueprint,
 		return nil, errors.Default.New("mode is not updatable")
 	}
 	// validation
-	err = validateBlueprint(blueprint)
+	err = validateBlueprintAndMakePlan(blueprint)
 	if err != nil {
 		return nil, errors.BadInput.WrapRaw(err)
 	}
@@ -246,15 +250,15 @@ func createPipelineByBlueprint(blueprintId uint64, name string, plan core.Pipeli
 	return pipeline, nil
 }
 
-// GeneratePlanJson generates pipeline plan by version
-func GeneratePlanJson(settings json.RawMessage) (json.RawMessage, errors.Error) {
+// MakePlanForBlueprint generates pipeline plan by version
+func MakePlanForBlueprint(blueprint *models.Blueprint) (core.PipelinePlan, errors.Error) {
 	bpSettings := new(models.BlueprintSettings)
-	err := errors.Convert(json.Unmarshal(settings, bpSettings))
+	err := errors.Convert(json.Unmarshal(blueprint.Settings, bpSettings))
 
 	if err != nil {
-		return nil, errors.Default.Wrap(err, fmt.Sprintf("settings:%s", string(settings)))
+		return nil, errors.Default.Wrap(err, fmt.Sprintf("settings:%s", string(blueprint.Settings)))
 	}
-	var plan interface{}
+	var plan core.PipelinePlan
 	switch bpSettings.Version {
 	case "1.0.0":
 		plan, err = GeneratePlanJsonV100(bpSettings)
@@ -264,87 +268,33 @@ func GeneratePlanJson(settings json.RawMessage) (json.RawMessage, errors.Error)
 	if err != nil {
 		return nil, err
 	}
-	return errors.Convert01(json.Marshal(plan))
+	return WrapPipelinePlans(bpSettings.BeforePlan, plan, bpSettings.AfterPlan)
 }
 
-// GeneratePlanJsonV100 generates pipeline plan according v1.0.0 definition
-func GeneratePlanJsonV100(settings *models.BlueprintSettings) (core.PipelinePlan, errors.Error) {
-	connections := make([]*core.BlueprintConnectionV100, 0)
-	err := errors.Convert(json.Unmarshal(settings.Connections, &connections))
-	if err != nil {
-		return nil, err
-	}
-	hasDoraEnrich := false
-	doraRules := make(map[string]interface{})
-	plans := make([]core.PipelinePlan, len(connections))
-	for i, connection := range connections {
-		if len(connection.Scope) == 0 {
-			return nil, errors.Default.New(fmt.Sprintf("connections[%d].scope is empty", i))
-		}
-		plugin, err := core.GetPlugin(connection.Plugin)
-		if err != nil {
-			return nil, err
-		}
-		if pluginBp, ok := plugin.(core.PluginBlueprintV100); ok {
-			plans[i], err = pluginBp.MakePipelinePlan(connection.ConnectionId, connection.Scope)
-			if err != nil {
-				return nil, err
-			}
-		} else {
-			return nil, errors.Default.New(fmt.Sprintf("plugin %s does not support blueprint protocol version 1.0.0", connection.Plugin))
-		}
-		for _, stage := range plans[i] {
-			for _, task := range stage {
-				if task.Plugin == "dora" {
-					hasDoraEnrich = true
-					for k, v := range task.Options {
-						doraRules[k] = v
-					}
-				}
-			}
-		}
-	}
-	mergedPipelinePlan := MergePipelinePlans(plans...)
-	if hasDoraEnrich {
-		plan := core.PipelineStage{
-			&core.PipelineTask{
-				Plugin:   "dora",
-				Subtasks: []string{"calculateChangeLeadTime", "ConnectIssueDeploy"},
-				Options:  doraRules,
-			},
-		}
-		mergedPipelinePlan = append(mergedPipelinePlan, plan)
-	}
-	return FormatPipelinePlans(settings.BeforePlan, mergedPipelinePlan, settings.AfterPlan)
-}
+// WrapPipelinePlans merges multiple pipelines and append before and after pipeline
+func WrapPipelinePlans(beforePlanJson json.RawMessage, mainPlan core.PipelinePlan, afterPlanJson json.RawMessage) (core.PipelinePlan, errors.Error) {
+	beforePipelinePlan := core.PipelinePlan{}
+	afterPipelinePlan := core.PipelinePlan{}
 
-// FormatPipelinePlans merges multiple pipelines and append before and after pipeline
-func FormatPipelinePlans(beforePlanJson json.RawMessage, mainPlan core.PipelinePlan, afterPlanJson json.RawMessage) (core.PipelinePlan, errors.Error) {
-	newPipelinePlan := core.PipelinePlan{}
 	if beforePlanJson != nil {
-		beforePipelinePlan := core.PipelinePlan{}
 		err := errors.Convert(json.Unmarshal(beforePlanJson, &beforePipelinePlan))
 		if err != nil {
 			return nil, err
 		}
-		newPipelinePlan = append(newPipelinePlan, beforePipelinePlan...)
 	}
-
-	newPipelinePlan = append(newPipelinePlan, mainPlan...)
-
 	if afterPlanJson != nil {
-		afterPipelinePlan := core.PipelinePlan{}
 		err := errors.Convert(json.Unmarshal(afterPlanJson, &afterPipelinePlan))
 		if err != nil {
 			return nil, err
 		}
-		newPipelinePlan = append(newPipelinePlan, afterPipelinePlan...)
 	}
-	return newPipelinePlan, nil
+
+	return SequencializePipelinePlans(beforePipelinePlan, mainPlan, afterPipelinePlan), nil
 }
 
-// MergePipelinePlans merges multiple pipelines into one unified pipeline
-func MergePipelinePlans(plans ...core.PipelinePlan) core.PipelinePlan {
+// ParallelizePipelinePlans merges multiple pipelines into one unified plan
+// by assuming they can be executed in parallel
+func ParallelizePipelinePlans(plans ...core.PipelinePlan) core.PipelinePlan {
 	merged := make(core.PipelinePlan, 0)
 	// iterate all pipelineTasks and try to merge them into `merged`
 	for _, plan := range plans {
@@ -360,6 +310,17 @@ func MergePipelinePlans(plans ...core.PipelinePlan) core.PipelinePlan {
 	return merged
 }
 
+// SequencializePipelinePlans merges multiple pipelines into one unified plan
+// by assuming they must be executed in sequencial order
+func SequencializePipelinePlans(plans ...core.PipelinePlan) core.PipelinePlan {
+	merged := make(core.PipelinePlan, 0)
+	// iterate all pipelineTasks and try to merge them into `merged`
+	for _, plan := range plans {
+		merged = append(merged, plan...)
+	}
+	return merged
+}
+
 // TriggerBlueprint triggers blueprint immediately
 func TriggerBlueprint(id uint64) (*models.Pipeline, errors.Error) {
 	// load record from db
diff --git a/services/blueprint_makeplan_v100.go b/services/blueprint_makeplan_v100.go
new file mode 100644
index 00000000..f3bd47d6
--- /dev/null
+++ b/services/blueprint_makeplan_v100.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 services
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/apache/incubator-devlake/errors"
+	"github.com/apache/incubator-devlake/models"
+	"github.com/apache/incubator-devlake/plugins/core"
+)
+
+// GeneratePlanJsonV100 generates pipeline plan according v1.0.0 definition
+func GeneratePlanJsonV100(settings *models.BlueprintSettings) (core.PipelinePlan, errors.Error) {
+	connections := make([]*core.BlueprintConnectionV100, 0)
+	err := errors.Convert(json.Unmarshal(settings.Connections, &connections))
+	if err != nil {
+		return nil, err
+	}
+	hasDoraEnrich := false
+	doraRules := make(map[string]interface{})
+	plans := make([]core.PipelinePlan, len(connections))
+	for i, connection := range connections {
+		if len(connection.Scope) == 0 {
+			return nil, errors.Default.New(fmt.Sprintf("connections[%d].scope is empty", i))
+		}
+		plugin, err := core.GetPlugin(connection.Plugin)
+		if err != nil {
+			return nil, err
+		}
+		if pluginBp, ok := plugin.(core.PluginBlueprintV100); ok {
+			plans[i], err = pluginBp.MakePipelinePlan(connection.ConnectionId, connection.Scope)
+			if err != nil {
+				return nil, err
+			}
+		} else {
+			return nil, errors.Default.New(fmt.Sprintf("plugin %s does not support blueprint protocol version 1.0.0", connection.Plugin))
+		}
+		for _, stage := range plans[i] {
+			for _, task := range stage {
+				if task.Plugin == "dora" {
+					hasDoraEnrich = true
+					for k, v := range task.Options {
+						doraRules[k] = v
+					}
+				}
+			}
+		}
+	}
+	mergedPipelinePlan := ParallelizePipelinePlans(plans...)
+	if hasDoraEnrich {
+		plan := core.PipelineStage{
+			&core.PipelineTask{
+				Plugin:   "dora",
+				Subtasks: []string{"calculateChangeLeadTime", "ConnectIssueDeploy"},
+				Options:  doraRules,
+			},
+		}
+		mergedPipelinePlan = append(mergedPipelinePlan, plan)
+	}
+	return mergedPipelinePlan, nil
+}
diff --git a/services/blueprint_makeplan_v200.go b/services/blueprint_makeplan_v200.go
new file mode 100644
index 00000000..c4835579
--- /dev/null
+++ b/services/blueprint_makeplan_v200.go
@@ -0,0 +1,122 @@
+/*
+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 services
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/apache/incubator-devlake/errors"
+	"github.com/apache/incubator-devlake/models"
+	"github.com/apache/incubator-devlake/models/domainlayer/crossdomain"
+	"github.com/apache/incubator-devlake/plugins/core"
+)
+
+// GeneratePlanJsonV200 generates pipeline plan according v2.0.0 definition
+func GeneratePlanJsonV200(
+	projectName string,
+	sources *models.BlueprintSettings,
+	metrics map[string]json.RawMessage,
+) (core.PipelinePlan, errors.Error) {
+	// generate plan and collect scopes
+	plan, scopes, err := genPlanJsonV200(projectName, sources, metrics)
+	if err != nil {
+		return nil, err
+	}
+	// refresh project_mapping table to reflect project/scopes relationship
+	if len(scopes) > 0 {
+		e := db.Where("project_name = ?", projectName).Delete(&crossdomain.ProjectMapping{}).Error
+		if e != nil {
+			return nil, errors.Convert(err)
+		}
+		e = db.Create(scopes).Error
+		if e != nil {
+			return nil, errors.Convert(err)
+		}
+	}
+	return plan, err
+}
+
+func genPlanJsonV200(
+	projectName string,
+	sources *models.BlueprintSettings,
+	metrics map[string]json.RawMessage,
+) (core.PipelinePlan, []core.Scope, errors.Error) {
+	connections := make([]*core.BlueprintConnectionV200, 0)
+	err := errors.Convert(json.Unmarshal(sources.Connections, &connections))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	// make plan for data-source plugins fist. generate plan for each
+	// connections, then merge them into one legitimate plan and collect the
+	// scopes produced by the data-source plugins
+	sourcePlans := make([]core.PipelinePlan, len(connections))
+	scopes := make([]core.Scope, 0, len(connections))
+	for i, connection := range connections {
+		if len(connection.Scopes) == 0 {
+			return nil, nil, errors.Default.New(fmt.Sprintf("connections[%d].scope is empty", i))
+		}
+		plugin, err := core.GetPlugin(connection.Plugin)
+		if err != nil {
+			return nil, nil, err
+		}
+		if pluginBp, ok := plugin.(core.DataSourcePluginBlueprintV200); ok {
+			var pluginScopes []core.Scope
+			sourcePlans[i], pluginScopes, err = pluginBp.MakeDataSourcePipelinePlanV200(
+				connection.ConnectionId,
+				connection.Scopes,
+			)
+			if err != nil {
+				return nil, nil, err
+			}
+			// collect scopes for the project. a github repository may produces
+			// 2 scopes, 1 repo and 1 board
+			scopes = append(scopes, pluginScopes...)
+		} else {
+			return nil, nil, errors.Default.New(
+				fmt.Sprintf("plugin %s does not support DataSourcePluginBlueprintV200", connection.Plugin),
+			)
+		}
+	}
+	// make plans for metric plugins
+	metricPlans := make([]core.PipelinePlan, len(metrics))
+	i := 0
+	for metricPluginName, metricPluginOptJson := range metrics {
+		plugin, err := core.GetPlugin(metricPluginName)
+		if err != nil {
+			return nil, nil, err
+		}
+		if pluginBp, ok := plugin.(core.MetricPluginBlueprintV200); ok {
+			metricPlans[i], err = pluginBp.MakeMetricPluginPipelinePlanV200(projectName, metricPluginOptJson)
+			if err != nil {
+				return nil, nil, err
+			}
+			i += 1
+		} else {
+			return nil, nil, errors.Default.New(
+				fmt.Sprintf("plugin %s does not support MetricPluginBlueprintV200", metricPluginName),
+			)
+		}
+	}
+	plan := SequencializePipelinePlans(
+		ParallelizePipelinePlans(sourcePlans...),
+		ParallelizePipelinePlans(metricPlans...),
+	)
+	return plan, scopes, err
+}
diff --git a/services/blueprint_makeplan_v200_test.go b/services/blueprint_makeplan_v200_test.go
new file mode 100644
index 00000000..7cee6bf8
--- /dev/null
+++ b/services/blueprint_makeplan_v200_test.go
@@ -0,0 +1,97 @@
+/*
+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 services
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/apache/incubator-devlake/mocks"
+	"github.com/apache/incubator-devlake/models"
+	"github.com/apache/incubator-devlake/models/domainlayer"
+	"github.com/apache/incubator-devlake/models/domainlayer/code"
+	"github.com/apache/incubator-devlake/models/domainlayer/ticket"
+	"github.com/apache/incubator-devlake/plugins/core"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestMakePlanV200(t *testing.T) {
+	const projectName = "TestMakePlanV200-project"
+	githubName := "TestMakePlanV200-github" // mimic github
+	// mock github plugin as a data source plugin
+	githubConnId := uint64(1)
+	githubScopes := []*core.BlueprintScopeV200{
+		{Id: "", Name: "apache/incubator-devlake"},
+		{Id: "", Name: "apache/incubator-devlake-website"},
+	}
+	githubOutputPlan := core.PipelinePlan{
+		{
+			{Plugin: githubName, Options: map[string]interface{}{"name": "apache/incubator-devlake"}},
+			{Plugin: "gitextractor", Options: map[string]interface{}{"url": "http://gihub.com/apache/incubator-devlake.git"}},
+		},
+		{
+			{Plugin: githubName, Options: map[string]interface{}{"name": "apache/incubator-devlake-website"}},
+			{Plugin: "gitextractor", Options: map[string]interface{}{"url": "http://gihub.com/apache/incubator-devlake-website.git"}},
+		},
+	}
+	githubOutputScopes := []core.Scope{
+		&code.Repo{DomainEntity: domainlayer.DomainEntity{Id: "github:GithubRepo:1:123"}, Name: "apache/incubator-devlake"},
+		&ticket.Board{DomainEntity: domainlayer.DomainEntity{Id: "github:GithubRepo:1:123"}, Name: "apache/incubator-devlake"},
+	}
+	github := new(mocks.CompositeDataSourcePluginBlueprintV200)
+	github.On("MakeDataSourcePipelinePlanV200", githubConnId, githubScopes).Return(githubOutputPlan, githubOutputScopes, nil)
+
+	// mock dora plugin as a metric plugin
+	doraName := "TestMakePlanV200-dora"
+	doraOutputPlan := core.PipelinePlan{
+		{
+			{Plugin: "refdiff", Subtasks: []string{"calculateDeploymentDiffs"}, Options: map[string]interface{}{"projectName": projectName}},
+			{Plugin: doraName},
+		},
+	}
+	dora := new(mocks.CompositeMetricPluginBlueprintV200)
+	dora.On("MakeMetricPluginPipelinePlanV200", projectName, json.RawMessage(nil)).Return(doraOutputPlan, nil)
+
+	// expectation, establish expectation before any code being launch to avoid unwanted modification
+	expectedPlan := make(core.PipelinePlan, 0)
+	expectedPlan = append(expectedPlan, githubOutputPlan...)
+	expectedPlan = append(expectedPlan, doraOutputPlan...)
+	expectedScopes := append(make([]core.Scope, 0), githubOutputScopes...)
+
+	// plugin registration
+	core.RegisterPlugin(githubName, github)
+	core.RegisterPlugin(doraName, dora)
+
+	// put them together and call GeneratePlanJsonV200
+	connections, _ := json.Marshal([]*core.BlueprintConnectionV200{
+		{Plugin: githubName, ConnectionId: githubConnId, Scopes: githubScopes},
+	})
+	sources := &models.BlueprintSettings{
+		Version:     "2.0.0",
+		Connections: connections,
+	}
+	metrics := map[string]json.RawMessage{
+		doraName: nil,
+	}
+
+	plan, scopes, err := genPlanJsonV200(projectName, sources, metrics)
+	assert.Nil(t, err)
+
+	assert.Equal(t, expectedPlan, plan)
+	assert.Equal(t, expectedScopes, scopes)
+}
diff --git a/services/blueprint_test.go b/services/blueprint_test.go
index 8c7862ad..b06be8d7 100644
--- a/services/blueprint_test.go
+++ b/services/blueprint_test.go
@@ -25,7 +25,7 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func TestMergePipelineTasks(t *testing.T) {
+func TestParallelizePipelineTasks(t *testing.T) {
 	plan1 := core.PipelinePlan{
 		{
 			{Plugin: "github"},
@@ -55,8 +55,8 @@ func TestMergePipelineTasks(t *testing.T) {
 		},
 	}
 
-	assert.Equal(t, plan1, MergePipelinePlans(plan1))
-	assert.Equal(t, plan2, MergePipelinePlans(plan2))
+	assert.Equal(t, plan1, ParallelizePipelinePlans(plan1))
+	assert.Equal(t, plan2, ParallelizePipelinePlans(plan2))
 	assert.Equal(
 		t,
 		core.PipelinePlan{
@@ -70,7 +70,7 @@ func TestMergePipelineTasks(t *testing.T) {
 				{Plugin: "gitextractor2"},
 			},
 		},
-		MergePipelinePlans(plan1, plan2),
+		ParallelizePipelinePlans(plan1, plan2),
 	)
 	assert.Equal(
 		t,
@@ -90,11 +90,11 @@ func TestMergePipelineTasks(t *testing.T) {
 				{Plugin: "jenkins"},
 			},
 		},
-		MergePipelinePlans(plan1, plan2, plan3),
+		ParallelizePipelinePlans(plan1, plan2, plan3),
 	)
 }
 
-func TestFormatPipelinePlans(t *testing.T) {
+func TestWrapPipelinePlans(t *testing.T) {
 	beforePlan2 := json.RawMessage(`[[{"plugin":"github"},{"plugin":"gitlab"}],[{"plugin":"gitextractor1"},{"plugin":"gitextractor2"}]]`)
 
 	mainPlan := core.PipelinePlan{
@@ -105,11 +105,11 @@ func TestFormatPipelinePlans(t *testing.T) {
 
 	afterPlan2 := json.RawMessage(`[[{"plugin":"jenkins"}],[{"plugin":"jenkins"}]]`)
 
-	result1, err1 := FormatPipelinePlans(nil, mainPlan, nil)
+	result1, err1 := WrapPipelinePlans(nil, mainPlan, nil)
 	assert.Nil(t, err1)
 	assert.Equal(t, mainPlan, result1)
 
-	result2, err2 := FormatPipelinePlans(beforePlan2, mainPlan, afterPlan2)
+	result2, err2 := WrapPipelinePlans(beforePlan2, mainPlan, afterPlan2)
 	assert.Nil(t, err2)
 	assert.Equal(t, core.PipelinePlan{
 		{
@@ -131,7 +131,7 @@ func TestFormatPipelinePlans(t *testing.T) {
 		},
 	}, result2)
 
-	result3, err3 := FormatPipelinePlans(json.RawMessage("[]"), mainPlan, json.RawMessage("[]"))
+	result3, err3 := WrapPipelinePlans(json.RawMessage("[]"), mainPlan, json.RawMessage("[]"))
 	assert.Nil(t, err3)
 	assert.Equal(t, mainPlan, result3)
 }