From 24be915f7a4a855a9967328b1d291899eef97de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C5=A1per=20Grom?= Date: Tue, 21 Jul 2026 12:38:46 +0100 Subject: [PATCH 1/7] feat: add lifecycle, health v2, and impact score to project insights (IN-1196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gašper Grom --- .../project_insights_copy_ds.datasource | 10 +- .../libs/tinybird/pipes/project_insights.pipe | 7 +- .../tinybird/pipes/project_insights_copy.pipe | 109 +++++++++++++++++- .../tinybird/pipes/project_repo_insights.pipe | 7 +- 4 files changed, 128 insertions(+), 5 deletions(-) diff --git a/services/libs/tinybird/datasources/project_insights_copy_ds.datasource b/services/libs/tinybird/datasources/project_insights_copy_ds.datasource index bcc867c41c..3e9b134f90 100644 --- a/services/libs/tinybird/datasources/project_insights_copy_ds.datasource +++ b/services/libs/tinybird/datasources/project_insights_copy_ds.datasource @@ -32,6 +32,10 @@ DESCRIPTION > - `activeContributorsPrevious365Days` column is the unique count of active contributors in the previous 365 days (365-730 days ago). - `activeOrganizationsPrevious365Days` column is the unique count of active organizations in the previous 365 days (365-730 days ago). - `status` column is the current status of the project (e.g., 'active', 'archived'). + - `healthLabel` column buckets the Akrites-methodology package `healthScore` rollup: 'excellent' (85+), 'healthy' (70-84), 'fair' (50-69), 'concerning' (30-49), 'critical' (<30). Lowercase, matches `ossPackages_enriched_ds.healthLabel` convention. + - `lifecycleLabel` column is the project's aggregated maintenance state across its packages, using best-state-wins precedence (active > stable > declining > abandoned > archived) over `ossPackages_enriched_ds.lifecycleLabel`. + - `impactScore` column is the average Osprey criticality `impact` (0-100) across the project's packages. Null when the project has no linked package data. + - `impactLabel` column buckets `impactScore`: 'foundational' (85-100), 'major' (60-84), 'moderate' (30-59), 'minor' (0-29). Null when `impactScore` is null. TAGS "Project insights", "Metrics" @@ -65,7 +69,11 @@ SCHEMA > `starsPrevious365Days` UInt64, `forksPrevious365Days` UInt64, `activeContributorsPrevious365Days` UInt64, - `activeOrganizationsPrevious365Days` UInt64 + `activeOrganizationsPrevious365Days` UInt64, + `healthLabel` Nullable(String), + `lifecycleLabel` Nullable(String), + `impactScore` Nullable(UInt8), + `impactLabel` Nullable(String) ENGINE MergeTree ENGINE_SORTING_KEY type, id diff --git a/services/libs/tinybird/pipes/project_insights.pipe b/services/libs/tinybird/pipes/project_insights.pipe index 5acfa51530..8caf768361 100644 --- a/services/libs/tinybird/pipes/project_insights.pipe +++ b/services/libs/tinybird/pipes/project_insights.pipe @@ -12,6 +12,7 @@ DESCRIPTION > - `pageSize`: Optional integer for result limit, defaults to 10 - `page`: Optional integer for pagination offset calculation, defaults to 0 - Response: Project records with all insights metrics including achievements as array of (leaderboardType, rank, totalCount) tuples + - `healthLabel`, `lifecycleLabel`, `impactScore`, `impactLabel` are the Akrites-methodology fields (see `project_insights_copy_ds` for band definitions); may be null when the project has no linked package data. TAGS "Insights, Widget", "Project" @@ -46,7 +47,11 @@ SQL > starsPrevious365Days, forksPrevious365Days, activeContributorsPrevious365Days, - activeOrganizationsPrevious365Days + activeOrganizationsPrevious365Days, + healthLabel, + lifecycleLabel, + impactScore, + impactLabel FROM project_insights_copy_ds WHERE type = 'project' diff --git a/services/libs/tinybird/pipes/project_insights_copy.pipe b/services/libs/tinybird/pipes/project_insights_copy.pipe index c5d8708202..e7eb5398a3 100644 --- a/services/libs/tinybird/pipes/project_insights_copy.pipe +++ b/services/libs/tinybird/pipes/project_insights_copy.pipe @@ -85,6 +85,37 @@ SQL > WHERE timestamp >= now() - INTERVAL 730 DAY AND timestamp <= now() GROUP BY segmentId +NODE project_insights_copy_project_purls +DESCRIPTION > + Distinct package purls linked to each project, via packageDownloads (the established project<->package bridge) + +SQL > + SELECT DISTINCT insightsProjectId, purl + FROM packageDownloads + WHERE insightsProjectId != '' AND purl != '' + +NODE project_insights_copy_project_akrites +DESCRIPTION > + Akrites methodology rollup (lifecycle, health v2, impact) aggregated from ossPackages_enriched_ds up to project level. + Lifecycle uses best-state-wins precedence: active > stable > declining > abandoned > archived. + Health v2 and impact are averaged across the project's linked packages. + +SQL > + SELECT + pp.insightsProjectId AS insightsProjectId, + arrayElement( + arraySort( + x -> indexOf(['active', 'stable', 'declining', 'abandoned', 'archived'], x), + groupArray(pkg.lifecycleLabel) + ), + 1 + ) AS lifecycleLabel, + toUInt8(round(avg(pkg.healthScore))) AS healthScoreV2, + avgOrNull(toFloat64OrNull(pkg.impact)) * 100 AS impactScoreRaw + FROM project_insights_copy_project_purls AS pp + INNER JOIN ossPackages_enriched_ds AS pkg ON pkg.purl = pp.purl + GROUP BY pp.insightsProjectId + NODE project_insights_copy_project_results DESCRIPTION > Join all project insights together @@ -120,11 +151,38 @@ SQL > pm.starsPrevious365Days AS starsPrevious365Days, pm.forksPrevious365Days AS forksPrevious365Days, pm.activeContributorsPrevious365Days AS activeContributorsPrevious365Days, - pm.activeOrganizationsPrevious365Days AS activeOrganizationsPrevious365Days + pm.activeOrganizationsPrevious365Days AS activeOrganizationsPrevious365Days, + multiIf( + akr.healthScoreV2 IS NULL, + NULL, + akr.healthScoreV2 >= 85, + 'excellent', + akr.healthScoreV2 >= 70, + 'healthy', + akr.healthScoreV2 >= 50, + 'fair', + akr.healthScoreV2 >= 30, + 'concerning', + 'critical' + ) AS healthLabel, + akr.lifecycleLabel AS lifecycleLabel, + toUInt8(round(akr.impactScoreRaw)) AS impactScore, + multiIf( + akr.impactScoreRaw IS NULL, + NULL, + akr.impactScoreRaw >= 85, + 'foundational', + akr.impactScoreRaw >= 60, + 'major', + akr.impactScoreRaw >= 30, + 'moderate', + 'minor' + ) AS impactLabel FROM project_insights_copy_base_projects AS base LEFT JOIN project_insights_copy_dependency_metrics AS dep ON base.slug = dep.slug LEFT JOIN project_insights_copy_achievements AS ach ON base.slug = ach.slug LEFT JOIN project_insights_copy_period_metrics AS pm USING (segmentId) + LEFT JOIN project_insights_copy_project_akrites AS akr ON base.id = akr.insightsProjectId NODE project_insights_copy_repo_base DESCRIPTION > @@ -174,6 +232,26 @@ SQL > WHERE timestamp >= now() - INTERVAL 730 DAY AND timestamp <= now() GROUP BY channel +NODE project_insights_copy_repo_akrites +DESCRIPTION > + Akrites methodology rollup (lifecycle, health v2, impact) aggregated from ossPackages_enriched_ds up to repo level, matched by repositoryUrl (a repo may host multiple packages, e.g. a monorepo) + +SQL > + SELECT + repositoryUrl, + arrayElement( + arraySort( + x -> indexOf(['active', 'stable', 'declining', 'abandoned', 'archived'], x), + groupArray(lifecycleLabel) + ), + 1 + ) AS lifecycleLabel, + toUInt8(round(avg(healthScore))) AS healthScoreV2, + avgOrNull(toFloat64OrNull(impact)) * 100 AS impactScoreRaw + FROM ossPackages_enriched_ds + WHERE repositoryUrl IS NOT NULL AND repositoryUrl != '' + GROUP BY repositoryUrl + NODE project_insights_copy_repo_results DESCRIPTION > Join all repository insights together @@ -209,10 +287,37 @@ SQL > COALESCE(rm.starsPrevious365Days, 0) AS starsPrevious365Days, COALESCE(rm.forksPrevious365Days, 0) AS forksPrevious365Days, COALESCE(rm.activeContributorsPrevious365Days, 0) AS activeContributorsPrevious365Days, - COALESCE(rm.activeOrganizationsPrevious365Days, 0) AS activeOrganizationsPrevious365Days + COALESCE(rm.activeOrganizationsPrevious365Days, 0) AS activeOrganizationsPrevious365Days, + multiIf( + akr.healthScoreV2 IS NULL, + NULL, + akr.healthScoreV2 >= 85, + 'excellent', + akr.healthScoreV2 >= 70, + 'healthy', + akr.healthScoreV2 >= 50, + 'fair', + akr.healthScoreV2 >= 30, + 'concerning', + 'critical' + ) AS healthLabel, + akr.lifecycleLabel AS lifecycleLabel, + toUInt8(round(akr.impactScoreRaw)) AS impactScore, + multiIf( + akr.impactScoreRaw IS NULL, + NULL, + akr.impactScoreRaw >= 85, + 'foundational', + akr.impactScoreRaw >= 60, + 'major', + akr.impactScoreRaw >= 30, + 'moderate', + 'minor' + ) AS impactLabel FROM project_insights_copy_repo_base AS base LEFT JOIN repo_health_score_copy_ds AS hs ON base.repoUrl = hs.channel LEFT JOIN project_insights_copy_repo_period_metrics AS rm ON base.repoUrl = rm.channel + LEFT JOIN project_insights_copy_repo_akrites AS akr ON base.repoUrl = akr.repositoryUrl NODE project_insights_copy_results DESCRIPTION > diff --git a/services/libs/tinybird/pipes/project_repo_insights.pipe b/services/libs/tinybird/pipes/project_repo_insights.pipe index 1ea2553e0c..7a68637176 100644 --- a/services/libs/tinybird/pipes/project_repo_insights.pipe +++ b/services/libs/tinybird/pipes/project_repo_insights.pipe @@ -11,6 +11,7 @@ DESCRIPTION > - `pageSize`: Optional integer for result limit, defaults to 10 - `page`: Optional integer for pagination offset calculation, defaults to 0 - Response: Project and repository records with insights metrics + - `healthLabel`, `lifecycleLabel`, `impactScore`, `impactLabel` are the Akrites-methodology fields (see `project_insights_copy_ds` for band definitions); may be null when the record has no linked package data. TAGS "Insights, Widget", "Project", "Repository" @@ -47,7 +48,11 @@ SQL > starsPrevious365Days, forksPrevious365Days, activeContributorsPrevious365Days, - activeOrganizationsPrevious365Days + activeOrganizationsPrevious365Days, + healthLabel, + lifecycleLabel, + impactScore, + impactLabel FROM project_insights_copy_ds WHERE 1 = 1 From e3709f1bd553f8cbbe80e82abe7bb31554364456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C5=A1per=20Grom?= Date: Tue, 21 Jul 2026 12:49:40 +0100 Subject: [PATCH 2/7] fix: expose distinct healthScoreV2 field, avoid overloading legacy healthScore (IN-1196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gašper Grom --- .../tinybird/datasources/project_insights_copy_ds.datasource | 4 +++- services/libs/tinybird/pipes/project_insights.pipe | 3 ++- services/libs/tinybird/pipes/project_insights_copy.pipe | 2 ++ services/libs/tinybird/pipes/project_repo_insights.pipe | 3 ++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/services/libs/tinybird/datasources/project_insights_copy_ds.datasource b/services/libs/tinybird/datasources/project_insights_copy_ds.datasource index 3e9b134f90..fde0a8fd52 100644 --- a/services/libs/tinybird/datasources/project_insights_copy_ds.datasource +++ b/services/libs/tinybird/datasources/project_insights_copy_ds.datasource @@ -32,7 +32,8 @@ DESCRIPTION > - `activeContributorsPrevious365Days` column is the unique count of active contributors in the previous 365 days (365-730 days ago). - `activeOrganizationsPrevious365Days` column is the unique count of active organizations in the previous 365 days (365-730 days ago). - `status` column is the current status of the project (e.g., 'active', 'archived'). - - `healthLabel` column buckets the Akrites-methodology package `healthScore` rollup: 'excellent' (85+), 'healthy' (70-84), 'fair' (50-69), 'concerning' (30-49), 'critical' (<30). Lowercase, matches `ossPackages_enriched_ds.healthLabel` convention. + - `healthScoreV2` column is the Akrites-methodology composite health score (0-100), averaged from `ossPackages_enriched_ds.healthScore` across the project's linked packages. Distinct from the legacy `healthScore` column (community/contributor-based); null when the project has no linked package data. + - `healthLabel` column buckets `healthScoreV2`: 'excellent' (85+), 'healthy' (70-84), 'fair' (50-69), 'concerning' (30-49), 'critical' (<30). Lowercase, matches `ossPackages_enriched_ds.healthLabel` convention. - `lifecycleLabel` column is the project's aggregated maintenance state across its packages, using best-state-wins precedence (active > stable > declining > abandoned > archived) over `ossPackages_enriched_ds.lifecycleLabel`. - `impactScore` column is the average Osprey criticality `impact` (0-100) across the project's packages. Null when the project has no linked package data. - `impactLabel` column buckets `impactScore`: 'foundational' (85-100), 'major' (60-84), 'moderate' (30-59), 'minor' (0-29). Null when `impactScore` is null. @@ -70,6 +71,7 @@ SCHEMA > `forksPrevious365Days` UInt64, `activeContributorsPrevious365Days` UInt64, `activeOrganizationsPrevious365Days` UInt64, + `healthScoreV2` Nullable(UInt8), `healthLabel` Nullable(String), `lifecycleLabel` Nullable(String), `impactScore` Nullable(UInt8), diff --git a/services/libs/tinybird/pipes/project_insights.pipe b/services/libs/tinybird/pipes/project_insights.pipe index 8caf768361..cc98c50ea1 100644 --- a/services/libs/tinybird/pipes/project_insights.pipe +++ b/services/libs/tinybird/pipes/project_insights.pipe @@ -12,7 +12,7 @@ DESCRIPTION > - `pageSize`: Optional integer for result limit, defaults to 10 - `page`: Optional integer for pagination offset calculation, defaults to 0 - Response: Project records with all insights metrics including achievements as array of (leaderboardType, rank, totalCount) tuples - - `healthLabel`, `lifecycleLabel`, `impactScore`, `impactLabel` are the Akrites-methodology fields (see `project_insights_copy_ds` for band definitions); may be null when the project has no linked package data. + - `healthScoreV2`, `healthLabel`, `lifecycleLabel`, `impactScore`, `impactLabel` are the Akrites-methodology fields (see `project_insights_copy_ds` for band definitions); may be null when the project has no linked package data. `healthScoreV2` is distinct from the legacy `healthScore` field. TAGS "Insights, Widget", "Project" @@ -48,6 +48,7 @@ SQL > forksPrevious365Days, activeContributorsPrevious365Days, activeOrganizationsPrevious365Days, + healthScoreV2, healthLabel, lifecycleLabel, impactScore, diff --git a/services/libs/tinybird/pipes/project_insights_copy.pipe b/services/libs/tinybird/pipes/project_insights_copy.pipe index e7eb5398a3..6bf3965b59 100644 --- a/services/libs/tinybird/pipes/project_insights_copy.pipe +++ b/services/libs/tinybird/pipes/project_insights_copy.pipe @@ -152,6 +152,7 @@ SQL > pm.forksPrevious365Days AS forksPrevious365Days, pm.activeContributorsPrevious365Days AS activeContributorsPrevious365Days, pm.activeOrganizationsPrevious365Days AS activeOrganizationsPrevious365Days, + akr.healthScoreV2 AS healthScoreV2, multiIf( akr.healthScoreV2 IS NULL, NULL, @@ -288,6 +289,7 @@ SQL > COALESCE(rm.forksPrevious365Days, 0) AS forksPrevious365Days, COALESCE(rm.activeContributorsPrevious365Days, 0) AS activeContributorsPrevious365Days, COALESCE(rm.activeOrganizationsPrevious365Days, 0) AS activeOrganizationsPrevious365Days, + akr.healthScoreV2 AS healthScoreV2, multiIf( akr.healthScoreV2 IS NULL, NULL, diff --git a/services/libs/tinybird/pipes/project_repo_insights.pipe b/services/libs/tinybird/pipes/project_repo_insights.pipe index 7a68637176..6553383db5 100644 --- a/services/libs/tinybird/pipes/project_repo_insights.pipe +++ b/services/libs/tinybird/pipes/project_repo_insights.pipe @@ -11,7 +11,7 @@ DESCRIPTION > - `pageSize`: Optional integer for result limit, defaults to 10 - `page`: Optional integer for pagination offset calculation, defaults to 0 - Response: Project and repository records with insights metrics - - `healthLabel`, `lifecycleLabel`, `impactScore`, `impactLabel` are the Akrites-methodology fields (see `project_insights_copy_ds` for band definitions); may be null when the record has no linked package data. + - `healthScoreV2`, `healthLabel`, `lifecycleLabel`, `impactScore`, `impactLabel` are the Akrites-methodology fields (see `project_insights_copy_ds` for band definitions); may be null when the record has no linked package data. `healthScoreV2` is distinct from the legacy `healthScore` field. TAGS "Insights, Widget", "Project", "Repository" @@ -49,6 +49,7 @@ SQL > forksPrevious365Days, activeContributorsPrevious365Days, activeOrganizationsPrevious365Days, + healthScoreV2, healthLabel, lifecycleLabel, impactScore, From 62fbffe355f4ed6157852e05ffecac8404dcfe34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C5=A1per=20Grom?= Date: Thu, 23 Jul 2026 13:26:52 +0100 Subject: [PATCH 3/7] feat: split Health Score v2 pipes, fix degradation and scoring (IN-1196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split health_score_v2 into 5 category pipes plus a lightweight combiner, fixing a perf regression from re-scanning base tables per category - Fix graceful degradation: availability flags checked IS NOT NULL on non-Nullable columns, which ClickHouse fills with 0/'' not NULL on LEFT JOIN miss, so degradation never triggered - Fix Impact Score defaulting to 100 instead of NULL for repos with no packages (least(NULL, 100) = 100 in ClickHouse) - Fix Maintainer Responsiveness scoring per product review: GitHub/GitLab score 0 (not blocked) when no PR/issue data; Gerrit scores 0 (not blocked) when no changeset data, issues excluded since Gerrit has no issue tracker; excluded=true repos stay blocked, never scored 0 - project_insights_copy now reads split datasources via cheap LEFT JOIN Signed-off-by: Gašper Grom --- .../health_score_v2_development_ds.datasource | 13 ++ .../health_score_v2_impact_ds.datasource | 13 ++ .../health_score_v2_lifecycle_ds.datasource | 13 ++ .../health_score_v2_maintainer_ds.datasource | 13 ++ .../health_score_v2_repo_copy_ds.datasource | 34 ++++++ .../health_score_v2_security_ds.datasource | 13 ++ .../libs/tinybird/pipes/health_score_v2.pipe | 56 +++++++++ .../pipes/health_score_v2_development.pipe | 108 +++++++++++++++++ .../pipes/health_score_v2_impact.pipe | 25 ++++ .../pipes/health_score_v2_lifecycle.pipe | 83 +++++++++++++ .../pipes/health_score_v2_maintainer.pipe | 105 +++++++++++++++++ .../pipes/health_score_v2_security.pipe | 85 ++++++++++++++ .../tinybird/pipes/project_insights_copy.pipe | 111 +++++++----------- 13 files changed, 605 insertions(+), 67 deletions(-) create mode 100644 services/libs/tinybird/datasources/health_score_v2_development_ds.datasource create mode 100644 services/libs/tinybird/datasources/health_score_v2_impact_ds.datasource create mode 100644 services/libs/tinybird/datasources/health_score_v2_lifecycle_ds.datasource create mode 100644 services/libs/tinybird/datasources/health_score_v2_maintainer_ds.datasource create mode 100644 services/libs/tinybird/datasources/health_score_v2_repo_copy_ds.datasource create mode 100644 services/libs/tinybird/datasources/health_score_v2_security_ds.datasource create mode 100644 services/libs/tinybird/pipes/health_score_v2.pipe create mode 100644 services/libs/tinybird/pipes/health_score_v2_development.pipe create mode 100644 services/libs/tinybird/pipes/health_score_v2_impact.pipe create mode 100644 services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe create mode 100644 services/libs/tinybird/pipes/health_score_v2_maintainer.pipe create mode 100644 services/libs/tinybird/pipes/health_score_v2_security.pipe diff --git a/services/libs/tinybird/datasources/health_score_v2_development_ds.datasource b/services/libs/tinybird/datasources/health_score_v2_development_ds.datasource new file mode 100644 index 0000000000..b93e61bf26 --- /dev/null +++ b/services/libs/tinybird/datasources/health_score_v2_development_ds.datasource @@ -0,0 +1,13 @@ +DESCRIPTION > + - `health_score_v2_development_ds` holds the per-repo Development Activity category score (0-25) + for Health Score v2. Populated by `health_score_v2_development.pipe`. + - `repoUrl` is the repository URL — the join key back to `repositories`. + - `developmentActivityScoreV2` — NULL when covered sub-signal weight is <40% of the 25pt max + (spec Layer 1 graceful degradation), otherwise the rescaled 0-25 score. + +SCHEMA > + `repoUrl` String, + `developmentActivityScoreV2` Nullable(UInt8) + +ENGINE MergeTree +ENGINE_SORTING_KEY repoUrl diff --git a/services/libs/tinybird/datasources/health_score_v2_impact_ds.datasource b/services/libs/tinybird/datasources/health_score_v2_impact_ds.datasource new file mode 100644 index 0000000000..c238839cf4 --- /dev/null +++ b/services/libs/tinybird/datasources/health_score_v2_impact_ds.datasource @@ -0,0 +1,13 @@ +DESCRIPTION > + - `health_score_v2_impact_ds` holds the per-repo raw Impact Score (0-100 float, pre-rounding) + for Health Score v2. Populated by `health_score_v2_impact.pipe`. + - `repoUrl` is the repository URL — the join key back to `repositories`. + - `impactScoreRaw` — MAX(packages.impact) * 100 over packages published by this repo. NULL when + the repo has no linked packages with a known impact value. + +SCHEMA > + `repoUrl` String, + `impactScoreRaw` Nullable(Float64) + +ENGINE MergeTree +ENGINE_SORTING_KEY repoUrl diff --git a/services/libs/tinybird/datasources/health_score_v2_lifecycle_ds.datasource b/services/libs/tinybird/datasources/health_score_v2_lifecycle_ds.datasource new file mode 100644 index 0000000000..fb1159bf18 --- /dev/null +++ b/services/libs/tinybird/datasources/health_score_v2_lifecycle_ds.datasource @@ -0,0 +1,13 @@ +DESCRIPTION > + - `health_score_v2_lifecycle_ds` holds the per-repo Lifecycle state for Health Score v2. + Populated by `health_score_v2_lifecycle.pipe`. + - `repoUrl` is the repository URL — the join key back to `repositories`. + - `lifecycleLabelV2` — one of active/stable/declining/abandoned/archived, per the spec's + 5-branch decision tree. + +SCHEMA > + `repoUrl` String, + `lifecycleLabelV2` String + +ENGINE MergeTree +ENGINE_SORTING_KEY repoUrl diff --git a/services/libs/tinybird/datasources/health_score_v2_maintainer_ds.datasource b/services/libs/tinybird/datasources/health_score_v2_maintainer_ds.datasource new file mode 100644 index 0000000000..63fe235610 --- /dev/null +++ b/services/libs/tinybird/datasources/health_score_v2_maintainer_ds.datasource @@ -0,0 +1,13 @@ +DESCRIPTION > + - `health_score_v2_maintainer_ds` holds the per-repo Maintainer Health category score (0-40) + for Health Score v2. Populated by `health_score_v2_maintainer.pipe`. + - `repoUrl` is the repository URL — the join key back to `repositories`. + - `maintainerHealthScoreV2` — NULL when covered sub-signal weight is <40% of the 40pt max + (spec Layer 1 graceful degradation), otherwise the rescaled 0-40 score. + +SCHEMA > + `repoUrl` String, + `maintainerHealthScoreV2` Nullable(UInt8) + +ENGINE MergeTree +ENGINE_SORTING_KEY repoUrl diff --git a/services/libs/tinybird/datasources/health_score_v2_repo_copy_ds.datasource b/services/libs/tinybird/datasources/health_score_v2_repo_copy_ds.datasource new file mode 100644 index 0000000000..3f4613c16a --- /dev/null +++ b/services/libs/tinybird/datasources/health_score_v2_repo_copy_ds.datasource @@ -0,0 +1,34 @@ +DESCRIPTION > + - `health_score_v2_repo_copy_ds` contains the per-repo Akrites Health Score v2 breakdown. + - Populated by `health_score_v2.pipe` copy pipe. + - Health Score v2 is computed entirely from repo-level/CDP data (maintainer activity, security posture, + development activity) — no package data, per the Health Score v2 methodology + (https://linuxfoundation-dxwx.dsp.so/4ezMyw7n-insights-projects-new-scores). Impact Score is the + one exception — package-mediated by design, computed here as MAX(packages.impact) over the repo's + own published packages (via packageRepos), not an average over unrelated packages. + - `repoUrl` is the repository URL — the join key back to `repositories`/`repos`. + - `maintainerHealthScoreV2` (0-40) — bus factor, org diversity, maintainer responsiveness. + - `securitySupplyChainScoreV2` (0-35) — open vulnerabilities, OpenSSF Scorecard, security practices, + dependency health (checked against the repo's own published packages' dependencies, not an average + across unrelated packages), supply chain integrity (hardcoded 0 — provenance/2FA data not yet piped). + - `developmentActivityScoreV2` (0-25) — release cadence, commit activity, issue resolution, PR merge health. + - `healthScoreV2` (0-100) is the sum of the three categories above, clamped to 100. + - `lifecycleLabelV2` — per-repo lifecycle state (active/stable/declining/abandoned/archived), computed + via the spec's 5-branch decision tree (archived flag > abandoned > declining > stable > active, + first match wins). Project-level rollup uses best-state-wins precedence in project_insights_copy.pipe. + - `impactScore` (0-100) — MAX(packages.impact) * 100 over packages published by this repo. NULL when + the repo has no linked packages. + - No graceful-degradation/signal-coverage redistribution yet — a repo with zero signal in a category + scores 0 there rather than having points redistributed from available categories. + +SCHEMA > + `repoUrl` String, + `maintainerHealthScoreV2` Nullable(UInt8), + `securitySupplyChainScoreV2` Nullable(UInt8), + `developmentActivityScoreV2` Nullable(UInt8), + `healthScoreV2` Nullable(UInt8), + `lifecycleLabelV2` Nullable(String), + `impactScore` Nullable(UInt8) + +ENGINE MergeTree +ENGINE_SORTING_KEY repoUrl diff --git a/services/libs/tinybird/datasources/health_score_v2_security_ds.datasource b/services/libs/tinybird/datasources/health_score_v2_security_ds.datasource new file mode 100644 index 0000000000..b049414c74 --- /dev/null +++ b/services/libs/tinybird/datasources/health_score_v2_security_ds.datasource @@ -0,0 +1,13 @@ +DESCRIPTION > + - `health_score_v2_security_ds` holds the per-repo Security & Supply Chain category score (0-35) + for Health Score v2. Populated by `health_score_v2_security.pipe`. + - `repoUrl` is the repository URL — the join key back to `repositories`. + - `securitySupplyChainScoreV2` — NULL when covered sub-signal weight is <40% of the 35pt max + (spec Layer 1 graceful degradation), otherwise the rescaled 0-35 score. + +SCHEMA > + `repoUrl` String, + `securitySupplyChainScoreV2` Nullable(UInt8) + +ENGINE MergeTree +ENGINE_SORTING_KEY repoUrl diff --git a/services/libs/tinybird/pipes/health_score_v2.pipe b/services/libs/tinybird/pipes/health_score_v2.pipe new file mode 100644 index 0000000000..5c937510f7 --- /dev/null +++ b/services/libs/tinybird/pipes/health_score_v2.pipe @@ -0,0 +1,56 @@ +DESCRIPTION > + - Combines the 5 independently-materialized Health Score v2 category/label datasources into the + final per-repo row: Maintainer Health (40) + Security & Supply Chain (35) + Development + Activity (25) + Lifecycle label + Impact Score, per the Health Score v2 methodology + (https://linuxfoundation-dxwx.dsp.so/4ezMyw7n-insights-projects-new-scores). + - Split into 5 category pipes (health_score_v2_maintainer/security/development/lifecycle/impact.pipe) + on 2026-07-22, each with its own COPY_SCHEDULE and small materialized *_ds datasource, because + the previous single-pipe 6-node version re-inlined all upstream NODEs into one query per copy + job — Tinybird does not materialize intermediate NODEs, so `repositories` and other base tables + were being re-scanned once per category, and the 6-node version's runtime grew from ~13min to + 20-28+ minutes across repeated runs even with an equivalent-cost SQL change (see git history). + This node now does a single cheap LEFT JOIN over 5 small pre-computed tables instead. + - Applies spec Layer 2 graceful degradation across categories: healthScoreV2 = SUM(available + category subtotals) * (100 / SUM(available category max weights)) — each category datasource + already carries NULL when its own coverage fell below 40% (Layer 1, done per-category pipe). + If all three categories are unavailable, healthScoreV2 itself becomes NULL, per spec, rather + than a fabricated 0. Impact stays NULL (not defaulted to 0) when the repo has no packages. + +NODE health_score_v2_results +SQL > + SELECT + repoUrl, + maintainerHealthScoreV2, + securitySupplyChainScoreV2, + developmentActivityScoreV2, + if( + coveredCategoryWeight = 0, + NULL, + toNullable(toUInt8(round(least(100.0, availableCategorySum * (100.0 / coveredCategoryWeight))))) + ) AS healthScoreV2, + lifecycleLabelV2, + if(impactScoreRaw IS NULL, NULL, toNullable(toUInt8(round(least(impactScoreRaw, 100))))) AS impactScore + FROM ( + SELECT + base.repoUrl AS repoUrl, + m.maintainerHealthScoreV2 AS maintainerHealthScoreV2, + s.securitySupplyChainScoreV2 AS securitySupplyChainScoreV2, + d.developmentActivityScoreV2 AS developmentActivityScoreV2, + (coalesce(m.maintainerHealthScoreV2,0) + coalesce(s.securitySupplyChainScoreV2,0) + coalesce(d.developmentActivityScoreV2,0)) AS availableCategorySum, + (if(m.maintainerHealthScoreV2 IS NOT NULL, 40, 0) + + if(s.securitySupplyChainScoreV2 IS NOT NULL, 35, 0) + + if(d.developmentActivityScoreV2 IS NOT NULL, 25, 0)) AS coveredCategoryWeight, + coalesce(l.lifecycleLabelV2, 'active') AS lifecycleLabelV2, + i.impactScoreRaw AS impactScoreRaw + FROM (SELECT DISTINCT url AS repoUrl FROM repositories WHERE deletedAt IS NULL) AS base + LEFT JOIN health_score_v2_maintainer_ds AS m ON m.repoUrl = base.repoUrl + LEFT JOIN health_score_v2_security_ds AS s ON s.repoUrl = base.repoUrl + LEFT JOIN health_score_v2_development_ds AS d ON d.repoUrl = base.repoUrl + LEFT JOIN health_score_v2_lifecycle_ds AS l ON l.repoUrl = base.repoUrl + LEFT JOIN health_score_v2_impact_ds AS i ON i.repoUrl = base.repoUrl + ) + +TYPE COPY +TARGET_DATASOURCE health_score_v2_repo_copy_ds +COPY_MODE replace +COPY_SCHEDULE 30 2 * * * diff --git a/services/libs/tinybird/pipes/health_score_v2_development.pipe b/services/libs/tinybird/pipes/health_score_v2_development.pipe new file mode 100644 index 0000000000..87f5b50065 --- /dev/null +++ b/services/libs/tinybird/pipes/health_score_v2_development.pipe @@ -0,0 +1,108 @@ +DESCRIPTION > + Development Activity category (max 25 pts): release cadence (8), commit activity (5), issue + resolution (7), PR merge health (5). Release cadence uses distinct release DAYS (not raw + publishedAt timestamps) because monorepo projects publish many packages within seconds of each + other, which would otherwise read as near-zero days between releases. Commit activity counts + `authored-commit` across all platforms (not just platform='git') since GitHub-sourced commits are + tagged platform='github', not 'git', in activityRelations. Falls back to repos.lastCommitAt + (4 pts if <3mo, 2 if <12mo, else 0) when a repo has zero rows in activityRelations at all — + per spec, and because only ~30K of 3.3M repos have any git activity ingested, vs. ~6M rows + (most repos) having lastCommitAt from GitHub enrichment — the fallback covers the vast majority. + - Split out of health_score_v2.pipe into its own copy pipe (2026-07-22) — see + health_score_v2_maintainer.pipe description for why. + +NODE health_score_v2_development_calc +SQL > + SELECT + repoUrl, + if(coveredWeight / 25.0 < 0.4, NULL, toNullable(toUInt8(round(least(25.0, rawScore * (25.0 / coveredWeight)))))) AS developmentActivityScoreV2 + FROM ( + SELECT + repoUrl, + (releaseCadenceAvailable * releaseCadenceScore + commitActivityScore + + issueResolutionAvailable * issueResolutionScore + prMergeAvailable * prMergeScore) AS rawScore, + (releaseCadenceAvailable * 8 + 5 + issueResolutionAvailable * 7 + prMergeAvailable * 5) AS coveredWeight + FROM ( + SELECT + rp.repoUrl AS repoUrl, + (rs.repoUrl != '') AS releaseCadenceAvailable, + multiIf( + rs.daysSinceLatest < 90 AND rs.daysBetweenRecent < 90, 8, + rs.daysSinceLatest < 180, 6, + rs.daysSinceLatest < 365, 4, + rs.daysSinceLatest < 730, 2, + 0 + ) AS releaseCadenceScore, + -- commit activity is never `blocked`: it has its own spec-mandated fallback to + -- repos.lastCommitAt, so it always contributes its full 5pt weight to coverage + multiIf( + c.repoUrl != '', + multiIf(c.commitsLast6m >= 50, 5, c.commitsLast6m >= 20, 4, c.commitsLast6m >= 5, 3, c.commitsLast6m >= 1, 1, 0), + rp.lastCommitAt > now() - INTERVAL 3 MONTH, 4, + rp.lastCommitAt > now() - INTERVAL 12 MONTH, 2, + 0 + ) AS commitActivityScore, + (ist.repoUrl != '') AS issueResolutionAvailable, + (multiIf(ist.opened12m = 0, 0, (ist.closed12m / ist.opened12m) >= 0.8, 4, (ist.closed12m / ist.opened12m) >= 0.5, 2, 0) + + multiIf(ist.medianCloseS < 604800, 3, ist.medianCloseS < 2592000, 2, 0)) AS issueResolutionScore, + (prs.repoUrl != '') AS prMergeAvailable, + (multiIf((prs.merged12m + prs.closedUnmerged12m) = 0, 0, (prs.merged12m / (prs.merged12m + prs.closedUnmerged12m)) >= 0.7, 3, (prs.merged12m / (prs.merged12m + prs.closedUnmerged12m)) >= 0.4, 1, 0) + + multiIf(prs.medianMergeS < 604800, 2, prs.medianMergeS < 2592000, 1, 0)) AS prMergeScore + FROM ( + SELECT base.url AS repoUrl, rc.lastCommitAt AS lastCommitAt + FROM (SELECT DISTINCT url FROM repositories WHERE deletedAt IS NULL) AS base + LEFT JOIN ( + SELECT url, argMax(lastCommitAt, updatedAt) AS lastCommitAt + FROM repos GROUP BY url + ) AS rc ON rc.url = base.url + ) AS rp + LEFT JOIN ( + SELECT + repoUrl, + dateDiff('day', top2[1], today()) AS daysSinceLatest, + if(length(top2) >= 2, dateDiff('day', top2[2], top2[1]), 9999) AS daysBetweenRecent + FROM ( + SELECT + r.url AS repoUrl, + arraySlice(arraySort(x -> -toInt64(x), arrayDistinct(groupArray(rd.releaseDay))), 1, 2) AS top2 + FROM repos r + INNER JOIN packageRepos pr ON pr.repoId = r.id + INNER JOIN ( + SELECT DISTINCT packageId, toDate(publishedAt) AS releaseDay + FROM versions WHERE publishedAt IS NOT NULL + ) rd ON rd.packageId = pr.packageId + GROUP BY r.url + ) + ) AS rs ON rs.repoUrl = rp.repoUrl + LEFT JOIN ( + SELECT channel AS repoUrl, + countIf(timestamp > now() - INTERVAL 6 MONTH) AS commitsLast6m + FROM activityRelations_deduplicated_cleaned_bucket_union + WHERE type = 'authored-commit' + GROUP BY channel + ) AS c ON c.repoUrl = rp.repoUrl + LEFT JOIN ( + SELECT channel AS repoUrl, + countIf(closedAt IS NOT NULL) AS closed12m, + count() AS opened12m, + quantileIf(0.5)(closedInSeconds, closedAt > now() - INTERVAL 12 MONTH) AS medianCloseS + FROM issues_analyzed + WHERE openedAt > now() - INTERVAL 12 MONTH + GROUP BY channel + ) AS ist ON ist.repoUrl = rp.repoUrl + LEFT JOIN ( + SELECT channel AS repoUrl, + countIf(mergedAt IS NOT NULL) AS merged12m, + countIf(closedAt IS NOT NULL AND mergedAt IS NULL) AS closedUnmerged12m, + quantileIf(0.5)(mergedInSeconds, mergedAt > now() - INTERVAL 12 MONTH) AS medianMergeS + FROM pull_requests_analyzed + WHERE openedAt > now() - INTERVAL 12 MONTH + GROUP BY channel + ) AS prs ON prs.repoUrl = rp.repoUrl + ) + ) + +TYPE COPY +TARGET_DATASOURCE health_score_v2_development_ds +COPY_MODE replace +COPY_SCHEDULE 10 2 * * * diff --git a/services/libs/tinybird/pipes/health_score_v2_impact.pipe b/services/libs/tinybird/pipes/health_score_v2_impact.pipe new file mode 100644 index 0000000000..ccd8b5a01d --- /dev/null +++ b/services/libs/tinybird/pipes/health_score_v2_impact.pipe @@ -0,0 +1,25 @@ +DESCRIPTION > + Impact Score (max 100, package-mediated — the one score meant to depend on packages) = + ROUND(100 * MAX(packages.impact)) over packages published by this repo, via + repos -> packageRepos -> ossPackages_enriched_ds. Per spec this is MAX (not AVG) and joins via + packageRepos/repos (repo's own published packages), NOT packageDownloads (a looser, + undocumented linkage that caused the same averaging bug in Health Score v2 before it was fixed + to be repo-derived). NULL when the repo has no linked packages with a known impact value. + - Split out of health_score_v2.pipe into its own copy pipe (2026-07-22) — see + health_score_v2_maintainer.pipe description for why. + +NODE health_score_v2_impact_calc +SQL > + SELECT + base.url AS repoUrl, + max(toFloat64OrNull(pk.impact)) * 100 AS impactScoreRaw + FROM (SELECT DISTINCT url FROM repositories WHERE deletedAt IS NULL) AS base + INNER JOIN repos r ON r.url = base.url + INNER JOIN packageRepos pr ON pr.repoId = r.id + INNER JOIN ossPackages_enriched_ds pk ON pk.id = pr.packageId + GROUP BY base.url + +TYPE COPY +TARGET_DATASOURCE health_score_v2_impact_ds +COPY_MODE replace +COPY_SCHEDULE 20 2 * * * diff --git a/services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe b/services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe new file mode 100644 index 0000000000..13c1de2bc2 --- /dev/null +++ b/services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe @@ -0,0 +1,83 @@ +DESCRIPTION > + Per-repo Lifecycle state, computed via the spec's 5-branch decision tree (first match wins): + archived (repos.archived) > abandoned (no commits in 18mo AND no issue/PR activity in 18mo) > + declining (commits down >50% vs prior 6mo AND issues opened up vs prior 6mo) > stable (release + within 12mo, <50 open issues, no open critical vulns, commits down >50% vs prior 6mo) > active + (fallback). Project-level rollup (best-state-wins) happens in project_insights_copy.pipe. + - Split out of health_score_v2.pipe into its own copy pipe (2026-07-22) — see + health_score_v2_maintainer.pipe description for why. + +NODE health_score_v2_lifecycle_calc +SQL > + SELECT + r.url AS repoUrl, + multiIf( + r.archived = 1, + 'archived', + (r.lastCommitAt < now() - INTERVAL 18 MONTH) + AND coalesce(w.issuesInWindow18m, 0) + coalesce(p.prsInWindow18m, 0) = 0, + 'abandoned', + coalesce(c.commitsLast6m, 0) < coalesce(c.commitsPrior6m, 0) * 0.5 + AND coalesce(w.issuesOpenedLast6m, 0) > coalesce(w.issuesOpenedPrior6m, 0), + 'declining', + (rl.latestReleaseDay > today() - 365) + AND coalesce(n.issuesOpenNow, 0) < 50 + AND coalesce(v.openCriticals, 0) = 0 + AND coalesce(c.commitsLast6m, 0) < coalesce(c.commitsPrior6m, 0) * 0.5, + 'stable', + 'active' + ) AS lifecycleLabelV2 + FROM ( + SELECT base.url AS url, base.archived AS archived, rc.lastCommitAt AS lastCommitAt + FROM (SELECT DISTINCT url, archived FROM repositories WHERE deletedAt IS NULL) AS base + LEFT JOIN ( + SELECT url, argMax(lastCommitAt, updatedAt) AS lastCommitAt + FROM repos GROUP BY url + ) AS rc ON rc.url = base.url + ) AS r + LEFT JOIN ( + SELECT channel AS repoUrl, + countIf(timestamp > now() - INTERVAL 6 MONTH) AS commitsLast6m, + countIf(timestamp <= now() - INTERVAL 6 MONTH AND timestamp > now() - INTERVAL 12 MONTH) AS commitsPrior6m + FROM activityRelations_deduplicated_cleaned_bucket_union + WHERE type = 'authored-commit' + GROUP BY channel + ) AS c ON c.repoUrl = r.url + LEFT JOIN ( + SELECT channel AS repoUrl, countIf(closedAt IS NULL) AS issuesOpenNow + FROM issues_analyzed + GROUP BY channel + ) AS n ON n.repoUrl = r.url + LEFT JOIN ( + SELECT channel AS repoUrl, + countIf(openedAt > now() - INTERVAL 6 MONTH) AS issuesOpenedLast6m, + countIf(openedAt <= now() - INTERVAL 6 MONTH AND openedAt > now() - INTERVAL 12 MONTH) AS issuesOpenedPrior6m, + countIf(openedAt > now() - INTERVAL 18 MONTH) AS issuesInWindow18m + FROM issues_analyzed + GROUP BY channel + ) AS w ON w.repoUrl = r.url + LEFT JOIN ( + SELECT channel AS repoUrl, countIf(openedAt > now() - INTERVAL 18 MONTH) AS prsInWindow18m + FROM pull_requests_analyzed + GROUP BY channel + ) AS p ON p.repoUrl = r.url + LEFT JOIN ( + SELECT repoUrl, countIf(status = 'OPEN' AND severity = 'CRITICAL') AS openCriticals + FROM vulnerabilities + GROUP BY repoUrl + ) AS v ON v.repoUrl = r.url + LEFT JOIN ( + SELECT r2.url AS repoUrl, max(rd.releaseDay) AS latestReleaseDay + FROM repos r2 + INNER JOIN packageRepos pr ON pr.repoId = r2.id + INNER JOIN ( + SELECT DISTINCT packageId, toDate(publishedAt) AS releaseDay + FROM versions WHERE publishedAt IS NOT NULL + ) rd ON rd.packageId = pr.packageId + GROUP BY r2.url + ) AS rl ON rl.repoUrl = r.url + +TYPE COPY +TARGET_DATASOURCE health_score_v2_lifecycle_ds +COPY_MODE replace +COPY_SCHEDULE 15 2 * * * diff --git a/services/libs/tinybird/pipes/health_score_v2_maintainer.pipe b/services/libs/tinybird/pipes/health_score_v2_maintainer.pipe new file mode 100644 index 0000000000..a078c72737 --- /dev/null +++ b/services/libs/tinybird/pipes/health_score_v2_maintainer.pipe @@ -0,0 +1,105 @@ +DESCRIPTION > + Maintainer Health category (max 40 pts): bus factor (18), org diversity (7), maintainer + responsiveness (15). Graceful degradation (spec Layer 1 + 2): bus factor and org diversity are + `available` (real data found) or `blocked` (no matching rows at all, weight excluded from both + the numerator and rescale denominator) — a real score of 0 is distinct from blocked. + Responsiveness follows Joana's 2026-07-23 review (per-platform, not a blanket rule): + - GitHub/GitLab (and any other non-Gerrit host): scores 0/15 when there are no PRs or issues + in the window — that's real signal (unresponsive), not a data gap, so it's never `blocked`. + - Gerrit (review.opendev.org, gerrit.*): Gerrit has no issue tracker, so only PR/changeset + response time counts. Scores 0/15 when there's no changeset data; issue-response is excluded + from the calculation entirely rather than penalizing repos for a concept that doesn't exist there. + - Repos marked `excluded` in the repositories table (e.g. .github meta repos where no PRs or + issues are ever expected) are `blocked` for responsiveness regardless of platform — never + scored 0 for an absence that was never expected to be filled. + If available sub-signals cover <40% of the 40pt max, the category is `unavailable` (NULL), not + a fabricated 0. + - Split out of health_score_v2.pipe into its own copy pipe (2026-07-22) so this join runs + independently instead of being re-inlined into every other category's query plan — the combined + 6-node single-pipe version was taking 20-28+ minutes per run (vs ~13min pre-split) because + Tinybird inlines all upstream NODEs into one query per copy job, causing `repositories` and other + base tables to be re-scanned once per category. health_score_v2_results.pipe now reads this via + a cheap LEFT JOIN over the small materialized result instead. + +NODE health_score_v2_maintainer_calc +SQL > + SELECT + base.repoUrl AS repoUrl, + if(coveredWeight / 40.0 < 0.4, NULL, toNullable(toUInt8(round(least(40.0, rawScore * (40.0 / coveredWeight)))))) AS maintainerHealthScoreV2 + FROM ( + SELECT + repoUrl, + (busFactorScore + orgDiversityScore + responsivenessScore) AS rawScore, + (busFactorAvailable * 18 + orgDiversityAvailable * 7 + responsivenessAvailable * 15) AS coveredWeight + FROM ( + SELECT + allRepos.repoUrl AS repoUrl, + bf.repoUrl != '' AS busFactorAvailable, + multiIf(bf.busFactorCount >= 5, 18, bf.busFactorCount >= 3, 15, bf.busFactorCount = 2, 6, bf.busFactorCount = 1, 3, 0) AS busFactorScore, + od.repoUrl != '' AS orgDiversityAvailable, + multiIf(coalesce(od.orgCount, 0) >= 3, 7, coalesce(od.orgCount, 0) = 2, 5, coalesce(od.orgCount, 0) = 1, 2, 0) AS orgDiversityScore, + multiIf( + allRepos.isExcluded, false, + allRepos.isGerrit, r.repoUrl != '', + true + ) AS responsivenessAvailable, + multiIf( + allRepos.isGerrit, + -- Gerrit: PR/changeset response time only; issues don't exist here, never penalize their absence + multiIf( + r.repoUrl = '', 0, + r.medianPrResponseS < 86400, 15, + r.medianPrResponseS < 259200, 12, + r.medianPrResponseS < 604800, 9, + r.medianPrResponseS < 2592000, 5, + r.medianPrResponseS < 7776000, 2, + 0 + ), + -- GitHub/GitLab/other: no PR+issue data at all is a real 0 (unresponsive), not a data gap + multiIf( + r.repoUrl = '' AND ir.repoUrl = '', 0, + coalesce((r.medianPrResponseS + ir.medianIssueResponseS) / 2, r.medianPrResponseS, ir.medianIssueResponseS) < 86400, 15, + coalesce((r.medianPrResponseS + ir.medianIssueResponseS) / 2, r.medianPrResponseS, ir.medianIssueResponseS) < 259200, 12, + coalesce((r.medianPrResponseS + ir.medianIssueResponseS) / 2, r.medianPrResponseS, ir.medianIssueResponseS) < 604800, 9, + coalesce((r.medianPrResponseS + ir.medianIssueResponseS) / 2, r.medianPrResponseS, ir.medianIssueResponseS) < 2592000, 5, + coalesce((r.medianPrResponseS + ir.medianIssueResponseS) / 2, r.medianPrResponseS, ir.medianIssueResponseS) < 7776000, 2, + 0 + ) + ) AS responsivenessScore + FROM ( + SELECT DISTINCT url AS repoUrl, + (domain(url) = 'review.opendev.org' OR domain(url) LIKE 'gerrit.%') AS isGerrit, + excluded AS isExcluded + FROM repositories WHERE deletedAt IS NULL + ) AS allRepos + LEFT JOIN ( + SELECT repoUrl, count(DISTINCT memberId) AS busFactorCount + FROM maintainers_roles_copy_ds + WHERE endDate = '1970-01-01 00:00:00' OR endDate > now() - INTERVAL 12 MONTH + GROUP BY repoUrl + ) AS bf ON bf.repoUrl = allRepos.repoUrl + LEFT JOIN ( + SELECT channel AS repoUrl, count(DISTINCT organizationId) AS orgCount + FROM activityRelations + WHERE timestamp > now() - INTERVAL 12 MONTH AND organizationId != '' + GROUP BY channel + ) AS od ON od.repoUrl = allRepos.repoUrl + LEFT JOIN ( + SELECT channel AS repoUrl, quantile(0.5)(reviewedInSeconds) AS medianPrResponseS + FROM pull_requests_analyzed + WHERE openedAt > now() - INTERVAL 12 MONTH AND reviewedInSeconds IS NOT NULL + GROUP BY channel + ) AS r ON r.repoUrl = allRepos.repoUrl + LEFT JOIN ( + SELECT channel AS repoUrl, quantile(0.5)(respondedInSeconds) AS medianIssueResponseS + FROM issues_analyzed + WHERE openedAt > now() - INTERVAL 12 MONTH AND respondedInSeconds IS NOT NULL + GROUP BY channel + ) AS ir ON ir.repoUrl = allRepos.repoUrl + ) + ) AS base + +TYPE COPY +TARGET_DATASOURCE health_score_v2_maintainer_ds +COPY_MODE replace +COPY_SCHEDULE 0 2 * * * diff --git a/services/libs/tinybird/pipes/health_score_v2_security.pipe b/services/libs/tinybird/pipes/health_score_v2_security.pipe new file mode 100644 index 0000000000..7798bf3572 --- /dev/null +++ b/services/libs/tinybird/pipes/health_score_v2_security.pipe @@ -0,0 +1,85 @@ +DESCRIPTION > + Security & Supply Chain category (max 35 pts): open vulnerabilities (10), OpenSSF Scorecard (7, + blocked for repos with no repos-table row — effectively non-GitHub hosts), security practices + (spec max 8, but capped here at 7 achievable — the spec's 6th bullet, security_contact_email, + has no corresponding column anywhere in our GitHub enrichment data, so it's permanently omitted + like Supply Chain Integrity below; blocked entirely when repos-table row missing), dependency + health (5, blocked when the repo has no published packages so vulnerability exposure is unknown), + supply chain integrity (5, permanently blocked — hardcoded per spec, provenance/2FA data not yet + in the pipeline). Graceful degradation (spec Layer 1+2): category_subtotal = + SUM(available_sub_scores) * (35 / SUM(available_sub_max_weights)); category is `unavailable` + (NULL) when covered weight is <40% of 35. + - Split out of health_score_v2.pipe into its own copy pipe (2026-07-22) — see + health_score_v2_maintainer.pipe description for why. + +NODE health_score_v2_security_calc +SQL > + SELECT + repoUrl, + if(coveredWeight / 35.0 < 0.4, NULL, toNullable(toUInt8(round(least(35.0, rawScore * (35.0 / coveredWeight)))))) AS securitySupplyChainScoreV2 + FROM ( + SELECT + repoUrl, + (openVulnScore + scorecardAvailable * scorecardScorePts + securityPracticesAvailable * securityPracticesScore + + dependencyHealthAvailable * dependencyHealthScore) AS rawScore, + (10 + scorecardAvailable * 7 + securityPracticesAvailable * 7 + dependencyHealthAvailable * 5) AS coveredWeight + FROM ( + SELECT + allRepos.url AS repoUrl, + greatest(0, 10 - 6*coalesce(vc.openCriticals,0) - 3*coalesce(vc.openHighs,0) - 1*coalesce(vc.openModerates,0)) AS openVulnScore, + toUInt8(round(least(toFloat64OrZero(rd.scorecardScore), 10) * 0.7)) AS scorecardScorePts, + (rd.url != '') AS scorecardAvailable, + (2*coalesce(rd.securityPolicyEnabled,0) + 2*coalesce(rd.branchProtectionEnabled,0) + + if(coalesce(rd.branchProtectionRequiredReviews,0) >= 1, 1, 0) + + coalesce(rd.branchProtectionRequiresStatusChecks,0) + + if(coalesce(rd.branchProtectionAllowsForcePush,1) = 0, 1, 0)) AS securityPracticesScore, + (rd.url != '') AS securityPracticesAvailable, + multiIf(coalesce(dh.vulnerableDeps,0) = 0, 5, dh.vulnerableDeps <= 2, 3, dh.vulnerableDeps <= 5, 1, 0) AS dependencyHealthScore, + (pkgs.repoUrl != '') AS dependencyHealthAvailable + FROM (SELECT DISTINCT url FROM repositories WHERE deletedAt IS NULL) AS allRepos + LEFT JOIN ( + SELECT url, + argMax(scorecardScore, updatedAt) AS scorecardScore, + argMax(securityPolicyEnabled, updatedAt) AS securityPolicyEnabled, + argMax(branchProtectionEnabled, updatedAt) AS branchProtectionEnabled, + argMax(branchProtectionRequiredReviews, updatedAt) AS branchProtectionRequiredReviews, + argMax(branchProtectionRequiresStatusChecks, updatedAt) AS branchProtectionRequiresStatusChecks, + argMax(branchProtectionAllowsForcePush, updatedAt) AS branchProtectionAllowsForcePush + FROM repos + GROUP BY url + ) AS rd ON rd.url = allRepos.url + LEFT JOIN ( + SELECT repoUrl, + countIf(status = 'OPEN' AND severity = 'CRITICAL') AS openCriticals, + countIf(status = 'OPEN' AND severity = 'HIGH') AS openHighs, + countIf(status = 'OPEN' AND severity = 'MODERATE') AS openModerates + FROM vulnerabilities + GROUP BY repoUrl + ) AS vc ON vc.repoUrl = allRepos.url + LEFT JOIN ( + SELECT r.url AS repoUrl, count(DISTINCT pd.dependsOnId) AS vulnerableDeps + FROM repos r + INNER JOIN packageRepos pr ON pr.repoId = r.id + INNER JOIN packageDependencies pd ON pd.packageId = pr.packageId + WHERE pd.dependsOnId IN ( + SELECT ap.packageId FROM advisoryPackages ap + INNER JOIN advisories a ON a.id = ap.advisoryId + WHERE a.severity IN ('HIGH', 'CRITICAL') + ) + GROUP BY r.url + ) AS dh ON dh.repoUrl = allRepos.url + LEFT JOIN ( + -- whether the repo has ANY published packages at all — determines dependency-health + -- availability (0 vulnerable deps out of 0 tracked deps is unknown, not "healthy") + SELECT r.url AS repoUrl, count() AS pkgCount + FROM repos r + INNER JOIN packageRepos pr ON pr.repoId = r.id + GROUP BY r.url + ) AS pkgs ON pkgs.repoUrl = allRepos.url + ) + ) + +TYPE COPY +TARGET_DATASOURCE health_score_v2_security_ds +COPY_MODE replace +COPY_SCHEDULE 5 2 * * * diff --git a/services/libs/tinybird/pipes/project_insights_copy.pipe b/services/libs/tinybird/pipes/project_insights_copy.pipe index 6bf3965b59..3fe4fe9f74 100644 --- a/services/libs/tinybird/pipes/project_insights_copy.pipe +++ b/services/libs/tinybird/pipes/project_insights_copy.pipe @@ -85,36 +85,33 @@ SQL > WHERE timestamp >= now() - INTERVAL 730 DAY AND timestamp <= now() GROUP BY segmentId -NODE project_insights_copy_project_purls +NODE project_insights_copy_health_v2_project DESCRIPTION > - Distinct package purls linked to each project, via packageDownloads (the established project<->package bridge) - -SQL > - SELECT DISTINCT insightsProjectId, purl - FROM packageDownloads - WHERE insightsProjectId != '' AND purl != '' - -NODE project_insights_copy_project_akrites -DESCRIPTION > - Akrites methodology rollup (lifecycle, health v2, impact) aggregated from ossPackages_enriched_ds up to project level. - Lifecycle uses best-state-wins precedence: active > stable > declining > abandoned > archived. - Health v2 and impact are averaged across the project's linked packages. + Lifecycle, Health Score v2, and Impact Score all rolled up to project level from the pre-computed + per-repo values in health_score_v2_repo_copy_ds (materialized by health_score_v2.pipe — the + expensive per-signal joins run there as their own copy job, not inline here). Per spec section 5/6: + - healthScoreV2: straight mean across the project's enabled, non-excluded repos. + - lifecycleLabelV2: best-state-wins (active > stable > declining > abandoned > archived). + - impactScoreRaw: MAX across the project's repos (each repo's own value is already a MAX over its + published packages) — matches the spec's "MAX(packages.impact) over packages published by any + repo in the project," since max-of-maxes = max-over-all. SQL > SELECT - pp.insightsProjectId AS insightsProjectId, + rep.insightsProjectId AS insightsProjectId, + avg(hv2.healthScoreV2) AS healthScoreV2, arrayElement( arraySort( x -> indexOf(['active', 'stable', 'declining', 'abandoned', 'archived'], x), - groupArray(pkg.lifecycleLabel) + groupArray(hv2.lifecycleLabelV2) ), 1 - ) AS lifecycleLabel, - toUInt8(round(avg(pkg.healthScore))) AS healthScoreV2, - avgOrNull(toFloat64OrNull(pkg.impact)) * 100 AS impactScoreRaw - FROM project_insights_copy_project_purls AS pp - INNER JOIN ossPackages_enriched_ds AS pkg ON pkg.purl = pp.purl - GROUP BY pp.insightsProjectId + ) AS lifecycleLabelV2, + maxOrNull(hv2.impactScore) AS impactScoreRaw + FROM repositories rep FINAL + INNER JOIN health_score_v2_repo_copy_ds hv2 ON hv2.repoUrl = rep.url + WHERE rep.insightsProjectId != '' AND rep.enabled = true AND rep.excluded = false + GROUP BY rep.insightsProjectId NODE project_insights_copy_project_results DESCRIPTION > @@ -152,30 +149,30 @@ SQL > pm.forksPrevious365Days AS forksPrevious365Days, pm.activeContributorsPrevious365Days AS activeContributorsPrevious365Days, pm.activeOrganizationsPrevious365Days AS activeOrganizationsPrevious365Days, - akr.healthScoreV2 AS healthScoreV2, + toUInt8(round(hv2.healthScoreV2)) AS healthScoreV2, multiIf( - akr.healthScoreV2 IS NULL, + hv2.healthScoreV2 IS NULL, NULL, - akr.healthScoreV2 >= 85, + hv2.healthScoreV2 >= 85, 'excellent', - akr.healthScoreV2 >= 70, + hv2.healthScoreV2 >= 70, 'healthy', - akr.healthScoreV2 >= 50, + hv2.healthScoreV2 >= 50, 'fair', - akr.healthScoreV2 >= 30, + hv2.healthScoreV2 >= 30, 'concerning', 'critical' ) AS healthLabel, - akr.lifecycleLabel AS lifecycleLabel, - toUInt8(round(akr.impactScoreRaw)) AS impactScore, + hv2.lifecycleLabelV2 AS lifecycleLabel, + hv2.impactScoreRaw AS impactScore, multiIf( - akr.impactScoreRaw IS NULL, + hv2.impactScoreRaw IS NULL, NULL, - akr.impactScoreRaw >= 85, + hv2.impactScoreRaw >= 85, 'foundational', - akr.impactScoreRaw >= 60, + hv2.impactScoreRaw >= 60, 'major', - akr.impactScoreRaw >= 30, + hv2.impactScoreRaw >= 30, 'moderate', 'minor' ) AS impactLabel @@ -183,7 +180,7 @@ SQL > LEFT JOIN project_insights_copy_dependency_metrics AS dep ON base.slug = dep.slug LEFT JOIN project_insights_copy_achievements AS ach ON base.slug = ach.slug LEFT JOIN project_insights_copy_period_metrics AS pm USING (segmentId) - LEFT JOIN project_insights_copy_project_akrites AS akr ON base.id = akr.insightsProjectId + LEFT JOIN project_insights_copy_health_v2_project AS hv2 ON base.id = hv2.insightsProjectId NODE project_insights_copy_repo_base DESCRIPTION > @@ -233,26 +230,6 @@ SQL > WHERE timestamp >= now() - INTERVAL 730 DAY AND timestamp <= now() GROUP BY channel -NODE project_insights_copy_repo_akrites -DESCRIPTION > - Akrites methodology rollup (lifecycle, health v2, impact) aggregated from ossPackages_enriched_ds up to repo level, matched by repositoryUrl (a repo may host multiple packages, e.g. a monorepo) - -SQL > - SELECT - repositoryUrl, - arrayElement( - arraySort( - x -> indexOf(['active', 'stable', 'declining', 'abandoned', 'archived'], x), - groupArray(lifecycleLabel) - ), - 1 - ) AS lifecycleLabel, - toUInt8(round(avg(healthScore))) AS healthScoreV2, - avgOrNull(toFloat64OrNull(impact)) * 100 AS impactScoreRaw - FROM ossPackages_enriched_ds - WHERE repositoryUrl IS NOT NULL AND repositoryUrl != '' - GROUP BY repositoryUrl - NODE project_insights_copy_repo_results DESCRIPTION > Join all repository insights together @@ -289,37 +266,37 @@ SQL > COALESCE(rm.forksPrevious365Days, 0) AS forksPrevious365Days, COALESCE(rm.activeContributorsPrevious365Days, 0) AS activeContributorsPrevious365Days, COALESCE(rm.activeOrganizationsPrevious365Days, 0) AS activeOrganizationsPrevious365Days, - akr.healthScoreV2 AS healthScoreV2, + toUInt8(round(hv2.healthScoreV2)) AS healthScoreV2, multiIf( - akr.healthScoreV2 IS NULL, + hv2.healthScoreV2 IS NULL, NULL, - akr.healthScoreV2 >= 85, + hv2.healthScoreV2 >= 85, 'excellent', - akr.healthScoreV2 >= 70, + hv2.healthScoreV2 >= 70, 'healthy', - akr.healthScoreV2 >= 50, + hv2.healthScoreV2 >= 50, 'fair', - akr.healthScoreV2 >= 30, + hv2.healthScoreV2 >= 30, 'concerning', 'critical' ) AS healthLabel, - akr.lifecycleLabel AS lifecycleLabel, - toUInt8(round(akr.impactScoreRaw)) AS impactScore, + hv2.lifecycleLabelV2 AS lifecycleLabel, + hv2.impactScore AS impactScore, multiIf( - akr.impactScoreRaw IS NULL, + hv2.impactScore IS NULL, NULL, - akr.impactScoreRaw >= 85, + hv2.impactScore >= 85, 'foundational', - akr.impactScoreRaw >= 60, + hv2.impactScore >= 60, 'major', - akr.impactScoreRaw >= 30, + hv2.impactScore >= 30, 'moderate', 'minor' ) AS impactLabel FROM project_insights_copy_repo_base AS base LEFT JOIN repo_health_score_copy_ds AS hs ON base.repoUrl = hs.channel LEFT JOIN project_insights_copy_repo_period_metrics AS rm ON base.repoUrl = rm.channel - LEFT JOIN project_insights_copy_repo_akrites AS akr ON base.repoUrl = akr.repositoryUrl + LEFT JOIN health_score_v2_repo_copy_ds AS hv2 ON base.repoUrl = hv2.repoUrl NODE project_insights_copy_results DESCRIPTION > From 2a60d73ba5c43a3f7483f5e50b797fec3fca2258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C5=A1per=20Grom?= Date: Fri, 24 Jul 2026 10:11:07 +0100 Subject: [PATCH 4/7] fix: exclude stale maintainer roles from bus factor score (IN-1196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maintainers_roles_copy_ds.endDate stays at the 1970-01-01 sentinel indefinitely unless someone explicitly closes the role, so archived/ dead repos retained a maxed-out busFactorScore=18 from maintainers with no commits or activity in years. Confirmed on Xamarin.Forms, HPCToolkit, Ursa, and SpaceVim, all archived, all showing healthScoreV2=100 after Layer 2 rescaling. Bus factor now also requires the maintainer to have activityRelations activity on that repo within the same trailing 12-month window already used for org diversity. Verified against live Tinybird data: Xamarin.Forms 7->0, SpaceVim 8->0, kubernetes/kubernetes 160->123 (unaffected), torvalds/linux 0->0 (unaffected). Signed-off-by: Gašper Grom --- .../pipes/health_score_v2_maintainer.pipe | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/services/libs/tinybird/pipes/health_score_v2_maintainer.pipe b/services/libs/tinybird/pipes/health_score_v2_maintainer.pipe index a078c72737..bd142b0630 100644 --- a/services/libs/tinybird/pipes/health_score_v2_maintainer.pipe +++ b/services/libs/tinybird/pipes/health_score_v2_maintainer.pipe @@ -20,6 +20,14 @@ DESCRIPTION > Tinybird inlines all upstream NODEs into one query per copy job, causing `repositories` and other base tables to be re-scanned once per category. health_score_v2_results.pipe now reads this via a cheap LEFT JOIN over the small materialized result instead. + - Bus factor fix (2026-07-24, IN-1196): `maintainers_roles_copy_ds.endDate` is a manually-curated + role record — it stays at the "still active" sentinel (1970-01-01) indefinitely unless someone + explicitly closes the role, so archived/dead repos (Xamarin.Forms, HPCToolkit, Ursa, SpaceVim + confirmed) retained a maxed-out busFactorScore=18 from maintainers with no commits/activity in + years. Bus factor now requires the maintainer to also have activityRelations activity on that + repo within the same trailing 12-month window used elsewhere in this pipe, not just an unexpired + role record. This was the direct cause of dead repos hitting healthScoreV2=100 after Layer 2 + rescaling (see LFX/cdp/health-score-v2-renormalization-bug.md). NODE health_score_v2_maintainer_calc SQL > @@ -73,10 +81,16 @@ SQL > FROM repositories WHERE deletedAt IS NULL ) AS allRepos LEFT JOIN ( - SELECT repoUrl, count(DISTINCT memberId) AS busFactorCount - FROM maintainers_roles_copy_ds - WHERE endDate = '1970-01-01 00:00:00' OR endDate > now() - INTERVAL 12 MONTH - GROUP BY repoUrl + SELECT mr.repoUrl AS repoUrl, count(DISTINCT mr.memberId) AS busFactorCount + FROM maintainers_roles_copy_ds mr + INNER JOIN ( + SELECT DISTINCT memberId, channel AS repoUrl + FROM activityRelations + WHERE timestamp > now() - INTERVAL 12 MONTH + ) AS recentActivity + ON recentActivity.memberId = mr.memberId AND recentActivity.repoUrl = mr.repoUrl + WHERE mr.endDate = '1970-01-01 00:00:00' OR mr.endDate > now() - INTERVAL 12 MONTH + GROUP BY mr.repoUrl ) AS bf ON bf.repoUrl = allRepos.repoUrl LEFT JOIN ( SELECT channel AS repoUrl, count(DISTINCT organizationId) AS orgCount From 8d0dd55dd256b57656f9d791667ea77dba21a2d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C5=A1per=20Grom?= Date: Fri, 24 Jul 2026 12:00:28 +0100 Subject: [PATCH 5/7] fix: add unavailable lifecycle state instead of defaulting to active (IN-1196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Joana's review: the lifecycle decision tree's final ELSE always resolved to 'active' whenever a repo failed every other branch, including repos with zero commits, zero issues, and zero PRs in every window checked. Confirmed on blocknetdx/blocknet: no commit-timeline data and exactly 0 activity everywhere, yet labeled 'active' purely because no other branch matched. health_score_v2_lifecycle.pipe now adds an explicit 'unavailable' branch before the active fallback, triggered only when commits are 0 in both 6-month windows AND issues+PRs are 0 in the 18-month window -- i.e. every signal this pipe looks at is exactly zero, not just one missing field. Repos with real activity that simply don't clear the abandoned/declining/stable thresholds (GravityView, Hitomi-Downloader, bminor/binutils-gdb -- all have substantial issue/PR activity despite missing repos.lastCommitAt or near-zero recent commits) correctly still resolve to 'active', unchanged. project_insights_copy.pipe's project-level best-state-wins rollup used a hardcoded 5-value priority array that didn't include 'unavailable' -- ClickHouse's indexOf() returns 0 for values not in the array, which would have sorted 'unavailable' as better than 'active' (index 1), letting one data-starved repo incorrectly override a genuinely active project. Added 'unavailable' explicitly as the lowest-priority state. Verified against live Tinybird data: blocknetdx/blocknet unavailable (was active); GravityView/Hitomi-Downloader/bminor-binutils-gdb still active (unchanged, real signal present); kubernetes/kubernetes unaffected (6331 commits last 6mo). Signed-off-by: Gašper Grom --- .../pipes/health_score_v2_lifecycle.pipe | 28 +++++++++++++++++-- .../tinybird/pipes/project_insights_copy.pipe | 11 ++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe b/services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe index 13c1de2bc2..179a5f2e98 100644 --- a/services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe +++ b/services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe @@ -1,11 +1,29 @@ DESCRIPTION > - Per-repo Lifecycle state, computed via the spec's 5-branch decision tree (first match wins): + Per-repo Lifecycle state, computed via the spec's decision tree (first match wins): archived (repos.archived) > abandoned (no commits in 18mo AND no issue/PR activity in 18mo) > declining (commits down >50% vs prior 6mo AND issues opened up vs prior 6mo) > stable (release - within 12mo, <50 open issues, no open critical vulns, commits down >50% vs prior 6mo) > active - (fallback). Project-level rollup (best-state-wins) happens in project_insights_copy.pipe. + within 12mo, <50 open issues, no open critical vulns, commits down >50% vs prior 6mo) > + unavailable (no usable signal at all — see below) > active (fallback). Project-level rollup + (best-state-wins) happens in project_insights_copy.pipe. - Split out of health_score_v2.pipe into its own copy pipe (2026-07-22) — see health_score_v2_maintainer.pipe description for why. + - `unavailable` branch added 2026-07-24 (IN-1196, per Joana's review): the original spec's + `active` fallback fired whenever a repo failed every other branch, including repos with NO + real signal at all. Confirmed on blocknetdx/blocknet: lastCommitAt IS NULL and commits/issues/ + PRs are all exactly 0 in every window — nothing here indicates the repo is active, it was + defaulting to 'active' purely because no other branch happened to match. + `unavailable` catches only repos with zero commit activity (both 6-month windows) AND zero + issues/PRs opened in the 18-month window — i.e. every activity signal this pipe looks at is + exactly zero, not just missing one field. `lastCommitAt IS NULL` alone does NOT trigger + `unavailable`: GravityView and Hitomi-Downloader both have `repos.lastCommitAt` unset (a + separate, upstream data gap in the `repos` table) but have substantial real issue/PR activity + (108-762 issues, 12-162 PRs in the 18mo window) — they correctly still resolve via their actual + signal (falling through to 'active', same as before this fix) rather than being misclassified + as `unavailable` just because one timestamp field is missing. bminor/binutils-gdb similarly + keeps falling through to 'active' despite commitsLast6m=0 (down from 1895 prior), because it + still has 3 real PRs in the 18mo window — real activity, just not enough to clear any of the + other branches' thresholds. That's a legitimate 'active' classification under the existing + spec, not the bug being fixed here. NODE health_score_v2_lifecycle_calc SQL > @@ -25,6 +43,10 @@ SQL > AND coalesce(v.openCriticals, 0) = 0 AND coalesce(c.commitsLast6m, 0) < coalesce(c.commitsPrior6m, 0) * 0.5, 'stable', + coalesce(c.commitsLast6m, 0) = 0 + AND coalesce(c.commitsPrior6m, 0) = 0 + AND coalesce(w.issuesInWindow18m, 0) + coalesce(p.prsInWindow18m, 0) = 0, + 'unavailable', 'active' ) AS lifecycleLabelV2 FROM ( diff --git a/services/libs/tinybird/pipes/project_insights_copy.pipe b/services/libs/tinybird/pipes/project_insights_copy.pipe index 3fe4fe9f74..db921de0ae 100644 --- a/services/libs/tinybird/pipes/project_insights_copy.pipe +++ b/services/libs/tinybird/pipes/project_insights_copy.pipe @@ -91,7 +91,14 @@ DESCRIPTION > per-repo values in health_score_v2_repo_copy_ds (materialized by health_score_v2.pipe — the expensive per-signal joins run there as their own copy job, not inline here). Per spec section 5/6: - healthScoreV2: straight mean across the project's enabled, non-excluded repos. - - lifecycleLabelV2: best-state-wins (active > stable > declining > abandoned > archived). + - lifecycleLabelV2: best-state-wins (active > stable > declining > abandoned > archived > + unavailable). `unavailable` (added 2026-07-24, IN-1196) sorts last, not just "not found": with + the previous 5-value array, indexOf() returned 0 for any label absent from the list, which + arraySort's ascending order treats as SMALLER than 'active' (index 1) — so a single-repo + project whose only repo has no activity signal at all would have incorrectly outranked a + multi-repo project where every other repo is legitimately 'active'. Explicitly listing + 'unavailable' last (index 6) makes it correctly the worst/most-cautious outcome, only surfacing + when literally every repo in the project has no usable signal. - impactScoreRaw: MAX across the project's repos (each repo's own value is already a MAX over its published packages) — matches the spec's "MAX(packages.impact) over packages published by any repo in the project," since max-of-maxes = max-over-all. @@ -102,7 +109,7 @@ SQL > avg(hv2.healthScoreV2) AS healthScoreV2, arrayElement( arraySort( - x -> indexOf(['active', 'stable', 'declining', 'abandoned', 'archived'], x), + x -> indexOf(['active', 'stable', 'declining', 'abandoned', 'archived', 'unavailable'], x), groupArray(hv2.lifecycleLabelV2) ), 1 From 7a33bf70a8a4224b5d45cb141326c3b41fa1194b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C5=A1per=20Grom?= Date: Fri, 24 Jul 2026 14:28:16 +0100 Subject: [PATCH 6/7] fix: return NULL instead of 'unavailable' string for lifecycle (IN-1196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Joana's follow-up: prefer NULL at the data layer for insufficient signal, consistent with how healthScoreV2 already represents it, and let the UI derive the Unavailable chip from NULL rather than carrying a literal string through the pipeline. health_score_v2_lifecycle.pipe: the zero-signal branch (zero commits in both 6-month windows AND zero issues/PRs in the 18-month window) now returns NULL instead of 'unavailable'. Archived repos are explicitly excluded from this check so they still correctly resolve to 'archived' even when they also have zero activity. Re-verified: blocknetdx/blocknet -> NULL (was 'unavailable', originally 'active'); xamarin/Xamarin.Forms -> 'archived' (unaffected); GravityView, Hitomi-Downloader, bminor/binutils-gdb -> 'active' (unaffected, real signal present). project_insights_copy.pipe: the project-level best-state-wins rollup no longer needs 'unavailable' in its priority array (back to the original 5 values), since groupArray() already drops NULL entries for free when at least one repo in the project has a real state. But an all-NULL or fully-excluded project makes groupArray() return an EMPTY array, and arrayElement([], 1) on an empty array silently returns an empty string, not NULL. Added an explicit empty-array guard so that case resolves to a real NULL too. While tracing this, found the actual root cause of 5 pre-existing blank-lifecycleLabel projects (Charliecloud, GNU Binutils and GDB, GravityView, GridGain Community Edition, Hitomi-Downloader): each project's sole repo has repositories.excluded = true, so the pipe's own WHERE rep.excluded = false filter drops every repo, groupArray() sees zero rows, and the old unguarded arrayElement returned ''. This empty-array guard fixes the class of bug generally; the excluded=true repo assignment for those 5 projects is a separate, pre-existing data situation, unrelated to IN-1196, not addressed here. Signed-off-by: Gašper Grom --- .../pipes/health_score_v2_lifecycle.pipe | 78 ++++++++++--------- .../tinybird/pipes/project_insights_copy.pipe | 41 ++++++---- 2 files changed, 69 insertions(+), 50 deletions(-) diff --git a/services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe b/services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe index 179a5f2e98..7cc9cc2883 100644 --- a/services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe +++ b/services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe @@ -2,52 +2,58 @@ DESCRIPTION > Per-repo Lifecycle state, computed via the spec's decision tree (first match wins): archived (repos.archived) > abandoned (no commits in 18mo AND no issue/PR activity in 18mo) > declining (commits down >50% vs prior 6mo AND issues opened up vs prior 6mo) > stable (release - within 12mo, <50 open issues, no open critical vulns, commits down >50% vs prior 6mo) > - unavailable (no usable signal at all — see below) > active (fallback). Project-level rollup - (best-state-wins) happens in project_insights_copy.pipe. + within 12mo, <50 open issues, no open critical vulns, commits down >50% vs prior 6mo) > active + (fallback) — but NULL overrides all of the above when there is no usable signal at all (see + below). Project-level rollup (best-state-wins) happens in project_insights_copy.pipe. - Split out of health_score_v2.pipe into its own copy pipe (2026-07-22) — see health_score_v2_maintainer.pipe description for why. - - `unavailable` branch added 2026-07-24 (IN-1196, per Joana's review): the original spec's - `active` fallback fired whenever a repo failed every other branch, including repos with NO - real signal at all. Confirmed on blocknetdx/blocknet: lastCommitAt IS NULL and commits/issues/ - PRs are all exactly 0 in every window — nothing here indicates the repo is active, it was - defaulting to 'active' purely because no other branch happened to match. - `unavailable` catches only repos with zero commit activity (both 6-month windows) AND zero + - `NULL` branch added 2026-07-24 (IN-1196, per Joana's review): the original spec's `active` + fallback fired whenever a repo failed every other branch, including repos with NO real signal + at all. Confirmed on blocknetdx/blocknet: lastCommitAt IS NULL and commits/issues/PRs are all + exactly 0 in every window — nothing here indicates the repo is active, it was defaulting to + 'active' purely because no other branch happened to match. + This branch now returns NULL (not a fabricated 'active', and not a literal 'unavailable' + string either — kept consistent with how `healthScoreV2` itself already represents + insufficient signal) for repos with zero commit activity (both 6-month windows) AND zero issues/PRs opened in the 18-month window — i.e. every activity signal this pipe looks at is - exactly zero, not just missing one field. `lastCommitAt IS NULL` alone does NOT trigger - `unavailable`: GravityView and Hitomi-Downloader both have `repos.lastCommitAt` unset (a - separate, upstream data gap in the `repos` table) but have substantial real issue/PR activity - (108-762 issues, 12-162 PRs in the 18mo window) — they correctly still resolve via their actual - signal (falling through to 'active', same as before this fix) rather than being misclassified - as `unavailable` just because one timestamp field is missing. bminor/binutils-gdb similarly - keeps falling through to 'active' despite commitsLast6m=0 (down from 1895 prior), because it - still has 3 real PRs in the 18mo window — real activity, just not enough to clear any of the - other branches' thresholds. That's a legitimate 'active' classification under the existing - spec, not the bug being fixed here. + exactly zero, not just missing one field. The UI layer (insights repo) is responsible for + rendering NULL as an "Unavailable" chip, same as it already does for a missing healthScoreV2. + `lastCommitAt IS NULL` alone does NOT trigger this branch: GravityView and Hitomi-Downloader + both have `repos.lastCommitAt` unset (a separate, upstream data gap in the `repos` table) but + have substantial real issue/PR activity (108-762 issues, 12-162 PRs in the 18mo window) — they + correctly still resolve via their actual signal (falling through to 'active', same as before + this fix) rather than being misclassified as unavailable just because one timestamp field is + missing. bminor/binutils-gdb similarly keeps falling through to 'active' despite + commitsLast6m=0 (down from 1895 prior), because it still has 3 real PRs in the 18mo window — + real activity, just not enough to clear any of the other branches' thresholds. That's a + legitimate 'active' classification under the existing spec, not the bug being fixed here. NODE health_score_v2_lifecycle_calc SQL > SELECT r.url AS repoUrl, - multiIf( - r.archived = 1, - 'archived', - (r.lastCommitAt < now() - INTERVAL 18 MONTH) - AND coalesce(w.issuesInWindow18m, 0) + coalesce(p.prsInWindow18m, 0) = 0, - 'abandoned', - coalesce(c.commitsLast6m, 0) < coalesce(c.commitsPrior6m, 0) * 0.5 - AND coalesce(w.issuesOpenedLast6m, 0) > coalesce(w.issuesOpenedPrior6m, 0), - 'declining', - (rl.latestReleaseDay > today() - 365) - AND coalesce(n.issuesOpenNow, 0) < 50 - AND coalesce(v.openCriticals, 0) = 0 - AND coalesce(c.commitsLast6m, 0) < coalesce(c.commitsPrior6m, 0) * 0.5, - 'stable', - coalesce(c.commitsLast6m, 0) = 0 + if( + r.archived != 1 + AND coalesce(c.commitsLast6m, 0) = 0 AND coalesce(c.commitsPrior6m, 0) = 0 AND coalesce(w.issuesInWindow18m, 0) + coalesce(p.prsInWindow18m, 0) = 0, - 'unavailable', - 'active' + NULL, + toNullable(multiIf( + r.archived = 1, + 'archived', + (r.lastCommitAt < now() - INTERVAL 18 MONTH) + AND coalesce(w.issuesInWindow18m, 0) + coalesce(p.prsInWindow18m, 0) = 0, + 'abandoned', + coalesce(c.commitsLast6m, 0) < coalesce(c.commitsPrior6m, 0) * 0.5 + AND coalesce(w.issuesOpenedLast6m, 0) > coalesce(w.issuesOpenedPrior6m, 0), + 'declining', + (rl.latestReleaseDay > today() - 365) + AND coalesce(n.issuesOpenNow, 0) < 50 + AND coalesce(v.openCriticals, 0) = 0 + AND coalesce(c.commitsLast6m, 0) < coalesce(c.commitsPrior6m, 0) * 0.5, + 'stable', + 'active' + )) ) AS lifecycleLabelV2 FROM ( SELECT base.url AS url, base.archived AS archived, rc.lastCommitAt AS lastCommitAt diff --git a/services/libs/tinybird/pipes/project_insights_copy.pipe b/services/libs/tinybird/pipes/project_insights_copy.pipe index db921de0ae..6c373ece72 100644 --- a/services/libs/tinybird/pipes/project_insights_copy.pipe +++ b/services/libs/tinybird/pipes/project_insights_copy.pipe @@ -91,14 +91,23 @@ DESCRIPTION > per-repo values in health_score_v2_repo_copy_ds (materialized by health_score_v2.pipe — the expensive per-signal joins run there as their own copy job, not inline here). Per spec section 5/6: - healthScoreV2: straight mean across the project's enabled, non-excluded repos. - - lifecycleLabelV2: best-state-wins (active > stable > declining > abandoned > archived > - unavailable). `unavailable` (added 2026-07-24, IN-1196) sorts last, not just "not found": with - the previous 5-value array, indexOf() returned 0 for any label absent from the list, which - arraySort's ascending order treats as SMALLER than 'active' (index 1) — so a single-repo - project whose only repo has no activity signal at all would have incorrectly outranked a - multi-repo project where every other repo is legitimately 'active'. Explicitly listing - 'unavailable' last (index 6) makes it correctly the worst/most-cautious outcome, only surfacing - when literally every repo in the project has no usable signal. + - lifecycleLabelV2: best-state-wins (active > stable > declining > abandoned > archived), with + NULL (no usable signal, see health_score_v2_lifecycle.pipe) as the most-cautious outcome. + `groupArray` silently drops NULL entries, so a project with at least one repo in a real state + already rolls up correctly for free — NULL only surfaces at the project level when EVERY repo + contributes NULL, or none contribute at all. + Guarded explicitly (2026-07-24, IN-1196) because `groupArray` over an empty or all-NULL input + returns an EMPTY array, and `arrayElement([], 1)` on that empty array returns an empty string + `''`, not NULL — this exact mechanism was confirmed live as the root cause of 5 blank- + lifecycleLabel projects found during IN-1196 verification (Charliecloud, GNU Binutils and GDB, + GravityView, GridGain Community Edition, Hitomi-Downloader): each has its sole repo marked + `repositories.excluded = true` (confirmed on GravityView specifically), so the `WHERE + rep.excluded = false` filter below drops every repo for that project, `groupArray` sees zero + rows, and the old unguarded `arrayElement` returned `''`. `if(empty(...), NULL, ...)` makes + that case (and the all-NULL-lifecycle case) resolve to a real NULL instead. The 5 named + projects will still show a blank/NULL lifecycle after this fix — this only stops it from being + a silent empty string; the underlying "this project's only repo is excluded" data situation is + unrelated to IN-1196 and not addressed here. - impactScoreRaw: MAX across the project's repos (each repo's own value is already a MAX over its published packages) — matches the spec's "MAX(packages.impact) over packages published by any repo in the project," since max-of-maxes = max-over-all. @@ -107,12 +116,16 @@ SQL > SELECT rep.insightsProjectId AS insightsProjectId, avg(hv2.healthScoreV2) AS healthScoreV2, - arrayElement( - arraySort( - x -> indexOf(['active', 'stable', 'declining', 'abandoned', 'archived', 'unavailable'], x), - groupArray(hv2.lifecycleLabelV2) - ), - 1 + if( + empty(groupArray(hv2.lifecycleLabelV2)), + NULL, + toNullable(arrayElement( + arraySort( + x -> indexOf(['active', 'stable', 'declining', 'abandoned', 'archived'], x), + groupArray(hv2.lifecycleLabelV2) + ), + 1 + )) ) AS lifecycleLabelV2, maxOrNull(hv2.impactScore) AS impactScoreRaw FROM repositories rep FINAL From a20d1bc214e7db6da628c8d5fb67d80b61a9c1bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C5=A1per=20Grom?= Date: Fri, 24 Jul 2026 14:32:44 +0100 Subject: [PATCH 7/7] fix: propagate NULL lifecycle through the full pipe chain (IN-1196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two chain breaks found during pre-deploy validation of 7a33bf70a that would have made the NULL-lifecycle fix fail outright or silently revert: health_score_v2_lifecycle_ds.datasource declared lifecycleLabelV2 as a plain String, not Nullable(String). Inserting NULL into a non-nullable ClickHouse column throws CANNOT_INSERT_NULL_IN_ORDINARY_COLUMN, so the lifecycle pipe's nightly copy job would hard-fail on its very first run after deploy, for any repo hitting the new NULL branch (confirmed: blocknetdx/blocknet is one right now). Widened to Nullable(String). health_score_v2.pipe wrapped the pass-through in coalesce(l.lifecycleLabelV2, 'active'), which would have silently converted the upstream NULL back into a fabricated 'active' one hop downstream, erasing the fix's effect before it ever reached health_score_v2_repo_copy_ds (whose own schema already correctly declares lifecycleLabelV2 as Nullable(String) — no change needed there). Confirmed live that this LEFT JOIN has zero actual row-misses across all repos, so the coalesce was never serving a real missing Signed-off-by: Gašper Grom --- .../datasources/health_score_v2_lifecycle_ds.datasource | 7 ++++--- .../datasources/health_score_v2_repo_copy_ds.datasource | 6 ++++-- services/libs/tinybird/pipes/health_score_v2.pipe | 9 ++++++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/services/libs/tinybird/datasources/health_score_v2_lifecycle_ds.datasource b/services/libs/tinybird/datasources/health_score_v2_lifecycle_ds.datasource index fb1159bf18..4c0f6b220e 100644 --- a/services/libs/tinybird/datasources/health_score_v2_lifecycle_ds.datasource +++ b/services/libs/tinybird/datasources/health_score_v2_lifecycle_ds.datasource @@ -2,12 +2,13 @@ DESCRIPTION > - `health_score_v2_lifecycle_ds` holds the per-repo Lifecycle state for Health Score v2. Populated by `health_score_v2_lifecycle.pipe`. - `repoUrl` is the repository URL — the join key back to `repositories`. - - `lifecycleLabelV2` — one of active/stable/declining/abandoned/archived, per the spec's - 5-branch decision tree. + - `lifecycleLabelV2` — one of active/stable/declining/abandoned/archived, or NULL when the + repo has no usable activity signal at all (2026-07-24, IN-1196 — see + health_score_v2_lifecycle.pipe for the exact condition). SCHEMA > `repoUrl` String, - `lifecycleLabelV2` String + `lifecycleLabelV2` Nullable(String) ENGINE MergeTree ENGINE_SORTING_KEY repoUrl diff --git a/services/libs/tinybird/datasources/health_score_v2_repo_copy_ds.datasource b/services/libs/tinybird/datasources/health_score_v2_repo_copy_ds.datasource index 3f4613c16a..55ff72956f 100644 --- a/services/libs/tinybird/datasources/health_score_v2_repo_copy_ds.datasource +++ b/services/libs/tinybird/datasources/health_score_v2_repo_copy_ds.datasource @@ -14,8 +14,10 @@ DESCRIPTION > - `developmentActivityScoreV2` (0-25) — release cadence, commit activity, issue resolution, PR merge health. - `healthScoreV2` (0-100) is the sum of the three categories above, clamped to 100. - `lifecycleLabelV2` — per-repo lifecycle state (active/stable/declining/abandoned/archived), computed - via the spec's 5-branch decision tree (archived flag > abandoned > declining > stable > active, - first match wins). Project-level rollup uses best-state-wins precedence in project_insights_copy.pipe. + via the spec's decision tree (archived flag > abandoned > declining > stable > active, first match + wins), or NULL when the repo has zero commits and zero issues/PRs in every window checked (2026-07-24, + IN-1196 — no usable activity signal at all). Project-level rollup uses best-state-wins precedence in + project_insights_copy.pipe. - `impactScore` (0-100) — MAX(packages.impact) * 100 over packages published by this repo. NULL when the repo has no linked packages. - No graceful-degradation/signal-coverage redistribution yet — a repo with zero signal in a category diff --git a/services/libs/tinybird/pipes/health_score_v2.pipe b/services/libs/tinybird/pipes/health_score_v2.pipe index 5c937510f7..c7605fef12 100644 --- a/services/libs/tinybird/pipes/health_score_v2.pipe +++ b/services/libs/tinybird/pipes/health_score_v2.pipe @@ -15,6 +15,13 @@ DESCRIPTION > already carries NULL when its own coverage fell below 40% (Layer 1, done per-category pipe). If all three categories are unavailable, healthScoreV2 itself becomes NULL, per spec, rather than a fabricated 0. Impact stays NULL (not defaulted to 0) when the repo has no packages. + - lifecycleLabelV2 passes through health_score_v2_lifecycle_ds unmodified (2026-07-24, + IN-1196) — previously wrapped in `coalesce(l.lifecycleLabelV2, 'active')`, which silently + converted the upstream pipe's genuine "no usable signal" NULL back into a fabricated 'active' + before it ever reached health_score_v2_repo_copy_ds. Confirmed live that this LEFT JOIN has + zero actual join-misses (every repo has a matching lifecycle row), so the coalesce was never + serving its ostensible "missing row" fallback purpose in practice — it was only erasing real + NULLs. NODE health_score_v2_results SQL > @@ -40,7 +47,7 @@ SQL > (if(m.maintainerHealthScoreV2 IS NOT NULL, 40, 0) + if(s.securitySupplyChainScoreV2 IS NOT NULL, 35, 0) + if(d.developmentActivityScoreV2 IS NOT NULL, 25, 0)) AS coveredCategoryWeight, - coalesce(l.lifecycleLabelV2, 'active') AS lifecycleLabelV2, + l.lifecycleLabelV2 AS lifecycleLabelV2, i.impactScoreRaw AS impactScoreRaw FROM (SELECT DISTINCT url AS repoUrl FROM repositories WHERE deletedAt IS NULL) AS base LEFT JOIN health_score_v2_maintainer_ds AS m ON m.repoUrl = base.repoUrl