Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

2 changes: 0 additions & 2 deletions frontend/app/components/modules/project/views/overview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ SPDX-License-Identifier: MIT
<div class="container">
<div class="flex justify-between pt-5 md:pt-10 lg:gap-10 gap-5 flex-col md:flex-row">
<div class="w-full md:w-3/4 flex flex-col gap-6">
<lfx-health-score-banner />
<lfx-card class="pt-6 flex flex-col md:gap-10 gap-5">
<lfx-project-trust-score-v2
:health-score-v2="healthScoreV2Data?.healthScoreV2 ?? null"
Expand Down Expand Up @@ -78,7 +77,6 @@ import LfxHealthBreakdownSection from '~/components/modules/project/components/o
import LfxProjectTrustScoreV2 from '~/components/modules/project/components/overview/trust-score-v2.vue';
// TEMPORARILY HIDDEN (IN-1243): Impact section disabled until underlying data quality issue is fixed. Re-enable by uncommenting.
// import LfxImpactBreakdownSection from '~/components/modules/project/components/overview/impact-breakdown-section.vue';
import LfxHealthScoreBanner from '~/components/modules/project/components/overview/health-score-banner.vue';
import { useProjectStore } from '~/components/modules/project/store/project.store';
import LfxReposExclusionFooter from '~/components/shared/components/repos-exclusion-footer.vue';
import LfxCard from '~/components/uikit/card/card.vue';
Expand Down
32 changes: 2 additions & 30 deletions frontend/docs/metrics/health-score/index.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Health Score Explained

<!-- TEMPORARILY HIDDEN (IN-1243): original intro described three assessments: "LFX Insights surfaces up to **three independent assessments** for open source projects: a **Lifecycle state**, a **Health Score**, and when applicable an **Impact Score**. Together, these replace the previous single composite score and answer three distinct questions:" Re-enable by uncommenting and removing the two-assessment replacement below. -->
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:
<!-- TEMPORARILY HIDDEN (IN-1243): original intro described three assessments: "LFX Insights surfaces up to **three independent assessments** for open source projects: a **Lifecycle state**, a **Health Score**, and when applicable an **Impact Score**. Together, they answer three distinct questions:" Re-enable by uncommenting and removing the two-assessment replacement below. -->
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?
Expand Down Expand Up @@ -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<!-- TEMPORARILY HIDDEN (IN-1243): originally "three independent assessments". Re-enable by uncommenting and restoring "three". --> and re-weighted health categories.
:::

| Aspect | Original Health Score | Current methodology |
|---|---|---|
| **Output** | Single score (0–100) | Two assessments: Lifecycle (state), Health Score (0–100)<!-- TEMPORARILY HIDDEN (IN-1243): cell originally read "Three assessments: Lifecycle (state), Health Score (0-100), Impact Score (0-100)". Re-enable by uncommenting and restoring the third assessment. --> |
| **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<!-- TEMPORARILY HIDDEN (IN-1243): cell originally read "Separate Impact Score; does not affect Health". Re-enable by uncommenting and restoring the Impact Score reference. --> |
| **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.
71 changes: 71 additions & 0 deletions frontend/server/api/badge/active-contributors.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

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<string, unknown>,
];
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();
});
});
16 changes: 12 additions & 4 deletions frontend/server/api/badge/active-contributors.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
// 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<void> => {
const query = getQuery(event);
const project: string = query?.project as string;
const repos: string[] = (query?.repos as string)?.split(',') || undefined;

try {
const res = await fetchFromTinybird<HealthScoreTinybird[]>(
'/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<TinybirdActiveContributorsSummary>(
'/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`;
Expand Down
Loading