-
-LFX Insights surfaces **two independent assessments** for open source projects: a **Lifecycle state** and a **Health Score**. Together, these replace the previous single composite score and answer two distinct questions:
+
+LFX Insights surfaces **two independent assessments** for open source projects: a **Lifecycle state** and a **Health Score**. Together, they answer two distinct questions:
- **Lifecycle:** what state is this project in?
- **Health Score:** how well-maintained is it?
@@ -299,31 +299,3 @@ For multi-repo projects, sub-signals in a `partial` coverage state are computed
::: info
Health Score comparisons across projects with different platform or data coverage are not directly comparable. A project with full GitHub and package data is scored on more signals than one on Gerrit with no published packages. Coverage details are always shown alongside the score.
:::
-
-## How This Differs from the Previous Health Score
-
-::: info
-The original Insights Health Score was a single 0–100 value, computed as an equal-weight mean of four sub-scores: Contributors (25 pts), Popularity (25 pts), Development (25 pts), and Security and Best Practices (25 pts). The current methodology replaces that with independent assessments and re-weighted health categories.
-:::
-
-| Aspect | Original Health Score | Current methodology |
-|---|---|---|
-| **Output** | Single score (0–100) | Two assessments: Lifecycle (state), Health Score (0–100) |
-| **Health categories** | 4 equal categories at 25 pts each | 3 weighted categories: Maintainer (40), Security and Supply Chain (35), Development Activity (25) |
-| **Popularity** | Baked into Health Score as 25% of the total | Not baked into Health Score |
-| **Lifecycle** | Not modeled | Six states: Active, Stable, Declining, Inert, Abandoned, Archived |
-| **Stable "done" libraries** | Penalized: low commits meant a low score | Lifecycle context added: a `Stable` classification signals low activity is intentional, but the Health Score formula still scores low-activity signals as low |
-| **Maintainer signal** | Contributor count only | Responsiveness, bus factor (curated plus observed), organizational diversity |
-| **Security** | OpenSSF Baseline pass/fail ratio | Open CVEs with severity weighting, security practices, OpenSSF Scorecard, dependency health, supply chain integrity |
-| **Missing data** | Returned unavailable if any sub-score was absent | Blocked sub-signals are imputed at the population median rate; full score requires ≥2 categories, 1 missing shows a partial indicator, ≥2 missing returns unavailable |
-| **Rating bands** | Excellent / Healthy / Stable / Unsteady / Critical | Excellent / Healthy / Fair / Concerning / Critical |
-
-### Why the original approach had gaps
-
-The previous model had several structural problems:
-
-- **Equal weighting ignored blast radius.** A project with a single maintainer and a critical open CVE could score the same as a project with ten maintainers and a clean security record, if their activity levels were similar.
-- **Popularity baked into health.** A massively downloaded project with a compromised or absent maintainer appeared healthy because download counts inflated the score.
-- **Stable libraries were penalized.** A mature utility with no bugs, no CVEs, and no need for active development scored near Critical simply because it had few recent commits.
-- **No lifecycle signal.** The model could not distinguish "abandoned with a high score" from "active with a high score," a critical difference for stewardship and dependency risk.
-- **Any missing sub-score returned unavailable.** Projects on GitLab or Gerrit, or projects without package data, often could not be scored at all.
diff --git a/frontend/server/api/badge/active-contributors.test.ts b/frontend/server/api/badge/active-contributors.test.ts
new file mode 100644
index 000000000..7664a01e2
--- /dev/null
+++ b/frontend/server/api/badge/active-contributors.test.ts
@@ -0,0 +1,71 @@
+// Copyright (c) 2025 The Linux Foundation and each contributor.
+// SPDX-License-Identifier: MIT
+
+import type { H3Event } from 'h3';
+import type { DateTime } from 'luxon';
+import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
+
+const mockSendRedirect = vi.fn();
+const mockFetchFromTinybird = vi.fn();
+
+vi.mock('~~/server/data/tinybird/tinybird', () => ({
+ fetchFromTinybird: (...args: unknown[]) => mockFetchFromTinybird(...args),
+}));
+
+vi.mock('~~/server/utils/log', () => ({ logError: vi.fn() }));
+
+global.defineEventHandler = vi.fn((handler) => handler);
+global.createError = vi.fn((error) => error);
+global.getQuery = vi.fn(() => ({ project: 'envoy' }));
+global.sendRedirect = mockSendRedirect;
+
+let handler: (event: H3Event) => Promise;
+
+beforeAll(async () => {
+ handler = (await import('./active-contributors')).default as unknown as typeof handler;
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-10-13T12:00:00Z'));
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+const redirectParam = (name: string) =>
+ new URL(mockSendRedirect.mock.calls[0]?.[1] as string).searchParams.get(name);
+
+describe('GET /api/badge/active-contributors', () => {
+ it('counts active contributors over the last 365 days, like the share preview', async () => {
+ mockFetchFromTinybird.mockResolvedValue({
+ data: [{ contributorCount: 1580, maintainerCount: 10, reviewerCount: 20 }],
+ });
+
+ await handler({} as H3Event);
+
+ const [path, params] = mockFetchFromTinybird.mock.calls[0] as [
+ string,
+ { startDate: DateTime; endDate: DateTime } & Record,
+ ];
+ expect(path).toBe('/v0/pipes/active_contributors.json');
+ expect(params).toMatchObject({
+ project: 'envoy',
+ includeCodeContributions: true,
+ includeCollaborations: false,
+ });
+ expect(params.startDate.toFormat('yyyy-MM-dd')).toBe('2025-10-13');
+ expect(params.endDate.toFormat('yyyy-MM-dd')).toBe('2026-10-13');
+ expect(redirectParam('label')).toBe('Active contributors (1Y)');
+ expect(redirectParam('message')).toBe('1.6K');
+ });
+
+ it('returns 404 when the pipe has no row for the project', async () => {
+ mockFetchFromTinybird.mockResolvedValue({ data: [] });
+
+ await expect(handler({} as H3Event)).rejects.toMatchObject({ statusCode: 404 });
+ expect(mockSendRedirect).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/server/api/badge/active-contributors.ts b/frontend/server/api/badge/active-contributors.ts
index d8d33597b..2ec88f201 100644
--- a/frontend/server/api/badge/active-contributors.ts
+++ b/frontend/server/api/badge/active-contributors.ts
@@ -1,9 +1,11 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
+import { DateTime } from 'luxon';
+
import { formatNumberShort } from '~/components/shared/utils/formatter';
+import type { TinybirdActiveContributorsSummary } from '~~/server/data/tinybird/responses.types';
import { fetchFromTinybird } from '~~/server/data/tinybird/tinybird';
import { logError } from '~~/server/utils/log';
-import type { HealthScoreTinybird } from '~~/types/overview/responses.types';
export default defineEventHandler(async (event): Promise => {
const query = getQuery(event);
@@ -11,17 +13,23 @@ export default defineEventHandler(async (event): Promise => {
const repos: string[] = (query?.repos as string)?.split(',') || undefined;
try {
- const res = await fetchFromTinybird(
- '/v0/pipes/health_score_active_contributors.json',
+ // Same pipe, filters and 365-day window as the share preview of this badge (active-contributor-badge.vue).
+ const now = DateTime.utc();
+ const res = await fetchFromTinybird(
+ '/v0/pipes/active_contributors.json',
{
project,
repos,
+ includeCodeContributions: true,
+ includeCollaborations: false,
+ startDate: now.minus({ days: 365 }),
+ endDate: now,
},
);
if (!res.data || res.data.length === 0) {
throw createError({ statusCode: 404, statusMessage: 'Project not found' });
}
- const activeContributors = res.data[0].activeContributors;
+ const activeContributors = res.data[0].contributorCount;
const label = encodeURIComponent('Active contributors (1Y)');
const message = encodeURIComponent(formatNumberShort(activeContributors || 0));
const url = `https://img.shields.io/static/v1?label=${label}&message=${message}&color=0094FF&logo=linuxfoundation&logoColor=white&style=flat`;