diff --git a/backend/core/models/domainlayer/code/pull_request.go b/backend/core/models/domainlayer/code/pull_request.go
index d659a5fdd22..ecd1febb330 100644
--- a/backend/core/models/domainlayer/code/pull_request.go
+++ b/backend/core/models/domainlayer/code/pull_request.go
@@ -60,6 +60,9 @@ type PullRequest struct {
Additions int
Deletions int
IsDraft bool
+ // SubProject is the monorepo sub-project this pull request was attributed to by the
+ // monorepo plugin, or empty/NULL when the project has no monorepo configuration.
+ SubProject string `gorm:"index;type:varchar(100)"`
}
func (PullRequest) TableName() string {
diff --git a/backend/core/models/domainlayer/code/pull_request_commit.go b/backend/core/models/domainlayer/code/pull_request_commit.go
index af82d726d49..6a9797dc213 100644
--- a/backend/core/models/domainlayer/code/pull_request_commit.go
+++ b/backend/core/models/domainlayer/code/pull_request_commit.go
@@ -30,6 +30,10 @@ type PullRequestCommit struct {
CommitAuthorEmail string `gorm:"type:varchar(255)"`
CommitAuthoredDate time.Time
common.NoPKModel
+ // SubProject mirrors the owning pull request's SubProject (see code.PullRequest), kept
+ // denormalized here so commit-level dashboards can group without joining back to
+ // pull_requests.
+ SubProject string `gorm:"index;type:varchar(100)"`
}
func (PullRequestCommit) TableName() string {
diff --git a/backend/core/models/domainlayer/crossdomain/project_pr_metric.go b/backend/core/models/domainlayer/crossdomain/project_pr_metric.go
index 3ddafb2ee7b..e06e6d8187c 100644
--- a/backend/core/models/domainlayer/crossdomain/project_pr_metric.go
+++ b/backend/core/models/domainlayer/crossdomain/project_pr_metric.go
@@ -40,6 +40,11 @@ type ProjectPrMetric struct {
PrCreatedDate *time.Time
PrMergedDate *time.Time
PrDeployedDate *time.Time
+
+ // SubProject mirrors pull_requests.sub_project, tagged by the monorepo plugin's
+ // updateProjectPrMetricsSubProject subtask after DORA computes this row. Empty/NULL
+ // when the project has no monorepo configuration.
+ SubProject string `gorm:"index;type:varchar(100)"`
}
func (ProjectPrMetric) TableName() string {
diff --git a/backend/core/models/domainlayer/devops/cicd_deployment_subproject.go b/backend/core/models/domainlayer/devops/cicd_deployment_subproject.go
new file mode 100644
index 00000000000..934cd2c09d2
--- /dev/null
+++ b/backend/core/models/domainlayer/devops/cicd_deployment_subproject.go
@@ -0,0 +1,46 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package devops
+
+import (
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// CicdDeploymentSubproject maps a deployment (identified by its pipeline id,
+// CicdDeploymentId) to the monorepo sub-project(s) it deployed. The relationship is
+// many-to-many: a single pipeline can run the deploy jobs of several sub-projects, in
+// which case it produces one row per sub-project rather than a delimited value, so
+// dashboards can `GROUP BY sub_project` without double counting.
+//
+// Rows are written by the monorepo plugin's attributeDeployments subtask. Projects
+// without monorepo configuration have no rows here at all; dashboards should treat a
+// missing mapping as the single, implicit "All" group via COALESCE(sub_project, 'All').
+type CicdDeploymentSubproject struct {
+ common.NoPKModel
+ ProjectName string `gorm:"primaryKey;type:varchar(100)"`
+ // CicdDeploymentId is the pipeline id, matching cicd_deployment_commits.cicd_deployment_id.
+ // It also carries its own secondary index (idx_cds_deployment) because it sits in the
+ // middle of the composite primary key, so dashboards filtering by deployment id alone
+ // cannot use the primary key's leftmost prefix.
+ CicdDeploymentId string `gorm:"primaryKey;type:varchar(255);index:idx_cds_deployment"`
+ SubProject string `gorm:"primaryKey;type:varchar(100)"`
+}
+
+func (CicdDeploymentSubproject) TableName() string {
+ return "cicd_deployment_subprojects"
+}
diff --git a/backend/core/models/domainlayer/domaininfo/domaininfo.go b/backend/core/models/domainlayer/domaininfo/domaininfo.go
index b88289e8d8f..843bdde4301 100644
--- a/backend/core/models/domainlayer/domaininfo/domaininfo.go
+++ b/backend/core/models/domainlayer/domaininfo/domaininfo.go
@@ -75,6 +75,7 @@ func GetDomainTablesInfo() []dal.Tabler {
&devops.CICDPipeline{},
&devops.CICDTask{},
&devops.CicdDeploymentCommit{},
+ &devops.CicdDeploymentSubproject{},
&devops.CiCDPipelineCommit{},
&devops.CicdScope{},
&devops.CICDDeployment{},
diff --git a/backend/core/models/migrationscripts/20260810_add_cicd_deployment_subprojects.go b/backend/core/models/migrationscripts/20260810_add_cicd_deployment_subprojects.go
new file mode 100644
index 00000000000..049f4801ac1
--- /dev/null
+++ b/backend/core/models/migrationscripts/20260810_add_cicd_deployment_subprojects.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 migrationscripts
+
+import (
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/models/domainlayer/devops"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "github.com/apache/incubator-devlake/helpers/migrationhelper"
+)
+
+var _ plugin.MigrationScript = (*addCicdDeploymentSubprojects)(nil)
+
+type addCicdDeploymentSubprojects struct{}
+
+// Up creates the new cicd_deployment_subprojects mapping table. This is a brand new
+// table (not an existing one gaining a column), so it is migrated straight from the live
+// domain model, matching the precedent set by the monorepo plugin's own
+// 20260809_add_init_tables.go rather than a versioned snapshot struct.
+func (script *addCicdDeploymentSubprojects) Up(basicRes context.BasicRes) errors.Error {
+ return migrationhelper.AutoMigrateTables(
+ basicRes,
+ &devops.CicdDeploymentSubproject{},
+ )
+}
+
+func (*addCicdDeploymentSubprojects) Version() uint64 {
+ return 20260810100100
+}
+
+func (*addCicdDeploymentSubprojects) Name() string {
+ return "create cicd_deployment_subprojects mapping table for monorepo support"
+}
diff --git a/backend/core/models/migrationscripts/20260810_add_sub_project_to_pr_and_metrics.go b/backend/core/models/migrationscripts/20260810_add_sub_project_to_pr_and_metrics.go
new file mode 100644
index 00000000000..524a0b3fbe9
--- /dev/null
+++ b/backend/core/models/migrationscripts/20260810_add_sub_project_to_pr_and_metrics.go
@@ -0,0 +1,80 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+)
+
+var _ plugin.MigrationScript = (*addSubProjectToPrAndMetrics)(nil)
+
+// pullRequest20260810 adds the sub_project column that the monorepo plugin's
+// attributePullRequests subtask writes. Nullable, so single-repo projects (and rows not
+// yet processed) simply read as NULL and dashboards fall back to the "All" group.
+type pullRequest20260810 struct {
+ SubProject string `gorm:"index;type:varchar(100)"`
+}
+
+func (pullRequest20260810) TableName() string {
+ return "pull_requests"
+}
+
+// pullRequestCommit20260810 mirrors the owning pull request's sub_project.
+type pullRequestCommit20260810 struct {
+ SubProject string `gorm:"index;type:varchar(100)"`
+}
+
+func (pullRequestCommit20260810) TableName() string {
+ return "pull_request_commits"
+}
+
+// projectPrMetric20260810 is tagged by the monorepo plugin's
+// updateProjectPrMetricsSubProject subtask after DORA computes this row.
+type projectPrMetric20260810 struct {
+ SubProject string `gorm:"index;type:varchar(100)"`
+}
+
+func (projectPrMetric20260810) TableName() string {
+ return "project_pr_metrics"
+}
+
+type addSubProjectToPrAndMetrics struct{}
+
+func (script *addSubProjectToPrAndMetrics) Up(basicRes context.BasicRes) errors.Error {
+ db := basicRes.GetDal()
+ if err := db.AutoMigrate(&pullRequest20260810{}); err != nil {
+ return err
+ }
+ if err := db.AutoMigrate(&pullRequestCommit20260810{}); err != nil {
+ return err
+ }
+ if err := db.AutoMigrate(&projectPrMetric20260810{}); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (*addSubProjectToPrAndMetrics) Version() uint64 {
+ return 20260810100000
+}
+
+func (*addSubProjectToPrAndMetrics) Name() string {
+ return "add sub_project to pull_requests, pull_request_commits and project_pr_metrics for monorepo support"
+}
diff --git a/backend/core/models/migrationscripts/20260810_backfill_sub_project_from_monorepo.go b/backend/core/models/migrationscripts/20260810_backfill_sub_project_from_monorepo.go
new file mode 100644
index 00000000000..826c510af5c
--- /dev/null
+++ b/backend/core/models/migrationscripts/20260810_backfill_sub_project_from_monorepo.go
@@ -0,0 +1,145 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+ "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/plugin"
+)
+
+var _ plugin.MigrationScript = (*backfillSubProjectFromMonorepo)(nil)
+
+type backfillSubProjectFromMonorepo struct{}
+
+// Up copies sub_project values that existing monorepo-plugin installs already computed
+// into the new core columns/table, so projects that were using the monorepo plugin before
+// this release keep their classification instead of reverting to NULL ("All").
+//
+// Every statement is written with a correlated subquery / NOT EXISTS guard rather than the
+// MySQL-only `UPDATE ... JOIN` or Postgres-only `UPDATE ... FROM` forms, so the same SQL
+// runs unmodified on both of DevLake's supported databases. Each statement only touches
+// rows it has not already touched (sub_project IS NULL / NOT EXISTS), which makes the
+// whole migration idempotent and safe to re-run if it is interrupted partway through - the
+// batching called out as a risk in the design doc was judged unnecessary for a first
+// implementation given that guard, but would be a reasonable follow-up for very large
+// instances (see the design doc's risk table).
+//
+// This is a core migration, so it runs on every DevLake install, including ones that have
+// never enabled the monorepo plugin. On those installs the plugin's own tables
+// (monorepo_subproject_pr_metrics / monorepo_subproject_deployments) do not exist, so each
+// half of the backfill is skipped independently when its source table is absent, rather
+// than breaking the migration for every non-monorepo user.
+func (script *backfillSubProjectFromMonorepo) Up(basicRes context.BasicRes) errors.Error {
+ db := basicRes.GetDal()
+
+ if db.HasTable("monorepo_subproject_pr_metrics") {
+ if err := backfillPrSubProjects(db); err != nil {
+ return err
+ }
+ }
+ if db.HasTable("monorepo_subproject_deployments") {
+ if err := backfillDeploymentSubProjects(db); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func backfillPrSubProjects(db dal.Dal) errors.Error {
+ // 1. pull_requests.sub_project <- monorepo_subproject_pr_metrics.sub_project
+ if err := db.Exec(`
+ UPDATE pull_requests
+ SET sub_project = (
+ SELECT m.sub_project FROM monorepo_subproject_pr_metrics m
+ WHERE m.pull_request_id = pull_requests.id
+ )
+ WHERE sub_project IS NULL
+ AND EXISTS (
+ SELECT 1 FROM monorepo_subproject_pr_metrics m2
+ WHERE m2.pull_request_id = pull_requests.id
+ )
+ `); err != nil {
+ return errors.Default.Wrap(err, "error backfilling pull_requests.sub_project")
+ }
+
+ // 2. pull_request_commits.sub_project <- pull_requests.sub_project
+ if err := db.Exec(`
+ UPDATE pull_request_commits
+ SET sub_project = (
+ SELECT pr.sub_project FROM pull_requests pr
+ WHERE pr.id = pull_request_commits.pull_request_id
+ )
+ WHERE sub_project IS NULL
+ AND EXISTS (
+ SELECT 1 FROM pull_requests pr2
+ WHERE pr2.id = pull_request_commits.pull_request_id
+ AND pr2.sub_project IS NOT NULL
+ )
+ `); err != nil {
+ return errors.Default.Wrap(err, "error backfilling pull_request_commits.sub_project")
+ }
+
+ // 3. project_pr_metrics.sub_project <- pull_requests.sub_project
+ if err := db.Exec(`
+ UPDATE project_pr_metrics
+ SET sub_project = (
+ SELECT pr.sub_project FROM pull_requests pr
+ WHERE pr.id = project_pr_metrics.id
+ )
+ WHERE sub_project IS NULL
+ AND EXISTS (
+ SELECT 1 FROM pull_requests pr2
+ WHERE pr2.id = project_pr_metrics.id
+ AND pr2.sub_project IS NOT NULL
+ )
+ `); err != nil {
+ return errors.Default.Wrap(err, "error backfilling project_pr_metrics.sub_project")
+ }
+
+ return nil
+}
+
+func backfillDeploymentSubProjects(db dal.Dal) errors.Error {
+ now := time.Now()
+ if err := db.Exec(`
+ INSERT INTO cicd_deployment_subprojects (project_name, cicd_deployment_id, sub_project, created_at, updated_at)
+ SELECT DISTINCT d.project_name, d.cicd_deployment_id, d.sub_project, ?, ?
+ FROM monorepo_subproject_deployments d
+ WHERE NOT EXISTS (
+ SELECT 1 FROM cicd_deployment_subprojects x
+ WHERE x.project_name = d.project_name
+ AND x.cicd_deployment_id = d.cicd_deployment_id
+ AND x.sub_project = d.sub_project
+ )
+ `, now, now); err != nil {
+ return errors.Default.Wrap(err, "error backfilling cicd_deployment_subprojects")
+ }
+ return nil
+}
+
+func (*backfillSubProjectFromMonorepo) Version() uint64 {
+ return 20260810100200
+}
+
+func (*backfillSubProjectFromMonorepo) Name() string {
+ return "backfill sub_project into core tables from existing monorepo plugin data"
+}
diff --git a/backend/core/models/migrationscripts/register.go b/backend/core/models/migrationscripts/register.go
index dd015b9b1c3..ea2c7d75739 100644
--- a/backend/core/models/migrationscripts/register.go
+++ b/backend/core/models/migrationscripts/register.go
@@ -150,6 +150,9 @@ func All() []plugin.MigrationScript {
new(addCqProjectMetricsHistory),
new(addIsBotToAccounts),
new(addSprintVelocityFields),
+ new(addSubProjectToPrAndMetrics),
+ new(addCicdDeploymentSubprojects),
+ new(backfillSubProjectFromMonorepo),
new(addBlueprintIdIndexToPipelines),
new(expandDomainTextColumns),
}
diff --git a/backend/plugins/gitlab/tasks/mr_commit_convertor.go b/backend/plugins/gitlab/tasks/mr_commit_convertor.go
index 6f7ff2a5f8e..cb7e2a18496 100644
--- a/backend/plugins/gitlab/tasks/mr_commit_convertor.go
+++ b/backend/plugins/gitlab/tasks/mr_commit_convertor.go
@@ -37,7 +37,10 @@ var ConvertApiMrCommitsMeta = plugin.SubTaskMeta{
EnabledByDefault: true,
Description: "Add domain layer PullRequestCommit according to GitlabMrCommit",
DomainTypes: []string{plugin.DOMAIN_TYPE_CODE_REVIEW},
- Dependencies: []*plugin.SubTaskMeta{&ConvertApiMergeRequestsMeta},
+ // ExtractApiMrCommitsMeta must run first: it populates _tool_gitlab_mr_commits, which
+ // this subtask reads from. Without this dependency, conversion can race ahead of
+ // extraction and silently convert zero or partial commits.
+ Dependencies: []*plugin.SubTaskMeta{&ConvertApiMergeRequestsMeta, &ExtractApiMrCommitsMeta},
}
func ConvertApiMergeRequestsCommits(subtaskCtx plugin.SubTaskContext) errors.Error {
diff --git a/backend/plugins/monorepo/e2e/attribution_test.go b/backend/plugins/monorepo/e2e/attribution_test.go
new file mode 100644
index 00000000000..a6f457bfebe
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/attribution_test.go
@@ -0,0 +1,227 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package e2e
+
+import (
+ "testing"
+
+ "github.com/apache/incubator-devlake/core/models/common"
+ "github.com/apache/incubator-devlake/core/models/domainlayer/code"
+ "github.com/apache/incubator-devlake/core/models/domainlayer/crossdomain"
+ "github.com/apache/incubator-devlake/core/models/domainlayer/devops"
+ "github.com/apache/incubator-devlake/helpers/e2ehelper"
+ "github.com/apache/incubator-devlake/plugins/monorepo/impl"
+ "github.com/apache/incubator-devlake/plugins/monorepo/models"
+ "github.com/apache/incubator-devlake/plugins/monorepo/tasks"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestMonorepoAttributionDataFlow exercises all three subtasks against a monorepo
+// containing serviceA and serviceB, each with its own deploy job.
+//
+// The fixtures deliberately include the cases that motivated this plugin:
+// - pr2 (serviceB) merges at 09:00 while serviceA deploys at 10:00 and serviceB only at
+// 12:00. A naive "nearest deployment repo-wide" heuristic would link pr2 to the 10:00
+// deployment; it must instead link to 12:00 (via DORA's project_pr_metrics, which the
+// fixture seeds directly rather than re-deriving from commit ancestry).
+// - pipeline3 runs both deploy jobs, so it must yield one row per sub-project in
+// cicd_deployment_subprojects, and pr3 (shipped by pipeline3) triggers an *expected*
+// label/deployment mismatch against the sub-project pipeline3 also deploys.
+// - a failed deployment and a staging deployment are still attributed by job name alone
+// (attribution never looks at result/environment).
+// - pipeline8 runs a job that matches no configured sub-project, so it - and pr4 - land
+// in the 'unattributed' bucket.
+// - pr6 belongs to a different project ("other") with no monorepo configuration in this
+// run, so it must be left with sub_project = NULL entirely untouched.
+// - pr7 is open (never merged), attributed anyway per Fix 2, but absent from
+// project_pr_metrics (DORA never computed metrics for it) and therefore absent from
+// the monorepo_subproject_pr_metrics backfill too.
+// - pr8 is labelled serviceA but its project_pr_metrics row says DORA shipped it via
+// pipeline2, which only deploys serviceB: a genuine label/deployJobPattern
+// misconfiguration that FindSubProjectMismatches must surface.
+func TestMonorepoAttributionDataFlow(t *testing.T) {
+ var plugin impl.Monorepo
+ dataflowTester := e2ehelper.NewDataFlowTester(t, "monorepo", plugin)
+
+ subProjects := []tasks.SubProjectConfig{
+ {
+ Name: "serviceA",
+ PrLabels: []string{"serviceA"},
+ DeployJobPattern: "^deploy-serviceA$",
+ },
+ {
+ Name: "serviceB",
+ PrLabels: []string{"serviceB"},
+ DeployJobPattern: "^deploy-serviceB$",
+ },
+ }
+ matcher, err := tasks.NewSubProjectMatcher(subProjects)
+ assert.Nil(t, err)
+
+ taskData := &tasks.MonorepoTaskData{
+ Options: &tasks.MonorepoOptions{
+ ProjectName: "monorepo",
+ SubProjects: subProjects,
+ // Exercise the default explicitly rather than relying on a nil pointer, so
+ // this test keeps working if the default ever changes.
+ IncludeUnattributed: boolPtr(true),
+ },
+ Matcher: matcher,
+ }
+
+ // seed the domain layer
+ dataflowTester.FlushTabler(&crossdomain.ProjectMapping{})
+ dataflowTester.FlushTabler(&devops.CICDTask{})
+ dataflowTester.FlushTabler(&devops.CicdDeploymentCommit{})
+ dataflowTester.FlushTabler(&code.PullRequest{})
+ dataflowTester.FlushTabler(&code.PullRequestLabel{})
+ dataflowTester.FlushTabler(&code.PullRequestCommit{})
+ dataflowTester.FlushTabler(&crossdomain.ProjectPrMetric{})
+ dataflowTester.FlushTabler(&devops.CicdDeploymentSubproject{})
+
+ dataflowTester.ImportCsvIntoTabler("./monorepo_attribution/project_mapping.csv", &crossdomain.ProjectMapping{})
+ dataflowTester.ImportCsvIntoTabler("./monorepo_attribution/cicd_tasks.csv", &devops.CICDTask{})
+ dataflowTester.ImportCsvIntoTabler("./monorepo_attribution/cicd_deployment_commits.csv", &devops.CicdDeploymentCommit{})
+ dataflowTester.ImportNullableCsvIntoTabler("./monorepo_attribution/pull_requests.csv", &code.PullRequest{})
+ dataflowTester.ImportCsvIntoTabler("./monorepo_attribution/pull_request_labels.csv", &code.PullRequestLabel{})
+ dataflowTester.ImportCsvIntoTabler("./monorepo_attribution/pull_request_commits.csv", &code.PullRequestCommit{})
+ dataflowTester.ImportCsvIntoTabler("./monorepo_attribution/project_pr_metrics.csv", &crossdomain.ProjectPrMetric{})
+
+ // 1. attributeDeployments must run first: updateProjectPrMetricsSubProject's
+ // cross-check reads cicd_deployment_subprojects back.
+ dataflowTester.FlushTabler(&models.SubProjectDeployment{})
+ dataflowTester.Subtask(tasks.AttributeDeploymentsMeta, taskData)
+ dataflowTester.VerifyTableWithOptions(&devops.CicdDeploymentSubproject{}, e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/cicd_deployment_subprojects.csv",
+ IgnoreTypes: []interface{}{common.NoPKModel{}},
+ })
+ dataflowTester.VerifyTableWithOptions(&models.SubProjectDeployment{}, e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/monorepo_subproject_deployments.csv",
+ IgnoreTypes: []interface{}{common.NoPKModel{}},
+ })
+
+ // 2. attributePullRequests: attribution only. Assert it does NOT touch
+ // monorepo_subproject_pr_metrics (that responsibility moved entirely to
+ // updateProjectPrMetricsSubProject) by flushing the compat table to empty first and
+ // confirming it is still empty right after this subtask runs.
+ dataflowTester.FlushTabler(&models.SubProjectPrMetric{})
+ dataflowTester.Subtask(tasks.AttributePullRequestsMeta, taskData)
+
+ var prMetricsAfterAttribution []models.SubProjectPrMetric
+ require.NoError(t, dataflowTester.Dal.All(&prMetricsAfterAttribution))
+ assert.Empty(t, prMetricsAfterAttribution,
+ "attributePullRequests must not write monorepo_subproject_pr_metrics - that belongs to updateProjectPrMetricsSubProject")
+
+ dataflowTester.VerifyTableWithOptions(&code.PullRequest{}, e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/pull_requests_sub_project.csv",
+ TargetFields: []string{"sub_project"},
+ })
+ dataflowTester.VerifyTableWithOptions(&code.PullRequestCommit{}, e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/pull_request_commits_sub_project.csv",
+ TargetFields: []string{"sub_project"},
+ })
+
+ // 3. updateProjectPrMetricsSubProject: tag project_pr_metrics, cross-check against
+ // deployment attribution, and (only now) backfill monorepo_subproject_pr_metrics.
+ dataflowTester.Subtask(tasks.UpdateProjectPrMetricsSubProjectMeta, taskData)
+ dataflowTester.VerifyTableWithOptions(&crossdomain.ProjectPrMetric{}, e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/project_pr_metrics_sub_project.csv",
+ TargetFields: []string{"sub_project"},
+ })
+ dataflowTester.VerifyTableWithOptions(&models.SubProjectPrMetric{}, e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/monorepo_subproject_pr_metrics.csv",
+ IgnoreTypes: []interface{}{common.NoPKModel{}},
+ })
+
+ // pr3 is shipped by pipeline3, which deploys both serviceA and serviceB; it is
+ // labelled serviceA, so it necessarily disagrees with pipeline3's serviceB mapping -
+ // that is expected noise from a multi-sub-project deployment, not a misconfiguration.
+ // pr8 is a genuine misconfiguration: labelled serviceA but shipped by pipeline2, which
+ // only ever deploys serviceB. Labels win either way: both PRs keep sub_project =
+ // serviceA in project_pr_metrics (verified above), the mismatch is only logged.
+ mismatches, mErr := tasks.FindSubProjectMismatches(dataflowTester.Dal, "monorepo")
+ require.NoError(t, mErr)
+ require.Len(t, mismatches, 2)
+ byPr := map[string]tasks.SubProjectMismatchRow{}
+ for _, m := range mismatches {
+ byPr[m.PrId] = m
+ }
+ require.Contains(t, byPr, "pr3")
+ assert.Equal(t, "serviceA", byPr["pr3"].PrSubProject)
+ assert.Equal(t, "serviceB", byPr["pr3"].DeploymentSubProject)
+ require.Contains(t, byPr, "pr8")
+ assert.Equal(t, "serviceA", byPr["pr8"].PrSubProject)
+ assert.Equal(t, "serviceB", byPr["pr8"].DeploymentSubProject)
+}
+
+// TestMonorepoAttributeDeploymentsIncludeUnattributedFalse exercises the
+// includeUnattributed=false path (design doc decision 3) against attributeDeployments:
+// pipeline8's job matches no configured sub-project, so with the "old" behaviour
+// restored, it should be skipped entirely rather than getting an 'unattributed' row in
+// either cicd_deployment_subprojects or the compat monorepo_subproject_deployments table.
+func TestMonorepoAttributeDeploymentsIncludeUnattributedFalse(t *testing.T) {
+ var plugin impl.Monorepo
+ dataflowTester := e2ehelper.NewDataFlowTester(t, "monorepo", plugin)
+
+ subProjects := []tasks.SubProjectConfig{
+ {Name: "serviceA", DeployJobPattern: "^deploy-serviceA$"},
+ {Name: "serviceB", DeployJobPattern: "^deploy-serviceB$"},
+ }
+ matcher, err := tasks.NewSubProjectMatcher(subProjects)
+ require.NoError(t, err)
+
+ taskData := &tasks.MonorepoTaskData{
+ Options: &tasks.MonorepoOptions{
+ ProjectName: "monorepo",
+ SubProjects: subProjects,
+ IncludeUnattributed: boolPtr(false),
+ },
+ Matcher: matcher,
+ }
+
+ dataflowTester.FlushTabler(&crossdomain.ProjectMapping{})
+ dataflowTester.FlushTabler(&devops.CICDTask{})
+ dataflowTester.FlushTabler(&devops.CicdDeploymentCommit{})
+ dataflowTester.FlushTabler(&devops.CicdDeploymentSubproject{})
+ dataflowTester.FlushTabler(&models.SubProjectDeployment{})
+
+ dataflowTester.ImportCsvIntoTabler("./monorepo_attribution/project_mapping.csv", &crossdomain.ProjectMapping{})
+ dataflowTester.ImportCsvIntoTabler("./monorepo_attribution/cicd_tasks.csv", &devops.CICDTask{})
+ dataflowTester.ImportCsvIntoTabler("./monorepo_attribution/cicd_deployment_commits.csv", &devops.CicdDeploymentCommit{})
+
+ dataflowTester.Subtask(tasks.AttributeDeploymentsMeta, taskData)
+
+ var mappings []devops.CicdDeploymentSubproject
+ require.NoError(t, dataflowTester.Dal.All(&mappings))
+ for _, m := range mappings {
+ assert.NotEqual(t, tasks.UnattributedSubProject, m.SubProject,
+ "pipeline8 must be skipped, not marked unattributed, when includeUnattributed is false")
+ }
+ // pipeline1/2/3(x2, one per sub-project)/6/7 are still attributed normally; only
+ // pipeline8 (which matches nothing) is affected by the flag.
+ assert.Len(t, mappings, 6)
+
+ var compat []models.SubProjectDeployment
+ require.NoError(t, dataflowTester.Dal.All(&compat))
+ assert.Len(t, compat, 6)
+}
+
+func boolPtr(b bool) *bool {
+ return &b
+}
diff --git a/backend/plugins/monorepo/e2e/migration_backfill_test.go b/backend/plugins/monorepo/e2e/migration_backfill_test.go
new file mode 100644
index 00000000000..905a1ac830a
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/migration_backfill_test.go
@@ -0,0 +1,159 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package e2e
+
+import (
+ "testing"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/config"
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/migration"
+ "github.com/apache/incubator-devlake/core/models/domainlayer/code"
+ "github.com/apache/incubator-devlake/core/models/domainlayer/devops"
+ coreMigration "github.com/apache/incubator-devlake/core/models/migrationscripts"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "github.com/apache/incubator-devlake/core/runner"
+ "github.com/apache/incubator-devlake/helpers/e2ehelper"
+ "github.com/apache/incubator-devlake/impls/dalgorm"
+ "github.com/apache/incubator-devlake/impls/logruslog"
+ "github.com/apache/incubator-devlake/plugins/monorepo/models"
+ monorepoMigration "github.com/apache/incubator-devlake/plugins/monorepo/models/migrationscripts"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// backfillMigrationVersion is 20260810_backfill_sub_project_from_monorepo.go's
+// Version(). The concrete script type is unexported (as is this codebase's convention
+// for migration scripts), so it is looked up by version through the exported
+// plugin.MigrationScript interface returned by coreMigration.All() rather than
+// constructed directly.
+const backfillMigrationVersion = uint64(20260810100200)
+
+func findMigration(t *testing.T, scripts []plugin.MigrationScript, version uint64) plugin.MigrationScript {
+ t.Helper()
+ for _, s := range scripts {
+ if s.Version() == version {
+ return s
+ }
+ }
+ t.Fatalf("migration version %d not found", version)
+ return nil
+}
+
+// TestMigrationAddsSubProjectColumnsAndTable runs the REAL registered core migration
+// scripts (not AutoMigrate on the runtime model, which would hide a mistake in the
+// migration itself) against a fresh, isolated database, and asserts that:
+//
+// 1. pull_requests, pull_request_commits and project_pr_metrics all gain a sub_project
+// column (20260810_add_sub_project_to_pr_and_metrics.go).
+// 2. cicd_deployment_subprojects exists (20260810_add_cicd_deployment_subprojects.go).
+//
+// Requires E2E_DB_URL (runs under `make e2e-test` / `make e2e-test-go-plugins`).
+func TestMigrationAddsSubProjectColumnsAndTable(t *testing.T) {
+ db := e2ehelper.NewIsolatedMigrationDb(t, "monorepo_migration_columns")
+ d := dalgorm.NewDalgorm(db)
+ basicRes := runner.CreateBasicRes(config.GetConfig(), logruslog.Global, db)
+
+ migrator, err := migration.NewMigrator(basicRes)
+ require.NoError(t, err)
+ migrator.Register(coreMigration.All(), "Framework")
+ require.NoError(t, migrator.Execute())
+
+ assert.True(t, d.HasColumn(&code.PullRequest{}, "sub_project"), "pull_requests.sub_project should exist")
+ assert.True(t, d.HasColumn(&code.PullRequestCommit{}, "sub_project"), "pull_request_commits.sub_project should exist")
+ assert.True(t, d.HasTable("cicd_deployment_subprojects"), "cicd_deployment_subprojects table should exist")
+ assert.True(t, d.HasColumn(&devops.CicdDeploymentSubproject{}, "sub_project"), "cicd_deployment_subprojects.sub_project should exist")
+}
+
+// TestBackfillSubProjectFromMonorepo runs the real core migrations AND the real monorepo
+// plugin migrations against a fresh, isolated database, seeds data in the shape an
+// existing monorepo-plugin install would have accumulated, re-runs just the backfill
+// migration (20260810_backfill_sub_project_from_monorepo.go) a second time by calling its
+// Up() directly, and asserts it produces the expected sub_project values - i.e. that
+// projects already using the monorepo plugin keep their classification across the
+// upgrade instead of reverting to NULL/"All". Re-invoking Up() a second time (the first
+// happened, as a no-op, during migrator.Execute() before any monorepo data existed) also
+// doubles as an idempotency check: the design doc requires the backfill be safe to re-run.
+//
+// Requires E2E_DB_URL (runs under `make e2e-test` / `make e2e-test-go-plugins`).
+func TestBackfillSubProjectFromMonorepo(t *testing.T) {
+ db := e2ehelper.NewIsolatedMigrationDb(t, "monorepo_migration_backfill")
+ d := dalgorm.NewDalgorm(db)
+ basicRes := runner.CreateBasicRes(config.GetConfig(), logruslog.Global, db)
+
+ migrator, err := migration.NewMigrator(basicRes)
+ require.NoError(t, err)
+ migrator.Register(coreMigration.All(), "Framework")
+ migrator.Register(monorepoMigration.All(), "monorepo")
+ require.NoError(t, migrator.Execute())
+
+ // Seed a pull_requests/pull_request_commits row via raw INSERT that omits the
+ // sub_project column entirely, so it gets the database's real column default (NULL) -
+ // exactly what an ALTER TABLE ADD COLUMN produces for rows that existed before this
+ // migration ran, and what the backfill's `WHERE sub_project IS NULL` guard expects.
+ // (Going through the GORM model here would write sub_project = '' instead, since
+ // code.PullRequest.SubProject is a plain string, not a pointer - a real difference,
+ // but not the scenario the backfill migration exists to handle.)
+ now := time.Now()
+ require.NoError(t, d.Exec(
+ "INSERT INTO pull_requests (id, base_repo_id, created_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
+ "pr1", "repo1", now, now, now,
+ ))
+ require.NoError(t, d.Exec(
+ "INSERT INTO pull_request_commits (commit_sha, pull_request_id, commit_authored_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
+ "commitA1", "pr1", now, now, now,
+ ))
+
+ require.NoError(t, d.Create(&models.SubProjectPrMetric{
+ ProjectName: "monorepo",
+ PullRequestId: "pr1",
+ SubProject: "serviceA",
+ }))
+ require.NoError(t, d.Create(&models.SubProjectDeployment{
+ ProjectName: "monorepo",
+ SubProject: "serviceA",
+ CicdDeploymentId: "pipeline1",
+ CommitSha: "commitA1",
+ }))
+
+ // Re-run the backfill script directly: the copy that ran inside migrator.Execute()
+ // above was a no-op because it executed before this seed data existed.
+ backfill := findMigration(t, coreMigration.All(), backfillMigrationVersion)
+ require.NoError(t, backfill.Up(basicRes))
+
+ var gotPr code.PullRequest
+ require.NoError(t, d.First(&gotPr, dal.Where("id = ?", "pr1")))
+ assert.Equal(t, "serviceA", gotPr.SubProject)
+
+ var gotCommit code.PullRequestCommit
+ require.NoError(t, d.First(&gotCommit, dal.Where("commit_sha = ?", "commitA1")))
+ assert.Equal(t, "serviceA", gotCommit.SubProject)
+
+ assert.True(t, d.HasTable("cicd_deployment_subprojects"))
+ mappingCount, cErr := d.Count(dal.From("cicd_deployment_subprojects"))
+ require.NoError(t, cErr)
+ assert.Equal(t, int64(1), mappingCount)
+
+ // Re-running the migration a second time must not error and must not duplicate rows
+ // (idempotency, per the design doc's risk mitigation).
+ require.NoError(t, backfill.Up(basicRes))
+ mappingCount, cErr = d.Count(dal.From("cicd_deployment_subprojects"))
+ require.NoError(t, cErr)
+ assert.Equal(t, int64(1), mappingCount)
+}
diff --git a/backend/plugins/monorepo/e2e/monorepo_attribution/cicd_deployment_commits.csv b/backend/plugins/monorepo/e2e/monorepo_attribution/cicd_deployment_commits.csv
new file mode 100644
index 00000000000..0a1288e1c06
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/monorepo_attribution/cicd_deployment_commits.csv
@@ -0,0 +1,8 @@
+id,cicd_deployment_id,cicd_scope_id,name,result,status,environment,repo_url,commit_sha,created_date,finished_date
+dc1,pipeline1,cicd1,deploy-serviceA,SUCCESS,DONE,PRODUCTION,https://gitlab.example.com/acme/monorepo,commitA1,2026-08-01T09:50:00.000+00:00,2026-08-01T10:00:00.000+00:00
+dc2,pipeline2,cicd1,deploy-serviceB,SUCCESS,DONE,PRODUCTION,https://gitlab.example.com/acme/monorepo,commitB1,2026-08-01T11:50:00.000+00:00,2026-08-01T12:00:00.000+00:00
+dc3,pipeline3,cicd1,deploy-both,SUCCESS,DONE,PRODUCTION,https://gitlab.example.com/acme/monorepo,commitAB,2026-08-02T09:50:00.000+00:00,2026-08-02T10:00:00.000+00:00
+dc5,pipeline5,cicd2,deploy-serviceA,SUCCESS,DONE,PRODUCTION,https://gitlab.example.com/acme/other,commitOther,2026-08-01T09:50:00.000+00:00,2026-08-01T10:00:00.000+00:00
+dc6,pipeline6,cicd1,deploy-serviceA,FAILURE,DONE,PRODUCTION,https://gitlab.example.com/acme/monorepo,commitFail,2026-08-01T09:20:00.000+00:00,2026-08-01T09:30:00.000+00:00
+dc7,pipeline7,cicd1,deploy-serviceA,SUCCESS,DONE,STAGING,https://gitlab.example.com/acme/monorepo,commitStg,2026-08-01T09:35:00.000+00:00,2026-08-01T09:45:00.000+00:00
+dc8,pipeline8,cicd1,deploy-unknown,SUCCESS,DONE,PRODUCTION,https://gitlab.example.com/acme/monorepo,commitUnknown,2026-08-01T13:50:00.000+00:00,2026-08-01T14:00:00.000+00:00
diff --git a/backend/plugins/monorepo/e2e/monorepo_attribution/cicd_tasks.csv b/backend/plugins/monorepo/e2e/monorepo_attribution/cicd_tasks.csv
new file mode 100644
index 00000000000..119c29185f7
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/monorepo_attribution/cicd_tasks.csv
@@ -0,0 +1,10 @@
+id,name,pipeline_id,type,result,status,environment,cicd_scope_id,created_date,finished_date
+task1,deploy-serviceA,pipeline1,DEPLOYMENT,SUCCESS,DONE,PRODUCTION,cicd1,2026-08-01T09:50:00.000+00:00,2026-08-01T10:00:00.000+00:00
+task1b,build,pipeline1,,SUCCESS,DONE,,cicd1,2026-08-01T09:40:00.000+00:00,2026-08-01T09:50:00.000+00:00
+task2,deploy-serviceB,pipeline2,DEPLOYMENT,SUCCESS,DONE,PRODUCTION,cicd1,2026-08-01T11:50:00.000+00:00,2026-08-01T12:00:00.000+00:00
+task3a,deploy-serviceA,pipeline3,DEPLOYMENT,SUCCESS,DONE,PRODUCTION,cicd1,2026-08-02T09:50:00.000+00:00,2026-08-02T10:00:00.000+00:00
+task3b,deploy-serviceB,pipeline3,DEPLOYMENT,SUCCESS,DONE,PRODUCTION,cicd1,2026-08-02T09:50:00.000+00:00,2026-08-02T10:00:00.000+00:00
+task5,deploy-serviceA,pipeline5,DEPLOYMENT,SUCCESS,DONE,PRODUCTION,cicd2,2026-08-01T09:50:00.000+00:00,2026-08-01T10:00:00.000+00:00
+task6,deploy-serviceA,pipeline6,DEPLOYMENT,FAILURE,DONE,PRODUCTION,cicd1,2026-08-01T09:20:00.000+00:00,2026-08-01T09:30:00.000+00:00
+task7,deploy-serviceA,pipeline7,DEPLOYMENT,SUCCESS,DONE,STAGING,cicd1,2026-08-01T09:35:00.000+00:00,2026-08-01T09:45:00.000+00:00
+task8,deploy-unknown,pipeline8,DEPLOYMENT,SUCCESS,DONE,PRODUCTION,cicd1,2026-08-01T13:50:00.000+00:00,2026-08-01T14:00:00.000+00:00
diff --git a/backend/plugins/monorepo/e2e/monorepo_attribution/project_mapping.csv b/backend/plugins/monorepo/e2e/monorepo_attribution/project_mapping.csv
new file mode 100644
index 00000000000..c871e7cb114
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/monorepo_attribution/project_mapping.csv
@@ -0,0 +1,5 @@
+project_name,table,row_id
+monorepo,cicd_scopes,cicd1
+monorepo,repos,repo1
+other,cicd_scopes,cicd2
+other,repos,repo2
diff --git a/backend/plugins/monorepo/e2e/monorepo_attribution/project_pr_metrics.csv b/backend/plugins/monorepo/e2e/monorepo_attribution/project_pr_metrics.csv
new file mode 100644
index 00000000000..acb9e3ee2d3
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/monorepo_attribution/project_pr_metrics.csv
@@ -0,0 +1,7 @@
+id,project_name,pr_coding_time,pr_pickup_time,pr_review_time,pr_deploy_time,pr_cycle_time,deployment_commit_id,pr_created_date,pr_merged_date,pr_deployed_date
+pr1,monorepo,100,20,30,60,220,dc1,2026-08-01T08:00:00.000+00:00,2026-08-01T09:00:00.000+00:00,2026-08-01T10:00:00.000+00:00
+pr2,monorepo,200,40,60,180,440,dc2,2026-08-01T08:00:00.000+00:00,2026-08-01T09:00:00.000+00:00,2026-08-01T12:00:00.000+00:00
+pr3,monorepo,300,60,90,1470,1830,dc3,2026-08-01T08:30:00.000+00:00,2026-08-01T09:30:00.000+00:00,2026-08-02T10:00:00.000+00:00
+pr4,monorepo,400,80,120,,460,,2026-08-01T08:00:00.000+00:00,2026-08-01T09:00:00.000+00:00,
+pr5,monorepo,500,100,150,,560,,2026-08-03T08:00:00.000+00:00,2026-08-03T09:00:00.000+00:00,
+pr8,monorepo,150,15,25,190,390,dc2,2026-08-01T08:00:00.000+00:00,2026-08-01T08:50:00.000+00:00,2026-08-01T12:00:00.000+00:00
diff --git a/backend/plugins/monorepo/e2e/monorepo_attribution/pull_request_commits.csv b/backend/plugins/monorepo/e2e/monorepo_attribution/pull_request_commits.csv
new file mode 100644
index 00000000000..678f9f7858f
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/monorepo_attribution/pull_request_commits.csv
@@ -0,0 +1,9 @@
+commit_sha,pull_request_id,commit_author_name,commit_author_email,commit_authored_date
+commitA1,pr1,Alice,alice@example.com,2026-08-01T08:30:00.000+00:00
+commitB1,pr2,Bob,bob@example.com,2026-08-01T08:30:00.000+00:00
+commitAB,pr3,Carol,carol@example.com,2026-08-01T09:00:00.000+00:00
+commitBug,pr4,Dave,dave@example.com,2026-08-01T08:30:00.000+00:00
+commitLate,pr5,Erin,erin@example.com,2026-08-03T08:30:00.000+00:00
+commitOther,pr6,Frank,frank@example.com,2026-08-01T08:30:00.000+00:00
+commitOpen,pr7,Grace,grace@example.com,2026-08-01T08:30:00.000+00:00
+commitPr8,pr8,Heidi,heidi@example.com,2026-08-01T08:30:00.000+00:00
diff --git a/backend/plugins/monorepo/e2e/monorepo_attribution/pull_request_labels.csv b/backend/plugins/monorepo/e2e/monorepo_attribution/pull_request_labels.csv
new file mode 100644
index 00000000000..87bd4fb9769
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/monorepo_attribution/pull_request_labels.csv
@@ -0,0 +1,10 @@
+pull_request_id,label_name
+pr1,serviceA
+pr2,serviceB
+pr3,serviceB
+pr3,serviceA
+pr4,bug
+pr5,serviceA
+pr6,serviceA
+pr7,serviceA
+pr8,serviceA
diff --git a/backend/plugins/monorepo/e2e/monorepo_attribution/pull_requests.csv b/backend/plugins/monorepo/e2e/monorepo_attribution/pull_requests.csv
new file mode 100644
index 00000000000..d00f390c8a4
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/monorepo_attribution/pull_requests.csv
@@ -0,0 +1,9 @@
+id,base_repo_id,created_date,merged_date,merge_commit_sha
+pr1,repo1,2026-08-01T08:00:00.000+00:00,2026-08-01T09:00:00.000+00:00,commitA1
+pr2,repo1,2026-08-01T08:00:00.000+00:00,2026-08-01T09:00:00.000+00:00,commitB1
+pr3,repo1,2026-08-01T08:30:00.000+00:00,2026-08-01T09:30:00.000+00:00,commitAB
+pr4,repo1,2026-08-01T08:00:00.000+00:00,2026-08-01T09:00:00.000+00:00,commitBug
+pr5,repo1,2026-08-03T08:00:00.000+00:00,2026-08-03T09:00:00.000+00:00,commitLate
+pr6,repo2,2026-08-01T08:00:00.000+00:00,2026-08-01T09:00:00.000+00:00,commitOther
+pr7,repo1,2026-08-01T08:00:00.000+00:00,NULL,commitOpen
+pr8,repo1,2026-08-01T08:00:00.000+00:00,2026-08-01T08:50:00.000+00:00,commitPr8
diff --git a/backend/plugins/monorepo/e2e/snapshot_tables/cicd_deployment_subprojects.csv b/backend/plugins/monorepo/e2e/snapshot_tables/cicd_deployment_subprojects.csv
new file mode 100644
index 00000000000..413a057d914
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/snapshot_tables/cicd_deployment_subprojects.csv
@@ -0,0 +1,8 @@
+project_name,cicd_deployment_id,sub_project
+monorepo,pipeline1,serviceA
+monorepo,pipeline2,serviceB
+monorepo,pipeline3,serviceA
+monorepo,pipeline3,serviceB
+monorepo,pipeline6,serviceA
+monorepo,pipeline7,serviceA
+monorepo,pipeline8,unattributed
diff --git a/backend/plugins/monorepo/e2e/snapshot_tables/monorepo_subproject_deployments.csv b/backend/plugins/monorepo/e2e/snapshot_tables/monorepo_subproject_deployments.csv
new file mode 100644
index 00000000000..d4b08cda270
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/snapshot_tables/monorepo_subproject_deployments.csv
@@ -0,0 +1,8 @@
+project_name,sub_project,cicd_deployment_id,commit_sha,job_name,result,environment,finished_date
+monorepo,serviceA,pipeline1,commitA1,deploy-serviceA,SUCCESS,PRODUCTION,2026-08-01T10:00:00.000+00:00
+monorepo,serviceB,pipeline2,commitB1,deploy-serviceB,SUCCESS,PRODUCTION,2026-08-01T12:00:00.000+00:00
+monorepo,serviceA,pipeline3,commitAB,deploy-serviceA,SUCCESS,PRODUCTION,2026-08-02T10:00:00.000+00:00
+monorepo,serviceB,pipeline3,commitAB,deploy-serviceB,SUCCESS,PRODUCTION,2026-08-02T10:00:00.000+00:00
+monorepo,serviceA,pipeline6,commitFail,deploy-serviceA,FAILURE,PRODUCTION,2026-08-01T09:30:00.000+00:00
+monorepo,serviceA,pipeline7,commitStg,deploy-serviceA,SUCCESS,STAGING,2026-08-01T09:45:00.000+00:00
+monorepo,unattributed,pipeline8,commitUnknown,deploy-unknown,SUCCESS,PRODUCTION,2026-08-01T14:00:00.000+00:00
diff --git a/backend/plugins/monorepo/e2e/snapshot_tables/monorepo_subproject_pr_metrics.csv b/backend/plugins/monorepo/e2e/snapshot_tables/monorepo_subproject_pr_metrics.csv
new file mode 100644
index 00000000000..5459396f5d2
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/snapshot_tables/monorepo_subproject_pr_metrics.csv
@@ -0,0 +1,7 @@
+project_name,pull_request_id,sub_project,coding_time,pickup_time,review_time,deploy_time,cycle_time,deployment_id,pr_created_date,pr_merged_date,deployed_date
+monorepo,pr1,serviceA,100,20,30,60,220,pipeline1,2026-08-01T08:00:00.000+00:00,2026-08-01T09:00:00.000+00:00,2026-08-01T10:00:00.000+00:00
+monorepo,pr2,serviceB,200,40,60,180,440,pipeline2,2026-08-01T08:00:00.000+00:00,2026-08-01T09:00:00.000+00:00,2026-08-01T12:00:00.000+00:00
+monorepo,pr3,serviceA,300,60,90,1470,1830,pipeline3,2026-08-01T08:30:00.000+00:00,2026-08-01T09:30:00.000+00:00,2026-08-02T10:00:00.000+00:00
+monorepo,pr4,unattributed,400,80,120,,460,,2026-08-01T08:00:00.000+00:00,2026-08-01T09:00:00.000+00:00,
+monorepo,pr5,serviceA,500,100,150,,560,,2026-08-03T08:00:00.000+00:00,2026-08-03T09:00:00.000+00:00,
+monorepo,pr8,serviceA,150,15,25,190,390,pipeline2,2026-08-01T08:00:00.000+00:00,2026-08-01T08:50:00.000+00:00,2026-08-01T12:00:00.000+00:00
diff --git a/backend/plugins/monorepo/e2e/snapshot_tables/project_pr_metrics_sub_project.csv b/backend/plugins/monorepo/e2e/snapshot_tables/project_pr_metrics_sub_project.csv
new file mode 100644
index 00000000000..6f4a2cdc9a6
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/snapshot_tables/project_pr_metrics_sub_project.csv
@@ -0,0 +1,7 @@
+id,project_name,sub_project
+pr1,monorepo,serviceA
+pr2,monorepo,serviceB
+pr3,monorepo,serviceA
+pr4,monorepo,unattributed
+pr5,monorepo,serviceA
+pr8,monorepo,serviceA
diff --git a/backend/plugins/monorepo/e2e/snapshot_tables/pull_request_commits_sub_project.csv b/backend/plugins/monorepo/e2e/snapshot_tables/pull_request_commits_sub_project.csv
new file mode 100644
index 00000000000..3974f0275cb
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/snapshot_tables/pull_request_commits_sub_project.csv
@@ -0,0 +1,9 @@
+commit_sha,pull_request_id,sub_project
+commitA1,pr1,serviceA
+commitB1,pr2,serviceB
+commitAB,pr3,serviceA
+commitBug,pr4,unattributed
+commitLate,pr5,serviceA
+commitOther,pr6,
+commitOpen,pr7,serviceA
+commitPr8,pr8,serviceA
diff --git a/backend/plugins/monorepo/e2e/snapshot_tables/pull_requests_sub_project.csv b/backend/plugins/monorepo/e2e/snapshot_tables/pull_requests_sub_project.csv
new file mode 100644
index 00000000000..2d5b4452bb8
--- /dev/null
+++ b/backend/plugins/monorepo/e2e/snapshot_tables/pull_requests_sub_project.csv
@@ -0,0 +1,9 @@
+id,sub_project
+pr1,serviceA
+pr2,serviceB
+pr3,serviceA
+pr4,unattributed
+pr5,serviceA
+pr6,
+pr7,serviceA
+pr8,serviceA
diff --git a/backend/plugins/monorepo/impl/impl.go b/backend/plugins/monorepo/impl/impl.go
new file mode 100644
index 00000000000..c753a58ca13
--- /dev/null
+++ b/backend/plugins/monorepo/impl/impl.go
@@ -0,0 +1,193 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package impl
+
+import (
+ "encoding/json"
+
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/errors"
+ coreModels "github.com/apache/incubator-devlake/core/models"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "github.com/apache/incubator-devlake/plugins/monorepo/models"
+ "github.com/apache/incubator-devlake/plugins/monorepo/models/migrationscripts"
+ "github.com/apache/incubator-devlake/plugins/monorepo/tasks"
+)
+
+// make sure interface is implemented
+var _ interface {
+ plugin.PluginMeta
+ plugin.PluginTask
+ plugin.PluginModel
+ plugin.PluginMetric
+ plugin.PluginMigration
+ plugin.MetricPluginBlueprintV200
+} = (*Monorepo)(nil)
+
+type Monorepo struct{}
+
+func (p Monorepo) Description() string {
+ return "Split a monorepo into sub-projects and compute per-sub-project DORA metrics"
+}
+
+func (p Monorepo) Name() string {
+ return "monorepo"
+}
+
+func (p Monorepo) Dashboards() []plugin.GrafanaDashboard {
+ return nil
+}
+
+func (p Monorepo) SvgIcon() string {
+ return ``
+}
+
+// RequiredDataEntities declares that deployments must be recognisable as CI/CD tasks of
+// type Deployment, which is what sub-project attribution matches job names against.
+func (p Monorepo) RequiredDataEntities() (data []map[string]interface{}, err errors.Error) {
+ return []map[string]interface{}{
+ {
+ "model": "cicd_tasks",
+ "requiredFields": map[string]string{
+ "column": "type",
+ "execptedValue": "Deployment",
+ },
+ },
+ }, nil
+}
+
+func (p Monorepo) GetTablesInfo() []dal.Tabler {
+ return []dal.Tabler{
+ &models.SubProjectDeployment{},
+ &models.SubProjectPrMetric{},
+ }
+}
+
+func (p Monorepo) IsProjectMetric() bool {
+ return true
+}
+
+// RunAfter declares that this plugin should run after dora. NOTE: this is currently
+// advisory metadata only (surfaced via the /plugins API) — core's blueprint plan builder
+// (server/services/blueprint_makeplan_v200.go GeneratePlanJsonV200) merges all enabled
+// metric plugins' plans with ParallelizePipelinePlans, which zips their stages together by
+// index and does not consult RunAfter. Actual ordering against dora is enforced by stage
+// padding in MakeMetricPluginPipelinePlanV200 below, not by this declaration.
+func (p Monorepo) RunAfter() ([]string, errors.Error) {
+ return []string{"dora"}, nil
+}
+
+func (p Monorepo) Settings() interface{} {
+ return nil
+}
+
+func (p Monorepo) SubTaskMetas() []plugin.SubTaskMeta {
+ return []plugin.SubTaskMeta{
+ tasks.AttributeDeploymentsMeta,
+ tasks.AttributePullRequestsMeta,
+ tasks.UpdateProjectPrMetricsSubProjectMeta,
+ }
+}
+
+func (p Monorepo) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]interface{}) (interface{}, errors.Error) {
+ op, err := tasks.DecodeAndValidateTaskOptions(options)
+ if err != nil {
+ return nil, err
+ }
+ matcher, err := tasks.NewSubProjectMatcher(op.SubProjects)
+ if err != nil {
+ return nil, err
+ }
+ return &tasks.MonorepoTaskData{
+ Options: op,
+ Matcher: matcher,
+ }, nil
+}
+
+// RootPkgPath information lost when compiled as plugin(.so)
+func (p Monorepo) RootPkgPath() string {
+ return "github.com/apache/incubator-devlake/plugins/monorepo"
+}
+
+func (p Monorepo) MigrationScripts() []plugin.MigrationScript {
+ return migrationscripts.All()
+}
+
+func (p Monorepo) MakeMetricPluginPipelinePlanV200(projectName string, options json.RawMessage) (coreModels.PipelinePlan, errors.Error) {
+ op := &tasks.MonorepoOptions{}
+ if options != nil && string(options) != "\"\"" {
+ if err := json.Unmarshal(options, op); err != nil {
+ return nil, errors.Default.WrapRaw(err)
+ }
+ }
+ if len(op.SubProjects) == 0 {
+ return nil, errors.BadInput.New(
+ "the monorepo plugin requires a subProjects list in its metric plugin options")
+ }
+ // Validate eagerly so a bad regex is reported when the blueprint is saved rather
+ // than midway through a pipeline run.
+ if _, err := tasks.NewSubProjectMatcher(op.SubProjects); err != nil {
+ return nil, err
+ }
+
+ subProjects := make([]map[string]interface{}, 0, len(op.SubProjects))
+ for _, sp := range op.SubProjects {
+ subProjects = append(subProjects, map[string]interface{}{
+ "name": sp.Name,
+ "prLabels": sp.PrLabels,
+ "deployJobPattern": sp.DeployJobPattern,
+ })
+ }
+ // Preserve an explicit false; only default to true when the caller didn't set it at all.
+ includeUnattributed := op.ShouldIncludeUnattributed()
+
+ // attributeDeployments reads cicd_deployment_commits, and attributePullRequests reads
+ // project_pr_metrics — both are written by dora's own multi-stage plan (currently 3
+ // stages: generate deployments, refdiff, calculate change lead time). Core's
+ // ParallelizePipelinePlans merges every enabled metric plugin's plan by stage index, so
+ // without padding, our single stage would run concurrently with dora's stage 0 instead
+ // of after its stage 2 — a real race that silently produces incomplete/nil-metric
+ // output (no error) when dora and monorepo are enabled together, since core's RunAfter
+ // contract above is not actually enforced by the scheduler. Padding with empty stages
+ // through dora's stage count (and then some, for headroom against future growth) is a
+ // workaround, not a fix: if dora's plan ever grows past this padding, the race returns.
+ // Revisit if DevLake ever adds real cross-plugin dependency scheduling.
+ const stagesToOutlastDora = 6
+ plan := make(coreModels.PipelinePlan, stagesToOutlastDora+1)
+ for i := 0; i < stagesToOutlastDora; i++ {
+ plan[i] = coreModels.PipelineStage{}
+ }
+ plan[stagesToOutlastDora] = coreModels.PipelineStage{
+ {
+ Plugin: "monorepo",
+ Options: map[string]interface{}{
+ "projectName": projectName,
+ "subProjects": subProjects,
+ "includeUnattributed": includeUnattributed,
+ },
+ Subtasks: []string{
+ tasks.AttributeDeploymentsMeta.Name,
+ tasks.AttributePullRequestsMeta.Name,
+ tasks.UpdateProjectPrMetricsSubProjectMeta.Name,
+ },
+ },
+ }
+ return plan, nil
+}
diff --git a/backend/plugins/monorepo/impl/impl_test.go b/backend/plugins/monorepo/impl/impl_test.go
new file mode 100644
index 00000000000..cd76b5c7bc6
--- /dev/null
+++ b/backend/plugins/monorepo/impl/impl_test.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 impl
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Core's blueprint plan builder (server/services/blueprint_makeplan_v200.go) merges every
+// enabled metric plugin's plan with ParallelizePipelinePlans, which zips stages together by
+// index and does NOT consult RunAfter(). If monorepo's real work sat in stage 0 like a naive
+// single-stage plan would, it would run concurrently with dora's stage 0 instead of after
+// dora's stage 2 (where project_pr_metrics/cicd_deployment_commits actually get written) —
+// silently producing nil-metric output. This test locks in the stage-padding workaround so a
+// future edit can't accidentally collapse the plan back to one stage.
+func TestMakeMetricPluginPipelinePlanV200_StagePadding(t *testing.T) {
+ options, err := json.Marshal(map[string]interface{}{
+ "subProjects": []map[string]interface{}{
+ {"name": "serviceA", "prLabels": []string{"serviceA"}, "deployJobPattern": "^deploy-serviceA$"},
+ },
+ })
+ require.NoError(t, err)
+
+ var p Monorepo
+ plan, err2 := p.MakeMetricPluginPipelinePlanV200("test-project", options)
+ require.NoError(t, err2)
+
+ require.Greater(t, len(plan), 3, "plan must have more stages than dora's plan (3), or monorepo's "+
+ "work would run concurrently with dora instead of after it")
+
+ for i := 0; i < len(plan)-1; i++ {
+ assert.Emptyf(t, plan[i], "stage %d should be empty padding, not real work", i)
+ }
+
+ lastStage := plan[len(plan)-1]
+ require.Len(t, lastStage, 1)
+ assert.Equal(t, "monorepo", lastStage[0].Plugin)
+ assert.ElementsMatch(t, []string{
+ "attributeDeployments", "attributePullRequests", "updateProjectPrMetricsSubProject",
+ }, lastStage[0].Subtasks)
+ assert.Equal(t, true, lastStage[0].Options["includeUnattributed"],
+ "includeUnattributed must default to true when the caller doesn't set it")
+}
+
+// TestMakeMetricPluginPipelinePlanV200_IncludeUnattributedExplicitFalse locks in that an
+// explicit `"includeUnattributed": false` survives into the task options unchanged,
+// distinguishing it from "not set" (which defaults to true).
+func TestMakeMetricPluginPipelinePlanV200_IncludeUnattributedExplicitFalse(t *testing.T) {
+ options, err := json.Marshal(map[string]interface{}{
+ "subProjects": []map[string]interface{}{
+ {"name": "serviceA", "prLabels": []string{"serviceA"}, "deployJobPattern": "^deploy-serviceA$"},
+ },
+ "includeUnattributed": false,
+ })
+ require.NoError(t, err)
+
+ var p Monorepo
+ plan, err2 := p.MakeMetricPluginPipelinePlanV200("test-project", options)
+ require.NoError(t, err2)
+
+ lastStage := plan[len(plan)-1]
+ assert.Equal(t, false, lastStage[0].Options["includeUnattributed"])
+}
diff --git a/backend/plugins/monorepo/models/migrationscripts/20260809_add_init_tables.go b/backend/plugins/monorepo/models/migrationscripts/20260809_add_init_tables.go
new file mode 100644
index 00000000000..ce485504013
--- /dev/null
+++ b/backend/plugins/monorepo/models/migrationscripts/20260809_add_init_tables.go
@@ -0,0 +1,44 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "github.com/apache/incubator-devlake/helpers/migrationhelper"
+ "github.com/apache/incubator-devlake/plugins/monorepo/models"
+)
+
+var _ plugin.MigrationScript = (*addInitTables)(nil)
+
+type addInitTables struct{}
+
+func (script *addInitTables) Up(basicRes context.BasicRes) errors.Error {
+ return migrationhelper.AutoMigrateTables(
+ basicRes,
+ &models.SubProjectDeployment{},
+ &models.SubProjectPrMetric{},
+ )
+}
+
+func (*addInitTables) Version() uint64 { return 20260809100000 }
+
+func (*addInitTables) Name() string {
+ return "create init tables for the monorepo plugin"
+}
diff --git a/backend/plugins/monorepo/models/migrationscripts/register.go b/backend/plugins/monorepo/models/migrationscripts/register.go
new file mode 100644
index 00000000000..ec054748c27
--- /dev/null
+++ b/backend/plugins/monorepo/models/migrationscripts/register.go
@@ -0,0 +1,29 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+ "github.com/apache/incubator-devlake/core/plugin"
+)
+
+// All return all the migration scripts
+func All() []plugin.MigrationScript {
+ return []plugin.MigrationScript{
+ new(addInitTables),
+ }
+}
diff --git a/backend/plugins/monorepo/models/subproject_deployment.go b/backend/plugins/monorepo/models/subproject_deployment.go
new file mode 100644
index 00000000000..13ba56bbc96
--- /dev/null
+++ b/backend/plugins/monorepo/models/subproject_deployment.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 models
+
+import (
+ "time"
+
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// SubProjectDeployment attributes a deployment to a single sub-project of a monorepo,
+// based on the name of the CI job that performed the deployment.
+//
+// Deprecated: this table is kept populated for one release for backward compatibility
+// with dashboards/integrations built against it, but new dashboards should read the core
+// devops.CicdDeploymentSubproject mapping table instead. It is a candidate for removal in
+// a follow-up release once the compat window closes.
+//
+// One deployment may produce several rows when a single pipeline runs the deploy jobs
+// of several sub-projects. That is not double counting: each sub-project really was
+// deployed by that pipeline.
+//
+// SubProject may hold the sentinel value "unattributed" (tasks.UnattributedSubProject)
+// when the deployment belongs to a monorepo project but matched none of the configured
+// sub-projects' DeployJobPattern, and the project's IncludeUnattributed option is left at
+// its default (true). This mirrors the same sentinel written to the new
+// devops.CicdDeploymentSubproject table and to pull_requests.sub_project - it is not a
+// breaking change to this table's shape, only a previously-omitted case now being filled
+// in with a visible value instead of silently dropped.
+type SubProjectDeployment struct {
+ common.NoPKModel
+ // The four primary key columns are deliberately kept narrow: MySQL caps a composite
+ // index at 3072 bytes, which is 768 characters under utf8mb4.
+ ProjectName string `gorm:"primaryKey;type:varchar(100)"`
+ SubProject string `gorm:"primaryKey;type:varchar(100)"`
+ // CicdDeploymentId is the id of the deployment (a cicd_pipelines.id when the
+ // deployment was generated from a pipeline), taken from cicd_deployment_commits.
+ CicdDeploymentId string `gorm:"primaryKey;type:varchar(255)"`
+ // CommitSha is wide enough for a SHA-256 hash; the source column is varchar(255) but
+ // only ever holds a git object id.
+ CommitSha string `gorm:"primaryKey;type:varchar(64)"`
+ // JobName is the cicd_tasks.name that matched this sub-project's DeployJobPattern.
+ JobName string `gorm:"type:varchar(255)"`
+ Result string `gorm:"type:varchar(100)"`
+ Environment string `gorm:"type:varchar(255)"`
+ FinishedDate *time.Time
+}
+
+func (SubProjectDeployment) TableName() string {
+ return "monorepo_subproject_deployments"
+}
diff --git a/backend/plugins/monorepo/models/subproject_pr_metric.go b/backend/plugins/monorepo/models/subproject_pr_metric.go
new file mode 100644
index 00000000000..4c51625d33c
--- /dev/null
+++ b/backend/plugins/monorepo/models/subproject_pr_metric.go
@@ -0,0 +1,68 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "time"
+
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// SubProjectPrMetric holds the change-lead-time breakdown for a merged pull request,
+// attributed to exactly one sub-project of a monorepo.
+//
+// Deprecated: this table is kept populated for one release for backward compatibility
+// with dashboards/integrations built against it, but new dashboards should read
+// project_pr_metrics.sub_project (joined through cicd_deployment_commits /
+// cicd_deployment_subprojects for deployment-side data) instead. It is a candidate for
+// removal in a follow-up release once the compat window closes.
+//
+// All five metric fields (CodingTime/PickupTime/ReviewTime/DeployTime/CycleTime) are now
+// written by project_pr_metrics_updater.go's updateProjectPrMetricsSubProject subtask,
+// copied verbatim from DORA's project_pr_metrics rather than recomputed here. In
+// particular, DeployTime/CycleTime used to be computed by AttributePullRequests using a
+// merge-date-nearest-deployment heuristic; that heuristic has been retired because it is
+// less accurate than DORA's own commit-based PR-to-deployment attribution
+// (project_pr_metrics.deployment_commit_id). Existing monorepo users will see these two
+// values change (improve) on upgrade - this is a correction, not a regression.
+//
+// All durations are in minutes, matching DORA's convention.
+type SubProjectPrMetric struct {
+ common.NoPKModel
+ ProjectName string `gorm:"primaryKey;type:varchar(100)"`
+ PullRequestId string `gorm:"primaryKey;type:varchar(255)"`
+ SubProject string `gorm:"index;type:varchar(255)"`
+
+ CodingTime *int64
+ PickupTime *int64
+ ReviewTime *int64
+ DeployTime *int64
+ CycleTime *int64
+
+ // DeploymentId is the cicd_deployment_id (pipeline id) of the deployment DORA
+ // attributed this PR to via project_pr_metrics.deployment_commit_id, if any.
+ DeploymentId string `gorm:"type:varchar(255)"`
+
+ PrCreatedDate *time.Time
+ PrMergedDate *time.Time
+ DeployedDate *time.Time
+}
+
+func (SubProjectPrMetric) TableName() string {
+ return "monorepo_subproject_pr_metrics"
+}
diff --git a/backend/plugins/monorepo/monorepo.go b/backend/plugins/monorepo/monorepo.go
new file mode 100644
index 00000000000..0b23cd9b74a
--- /dev/null
+++ b/backend/plugins/monorepo/monorepo.go
@@ -0,0 +1,43 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main // must be main for plugin entry point
+
+import (
+ "github.com/apache/incubator-devlake/core/runner"
+ "github.com/apache/incubator-devlake/plugins/monorepo/impl"
+ "github.com/spf13/cobra"
+)
+
+// PluginEntry exports for Framework to search and load
+var PluginEntry impl.Monorepo //nolint
+
+// standalone mode for debugging
+func main() {
+ cmd := &cobra.Command{Use: "monorepo"}
+
+ projectName := cmd.Flags().StringP("projectName", "p", "", "project name")
+ timeAfter := cmd.Flags().StringP("timeAfter", "a", "", "collect data that are created after specified time, ie 2006-01-02T15:04:05Z")
+ _ = cmd.MarkFlagRequired("projectName")
+
+ cmd.Run = func(cmd *cobra.Command, args []string) {
+ runner.DirectRun(cmd, args, PluginEntry, map[string]interface{}{
+ "projectName": *projectName,
+ }, *timeAfter)
+ }
+ runner.RunCmd(cmd)
+}
diff --git a/backend/plugins/monorepo/tasks/deployment_attributor.go b/backend/plugins/monorepo/tasks/deployment_attributor.go
new file mode 100644
index 00000000000..1717d0c8088
--- /dev/null
+++ b/backend/plugins/monorepo/tasks/deployment_attributor.go
@@ -0,0 +1,145 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "reflect"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/models/common"
+ "github.com/apache/incubator-devlake/core/models/domainlayer/devops"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/monorepo/models"
+)
+
+var AttributeDeploymentsMeta = plugin.SubTaskMeta{
+ Name: "attributeDeployments",
+ EntryPoint: AttributeDeployments,
+ EnabledByDefault: true,
+ Description: "Attribute each deployment to a monorepo sub-project by the name of the CI job that deployed it",
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CICD},
+}
+
+// deploymentJobRow is one (deployment, deploy job) pair as returned by the query below.
+//
+// RawDataOrigin is embedded because DataConverter copies that field from the input row
+// onto every result; without it the conversion panics.
+type deploymentJobRow struct {
+ common.RawDataOrigin
+ CicdDeploymentId string
+ CommitSha string
+ Result string
+ Environment string
+ FinishedDate *time.Time
+ JobName string
+}
+
+// AttributeDeployments populates the core cicd_deployment_subprojects mapping table from
+// cicd_deployment_commits + cicd_tasks regex matching, and - for one release, for backward
+// compatibility - the monorepo plugin's own monorepo_subproject_deployments table with the
+// exact same matches. There is no heuristic involved in either write (both are a direct
+// regex match against the deploying job's name), so dual-writing is cheap and safe.
+func AttributeDeployments(taskCtx plugin.SubTaskContext) errors.Error {
+ db := taskCtx.GetDal()
+ data := taskCtx.GetData().(*MonorepoTaskData)
+
+ // Rebuild from scratch: attribution depends on configuration that may have changed
+ // since the last run, so stale rows cannot be reconciled incrementally.
+ if err := db.Exec(
+ "DELETE FROM cicd_deployment_subprojects WHERE project_name = ?",
+ data.Options.ProjectName,
+ ); err != nil {
+ return errors.Default.Wrap(err, "error deleting previous cicd_deployment_subprojects")
+ }
+ if err := db.Exec(
+ "DELETE FROM monorepo_subproject_deployments WHERE project_name = ?",
+ data.Options.ProjectName,
+ ); err != nil {
+ return errors.Default.Wrap(err, "error deleting previous monorepo_subproject_deployments")
+ }
+
+ // Only deployments generated from pipelines can be attributed: cicd_deployment_id is
+ // the pipeline id, which is what cicd_tasks rows hang off. Deployments imported
+ // straight from a provider's deployment API carry no job and are skipped.
+ clauses := []dal.Clause{
+ dal.Select(`dc.cicd_deployment_id, dc.commit_sha, dc.result, dc.environment,
+ dc.finished_date, t.name AS job_name`),
+ dal.From("cicd_deployment_commits dc"),
+ dal.Join("JOIN project_mapping pm ON (pm.table = 'cicd_scopes' AND pm.row_id = dc.cicd_scope_id)"),
+ dal.Join("JOIN cicd_tasks t ON (t.pipeline_id = dc.cicd_deployment_id)"),
+ dal.Where("pm.project_name = ? AND t.type = ?", data.Options.ProjectName, devops.DEPLOYMENT),
+ }
+ cursor, err := db.Cursor(clauses...)
+ if err != nil {
+ return err
+ }
+ defer cursor.Close()
+
+ includeUnattributed := data.Options.ShouldIncludeUnattributed()
+ converter, err := api.NewDataConverter(api.DataConverterArgs{
+ RawDataSubTaskArgs: api.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Params: MonorepoApiParams{
+ ProjectName: data.Options.ProjectName,
+ },
+ Table: "cicd_deployment_commits",
+ },
+ InputRowType: reflect.TypeOf(deploymentJobRow{}),
+ Input: cursor,
+ Convert: func(inputRow interface{}) ([]interface{}, errors.Error) {
+ row := inputRow.(*deploymentJobRow)
+ matched := data.Matcher.MatchDeployJob(row.JobName)
+ if len(matched) == 0 {
+ if !includeUnattributed {
+ // No sub-project matches and the caller opted out of the
+ // 'unattributed' bucket: behave as before and skip the row.
+ return nil, nil
+ }
+ matched = []string{UnattributedSubProject}
+ }
+
+ results := make([]interface{}, 0, len(matched)*2)
+ for _, subProject := range matched {
+ results = append(results, &devops.CicdDeploymentSubproject{
+ ProjectName: data.Options.ProjectName,
+ CicdDeploymentId: row.CicdDeploymentId,
+ SubProject: subProject,
+ })
+ results = append(results, &models.SubProjectDeployment{
+ ProjectName: data.Options.ProjectName,
+ SubProject: subProject,
+ CicdDeploymentId: row.CicdDeploymentId,
+ CommitSha: row.CommitSha,
+ JobName: row.JobName,
+ Result: row.Result,
+ Environment: row.Environment,
+ FinishedDate: row.FinishedDate,
+ })
+ }
+ return results, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+
+ return converter.Execute()
+}
diff --git a/backend/plugins/monorepo/tasks/pr_attributor.go b/backend/plugins/monorepo/tasks/pr_attributor.go
new file mode 100644
index 00000000000..4a4c6eb97b8
--- /dev/null
+++ b/backend/plugins/monorepo/tasks/pr_attributor.go
@@ -0,0 +1,155 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/models/domainlayer/code"
+ "github.com/apache/incubator-devlake/core/plugin"
+)
+
+var AttributePullRequestsMeta = plugin.SubTaskMeta{
+ Name: "attributePullRequests",
+ EntryPoint: AttributePullRequests,
+ EnabledByDefault: true,
+ Description: "Attribute all pull requests (open, closed and merged) to monorepo sub-projects by label",
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CODE_REVIEW},
+}
+
+type prIdRow struct {
+ Id string
+}
+
+type prLabelRow struct {
+ PullRequestId string
+ LabelName string
+}
+
+// AttributePullRequests is attribution-only: it tags pull_requests.sub_project (and, from
+// there, pull_request_commits.sub_project) for every pull request of the project,
+// regardless of merge status. It intentionally does not compute coding/pickup/review/
+// deploy/cycle time - that responsibility belongs to updateProjectPrMetricsSubProject,
+// which sources those numbers from DORA's project_pr_metrics instead of recomputing them.
+//
+// This also removes the historical `pr.merged_date IS NOT NULL` filter: open and closed
+// (but unmerged) pull requests are attributed too, so a monorepo's PR-volume dashboards
+// are not silently missing everything that hasn't merged yet.
+func AttributePullRequests(taskCtx plugin.SubTaskContext) errors.Error {
+ db := taskCtx.GetDal()
+ logger := taskCtx.GetLogger()
+ data := taskCtx.GetData().(*MonorepoTaskData)
+ projectName := data.Options.ProjectName
+ includeUnattributed := data.Options.ShouldIncludeUnattributed()
+
+ labelsByPr, err := loadPrLabels(db, projectName)
+ if err != nil {
+ return err
+ }
+
+ clauses := []dal.Clause{
+ dal.Select("pr.id"),
+ dal.From("pull_requests pr"),
+ dal.Join("JOIN project_mapping pm ON (pm.table = 'repos' AND pm.row_id = pr.base_repo_id)"),
+ dal.Where("pm.project_name = ?", projectName),
+ }
+ var rows []prIdRow
+ if err := db.All(&rows, clauses...); err != nil {
+ return errors.Default.Wrap(err, "error loading pull requests to attribute")
+ }
+
+ matchedCount, unattributedCount, skippedCount := 0, 0, 0
+ for _, row := range rows {
+ labelMatch := data.Matcher.MatchPrLabels(labelsByPr[row.Id])
+ subProject, matched, unattributed, skipped := resolveSubProject(labelMatch, includeUnattributed)
+ if matched {
+ matchedCount++
+ }
+ if unattributed {
+ unattributedCount++
+ }
+ if skipped {
+ skippedCount++
+ }
+
+ var value interface{}
+ if subProject != "" {
+ value = subProject
+ }
+ pr := &code.PullRequest{}
+ pr.Id = row.Id
+ if err := db.UpdateColumn(pr, "sub_project", value); err != nil {
+ return errors.Default.Wrap(err, "error updating pull_requests.sub_project")
+ }
+ }
+ logger.Info("monorepo: attributed %d pull requests (%d matched, %d unattributed, %d left unclassified)",
+ len(rows), matchedCount, unattributedCount, skippedCount)
+
+ // Propagate to pull_request_commits in one set-based statement. Written as a
+ // correlated subquery / IN-subquery rather than a three-way UPDATE...JOIN so the same
+ // SQL works on both MySQL and PostgreSQL without a dialect branch.
+ if err := db.Exec(`
+ UPDATE pull_request_commits
+ SET sub_project = (
+ SELECT pr.sub_project FROM pull_requests pr
+ WHERE pr.id = pull_request_commits.pull_request_id
+ )
+ WHERE pull_request_id IN (
+ SELECT pr2.id FROM pull_requests pr2
+ JOIN project_mapping pm ON (pm.table = 'repos' AND pm.row_id = pr2.base_repo_id)
+ WHERE pm.project_name = ?
+ )
+ `, projectName); err != nil {
+ return errors.Default.Wrap(err, "error updating pull_request_commits.sub_project")
+ }
+
+ return nil
+}
+
+// resolveSubProject decides the sub_project value to write for a pull request, given the
+// (possibly empty) result of matching its labels and whether unattributed rows are
+// enabled. An empty returned subProject means "write NULL" (leave/clear unclassified).
+func resolveSubProject(labelMatch string, includeUnattributed bool) (subProject string, matched, unattributed, skipped bool) {
+ switch {
+ case labelMatch != "":
+ return labelMatch, true, false, false
+ case includeUnattributed:
+ return UnattributedSubProject, false, true, false
+ default:
+ return "", false, false, true
+ }
+}
+
+func loadPrLabels(db dal.Dal, projectName string) (map[string][]string, errors.Error) {
+ var rows []prLabelRow
+ err := db.All(&rows,
+ dal.Select("prl.pull_request_id, prl.label_name"),
+ dal.From("pull_request_labels prl"),
+ dal.Join("JOIN pull_requests pr ON (pr.id = prl.pull_request_id)"),
+ dal.Join("JOIN project_mapping pm ON (pm.table = 'repos' AND pm.row_id = pr.base_repo_id)"),
+ dal.Where("pm.project_name = ?", projectName),
+ )
+ if err != nil {
+ return nil, errors.Default.Wrap(err, "error loading pull request labels")
+ }
+ byPr := make(map[string][]string)
+ for _, r := range rows {
+ byPr[r.PullRequestId] = append(byPr[r.PullRequestId], r.LabelName)
+ }
+ return byPr, nil
+}
diff --git a/backend/plugins/monorepo/tasks/pr_attributor_test.go b/backend/plugins/monorepo/tasks/pr_attributor_test.go
new file mode 100644
index 00000000000..96df1f26ec3
--- /dev/null
+++ b/backend/plugins/monorepo/tasks/pr_attributor_test.go
@@ -0,0 +1,75 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestResolveSubProject exercises the three buckets a pull request can land in during
+// attribution: matched by label, unattributed (when enabled), or left unclassified (when
+// unattributed rows are disabled). This is the pure decision logic behind
+// AttributePullRequests; the actual DB reads/writes around it are covered by the e2e
+// dataflow test, since AttributePullRequests no longer contains any other pure logic to
+// unit test - the coding/pickup/review/deploy/cycle-time computation that used to live
+// here (and was unit tested via firstDeploymentAfter/computeTimeSpan) has been retired in
+// favor of updateProjectPrMetricsSubProject sourcing those numbers from DORA.
+func TestResolveSubProject(t *testing.T) {
+ cases := []struct {
+ name string
+ labelMatch string
+ includeUnattributed bool
+ wantSubProject string
+ wantMatched bool
+ wantUnattributed bool
+ wantSkipped bool
+ }{
+ {
+ name: "label match wins regardless of includeUnattributed",
+ labelMatch: "serviceA",
+ includeUnattributed: false,
+ wantSubProject: "serviceA",
+ wantMatched: true,
+ },
+ {
+ name: "no match, unattributed enabled",
+ labelMatch: "",
+ includeUnattributed: true,
+ wantSubProject: UnattributedSubProject,
+ wantUnattributed: true,
+ },
+ {
+ name: "no match, unattributed disabled leaves it unclassified",
+ labelMatch: "",
+ includeUnattributed: false,
+ wantSubProject: "",
+ wantSkipped: true,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ subProject, matched, unattributed, skipped := resolveSubProject(tc.labelMatch, tc.includeUnattributed)
+ assert.Equal(t, tc.wantSubProject, subProject)
+ assert.Equal(t, tc.wantMatched, matched)
+ assert.Equal(t, tc.wantUnattributed, unattributed)
+ assert.Equal(t, tc.wantSkipped, skipped)
+ })
+ }
+}
diff --git a/backend/plugins/monorepo/tasks/project_pr_metrics_updater.go b/backend/plugins/monorepo/tasks/project_pr_metrics_updater.go
new file mode 100644
index 00000000000..7494141e1e7
--- /dev/null
+++ b/backend/plugins/monorepo/tasks/project_pr_metrics_updater.go
@@ -0,0 +1,219 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "reflect"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/models/common"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/monorepo/models"
+)
+
+var UpdateProjectPrMetricsSubProjectMeta = plugin.SubTaskMeta{
+ Name: "updateProjectPrMetricsSubProject",
+ EntryPoint: UpdateProjectPrMetricsSubProject,
+ // This subtask reads pull_requests.sub_project (written by attributePullRequests) and
+ // project_pr_metrics.deployment_commit_id (written by DORA's calculateChangeLeadTime),
+ // so both must have already run. Ordering against attributePullRequests is guaranteed
+ // by subtask declaration order within this plugin's single stage; ordering against DORA
+ // is guaranteed by the stage-padding workaround in impl.go.
+ EnabledByDefault: true,
+ Description: "Tag project_pr_metrics with the monorepo sub-project of the pull request it belongs to, " +
+ "cross-check it against the deployment DORA attributed the PR to, and backfill " +
+ "monorepo_subproject_pr_metrics for backward compatibility",
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CICD, plugin.DOMAIN_TYPE_CODE_REVIEW},
+}
+
+// SubProjectMismatchRow is one PR whose label-derived sub-project disagrees with the
+// deploy-job-pattern-derived sub-project of the deployment that actually shipped it.
+// Exported so tests (including e2e tests in a different package) can query for mismatches
+// directly rather than scraping log output.
+type SubProjectMismatchRow struct {
+ PrId string
+ PrSubProject string
+ DeploymentSubProject string
+}
+
+// prMetricSubProjectRow is one project_pr_metrics row joined with the pull request's
+// sub_project and (if any) the pipeline id of the deployment DORA attributed it to. It is
+// the source data for the monorepo_subproject_pr_metrics backfill.
+//
+// RawDataOrigin is embedded because DataConverter copies that field from the input row
+// onto every result; without it the conversion panics.
+type prMetricSubProjectRow struct {
+ common.RawDataOrigin
+ PullRequestId string
+ SubProject string
+ CodingTime *int64
+ PickupTime *int64
+ ReviewTime *int64
+ DeployTime *int64
+ CycleTime *int64
+ DeploymentId string
+ PrCreatedDate *time.Time
+ PrMergedDate *time.Time
+ DeployedDate *time.Time
+}
+
+// UpdateProjectPrMetricsSubProject is the third and final monorepo subtask. It:
+//
+// 1. Tags project_pr_metrics.sub_project from pull_requests.sub_project.
+// 2. Cross-checks that tag against the sub-project(s) of the deployment that DORA's
+// commit-accurate attribution (project_pr_metrics.deployment_commit_id) says actually
+// shipped the PR, logging - not failing on - any disagreement as a configuration
+// hygiene signal (see the design doc's risk table).
+// 3. Backfills monorepo_subproject_pr_metrics for one release, for backward
+// compatibility, using DORA's already-computed numbers rather than recomputing them.
+// This retires the old merge-date-nearest-deployment heuristic that used to live in
+// AttributePullRequests: deploy/cycle time sourced this way are more accurate, so
+// existing monorepo users will see those two columns change (improve) on upgrade.
+func UpdateProjectPrMetricsSubProject(taskCtx plugin.SubTaskContext) errors.Error {
+ db := taskCtx.GetDal()
+ logger := taskCtx.GetLogger()
+ data := taskCtx.GetData().(*MonorepoTaskData)
+ projectName := data.Options.ProjectName
+
+ if err := tagProjectPrMetricsSubProject(db, projectName); err != nil {
+ return err
+ }
+ mismatches, err := FindSubProjectMismatches(db, projectName)
+ if err != nil {
+ return err
+ }
+ for _, row := range mismatches {
+ logger.Warn(nil,
+ "monorepo: pull request %s is labelled for sub-project %q but was shipped by a deployment "+
+ "attributed to sub-project %q - check prLabels/deployJobPattern configuration",
+ row.PrId, row.PrSubProject, row.DeploymentSubProject)
+ }
+ if len(mismatches) > 0 {
+ logger.Info("monorepo: found %d pull request(s) with a label/deployment sub-project mismatch", len(mismatches))
+ }
+ return backfillSubProjectPrMetrics(taskCtx, db, projectName)
+}
+
+// tagProjectPrMetricsSubProject implements Step 1. It is written as a correlated
+// subquery rather than the MySQL-only `UPDATE ... JOIN` form so the same SQL runs
+// unmodified on both of DevLake's supported databases.
+func tagProjectPrMetricsSubProject(db dal.Dal, projectName string) errors.Error {
+ if err := db.Exec(`
+ UPDATE project_pr_metrics
+ SET sub_project = (
+ SELECT pr.sub_project FROM pull_requests pr
+ WHERE pr.id = project_pr_metrics.id
+ )
+ WHERE project_name = ?
+ `, projectName); err != nil {
+ return errors.Default.Wrap(err, "error tagging project_pr_metrics.sub_project")
+ }
+ return nil
+}
+
+// FindSubProjectMismatches implements Step 2's cross-check query. A mismatch means the
+// PR's label-derived sub-project disagrees with the deploy-job-pattern-derived
+// sub-project of a deployment that shipped it - almost always a misconfigured
+// deployJobPattern/prLabels entry. The caller (UpdateProjectPrMetricsSubProject) only
+// logs these as warnings; it never fails the subtask or overrides
+// pull_requests/project_pr_metrics.sub_project because of them - labels win, the
+// deployment side is only a cross-check.
+//
+// Note that a PR shipped by a pipeline that deploys several sub-projects (one row per
+// sub-project in cicd_deployment_subprojects) will always produce a mismatch row against
+// every sub-project other than its own - that is expected noise from the many-to-many
+// deployment mapping, not necessarily a misconfiguration.
+func FindSubProjectMismatches(db dal.Dal, projectName string) ([]SubProjectMismatchRow, errors.Error) {
+ var rows []SubProjectMismatchRow
+ err := db.All(&rows,
+ dal.Select("ppm.id AS pr_id, pr.sub_project AS pr_sub_project, ds.sub_project AS deployment_sub_project"),
+ dal.From("project_pr_metrics ppm"),
+ dal.Join("JOIN pull_requests pr ON pr.id = ppm.id"),
+ dal.Join("JOIN cicd_deployment_commits dc ON dc.id = ppm.deployment_commit_id"),
+ dal.Join("JOIN cicd_deployment_subprojects ds ON ds.cicd_deployment_id = dc.cicd_deployment_id AND ds.project_name = ppm.project_name"),
+ dal.Where("ppm.project_name = ? AND pr.sub_project IS NOT NULL AND ds.sub_project <> pr.sub_project", projectName),
+ )
+ if err != nil {
+ return nil, errors.Default.Wrap(err, "error checking for sub-project label/deployment mismatches")
+ }
+ return rows, nil
+}
+
+// backfillSubProjectPrMetrics implements Step 3.
+func backfillSubProjectPrMetrics(taskCtx plugin.SubTaskContext, db dal.Dal, projectName string) errors.Error {
+ if err := db.Exec(
+ "DELETE FROM monorepo_subproject_pr_metrics WHERE project_name = ?",
+ projectName,
+ ); err != nil {
+ return errors.Default.Wrap(err, "error deleting previous monorepo_subproject_pr_metrics")
+ }
+
+ clauses := []dal.Clause{
+ dal.Select(`ppm.id AS pull_request_id, pr.sub_project AS sub_project,
+ ppm.pr_coding_time AS coding_time, ppm.pr_pickup_time AS pickup_time,
+ ppm.pr_review_time AS review_time, ppm.pr_deploy_time AS deploy_time,
+ ppm.pr_cycle_time AS cycle_time, dc.cicd_deployment_id AS deployment_id,
+ ppm.pr_created_date AS pr_created_date, ppm.pr_merged_date AS pr_merged_date,
+ ppm.pr_deployed_date AS deployed_date`),
+ dal.From("project_pr_metrics ppm"),
+ dal.Join("JOIN pull_requests pr ON pr.id = ppm.id"),
+ dal.Join("LEFT JOIN cicd_deployment_commits dc ON dc.id = ppm.deployment_commit_id"),
+ dal.Where("ppm.project_name = ? AND pr.sub_project IS NOT NULL", projectName),
+ }
+ cursor, err := db.Cursor(clauses...)
+ if err != nil {
+ return err
+ }
+ defer cursor.Close()
+
+ converter, err := api.NewDataConverter(api.DataConverterArgs{
+ RawDataSubTaskArgs: api.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Params: MonorepoApiParams{
+ ProjectName: projectName,
+ },
+ Table: "project_pr_metrics",
+ },
+ InputRowType: reflect.TypeOf(prMetricSubProjectRow{}),
+ Input: cursor,
+ Convert: func(inputRow interface{}) ([]interface{}, errors.Error) {
+ row := inputRow.(*prMetricSubProjectRow)
+ return []interface{}{&models.SubProjectPrMetric{
+ ProjectName: projectName,
+ PullRequestId: row.PullRequestId,
+ SubProject: row.SubProject,
+ CodingTime: row.CodingTime,
+ PickupTime: row.PickupTime,
+ ReviewTime: row.ReviewTime,
+ DeployTime: row.DeployTime,
+ CycleTime: row.CycleTime,
+ DeploymentId: row.DeploymentId,
+ PrCreatedDate: row.PrCreatedDate,
+ PrMergedDate: row.PrMergedDate,
+ DeployedDate: row.DeployedDate,
+ }}, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return converter.Execute()
+}
diff --git a/backend/plugins/monorepo/tasks/task_data.go b/backend/plugins/monorepo/tasks/task_data.go
new file mode 100644
index 00000000000..f91ece63d93
--- /dev/null
+++ b/backend/plugins/monorepo/tasks/task_data.go
@@ -0,0 +1,190 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "fmt"
+ "regexp"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+)
+
+type MonorepoApiParams struct {
+ ProjectName string
+}
+
+const (
+ // UnattributedSubProject is the sentinel sub_project value written for PRs/deployments
+ // that belong to a monorepo project (i.e. one with SubProjects configured) but matched
+ // none of the configured sub-projects. Whether it is written at all is controlled by
+ // MonorepoOptions.IncludeUnattributed.
+ UnattributedSubProject = "unattributed"
+ // AllSubProjectsLabel is the dashboard-side label shown for rows with no sub_project at
+ // all (single-repo projects, or rows the monorepo plugin has not processed). It is never
+ // written to the database — dashboards derive it via COALESCE(sub_project, 'All') — but
+ // it is reserved here too so it cannot be configured as a real sub-project name and
+ // collide with that convention.
+ AllSubProjectsLabel = "All"
+)
+
+// SubProjectConfig declares one logical project living inside a monorepo.
+type SubProjectConfig struct {
+ // Name identifies the sub-project in the output tables and dashboards.
+ Name string `json:"name" mapstructure:"name"`
+ // PrLabels are the pull request labels that mark a PR as belonging to this
+ // sub-project. Matching is exact and case-sensitive.
+ PrLabels []string `json:"prLabels" mapstructure:"prLabels"`
+ // DeployJobPattern is a regular expression matched against cicd_tasks.name to
+ // recognise this sub-project's deployment jobs, e.g. "^deploy-serviceA$".
+ DeployJobPattern string `json:"deployJobPattern" mapstructure:"deployJobPattern"`
+}
+
+type MonorepoOptions struct {
+ ProjectName string `json:"projectName" mapstructure:"projectName"`
+ // SubProjects is ordered: when a pull request carries the labels of more than one
+ // sub-project, the earliest entry in this list wins.
+ SubProjects []SubProjectConfig `json:"subProjects" mapstructure:"subProjects"`
+ // IncludeUnattributed controls whether PRs/deployments that belong to this monorepo
+ // project but matched none of the configured sub-projects get sub_project =
+ // UnattributedSubProject (true, the default) or are left unclassified / skipped
+ // entirely (false, the pre-existing behaviour). A pointer so decoding can distinguish
+ // "the caller didn't set this" (nil, defaults to true) from an explicit false.
+ IncludeUnattributed *bool `json:"includeUnattributed" mapstructure:"includeUnattributed"`
+}
+
+// ShouldIncludeUnattributed returns the effective value of IncludeUnattributed, defaulting
+// to true when the option was not set.
+func (op *MonorepoOptions) ShouldIncludeUnattributed() bool {
+ return op.IncludeUnattributed == nil || *op.IncludeUnattributed
+}
+
+type MonorepoTaskData struct {
+ Options *MonorepoOptions
+ Matcher *SubProjectMatcher
+}
+
+// SubProjectMatcher resolves deployments and pull requests to sub-projects. It holds
+// the compiled form of the configuration so the regexes are built once per task rather
+// than once per row.
+type SubProjectMatcher struct {
+ names []string
+ prLabels []map[string]struct{}
+ deployJobRes []*regexp.Regexp
+}
+
+// NewSubProjectMatcher compiles the sub-project configuration, validating it along the way.
+func NewSubProjectMatcher(subProjects []SubProjectConfig) (*SubProjectMatcher, errors.Error) {
+ m := &SubProjectMatcher{
+ names: make([]string, 0, len(subProjects)),
+ prLabels: make([]map[string]struct{}, 0, len(subProjects)),
+ deployJobRes: make([]*regexp.Regexp, 0, len(subProjects)),
+ }
+ seen := make(map[string]struct{}, len(subProjects))
+ for i, sp := range subProjects {
+ if sp.Name == "" {
+ return nil, errors.BadInput.New(fmt.Sprintf("subProjects[%d]: name is required", i))
+ }
+ if sp.Name == UnattributedSubProject || sp.Name == AllSubProjectsLabel {
+ return nil, errors.BadInput.New(fmt.Sprintf(
+ "subProjects[%d]: name %q is reserved and cannot be used as a sub-project name", i, sp.Name))
+ }
+ if _, dup := seen[sp.Name]; dup {
+ return nil, errors.BadInput.New(fmt.Sprintf("subProjects[%d]: duplicate name %q", i, sp.Name))
+ }
+ seen[sp.Name] = struct{}{}
+
+ var jobRe *regexp.Regexp
+ if sp.DeployJobPattern != "" {
+ compiled, err := regexp.Compile(sp.DeployJobPattern)
+ if err != nil {
+ return nil, errors.BadInput.Wrap(err, fmt.Sprintf(
+ "subProjects[%d] (%s): invalid deployJobPattern", i, sp.Name))
+ }
+ jobRe = compiled
+ }
+
+ labels := make(map[string]struct{}, len(sp.PrLabels))
+ for j, l := range sp.PrLabels {
+ if l == "" {
+ return nil, errors.BadInput.New(fmt.Sprintf(
+ "subProjects[%d] (%s): prLabels[%d] must not be empty", i, sp.Name, j))
+ }
+ labels[l] = struct{}{}
+ }
+
+ m.names = append(m.names, sp.Name)
+ m.prLabels = append(m.prLabels, labels)
+ m.deployJobRes = append(m.deployJobRes, jobRe)
+ }
+ return m, nil
+}
+
+// MatchDeployJob returns every sub-project whose DeployJobPattern matches jobName.
+//
+// More than one match is possible and is reported faithfully: a single pipeline running
+// both deploy-serviceA and deploy-serviceB genuinely deploys two sub-projects. If a
+// single job name matches two patterns, that indicates overlapping configuration.
+func (m *SubProjectMatcher) MatchDeployJob(jobName string) []string {
+ var matched []string
+ for i, re := range m.deployJobRes {
+ if re != nil && re.MatchString(jobName) {
+ matched = append(matched, m.names[i])
+ }
+ }
+ return matched
+}
+
+// MatchPrLabels returns the single sub-project a pull request belongs to, or "" when no
+// sub-project claims it. When several sub-projects match, the earliest one in the
+// configured order wins — labels carry no size signal that could rank them otherwise.
+func (m *SubProjectMatcher) MatchPrLabels(labels []string) string {
+ if len(labels) == 0 {
+ return ""
+ }
+ present := make(map[string]struct{}, len(labels))
+ for _, l := range labels {
+ present[l] = struct{}{}
+ }
+ for i, wanted := range m.prLabels {
+ for l := range wanted {
+ if _, ok := present[l]; ok {
+ return m.names[i]
+ }
+ }
+ }
+ return ""
+}
+
+func DecodeAndValidateTaskOptions(options map[string]interface{}) (*MonorepoOptions, errors.Error) {
+ var op MonorepoOptions
+ if err := helper.Decode(options, &op, nil); err != nil {
+ return nil, errors.Default.Wrap(err, "error decoding monorepo task options")
+ }
+ if op.ProjectName == "" {
+ return nil, errors.BadInput.New("projectName is required for the monorepo plugin")
+ }
+ if len(op.SubProjects) == 0 {
+ return nil, errors.BadInput.New("at least one entry in subProjects is required for the monorepo plugin")
+ }
+ if op.IncludeUnattributed == nil {
+ defaultTrue := true
+ op.IncludeUnattributed = &defaultTrue
+ }
+ return &op, nil
+}
diff --git a/backend/plugins/monorepo/tasks/task_data_test.go b/backend/plugins/monorepo/tasks/task_data_test.go
new file mode 100644
index 00000000000..7d47150b529
--- /dev/null
+++ b/backend/plugins/monorepo/tasks/task_data_test.go
@@ -0,0 +1,251 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// twoServices is the canonical monorepo configuration used across these tests:
+// serviceA is declared first, so it wins any tie.
+func twoServices() []SubProjectConfig {
+ return []SubProjectConfig{
+ {
+ Name: "serviceA",
+ PrLabels: []string{"serviceA"},
+ DeployJobPattern: "^deploy-serviceA$",
+ },
+ {
+ Name: "serviceB",
+ PrLabels: []string{"serviceB", "svc-b"},
+ DeployJobPattern: "^deploy-serviceB$",
+ },
+ }
+}
+
+func TestMatchDeployJob(t *testing.T) {
+ matcher, err := NewSubProjectMatcher(twoServices())
+ assert.Nil(t, err)
+
+ cases := []struct {
+ name string
+ jobName string
+ expected []string
+ }{
+ {"matches serviceA", "deploy-serviceA", []string{"serviceA"}},
+ {"matches serviceB", "deploy-serviceB", []string{"serviceB"}},
+ {"build job is not a deployment", "build-serviceA", nil},
+ {"unrelated job matches nothing", "run-tests", nil},
+ {"anchored pattern rejects a superstring", "deploy-serviceAB", nil},
+ {"empty job name matches nothing", "", nil},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.expected, matcher.MatchDeployJob(tc.jobName))
+ })
+ }
+}
+
+// A pipeline that runs both services' deploy jobs produces a row for each. The two jobs
+// arrive as separate rows, so each resolves to exactly one sub-project.
+func TestMatchDeployJob_PipelineDeployingBothServices(t *testing.T) {
+ matcher, err := NewSubProjectMatcher(twoServices())
+ assert.Nil(t, err)
+
+ assert.Equal(t, []string{"serviceA"}, matcher.MatchDeployJob("deploy-serviceA"))
+ assert.Equal(t, []string{"serviceB"}, matcher.MatchDeployJob("deploy-serviceB"))
+}
+
+// Overlapping patterns are reported faithfully rather than silently resolved, so a
+// misconfiguration is visible in the data instead of hidden.
+func TestMatchDeployJob_OverlappingPatterns(t *testing.T) {
+ matcher, err := NewSubProjectMatcher([]SubProjectConfig{
+ {Name: "serviceA", DeployJobPattern: "deploy"},
+ {Name: "serviceB", DeployJobPattern: "^deploy-serviceB$"},
+ })
+ assert.Nil(t, err)
+
+ assert.Equal(t, []string{"serviceA", "serviceB"}, matcher.MatchDeployJob("deploy-serviceB"))
+}
+
+func TestMatchDeployJob_NoPatternNeverMatches(t *testing.T) {
+ matcher, err := NewSubProjectMatcher([]SubProjectConfig{
+ {Name: "labelsOnly", PrLabels: []string{"labelsOnly"}},
+ })
+ assert.Nil(t, err)
+
+ assert.Nil(t, matcher.MatchDeployJob("deploy-labelsOnly"))
+}
+
+func TestMatchPrLabels(t *testing.T) {
+ matcher, err := NewSubProjectMatcher(twoServices())
+ assert.Nil(t, err)
+
+ cases := []struct {
+ name string
+ labels []string
+ expected string
+ }{
+ {"single matching label", []string{"serviceA"}, "serviceA"},
+ {"alias label resolves to its sub-project", []string{"svc-b"}, "serviceB"},
+ {"matching label among unrelated ones", []string{"bug", "serviceB", "urgent"}, "serviceB"},
+ {"no matching label", []string{"bug", "urgent"}, ""},
+ {"no labels at all", nil, ""},
+ {"empty label slice", []string{}, ""},
+ {"matching is case sensitive", []string{"servicea"}, ""},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.expected, matcher.MatchPrLabels(tc.labels))
+ })
+ }
+}
+
+// A PR labelled for several sub-projects is assigned to exactly one: the earliest in
+// configuration order. Labels carry no size signal, so declaration order is the tie-break.
+func TestMatchPrLabels_TieBreakIsConfigOrder(t *testing.T) {
+ both := []string{"serviceB", "serviceA"}
+
+ matcher, err := NewSubProjectMatcher(twoServices())
+ assert.Nil(t, err)
+ assert.Equal(t, "serviceA", matcher.MatchPrLabels(both))
+
+ // Reversing the configuration reverses the winner, proving order drives the result
+ // rather than the order of labels on the PR.
+ reversed := []SubProjectConfig{twoServices()[1], twoServices()[0]}
+ reversedMatcher, err := NewSubProjectMatcher(reversed)
+ assert.Nil(t, err)
+ assert.Equal(t, "serviceB", reversedMatcher.MatchPrLabels(both))
+}
+
+func TestNewSubProjectMatcher_Validation(t *testing.T) {
+ cases := []struct {
+ name string
+ subProjects []SubProjectConfig
+ expectErr bool
+ }{
+ {
+ name: "valid configuration",
+ subProjects: twoServices(),
+ },
+ {
+ name: "empty configuration is allowed here, rejected by option decoding",
+ subProjects: nil,
+ },
+ {
+ name: "missing name",
+ subProjects: []SubProjectConfig{{PrLabels: []string{"x"}}},
+ expectErr: true,
+ },
+ {
+ name: "duplicate names",
+ subProjects: []SubProjectConfig{
+ {Name: "serviceA", DeployJobPattern: "^a$"},
+ {Name: "serviceA", DeployJobPattern: "^b$"},
+ },
+ expectErr: true,
+ },
+ {
+ name: "invalid deploy job regex",
+ subProjects: []SubProjectConfig{{Name: "serviceA", DeployJobPattern: "^deploy-(unclosed"}},
+ expectErr: true,
+ },
+ {
+ name: "name 'unattributed' collides with the sentinel and is rejected",
+ subProjects: []SubProjectConfig{{Name: "unattributed", DeployJobPattern: "^deploy-x$"}},
+ expectErr: true,
+ },
+ {
+ name: "name 'All' collides with the dashboard-side sentinel and is rejected",
+ subProjects: []SubProjectConfig{{Name: "All", DeployJobPattern: "^deploy-x$"}},
+ expectErr: true,
+ },
+ {
+ name: "empty prLabels entry is rejected",
+ subProjects: []SubProjectConfig{
+ {Name: "serviceA", PrLabels: []string{"serviceA", ""}},
+ },
+ expectErr: true,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ matcher, err := NewSubProjectMatcher(tc.subProjects)
+ if tc.expectErr {
+ assert.NotNil(t, err)
+ assert.Nil(t, matcher)
+ return
+ }
+ assert.Nil(t, err)
+ assert.NotNil(t, matcher)
+ })
+ }
+}
+
+func TestDecodeAndValidateTaskOptions(t *testing.T) {
+ t.Run("valid options", func(t *testing.T) {
+ op, err := DecodeAndValidateTaskOptions(map[string]interface{}{
+ "projectName": "monorepo",
+ "subProjects": []interface{}{
+ map[string]interface{}{
+ "name": "serviceA",
+ "prLabels": []interface{}{"serviceA"},
+ "deployJobPattern": "^deploy-serviceA$",
+ },
+ },
+ })
+ assert.Nil(t, err)
+ assert.Equal(t, "monorepo", op.ProjectName)
+ assert.Len(t, op.SubProjects, 1)
+ assert.Equal(t, "serviceA", op.SubProjects[0].Name)
+ assert.Equal(t, []string{"serviceA"}, op.SubProjects[0].PrLabels)
+ assert.Equal(t, "^deploy-serviceA$", op.SubProjects[0].DeployJobPattern)
+ assert.True(t, op.ShouldIncludeUnattributed(),
+ "includeUnattributed must default to true when the caller doesn't set it")
+ })
+
+ t.Run("includeUnattributed explicit false is preserved", func(t *testing.T) {
+ op, err := DecodeAndValidateTaskOptions(map[string]interface{}{
+ "projectName": "monorepo",
+ "subProjects": []interface{}{
+ map[string]interface{}{"name": "serviceA"},
+ },
+ "includeUnattributed": false,
+ })
+ assert.Nil(t, err)
+ assert.False(t, op.ShouldIncludeUnattributed())
+ })
+
+ t.Run("missing projectName is rejected", func(t *testing.T) {
+ _, err := DecodeAndValidateTaskOptions(map[string]interface{}{
+ "subProjects": []interface{}{
+ map[string]interface{}{"name": "serviceA"},
+ },
+ })
+ assert.NotNil(t, err)
+ })
+
+ t.Run("missing subProjects is rejected", func(t *testing.T) {
+ _, err := DecodeAndValidateTaskOptions(map[string]interface{}{
+ "projectName": "monorepo",
+ })
+ assert.NotNil(t, err)
+ })
+}
diff --git a/config-ui/src/routes/project/detail/settings-panel.tsx b/config-ui/src/routes/project/detail/settings-panel.tsx
index b7a78946466..39832ca3695 100644
--- a/config-ui/src/routes/project/detail/settings-panel.tsx
+++ b/config-ui/src/routes/project/detail/settings-panel.tsx
@@ -18,6 +18,7 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
+import { CloseOutlined, PlusOutlined } from '@ant-design/icons';
import { Flex, Space, Card, Modal, Input, Checkbox, Button } from 'antd';
import API from '@/api';
@@ -30,6 +31,21 @@ import * as S from './styled';
const RegexPrIssueDefaultValue = '(?mi)(Closes)[\\s]*.*(((and )?#\\d+[ ]*)+)';
+interface ISubProject {
+ name: string;
+ // Comma-separated in the UI; split into an array on save.
+ prLabels: string;
+ deployJobPattern: string;
+}
+
+const emptySubProject: ISubProject = { name: '', prLabels: '', deployJobPattern: '' };
+
+// Mirrors the backend's reserved names (backend/plugins/monorepo/tasks/task_data.go):
+// 'unattributed' is the sentinel written for unmatched PRs/deployments, and 'All' is the
+// label dashboards show for rows with no sub_project at all. Configuring a sub-project
+// with either name would make it indistinguishable from that sentinel in the UI.
+const RESERVED_SUB_PROJECT_NAMES = ['unattributed', 'All'];
+
interface Props {
project: IProject;
onRefresh: () => void;
@@ -47,6 +63,10 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => {
const [issueTrace, setIssueTrace] = useState({
enable: false,
});
+ const [monorepo, setMonorepo] = useState<{ enable: boolean; subProjects: ISubProject[] }>({
+ enable: false,
+ subProjects: [emptySubProject],
+ });
const [operating, setOperating] = useState(false);
const [open, setOpen] = useState(false);
@@ -56,6 +76,7 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => {
const dora = project.metrics.find((ms) => ms.pluginName === 'dora');
const linker = project.metrics.find((ms) => ms.pluginName === 'linker');
const issueTrace = project.metrics.find((ms) => ms.pluginName === 'issue_trace');
+ const monorepo = project.metrics.find((ms) => ms.pluginName === 'monorepo');
setName(project.name);
setDora({
@@ -68,8 +89,40 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => {
setIssueTrace({
enable: issueTrace?.enable ?? false,
});
+ const subProjects = monorepo?.pluginOption?.subProjects;
+ setMonorepo({
+ enable: monorepo?.enable ?? false,
+ subProjects:
+ Array.isArray(subProjects) && subProjects.length
+ ? subProjects.map((sp: any) => ({
+ name: sp.name ?? '',
+ prLabels: Array.isArray(sp.prLabels) ? sp.prLabels.join(',') : '',
+ deployJobPattern: sp.deployJobPattern ?? '',
+ }))
+ : [emptySubProject],
+ });
}, [project]);
+ const handleAddSubProject = () => {
+ setMonorepo({ ...monorepo, subProjects: [...monorepo.subProjects, { ...emptySubProject }] });
+ };
+
+ const handleDeleteSubProject = (index: number) => {
+ setMonorepo({ ...monorepo, subProjects: monorepo.subProjects.filter((_, i) => i !== index) });
+ };
+
+ const handleUpdateSubProject = (index: number, field: keyof ISubProject, value: string) => {
+ setMonorepo({
+ ...monorepo,
+ subProjects: monorepo.subProjects.map((sp, i) => (i === index ? { ...sp, [field]: value } : sp)),
+ });
+ };
+
+ // Blank rows are filtered out on save (see handleUpdate), so only named rows are checked.
+ const reservedSubProjectName = monorepo.enable
+ ? monorepo.subProjects.map((sp) => sp.name.trim()).find((n) => RESERVED_SUB_PROJECT_NAMES.includes(n))
+ : undefined;
+
const handleUpdate = async () => {
const [success] = await operator(
() =>
@@ -94,6 +147,22 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => {
pluginOption: {},
enable: issueTrace.enable,
},
+ {
+ pluginName: 'monorepo',
+ pluginOption: {
+ subProjects: monorepo.subProjects
+ .filter((sp) => sp.name.trim())
+ .map((sp) => ({
+ name: sp.name.trim(),
+ prLabels: sp.prLabels
+ .split(',')
+ .map((l) => l.trim())
+ .filter((l) => l),
+ deployJobPattern: sp.deployJobPattern.trim(),
+ })),
+ },
+ enable: monorepo.enable,
+ },
],
}),
{
@@ -191,8 +260,64 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => {
}
description="Parse the issue status and assignee history from issue changelogs. Currently, only Jira issues are supported."
/>
+ setMonorepo({ ...monorepo, enable: e.target.checked })}
+ >
+ Enable Monorepo Sub-Projects
+
+ }
+ description={
+
+ Split a single repository into several logical sub-projects for DORA-style metrics. Deployments are
+ matched by CI job name, pull requests by label. When a pull request carries more than one
+ sub-project's label, the first matching sub-project in the list below wins.
+
+
+ }
+ >
+ {monorepo.enable && (
+
+ {monorepo.subProjects.map((sp, i) => (
+