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) => ( +
+ handleUpdateSubProject(i, 'name', e.target.value)} + /> + handleUpdateSubProject(i, 'prLabels', e.target.value)} + /> + handleUpdateSubProject(i, 'deployJobPattern', e.target.value)} + /> + {monorepo.subProjects.length > 1 && ( +
+ ))} + + {reservedSubProjectName && ( + + )} +
+ )} +
- diff --git a/config-ui/src/routes/project/detail/styled.ts b/config-ui/src/routes/project/detail/styled.ts index f2ac11cfbce..59652b5a95e 100644 --- a/config-ui/src/routes/project/detail/styled.ts +++ b/config-ui/src/routes/project/detail/styled.ts @@ -24,3 +24,16 @@ export const DialogBody = styled.div` display: flex; align-items: center; `; + +export const SubProjectList = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; + + .row { + display: flex; + align-items: center; + gap: 8px; + } +`; diff --git a/grafana/dashboards/mysql/Gitlab.json b/grafana/dashboards/mysql/Gitlab.json index 3a7043b69ec..b0dec184bf6 100644 --- a/grafana/dashboards/mysql/Gitlab.json +++ b/grafana/dashboards/mysql/Gitlab.json @@ -969,7 +969,7 @@ "hide": false, "metricColumn": "none", "rawQuery": true, - "rawSql": "-- The PR/MR statuses are standardized to 'OPEN', 'MERGED' and 'CLOSED'. You can check out the original status from the field `original_status`\nSELECT\n DATE_ADD(date(created_date), INTERVAL -$interval(date(created_date))+1 DAY) as time,\n count(distinct case when status = 'CLOSED' then id else null end)/count(distinct case when status in ('CLOSED', 'MERGED') then id else null end) as ratio\nFROM pull_requests\nWHERE\n $__timeFilter(created_date)\n and base_repo_id in (${repo_id})\ngroup by 1\n", + "rawSql": "-- The PR/MR statuses are standardized to 'OPEN', 'MERGED' and 'CLOSED'. You can check out the original status from the field `original_status`\nSELECT\n DATE_ADD(date(created_date), INTERVAL -$interval(date(created_date))+1 DAY) as time,\n COALESCE(sub_project, 'All') as sub_project,\n count(distinct case when status = 'CLOSED' then id else null end)/count(distinct case when status in ('CLOSED', 'MERGED') then id else null end) as ratio\nFROM pull_requests\nWHERE\n $__timeFilter(created_date)\n and base_repo_id in (${repo_id})\ngroup by 1, 2", "refId": "A", "select": [ [ diff --git a/grafana/dashboards/mysql/engineering-overview.json b/grafana/dashboards/mysql/engineering-overview.json index ad6d5dfefb6..6e4f2be7c07 100644 --- a/grafana/dashboards/mysql/engineering-overview.json +++ b/grafana/dashboards/mysql/engineering-overview.json @@ -1483,7 +1483,7 @@ "metricColumn": "none", "queryType": "randomWalk", "rawQuery": true, - "rawSql": "select\n DATE_ADD(date(created_date), INTERVAL -DAY(date(created_date))+1 DAY) as time,\n 100*count(distinct case when pr.id in (select pull_request_id from pull_request_issues) then pr.id else null end)/count(distinct pr.id) as unlinked_pr_rate\nfrom pull_requests pr\njoin project_mapping pm on pr.base_repo_id = pm.row_id and pm.table = 'repos' \nwhere pm.project_name in (${project})\nand $__timeFilter(created_date)\nand created_date >= DATE_ADD(DATE_ADD($__timeFrom(), INTERVAL -DAY($__timeFrom())+1 DAY), INTERVAL +1 MONTH)\ngroup by time\n\n", + "rawSql": "select\n DATE_ADD(date(created_date), INTERVAL -DAY(date(created_date))+1 DAY) as time,\n COALESCE(pr.sub_project, 'All') as sub_project,\n 100*count(distinct case when pr.id in (select pull_request_id from pull_request_issues) then pr.id else null end)/count(distinct pr.id) as unlinked_pr_rate\nfrom pull_requests pr\njoin project_mapping pm on pr.base_repo_id = pm.row_id and pm.table = 'repos' \nwhere pm.project_name in (${project})\nand $__timeFilter(created_date)\nand created_date >= DATE_ADD(DATE_ADD($__timeFrom(), INTERVAL -DAY($__timeFrom())+1 DAY), INTERVAL +1 MONTH)\ngroup by time, pr.sub_project", "refId": "A", "select": [ [ @@ -1969,7 +1969,7 @@ "metricColumn": "none", "queryType": "randomWalk", "rawQuery": true, - "rawSql": "select\n DATE_ADD(date(pr.created_date), INTERVAL -DAY(date(pr.created_date))+1 DAY) as time,\n AVG(TIMESTAMPDIFF(MINUTE, pr.created_date, pr.merged_date) / 1440) as pr_time_to_merge_in_days\nfrom\n pull_requests pr\n join project_mapping pm on pr.base_repo_id = pm.row_id and pm.table = 'repos' \nwhere\n pm.project_name in (${project}) and\n pr.merged_date is not null\n and $__timeFilter(pr.created_date)\n and pr.created_date >= DATE_ADD(DATE_ADD($__timeFrom(), INTERVAL -DAY($__timeFrom())+1 DAY), INTERVAL +1 MONTH)\ngroup by time\norder by time", + "rawSql": "select\n DATE_ADD(date(pr.created_date), INTERVAL -DAY(date(pr.created_date))+1 DAY) as time,\n COALESCE(pr.sub_project, 'All') as sub_project,\n AVG(TIMESTAMPDIFF(MINUTE, pr.created_date, pr.merged_date) / 1440) as pr_time_to_merge_in_days\nfrom\n pull_requests pr\n join project_mapping pm on pr.base_repo_id = pm.row_id and pm.table = 'repos' \nwhere\n pm.project_name in (${project}) and\n pr.merged_date is not null\n and $__timeFilter(pr.created_date)\n and pr.created_date >= DATE_ADD(DATE_ADD($__timeFrom(), INTERVAL -DAY($__timeFrom())+1 DAY), INTERVAL +1 MONTH)\ngroup by time, pr.sub_project\norder by time", "refId": "A", "select": [ [ diff --git a/grafana/dashboards/mysql/engineering-throughput-and-cycle-time.json b/grafana/dashboards/mysql/engineering-throughput-and-cycle-time.json index fd62b4e053f..3db70532cd3 100644 --- a/grafana/dashboards/mysql/engineering-throughput-and-cycle-time.json +++ b/grafana/dashboards/mysql/engineering-throughput-and-cycle-time.json @@ -622,7 +622,7 @@ "hide": false, "metricColumn": "none", "rawQuery": true, - "rawSql": "SELECT\n DATE_ADD(date(pr.created_date), INTERVAL -$interval(date(pr.created_date))+1 DAY) as time,\n count(distinct prc.id)/count(distinct pr.id) as \"PR Review Depth\"\nFROM \n pull_requests pr\n left join pull_request_comments prc on pr.id = prc.pull_request_id\n join project_mapping pm on pr.base_repo_id = pm.row_id and pm.table = 'repos' \nWHERE\n $__timeFilter(pr.created_date)\n and pm.project_name in (${project})\n and pr.merged_date is not null\ngroup by 1\n", + "rawSql": "SELECT\n DATE_ADD(date(pr.created_date), INTERVAL -$interval(date(pr.created_date))+1 DAY) as time,\n COALESCE(pr.sub_project, 'All') as sub_project,\n count(distinct prc.id)/count(distinct pr.id) as \"PR Review Depth\"\nFROM \n pull_requests pr\n left join pull_request_comments prc on pr.id = prc.pull_request_id\n join project_mapping pm on pr.base_repo_id = pm.row_id and pm.table = 'repos' \nWHERE\n $__timeFilter(pr.created_date)\n and pm.project_name in (${project})\n and pr.merged_date is not null\ngroup by 1, 2", "refId": "A", "select": [ [ @@ -1004,7 +1004,7 @@ "hide": false, "metricColumn": "none", "rawQuery": true, - "rawSql": "SELECT\n DATE_ADD(date(pr.created_date), INTERVAL -$interval(date(pr.created_date))+1 DAY) as time,\n sum(case when pr.id not in (SELECT pull_request_id FROM pull_request_comments) then 1 else 0 end) as \"PRs Merged w/o Review\"\nFROM \n pull_requests pr\n join project_mapping pm on pr.base_repo_id = pm.row_id and pm.table = 'repos' \nWHERE\n $__timeFilter(pr.created_date)\n and pm.project_name in (${project})\n and pr.merged_date is not null\nGROUP BY 1\nORDER BY 1", + "rawSql": "SELECT\n DATE_ADD(date(pr.created_date), INTERVAL -$interval(date(pr.created_date))+1 DAY) as time,\n COALESCE(pr.sub_project, 'All') as sub_project,\n sum(case when pr.id not in (SELECT pull_request_id FROM pull_request_comments) then 1 else 0 end) as \"PRs Merged w/o Review\"\nFROM \n pull_requests pr\n join project_mapping pm on pr.base_repo_id = pm.row_id and pm.table = 'repos' \nWHERE\n $__timeFilter(pr.created_date)\n and pm.project_name in (${project})\n and pr.merged_date is not null\nGROUP BY 1, 2\nORDER BY 1", "refId": "A", "select": [ [ diff --git a/grafana/dashboards/mysql/monorepo-subprojects.json b/grafana/dashboards/mysql/monorepo-subprojects.json new file mode 100644 index 00000000000..0ad2757d998 --- /dev/null +++ b/grafana/dashboards/mysql/monorepo-subprojects.json @@ -0,0 +1,340 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "datasource", "uid": "grafana" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Per-sub-project DORA metrics for a monorepo, produced by the monorepo plugin. Deployments are attributed by CI job name, pull requests by label.", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { "type": "datasource", "uid": "grafana" }, + "gridPos": { "h": 3, "w": 24, "x": 0, "y": 0 }, + "id": 10, + "options": { + "code": { "language": "plaintext", "showLineNumbers": false, "showMiniMap": false }, + "content": "## Monorepo Sub-Projects\n\nEach **sub-project** is a logical project inside a single Git repository. Deployments are attributed by **CI job name**, pull requests by **label**. Only Deployment Frequency and Lead Time for Changes are available — Change Failure Rate and Time to Restore require incident data, which this plugin does not attribute.", + "mode": "markdown" + }, + "pluginVersion": "10.1.0", + "title": "", + "type": "text" + }, + { + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "description": "Number of successful production deployments per sub-project in the selected time range. A sub-project showing zero usually means its deployJobPattern matches no CI job.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisLabel": "deployments", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineWidth": 1, + "scaleDistribution": { "type": "linear" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "noValue": "0", + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 3 }, + "id": 1, + "options": { + "barRadius": 0, + "barWidth": 0.7, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "tooltip": { "mode": "single", "sort": "none" }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(ds.sub_project, 'All') AS 'Sub-Project', COUNT(DISTINCT dc.cicd_deployment_id) AS 'Deployments'\nFROM cicd_deployment_commits dc\nJOIN project_mapping pm ON (pm.`table` = 'cicd_scopes' AND pm.row_id = dc.cicd_scope_id)\nLEFT JOIN cicd_deployment_subprojects ds\n ON ds.cicd_deployment_id = dc.cicd_deployment_id AND ds.project_name = pm.project_name\nWHERE dc.result = 'SUCCESS'\n AND dc.environment = 'PRODUCTION'\n AND ('${project:csv}' = '' OR pm.project_name IN (${project:singlequote}))\n AND $__timeFilter(dc.finished_date)\nGROUP BY 1\nORDER BY 2 DESC", + "refId": "A", + "sql": { "columns": [{ "parameters": [], "type": "function" }], "groupBy": [{ "property": { "type": "string" }, "type": "groupBy" }], "limit": 50 } + } + ], + "title": "Deployment Count by Sub-Project", + "type": "barchart" + }, + { + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "description": "Successful production deployments over time, one series per sub-project. This is the metric that a plain DevLake setup cannot separate: without attribution every sub-project shows the whole repository's deployment count.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisLabel": "deployments", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "noValue": "0", + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 3 }, + "id": 2, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "editorMode": "code", + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT\n DATE(dc.finished_date) AS time,\n COALESCE(ds.sub_project, 'All') AS metric,\n COUNT(DISTINCT dc.cicd_deployment_id) AS value\nFROM cicd_deployment_commits dc\nJOIN project_mapping pm ON (pm.`table` = 'cicd_scopes' AND pm.row_id = dc.cicd_scope_id)\nLEFT JOIN cicd_deployment_subprojects ds\n ON ds.cicd_deployment_id = dc.cicd_deployment_id AND ds.project_name = pm.project_name\nWHERE dc.result = 'SUCCESS'\n AND dc.environment = 'PRODUCTION'\n AND ('${project:csv}' = '' OR pm.project_name IN (${project:singlequote}))\n AND $__timeFilter(dc.finished_date)\nGROUP BY 1, 2\nORDER BY 1", + "refId": "A" + } + ], + "title": "Deployment Frequency over Time", + "type": "timeseries" + }, + { + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "description": "Average change lead time per sub-project, broken into its stages. Coding, pickup and review come from DORA unchanged; the deploy leg is recomputed against this sub-project's own deployments. All values in hours.", + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "noValue": "-", + "thresholds": { "mode": "absolute", "steps": [{ "color": "text", "value": null }] } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Cycle Time (h)" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "type": "color-text" } }, + { "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } } + ] + } + ] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 12 }, + "id": 3, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true + }, + "targets": [ + { + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n COALESCE(sub_project, 'All') AS 'Sub-Project',\n COUNT(*) AS 'PRs',\n ROUND(AVG(pr_coding_time) / 60, 1) AS 'Coding (h)',\n ROUND(AVG(pr_pickup_time) / 60, 1) AS 'Pickup (h)',\n ROUND(AVG(pr_review_time) / 60, 1) AS 'Review (h)',\n ROUND(AVG(pr_deploy_time) / 60, 1) AS 'Deploy (h)',\n ROUND(AVG(pr_cycle_time) / 60, 1) AS 'Cycle Time (h)'\nFROM project_pr_metrics\nWHERE ('${project:csv}' = '' OR project_name IN (${project:singlequote}))\n AND $__timeFilter(pr_merged_date)\nGROUP BY 1\nORDER BY 1", + "refId": "A" + } + ], + "title": "Change Lead Time Breakdown by Sub-Project", + "type": "table" + }, + { + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "description": "Merged pull requests carrying none of the configured sub-project labels. These are invisible to every metric on this dashboard. A rising number means labelling discipline is slipping, not that the code is wrong.", + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "noValue": "0", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "#EAB839", "value": 1 }, + { "color": "red", "value": 10 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 12 }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COUNT(*) AS 'Unattributed merged PRs'\nFROM pull_requests pr\nJOIN project_mapping pm ON (pm.`table` = 'repos' AND pm.row_id = pr.base_repo_id)\nWHERE pr.merged_date IS NOT NULL\n AND pr.sub_project = 'unattributed'\n AND ('${project:csv}' = '' OR pm.project_name IN (${project:singlequote}))\n AND $__timeFilter(pr.merged_date)", + "refId": "A" + } + ], + "title": "Unattributed PRs", + "type": "stat" + }, + { + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "description": "Merged pull requests that have been attributed to a sub-project but that no deployment has shipped yet, or whose shipping deployment could not be identified.", + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "noValue": "0", + "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 12 }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COUNT(*) AS 'Merged, not yet deployed'\nFROM project_pr_metrics\nWHERE (deployment_commit_id = '' OR deployment_commit_id IS NULL)\n AND sub_project IS NOT NULL\n AND ('${project:csv}' = '' OR project_name IN (${project:singlequote}))\n AND $__timeFilter(pr_merged_date)", + "refId": "A" + } + ], + "title": "Awaiting Deployment", + "type": "stat" + }, + { + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "description": "Every attributed deployment, newest first, sourced from cicd_deployment_commits joined through the core cicd_deployment_subprojects mapping table.", + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "filterable": true, "inspect": false }, + "mappings": [], + "noValue": "-", + "thresholds": { "mode": "absolute", "steps": [{ "color": "text", "value": null }] } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Result" }, + "properties": [ + { "id": "custom.cellOptions", "value": { "type": "color-text" } }, + { + "id": "mappings", + "value": [ + { "options": { "SUCCESS": { "color": "green", "index": 0 } }, "type": "value" }, + { "options": { "FAILURE": { "color": "red", "index": 1 } }, "type": "value" } + ] + } + ] + } + ] + }, + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 21 }, + "id": 6, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "Finished" }] + }, + "targets": [ + { + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n dc.finished_date AS 'Finished',\n COALESCE(ds.sub_project, 'All') AS 'Sub-Project',\n dc.environment AS 'Environment',\n dc.result AS 'Result',\n dc.cicd_deployment_id AS 'Deployment',\n dc.commit_sha AS 'Commit'\nFROM cicd_deployment_commits dc\nJOIN project_mapping pm ON (pm.`table` = 'cicd_scopes' AND pm.row_id = dc.cicd_scope_id)\nLEFT JOIN cicd_deployment_subprojects ds\n ON ds.cicd_deployment_id = dc.cicd_deployment_id AND ds.project_name = pm.project_name\nWHERE ('${project:csv}' = '' OR pm.project_name IN (${project:singlequote}))\n AND $__timeFilter(dc.finished_date)\nORDER BY dc.finished_date DESC\nLIMIT 200", + "refId": "A" + } + ], + "title": "Attributed Deployments", + "type": "table" + } + ], + "refresh": "", + "schemaVersion": 38, + "style": "dark", + "tags": ["monorepo", "dora"], + "templating": { + "list": [ + { + "current": { "selected": true, "text": ["All"], "value": ["$__all"] }, + "datasource": { "type": "mysql", "uid": "devlake-mysql-api" }, + "definition": "select distinct name from projects", + "hide": 0, + "includeAll": true, + "label": "Project", + "multi": true, + "name": "project", + "options": [], + "query": "select distinct name from projects", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { "from": "now-90d", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Monorepo Sub-Projects", + "uid": "monorepo-subprojects", + "version": 1, + "weekStart": "" +} diff --git a/grafana/dashboards/postgresql/Gitlab.json b/grafana/dashboards/postgresql/Gitlab.json index af78160fc19..d038db58a23 100644 --- a/grafana/dashboards/postgresql/Gitlab.json +++ b/grafana/dashboards/postgresql/Gitlab.json @@ -969,7 +969,7 @@ "hide": false, "metricColumn": "none", "rawQuery": true, - "rawSql": "/* The PR/MR statuses are standardized to 'OPEN', 'MERGED' and 'CLOSED'. You can check out the original status from the field \"original_status\" */ SELECT CAST(created_date AS DATE) + INTERVAL '1 DAY' * (-CASE WHEN '$interval' = 'DAYOFMONTH' THEN EXTRACT(DAY FROM CAST(created_date AS DATE)) ELSE (EXTRACT(ISODOW FROM CAST(created_date AS DATE)) - 1) END + 1) AS time, CAST(COUNT(DISTINCT CASE WHEN status = 'CLOSED' THEN id ELSE NULL END) AS NUMERIC) / NULLIF(COUNT(DISTINCT CASE WHEN status IN ('CLOSED', 'MERGED') THEN id ELSE NULL END), 0) AS ratio FROM pull_requests WHERE $__timeFilter(created_date) AND ('${repo_id:csv}' = '' OR base_repo_id::text = ANY(ARRAY[${repo_id:singlequote}]::text[])) GROUP BY 1", + "rawSql": "/* The PR/MR statuses are standardized to 'OPEN', 'MERGED' and 'CLOSED'. You can check out the original status from the field \"original_status\" */ SELECT CAST(created_date AS DATE) + INTERVAL '1 DAY' * (-CASE WHEN '$interval' = 'DAYOFMONTH' THEN EXTRACT(DAY FROM CAST(created_date AS DATE)) ELSE (EXTRACT(ISODOW FROM CAST(created_date AS DATE)) - 1) END + 1) AS time, COALESCE(sub_project, 'All') AS sub_project, CAST(COUNT(DISTINCT CASE WHEN status = 'CLOSED' THEN id ELSE NULL END) AS NUMERIC) / NULLIF(COUNT(DISTINCT CASE WHEN status IN ('CLOSED', 'MERGED') THEN id ELSE NULL END), 0) AS ratio FROM pull_requests WHERE $__timeFilter(created_date) AND ('${repo_id:csv}' = '' OR base_repo_id::text = ANY(ARRAY[${repo_id:singlequote}]::text[])) GROUP BY 1, 2", "refId": "A", "select": [ [ diff --git a/grafana/dashboards/postgresql/engineering-overview.json b/grafana/dashboards/postgresql/engineering-overview.json index 6d22a879f9c..287973048ca 100644 --- a/grafana/dashboards/postgresql/engineering-overview.json +++ b/grafana/dashboards/postgresql/engineering-overview.json @@ -1483,7 +1483,7 @@ "metricColumn": "none", "queryType": "randomWalk", "rawQuery": true, - "rawSql": "SELECT CAST(created_date AS DATE) + INTERVAL '1 DAY' * (-EXTRACT(DAY FROM CAST(created_date AS DATE)) + 1) AS time, CAST(100 * COUNT(DISTINCT CASE WHEN pr.id IN (SELECT pull_request_id FROM pull_request_issues) THEN pr.id ELSE NULL END) AS NUMERIC) / NULLIF(COUNT(DISTINCT pr.id), 0) AS unlinked_pr_rate FROM pull_requests AS pr JOIN project_mapping AS pm ON pr.base_repo_id = pm.row_id AND pm.table = 'repos' WHERE ('${project:csv}' = '' OR pm.project_name::text = ANY(ARRAY[${project:singlequote}]::text[])) AND $__timeFilter(created_date) AND created_date >= $__timeFrom()::timestamp + INTERVAL '1 DAY' * (-EXTRACT(DAY FROM CAST($__timeFrom() AS DATE)) + 1) + INTERVAL '1 MONTH' GROUP BY \"time\"", + "rawSql": "SELECT CAST(created_date AS DATE) + INTERVAL '1 DAY' * (-EXTRACT(DAY FROM CAST(created_date AS DATE)) + 1) AS time, COALESCE(pr.sub_project, 'All') AS sub_project, CAST(100 * COUNT(DISTINCT CASE WHEN pr.id IN (SELECT pull_request_id FROM pull_request_issues) THEN pr.id ELSE NULL END) AS NUMERIC) / NULLIF(COUNT(DISTINCT pr.id), 0) AS unlinked_pr_rate FROM pull_requests AS pr JOIN project_mapping AS pm ON pr.base_repo_id = pm.row_id AND pm.table = 'repos' WHERE ('${project:csv}' = '' OR pm.project_name::text = ANY(ARRAY[${project:singlequote}]::text[])) AND $__timeFilter(created_date) AND created_date >= $__timeFrom()::timestamp + INTERVAL '1 DAY' * (-EXTRACT(DAY FROM CAST($__timeFrom() AS DATE)) + 1) + INTERVAL '1 MONTH' GROUP BY \"time\", pr.sub_project", "refId": "A", "select": [ [ @@ -1969,7 +1969,7 @@ "metricColumn": "none", "queryType": "randomWalk", "rawQuery": true, - "rawSql": "SELECT CAST(pr.created_date AS DATE) + INTERVAL '1 DAY' * (-EXTRACT(DAY FROM CAST(pr.created_date AS DATE)) + 1) AS time, AVG(CAST((EXTRACT(EPOCH FROM (pr.merged_date - pr.created_date))/60) AS NUMERIC) / NULLIF(1440, 0)) AS pr_time_to_merge_in_days FROM pull_requests AS pr JOIN project_mapping AS pm ON pr.base_repo_id = pm.row_id AND pm.table = 'repos' WHERE ('${project:csv}' = '' OR pm.project_name::text = ANY(ARRAY[${project:singlequote}]::text[])) AND NOT pr.merged_date IS NULL AND $__timeFilter(pr.created_date) AND pr.created_date >= $__timeFrom()::timestamp + INTERVAL '1 DAY' * (-EXTRACT(DAY FROM CAST($__timeFrom() AS DATE)) + 1) + INTERVAL '1 MONTH' GROUP BY \"time\" ORDER BY time NULLS FIRST", + "rawSql": "SELECT CAST(pr.created_date AS DATE) + INTERVAL '1 DAY' * (-EXTRACT(DAY FROM CAST(pr.created_date AS DATE)) + 1) AS time, COALESCE(pr.sub_project, 'All') AS sub_project, AVG(CAST((EXTRACT(EPOCH FROM (pr.merged_date - pr.created_date))/60) AS NUMERIC) / NULLIF(1440, 0)) AS pr_time_to_merge_in_days FROM pull_requests AS pr JOIN project_mapping AS pm ON pr.base_repo_id = pm.row_id AND pm.table = 'repos' WHERE ('${project:csv}' = '' OR pm.project_name::text = ANY(ARRAY[${project:singlequote}]::text[])) AND NOT pr.merged_date IS NULL AND $__timeFilter(pr.created_date) AND pr.created_date >= $__timeFrom()::timestamp + INTERVAL '1 DAY' * (-EXTRACT(DAY FROM CAST($__timeFrom() AS DATE)) + 1) + INTERVAL '1 MONTH' GROUP BY \"time\", pr.sub_project ORDER BY time NULLS FIRST", "refId": "A", "select": [ [ diff --git a/grafana/dashboards/postgresql/engineering-throughput-and-cycle-time.json b/grafana/dashboards/postgresql/engineering-throughput-and-cycle-time.json index 174ad11e25e..6abd47e0afd 100644 --- a/grafana/dashboards/postgresql/engineering-throughput-and-cycle-time.json +++ b/grafana/dashboards/postgresql/engineering-throughput-and-cycle-time.json @@ -622,7 +622,7 @@ "hide": false, "metricColumn": "none", "rawQuery": true, - "rawSql": "SELECT CAST(pr.created_date AS DATE) + INTERVAL '1 DAY' * (-CASE WHEN '$interval' = 'DAYOFMONTH' THEN EXTRACT(DAY FROM CAST(pr.created_date AS DATE)) ELSE (EXTRACT(ISODOW FROM CAST(pr.created_date AS DATE)) - 1) END + 1) AS time, CAST(COUNT(DISTINCT prc.id) AS NUMERIC) / NULLIF(COUNT(DISTINCT pr.id), 0) AS \"PR Review Depth\" FROM pull_requests AS pr LEFT JOIN pull_request_comments AS prc ON pr.id = prc.pull_request_id JOIN project_mapping AS pm ON pr.base_repo_id = pm.row_id AND pm.table = 'repos' WHERE $__timeFilter(pr.created_date) AND ('${project:csv}' = '' OR pm.project_name::text = ANY(ARRAY[${project:singlequote}]::text[])) AND NOT pr.merged_date IS NULL GROUP BY 1", + "rawSql": "SELECT CAST(pr.created_date AS DATE) + INTERVAL '1 DAY' * (-CASE WHEN '$interval' = 'DAYOFMONTH' THEN EXTRACT(DAY FROM CAST(pr.created_date AS DATE)) ELSE (EXTRACT(ISODOW FROM CAST(pr.created_date AS DATE)) - 1) END + 1) AS time, COALESCE(pr.sub_project, 'All') AS sub_project, CAST(COUNT(DISTINCT prc.id) AS NUMERIC) / NULLIF(COUNT(DISTINCT pr.id), 0) AS \"PR Review Depth\" FROM pull_requests AS pr LEFT JOIN pull_request_comments AS prc ON pr.id = prc.pull_request_id JOIN project_mapping AS pm ON pr.base_repo_id = pm.row_id AND pm.table = 'repos' WHERE $__timeFilter(pr.created_date) AND ('${project:csv}' = '' OR pm.project_name::text = ANY(ARRAY[${project:singlequote}]::text[])) AND NOT pr.merged_date IS NULL GROUP BY 1, 2", "refId": "A", "select": [ [ @@ -1004,7 +1004,7 @@ "hide": false, "metricColumn": "none", "rawQuery": true, - "rawSql": "SELECT CAST(pr.created_date AS DATE) + INTERVAL '1 DAY' * (-CASE WHEN '$interval' = 'DAYOFMONTH' THEN EXTRACT(DAY FROM CAST(pr.created_date AS DATE)) ELSE (EXTRACT(ISODOW FROM CAST(pr.created_date AS DATE)) - 1) END + 1) AS time, SUM(CASE WHEN NOT pr.id IN (SELECT pull_request_id FROM pull_request_comments) THEN 1 ELSE 0 END) AS \"PRs Merged w/o Review\" FROM pull_requests AS pr JOIN project_mapping AS pm ON pr.base_repo_id = pm.row_id AND pm.table = 'repos' WHERE $__timeFilter(pr.created_date) AND ('${project:csv}' = '' OR pm.project_name::text = ANY(ARRAY[${project:singlequote}]::text[])) AND NOT pr.merged_date IS NULL GROUP BY 1 ORDER BY 1 NULLS FIRST", + "rawSql": "SELECT CAST(pr.created_date AS DATE) + INTERVAL '1 DAY' * (-CASE WHEN '$interval' = 'DAYOFMONTH' THEN EXTRACT(DAY FROM CAST(pr.created_date AS DATE)) ELSE (EXTRACT(ISODOW FROM CAST(pr.created_date AS DATE)) - 1) END + 1) AS time, COALESCE(pr.sub_project, 'All') AS sub_project, SUM(CASE WHEN NOT pr.id IN (SELECT pull_request_id FROM pull_request_comments) THEN 1 ELSE 0 END) AS \"PRs Merged w/o Review\" FROM pull_requests AS pr JOIN project_mapping AS pm ON pr.base_repo_id = pm.row_id AND pm.table = 'repos' WHERE $__timeFilter(pr.created_date) AND ('${project:csv}' = '' OR pm.project_name::text = ANY(ARRAY[${project:singlequote}]::text[])) AND NOT pr.merged_date IS NULL GROUP BY 1, 2 ORDER BY 1 NULLS FIRST", "refId": "A", "select": [ [ diff --git a/grafana/dashboards/postgresql/monorepo-subprojects.json b/grafana/dashboards/postgresql/monorepo-subprojects.json new file mode 100644 index 00000000000..372860d58c1 --- /dev/null +++ b/grafana/dashboards/postgresql/monorepo-subprojects.json @@ -0,0 +1,628 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Per-sub-project DORA metrics for a monorepo, produced by the monorepo plugin. Deployments are attributed by CI job name, pull requests by label.", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 10, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "## Monorepo Sub-Projects\n\nEach **sub-project** is a logical project inside a single Git repository. Deployments are attributed by **CI job name**, pull requests by **label**. Only Deployment Frequency and Lead Time for Changes are available \u2014 Change Failure Rate and Time to Restore require incident data, which this plugin does not attribute.", + "mode": "markdown" + }, + "pluginVersion": "10.1.0", + "title": "", + "type": "text" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Number of successful production deployments per sub-project in the selected time range. A sub-project showing zero usually means its deployJobPattern matches no CI job.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisLabel": "deployments", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "noValue": "0", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 3 + }, + "id": 1, + "options": { + "barRadius": 0, + "barWidth": 0.7, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(ds.sub_project, 'All') AS \"Sub-Project\", COUNT(DISTINCT dc.cicd_deployment_id) AS \"Deployments\"\nFROM cicd_deployment_commits dc\nJOIN project_mapping pm ON (pm.\"table\" = 'cicd_scopes' AND pm.row_id = dc.cicd_scope_id)\nLEFT JOIN cicd_deployment_subprojects ds\n ON ds.cicd_deployment_id = dc.cicd_deployment_id AND ds.project_name = pm.project_name\nWHERE dc.result = 'SUCCESS'\n AND dc.environment = 'PRODUCTION'\n AND ('${project:csv}' = '' OR pm.project_name::text = ANY(ARRAY[${project:singlequote}]::text[]))\n AND $__timeFilter(dc.finished_date)\nGROUP BY 1\nORDER BY 2 DESC", + "refId": "A", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + ], + "title": "Deployment Count by Sub-Project", + "type": "barchart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Successful production deployments over time, one series per sub-project. This is the metric that a plain DevLake setup cannot separate: without attribution every sub-project shows the whole repository's deployment count.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisLabel": "deployments", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "noValue": "0", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 3 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT\n date_trunc('day', dc.finished_date) AS time,\n COALESCE(ds.sub_project, 'All') AS metric,\n COUNT(DISTINCT dc.cicd_deployment_id) AS value\nFROM cicd_deployment_commits dc\nJOIN project_mapping pm ON (pm.\"table\" = 'cicd_scopes' AND pm.row_id = dc.cicd_scope_id)\nLEFT JOIN cicd_deployment_subprojects ds\n ON ds.cicd_deployment_id = dc.cicd_deployment_id AND ds.project_name = pm.project_name\nWHERE dc.result = 'SUCCESS'\n AND dc.environment = 'PRODUCTION'\n AND ('${project:csv}' = '' OR pm.project_name::text = ANY(ARRAY[${project:singlequote}]::text[]))\n AND $__timeFilter(dc.finished_date)\nGROUP BY 1, 2\nORDER BY 1", + "refId": "A" + } + ], + "title": "Deployment Frequency over Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Average change lead time per sub-project, broken into its stages. Coding, pickup and review come from DORA unchanged; the deploy leg is recomputed against this sub-project's own deployments. All values in hours.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "noValue": "-", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Cycle Time (h)" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + }, + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 12 + }, + "id": 3, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n COALESCE(sub_project, 'All') AS \"Sub-Project\",\n COUNT(*) AS \"PRs\",\n ROUND((AVG(pr_coding_time) / 60)::numeric, 1) AS \"Coding (h)\",\n ROUND((AVG(pr_pickup_time) / 60)::numeric, 1) AS \"Pickup (h)\",\n ROUND((AVG(pr_review_time) / 60)::numeric, 1) AS \"Review (h)\",\n ROUND((AVG(pr_deploy_time) / 60)::numeric, 1) AS \"Deploy (h)\",\n ROUND((AVG(pr_cycle_time) / 60)::numeric, 1) AS \"Cycle Time (h)\"\nFROM project_pr_metrics\nWHERE ('${project:csv}' = '' OR project_name::text = ANY(ARRAY[${project:singlequote}]::text[]))\n AND $__timeFilter(pr_merged_date)\nGROUP BY 1\nORDER BY 1", + "refId": "A" + } + ], + "title": "Change Lead Time Breakdown by Sub-Project", + "type": "table" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Merged pull requests carrying none of the configured sub-project labels. These are invisible to every metric on this dashboard. A rising number means labelling discipline is slipping, not that the code is wrong.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "noValue": "0", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 1 + }, + { + "color": "red", + "value": 10 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 12 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COUNT(*) AS \"Unattributed merged PRs\"\nFROM pull_requests pr\nJOIN project_mapping pm ON (pm.\"table\" = 'repos' AND pm.row_id = pr.base_repo_id)\nWHERE pr.merged_date IS NOT NULL\n AND pr.sub_project = 'unattributed'\n AND ('${project:csv}' = '' OR pm.project_name::text = ANY(ARRAY[${project:singlequote}]::text[]))\n AND $__timeFilter(pr.merged_date)", + "refId": "A" + } + ], + "title": "Unattributed PRs", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Merged pull requests that have been attributed to a sub-project but that no deployment has shipped yet, or whose shipping deployment could not be identified.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "noValue": "0", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 12 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COUNT(*) AS \"Merged, not yet deployed\"\nFROM project_pr_metrics\nWHERE (deployment_commit_id = '' OR deployment_commit_id IS NULL)\n AND sub_project IS NOT NULL\n AND ('${project:csv}' = '' OR project_name::text = ANY(ARRAY[${project:singlequote}]::text[]))\n AND $__timeFilter(pr_merged_date)", + "refId": "A" + } + ], + "title": "Awaiting Deployment", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Every attributed deployment, newest first, sourced from cicd_deployment_commits joined through the core cicd_deployment_subprojects mapping table.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "noValue": "-", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Result" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + }, + { + "id": "mappings", + "value": [ + { + "options": { + "SUCCESS": { + "color": "green", + "index": 0 + } + }, + "type": "value" + }, + { + "options": { + "FAILURE": { + "color": "red", + "index": 1 + } + }, + "type": "value" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 21 + }, + "id": 6, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Finished" + } + ] + }, + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n dc.finished_date AS \"Finished\",\n COALESCE(ds.sub_project, 'All') AS \"Sub-Project\",\n dc.environment AS \"Environment\",\n dc.result AS \"Result\",\n dc.cicd_deployment_id AS \"Deployment\",\n dc.commit_sha AS \"Commit\"\nFROM cicd_deployment_commits dc\nJOIN project_mapping pm ON (pm.\"table\" = 'cicd_scopes' AND pm.row_id = dc.cicd_scope_id)\nLEFT JOIN cicd_deployment_subprojects ds\n ON ds.cicd_deployment_id = dc.cicd_deployment_id AND ds.project_name = pm.project_name\nWHERE ('${project:csv}' = '' OR pm.project_name::text = ANY(ARRAY[${project:singlequote}]::text[]))\n AND $__timeFilter(dc.finished_date)\nORDER BY dc.finished_date DESC\nLIMIT 200", + "refId": "A" + } + ], + "title": "Attributed Deployments", + "type": "table" + } + ], + "refresh": "", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "monorepo", + "dora" + ], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "definition": "select distinct name from projects", + "hide": 0, + "includeAll": true, + "label": "Project", + "multi": true, + "name": "project", + "options": [], + "query": "select distinct name from projects", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-90d", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Monorepo Sub-Projects", + "uid": "monorepo-subprojects", + "version": 1, + "weekStart": "" +}