Skip to content

feat: Health Score v2 (Akrites) scoring pipeline, lifecycle, and impact score (CM-IN-1196)#4394

Open
gaspergrom wants to merge 7 commits into
mainfrom
feat/IN-1196-akrites-project-scores
Open

feat: Health Score v2 (Akrites) scoring pipeline, lifecycle, and impact score (CM-IN-1196)#4394
gaspergrom wants to merge 7 commits into
mainfrom
feat/IN-1196-akrites-project-scores

Conversation

@gaspergrom

Copy link
Copy Markdown
Contributor

Summary

Adds the Health Score v2 (Akrites methodology) scoring pipeline: per-repo maintainer health, security/supply-chain, and development-activity category scores, plus a lifecycle classifier and an impact score, rolled up to project level. Also fixes several correctness bugs found during data verification against production before rollout.

  • Split Health Score v2 computation into 5 independently-materialized category pipes (maintainer/security/development/lifecycle/impact) to keep copy-job runtime down — a single combined pipe was re-scanning base tables per category and had grown from ~13min to 20-28+ min per run.
  • healthScoreV2 applies spec-defined graceful degradation: if 1-2 of 3 categories are unavailable, the score rescales across only the available categories rather than defaulting to a fabricated 0; NULL only when all three are unavailable.
  • Bus factor fix: the maintainer-health bus-factor score counted maintainer role records purely by an unexpired endDate (a manually-curated field that stays at a "still active" sentinel indefinitely unless someone explicitly closes the role). This let archived/dead repos keep a maxed-out bus-factor score from maintainers with no real activity in years, which combined with the graceful-degradation rescale to produce healthScoreV2 = 100 on dead repos. Bus factor now also requires the maintainer to have actual activityRelations activity on that repo within the same trailing 12-month window. Verified against production: Xamarin.Forms and SpaceVim (archived) drop from a maxed bus-factor score to 0; kubernetes/kubernetes stays healthy and unaffected.
  • Lifecycle fallback fix: the lifecycle decision tree's final ELSE branch 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 live on a fully dormant, non-archived repo with no commit-timeline data and no signal at all). The chain now returns NULL for that case instead of a fabricated 'active', propagated correctly through:
    • health_score_v2_lifecycle_ds — widened lifecycleLabelV2 from String to Nullable(String) (was going to hard-fail the copy job on any NULL insert).
    • health_score_v2.pipe — removed a coalesce(lifecycleLabelV2, 'active') that would have silently erased the NULL back into 'active' one hop downstream.
    • project_insights_copy.pipe — the project-level "best-state-wins" rollup already drops NULL entries for free via groupArray, but an all-NULL or fully-excluded project produces an empty array, and arrayElement([], 1) on an empty array silently returns '' rather than NULL. Added an explicit guard. While tracing this, found the actual root cause of 5 pre-existing blank-lifecycleLabel projects (each project's sole repo has repositories.excluded = true) — a separate, pre-existing data situation, not fixed here, but no longer surfaces as a silent empty string.

All fixes verified against live production Tinybird data before merge, including full-chain propagation checks and regression checks against known-healthy projects (kubernetes, projects with real but thin activity signal).

Test plan

  • tb check passes on all changed pipes/datasources
  • Verified bus-factor fix against production data: archived repos (Xamarin.Forms, SpaceVim, HPCToolkit, Ursa) drop from healthScoreV2=100 to realistic scores (22-62); active repos (kubernetes, GravityView, Hitomi-Downloader, binutils-gdb) unaffected
  • Verified lifecycle NULL fix against production data: a genuinely zero-signal repo (blocknetdx/blocknet) resolves to NULL at both repo and project level; archived repos still correctly resolve to 'archived' even with zero activity; repos with any real signal still resolve to a real state, not NULL
  • Verified datasource schema widening (StringNullable(String)) is a non-breaking, metadata-only ClickHouse ALTER
  • Deploy to staging, confirm pipes deploy cleanly (staging data is known to be sparse, so this validates deployability not data correctness)
  • Deploy to production, re-validate full chain against live data post-deploy

Deploy notes

Deploy order matters — datasources before pipes, then pipes in dependency order:

  1. health_score_v2_lifecycle_ds.datasource, health_score_v2_repo_copy_ds.datasource (schema changes)
  2. health_score_v2_lifecycle.pipe, health_score_v2_maintainer.pipe (independent, both feed health_score_v2)
  3. health_score_v2.pipe (reads both category datasources above)
  4. project_insights_copy.pipe (reads health_score_v2_repo_copy_ds)

After pushing pipes, the copy jobs need a forced run (tbc <pipe> --yes) rather than waiting for the nightly cron, in the same order, so the NULL/bus-factor fixes propagate through the full chain before this is user-visible.

…(IN-1196)

Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
…althScore (IN-1196)

Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
- 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 <gasper.grom@gmail.com>
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 <gasper.grom@gmail.com>
…(IN-1196)

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 <gasper.grom@gmail.com>
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 <gasper.grom@gmail.com>
Two chain breaks found during pre-deploy validation of 7a33bf7 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 <gasper.grom@gmail.com>
Copilot AI review requested due to automatic review settings July 24, 2026 13:39
@cursor

cursor Bot commented Jul 24, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Large new analytics pipeline and user-visible score/label fields; scoring logic changes can mis-rank projects until copy jobs run in deploy order.

Overview
Introduces the Akrites Health Score v2 Tinybird stack: five scheduled copy pipes (maintainer, security, development, lifecycle, impact) materialize per-repo category scores and labels, and health_score_v2.pipe joins them into health_score_v2_repo_copy_ds with Layer 2 rescaling when some categories are NULL instead of fabricating zeros.

Project insights gains healthScoreV2, healthLabel, lifecycleLabel, impactScore, and impactLabel on project_insights_copy_ds, rolled up in project_insights_copy.pipe (repo mean for health, best-state-wins lifecycle, max impact) and exposed from project_insights / project_repo_insights APIs.

Correctness fixes: bus factor now requires 12-month activityRelations activity, not only an open maintainer role (stops dead repos rescaling to healthScoreV2 = 100); lifecycle returns NULL when all activity signals are zero (not default 'active'); health_score_v2.pipe drops coalesce(..., 'active') on lifecycle; project lifecycle rollup guards empty groupArray so it returns NULL instead of ''.

Reviewed by Cursor Bugbot for commit a20d1bc. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR titles must follow Conventional Commits. Love from, Your reviewers ❤️.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a20d1bc. Configure here.

SELECT channel AS repoUrl, count(DISTINCT organizationId) AS orgCount
FROM activityRelations
WHERE timestamp > now() - INTERVAL 12 MONTH AND organizationId != ''
GROUP BY channel

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bus factor counts non-contribution activity

Medium Severity

The bus-factor fix requires recent activityRelations rows on the repo, but the subquery does not restrict activity type. Stars, forks, and other non-contribution events can satisfy the join, so maintainers with no real maintenance work in the window may still inflate bus factor—similar to the stale role-record issue this change was meant to fix.

Fix in Cursor Fix in Web

Triggered by learned rule: Tinybird pipes counting contributors must filter by activityTypes

Reviewed by Cursor Bugbot for commit a20d1bc. Configure here.

pm.activeContributorsPrevious365Days AS activeContributorsPrevious365Days,
pm.activeOrganizationsPrevious365Days AS activeOrganizationsPrevious365Days
pm.activeOrganizationsPrevious365Days AS activeOrganizationsPrevious365Days,
toUInt8(round(hv2.healthScoreV2)) AS healthScoreV2,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NULL health score becomes zero

High Severity

Project and repo rows set healthScoreV2 with toUInt8(round(hv2.healthScoreV2)) without a NULL guard, unlike upstream pipes that use toNullable. Missing or unavailable scores can become 0, which is a valid “critical” score and contradicts healthLabel when that field is NULL. When every included repo has NULL healthScoreV2, avg can yield NaN; NaN is not treated as NULL in the label multiIf, so the project can show healthLabel critical while the score is wrong.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a20d1bc. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the Akrites Health Score v2 pipeline and exposes repository/project health, lifecycle, and impact metrics.

Changes:

  • Materializes five independent scoring categories and combines available health signals.
  • Adds lifecycle and impact scoring with project-level rollups.
  • Exposes the new metrics through insights endpoints and datasources.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
pipes/project_repo_insights.pipe Exposes Akrites fields for projects and repositories.
pipes/project_insights.pipe Exposes Akrites project fields.
pipes/project_insights_copy.pipe Rolls up and labels repository scores.
pipes/health_score_v2.pipe Combines category scores with graceful degradation.
pipes/health_score_v2_security.pipe Computes security and supply-chain health.
pipes/health_score_v2_maintainer.pipe Computes maintainer health.
pipes/health_score_v2_lifecycle.pipe Classifies repository lifecycle.
pipes/health_score_v2_impact.pipe Computes package-mediated impact.
pipes/health_score_v2_development.pipe Computes development activity health.
datasources/project_insights_copy_ds.datasource Adds Akrites insight columns.
datasources/health_score_v2_security_ds.datasource Stores security scores.
datasources/health_score_v2_repo_copy_ds.datasource Stores combined repository results.
datasources/health_score_v2_maintainer_ds.datasource Stores maintainer scores.
datasources/health_score_v2_lifecycle_ds.datasource Stores lifecycle classifications.
datasources/health_score_v2_impact_ds.datasource Stores raw impact scores.
datasources/health_score_v2_development_ds.datasource Stores development scores.
Comments suppressed due to low confidence (3)

services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe:85

  • All three values only use issues opened in the last 18 months, but the aggregation currently reads every partition in issues_analyzed. Add the 18-month predicate to WHERE so ClickHouse can prune old yearly partitions.
        FROM issues_analyzed
        GROUP BY channel

services/libs/tinybird/pipes/health_score_v2_lifecycle.pipe:90

  • The metric only counts PRs from the last 18 months, yet this scans all yearly partitions in pull_requests_analyzed. Push the same time condition into WHERE to prune historical partitions.
        FROM pull_requests_analyzed
        GROUP BY channel

services/libs/tinybird/datasources/health_score_v2_repo_copy_ds.datasource:24

  • This statement contradicts the implemented Layer 2 graceful degradation in health_score_v2.pipe: unavailable categories are excluded and remaining category scores are rescaled, while the result is NULL only when all categories are unavailable. The earlier “sum” description is also incomplete for that case.
    - 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.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

+ if(d.developmentActivityScoreV2 IS NOT NULL, 25, 0)) AS coveredCategoryWeight,
l.lifecycleLabelV2 AS lifecycleLabelV2,
i.impactScoreRaw AS impactScoreRaw
FROM (SELECT DISTINCT url AS repoUrl FROM repositories WHERE deletedAt IS NULL) AS base
(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
+ 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
SELECT
base.url AS repoUrl,
max(toFloat64OrNull(pk.impact)) * 100 AS impactScoreRaw
FROM (SELECT DISTINCT url FROM repositories WHERE deletedAt IS NULL) AS base
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
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'
Comment on lines +35 to +38
- `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.
- `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
- `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.
- `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
- `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.
Comment on lines +11 to +13
- `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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants