diff --git a/frontend/app/components/modules/collection/components/details/collection-health-score-pill.vue b/frontend/app/components/modules/collection/components/details/collection-health-score-pill.vue
index b5f16d276..d06e113eb 100644
--- a/frontend/app/components/modules/collection/components/details/collection-health-score-pill.vue
+++ b/frontend/app/components/modules/collection/components/details/collection-health-score-pill.vue
@@ -31,6 +31,7 @@ import LfxChip from '~/components/uikit/chip/chip.vue';
const props = defineProps<{
score: number;
+ healthLabel?: string | null;
unavailable?: boolean;
}>();
@@ -39,19 +40,32 @@ const props = defineProps<{
// labeled metrics chip). Duplicated rather than shared: only two call sites (row/card) and the
// logic is ~10 lines, so a composable would be more ceremony than the duplication it avoids.
// health-score.vue itself is intentionally left untouched (used elsewhere in the app).
+//
+// Prefers the API's real healthLabel (v2) when present; falls back to deriving the label from
+// the score for sparse rows where the pipe didn't return a label.
const healthScoreLabel = computed(() => {
+ if (props.healthLabel) {
+ return props.healthLabel.charAt(0).toUpperCase() + props.healthLabel.slice(1);
+ }
const score = props.score;
- if (score >= 80) return 'Excellent';
- if (score >= 60) return 'Healthy';
- if (score >= 40) return 'Fair';
- if (score >= 20) return 'Concerning';
+ if (score >= 85) return 'Excellent';
+ if (score >= 70) return 'Healthy';
+ if (score >= 50) return 'Fair';
+ if (score >= 30) return 'Concerning';
return 'Critical';
});
const healthScoreDotClass = computed(() => {
+ if (props.healthLabel) {
+ if (props.healthLabel === 'excellent' || props.healthLabel === 'healthy') return 'bg-health-healthy';
+ if (props.healthLabel === 'fair') return 'bg-health-fair';
+ if (props.healthLabel === 'concerning') return 'bg-health-concerning';
+ return 'bg-health-critical';
+ }
const score = props.score;
- if (score >= 60) return 'bg-health-healthy';
- if (score >= 20) return 'bg-health-concerning';
+ if (score >= 70) return 'bg-health-healthy';
+ if (score >= 50) return 'bg-health-fair';
+ if (score >= 30) return 'bg-health-concerning';
return 'bg-health-critical';
});
diff --git a/frontend/app/components/modules/collection/components/details/collection-metrics-row.vue b/frontend/app/components/modules/collection/components/details/collection-metrics-row.vue
index ed022e8e0..eede8b35a 100644
--- a/frontend/app/components/modules/collection/components/details/collection-metrics-row.vue
+++ b/frontend/app/components/modules/collection/components/details/collection-metrics-row.vue
@@ -103,17 +103,18 @@ const avgHealthScore = computed(() => props.metrics?.avgHealthScore);
// intentionally left untouched. Colors use the shared health-* tokens from colors.ts.
const healthScoreLabel = computed(() => {
const score = avgHealthScore.value ?? 0;
- if (score >= 80) return 'Excellent';
- if (score >= 60) return 'Healthy';
- if (score >= 40) return 'Fair';
- if (score >= 20) return 'Concerning';
+ if (score >= 85) return 'Excellent';
+ if (score >= 70) return 'Healthy';
+ if (score >= 50) return 'Fair';
+ if (score >= 30) return 'Concerning';
return 'Critical';
});
const healthScoreDotClass = computed(() => {
const score = avgHealthScore.value ?? 0;
- if (score >= 60) return 'bg-health-healthy';
- if (score >= 20) return 'bg-health-concerning';
+ if (score >= 70) return 'bg-health-healthy';
+ if (score >= 50) return 'bg-health-fair';
+ if (score >= 30) return 'bg-health-concerning';
return 'bg-health-critical';
});
diff --git a/frontend/app/components/modules/collection/components/details/collection-project-item.vue b/frontend/app/components/modules/collection/components/details/collection-project-item.vue
index 0e0af44db..ed5cfe3e2 100644
--- a/frontend/app/components/modules/collection/components/details/collection-project-item.vue
+++ b/frontend/app/components/modules/collection/components/details/collection-project-item.vue
@@ -68,17 +68,11 @@ SPDX-License-Identifier: MIT
:unavailable="true"
:score="0"
/>
-
-
-
-
-
-
+ :score="project.healthScoreV2 ?? 0"
+ :health-label="project.healthLabel"
+ />
{{ formatNumber(props.project.contributorCount) }}
@@ -161,7 +155,8 @@ SPDX-License-Identifier: MIT
・
@@ -190,7 +185,6 @@ import LfxTooltip from '~/components/uikit/tooltip/tooltip.vue';
import { formatNumber } from '~/components/shared/utils/formatter';
import { LfxRoutes } from '~/components/shared/types/routes';
import LfxCollectionHealthScorePill from '~/components/modules/collection/components/details/collection-health-score-pill.vue';
-import LfxHealthScoreDetails from '~/components/modules/collection/components/details/health-score-details.vue';
import LfxDependencyColumn from '~/components/modules/collection/components/details/dependency-column.vue';
import LfxDependencyDetails from '~/components/modules/collection/components/details/dependency-details.vue';
import LfxBadgeDetails from '~/components/modules/collection/components/details/badge-details.vue';
@@ -242,12 +236,7 @@ const isOnboarded = computed(() => {
return props.project.contributorCount > 0 || props.project.organizationCount > 0;
});
-const isHealthScoreUnavailable = computed(() => {
- const { contributorHealthScore, popularityHealthScore, developmentHealthScore, securityHealthScore } = props.project;
- return [contributorHealthScore, popularityHealthScore, developmentHealthScore, securityHealthScore].some(
- (score) => !score,
- );
-});
+const isHealthScoreUnavailable = computed(() => props.project.healthScoreV2 == null);
const navigateToItem = () => {
if (props.project.type === 'repo') {
diff --git a/frontend/app/components/modules/collection/components/details/health-score-details.vue b/frontend/app/components/modules/collection/components/details/health-score-details.vue
deleted file mode 100644
index f66a28677..000000000
--- a/frontend/app/components/modules/collection/components/details/health-score-details.vue
+++ /dev/null
@@ -1,121 +0,0 @@
-
-
-
-
-
Health Score breakdown
-
-
-
-
-
-
-
- {{ item.label }}
- ・{{ getHealthLabel(item.score) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/app/components/modules/project/components/overview/health-breakdown-section.vue b/frontend/app/components/modules/project/components/overview/health-breakdown-section.vue
new file mode 100644
index 000000000..83433d10c
--- /dev/null
+++ b/frontend/app/components/modules/project/components/overview/health-breakdown-section.vue
@@ -0,0 +1,238 @@
+
+
+
+
+
Health breakdown
+
+
+ {{ scoreLabel }}
+ ({{ props.healthScoreV2 }}/100)
+
+
+
+ Maintainer availability, known risks and supply chain posture, and whether the project is actively developed or
+ appropriately stable.
+
+
+
+
+ Health Score unavailable. All three categories have less than 40% signal coverage for this project.
+
+
+
+
+
+
+
+
+
+
+ {{ row.name }}
+
+
{{ row.description }}
+
+
+
+
+
+ View more in {{ selectedCategoryRoute.label }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/app/components/modules/project/components/overview/health-breakdown/category-card.vue b/frontend/app/components/modules/project/components/overview/health-breakdown/category-card.vue
new file mode 100644
index 000000000..e974d23c0
--- /dev/null
+++ b/frontend/app/components/modules/project/components/overview/health-breakdown/category-card.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+ {{ props.name }}
+
+ {{ props.score }} /{{ props.maxScore }}
+
+ No data
+
+
+
+ {{ props.description }}
+
+
+
+
+
+
+
diff --git a/frontend/app/components/modules/project/components/overview/impact-breakdown-section.vue b/frontend/app/components/modules/project/components/overview/impact-breakdown-section.vue
new file mode 100644
index 000000000..b88fabc5a
--- /dev/null
+++ b/frontend/app/components/modules/project/components/overview/impact-breakdown-section.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
Impact breakdown
+
+ {{ impactLabelDisplay }}
+ ({{ props.impactScore }}/100)
+
+
+
+
+
+
+ {{ impactDescription }}
+
+
+
+
+
+
+
+
+
+
+ Something went wrong while loading the Impact breakdown for this project. Please try again later.
+
+
+
+
+
+
+
+
diff --git a/frontend/app/components/modules/project/components/overview/impact-breakdown/metric-row.vue b/frontend/app/components/modules/project/components/overview/impact-breakdown/metric-row.vue
new file mode 100644
index 000000000..efc1e8837
--- /dev/null
+++ b/frontend/app/components/modules/project/components/overview/impact-breakdown/metric-row.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+ {{ props.name }}
+ Pending
+ {{ props.band }}
+ No data
+
+
{{ props.signalType }} signal
+
+ {{ props.description }}
+
+
+
+ {{ props.value !== null ? formatNumberShort(props.value) : '—' }}
+
+
+
+
+
+
+
diff --git a/frontend/app/components/modules/project/components/overview/score-details/details-empty.vue b/frontend/app/components/modules/project/components/overview/score-details/details-empty.vue
deleted file mode 100644
index 52f32e268..000000000
--- a/frontend/app/components/modules/project/components/overview/score-details/details-empty.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
- {{ label }} metrics are unavailable because the required data isn't available for this project.
-
-
-
-
-
-
-
diff --git a/frontend/app/components/modules/project/components/overview/score-details/score-accordion-view.vue b/frontend/app/components/modules/project/components/overview/score-details/score-accordion-view.vue
deleted file mode 100644
index 6cb577b53..000000000
--- a/frontend/app/components/modules/project/components/overview/score-details/score-accordion-view.vue
+++ /dev/null
@@ -1,85 +0,0 @@
-
-
-
-
-
- {{ tab.label }}
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/app/components/modules/project/components/overview/score-details/score-item.vue b/frontend/app/components/modules/project/components/overview/score-details/score-item.vue
deleted file mode 100644
index e1c7e24d2..000000000
--- a/frontend/app/components/modules/project/components/overview/score-details/score-item.vue
+++ /dev/null
@@ -1,105 +0,0 @@
-
-
-
-
-
-
-
- {{ title }}
-
-
- {{ description }}
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/app/components/modules/project/components/overview/score-details/score-list.vue b/frontend/app/components/modules/project/components/overview/score-details/score-list.vue
deleted file mode 100644
index 1632ff963..000000000
--- a/frontend/app/components/modules/project/components/overview/score-details/score-list.vue
+++ /dev/null
@@ -1,74 +0,0 @@
-
-
-
-
-
-
-
diff --git a/frontend/app/components/modules/project/components/overview/score-details/score-tab-view.vue b/frontend/app/components/modules/project/components/overview/score-details/score-tab-view.vue
deleted file mode 100644
index 89ca989df..000000000
--- a/frontend/app/components/modules/project/components/overview/score-details/score-tab-view.vue
+++ /dev/null
@@ -1,164 +0,0 @@
-
-
-
-
-
-
-
-
- {{ option.label }} metrics are unavailable because the required data isn't available for this project.
- Learn more
-
-
-
-
- {{ option.label }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/app/components/modules/project/components/overview/score-tabs.vue b/frontend/app/components/modules/project/components/overview/score-tabs.vue
deleted file mode 100644
index ee334140f..000000000
--- a/frontend/app/components/modules/project/components/overview/score-tabs.vue
+++ /dev/null
@@ -1,158 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/app/components/modules/project/components/overview/trust-score-v2.vue b/frontend/app/components/modules/project/components/overview/trust-score-v2.vue
new file mode 100644
index 000000000..d965cccbb
--- /dev/null
+++ b/frontend/app/components/modules/project/components/overview/trust-score-v2.vue
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+
+
+
+ HEALTH SCORE
+
+
+
+
+ The Insights Health Score measures an open source project's overall trustworthiness, based on
+ maintainer activity, security posture, and development cadence.
+
+
+
+
+
{{ scoreLabel }}
+
+
+
+
+
+ {{ healthScoreDescription }}
+
+
+
+
+
IMPACT
+
+ {{ impactLabelDisplay }}
+ ({{ impactScore }}/100)
+
+
+
+
+ {{ impactDescription }}
+
+
+
+
+
LIFECYCLE
+
+
+ {{ lifecycleConfig.label }}
+
+
+
+ {{ lifecycleDescription }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Something went wrong while loading the Health score for this project. Please try again later.
+
+
+
+
+
+
+
+
+
diff --git a/frontend/app/components/modules/project/components/overview/trust-score.vue b/frontend/app/components/modules/project/components/overview/trust-score.vue
deleted file mode 100644
index a19f6d1d1..000000000
--- a/frontend/app/components/modules/project/components/overview/trust-score.vue
+++ /dev/null
@@ -1,133 +0,0 @@
-
-
-
-
-
-
-
-
Health score
-
-
-
-
-
-
-
- LFX Insights does not have enough meaningful data to generate an overall Health score for this project.
-
-
-
-
-
-
- Select “All repositories” in order to get the aggregated Health Score
-
-
- The Insights Health Score combines the four key areas to measure an open source project's overall
- trustworthiness.
- Learn more
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/app/components/modules/project/components/overview/trust-score/health-score-ring.vue b/frontend/app/components/modules/project/components/overview/trust-score/health-score-ring.vue
new file mode 100644
index 000000000..b0dda701a
--- /dev/null
+++ b/frontend/app/components/modules/project/components/overview/trust-score/health-score-ring.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+ {{
+ props.unavailable ? '—' : props.score
+ }}
+ out of 100
+
+
+
+
+
+
+
diff --git a/frontend/app/components/modules/project/components/overview/trust-score/score-display.vue b/frontend/app/components/modules/project/components/overview/trust-score/score-display.vue
deleted file mode 100644
index 83f028315..000000000
--- a/frontend/app/components/modules/project/components/overview/trust-score/score-display.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
-
- {{ scoreConfig.label }}
-
-
-
-
- {{ scoreConfig.label }}
-
-
-
- Health score:
- {{ overallScore }}/100 points
-
-
-
-
-
-
-
-
-
diff --git a/frontend/app/components/modules/project/components/overview/trust-score/share-badge.vue b/frontend/app/components/modules/project/components/overview/trust-score/share-badge.vue
index 7a416e163..ada82ccd1 100644
--- a/frontend/app/components/modules/project/components/overview/trust-score/share-badge.vue
+++ b/frontend/app/components/modules/project/components/overview/trust-score/share-badge.vue
@@ -3,24 +3,27 @@ Copyright (c) 2025 The Linux Foundation and each contributor.
SPDX-License-Identifier: MIT
-->
-
-
+
+
+
+
+ Display your project Health score on your GitHub page.
+ Display your repository's number of active contributors on your GitHub page.
+
+
-
- Share your project Health Score in your GitHub page.
- Share your repository's number of active contributors on your GitHub page.
-
-
- Generate badge
-
+
@@ -31,6 +34,7 @@ import { useRoute } from 'nuxt/app';
import { useShareStore } from '~/components/shared/modules/share/store/share.store';
import { useProjectStore } from '~~/app/components/modules/project/store/project.store';
import { getBadgeUrl } from '~~/config/trust-score';
+import LfxButton from '~/components/uikit/button/button.vue';
const props = defineProps<{
isRepoSelected?: boolean;
diff --git a/frontend/app/components/modules/project/services/overview.api.service.ts b/frontend/app/components/modules/project/services/overview.api.service.ts
index d24352054..6a6a2076d 100644
--- a/frontend/app/components/modules/project/services/overview.api.service.ts
+++ b/frontend/app/components/modules/project/services/overview.api.service.ts
@@ -8,7 +8,11 @@ import type { WidgetArea } from '../../widget/types/widget-area';
import type { Widget } from '../../widget/types/widget';
import type { WidgetConfig } from '../../widget/config/widget.config';
import { lfxWidgets } from '../../widget/config/widget.config';
-import type { HealthScoreResults } from '~~/types/overview/responses.types';
+import type {
+ HealthScoreV2Results,
+ ImpactBreakdownResults,
+ HealthBreakdownResults,
+} from '~~/types/overview/responses.types';
import { TanstackKey } from '~/components/shared/types/tanstack';
import type { Organization } from '~~/types/contributors/responses.types';
@@ -23,35 +27,40 @@ export interface ScoreDataQueryParams extends OverviewQueryParams {
// TODO: Refactor other services to follow this pattern
class OverviewApiService {
- fetchHealthScoreOverview(params: ComputedRef
) {
+ fetchHealthScoreV2(params: ComputedRef<{ projectSlug: string }>) {
+ const queryKey = computed(() => [TanstackKey.HEALTH_SCORE_V2, params.value.projectSlug]);
+ const queryFn: QueryFunction = async () =>
+ await $fetch(`/api/project/${params.value.projectSlug}/overview/health-score-v2`);
+
+ return useQuery({
+ queryKey,
+ queryFn,
+ });
+ }
+
+ fetchHealthScoreImpactBreakdown(params: ComputedRef<{ projectSlug: string }>) {
const queryKey = computed(() => [
- TanstackKey.HEALTH_SCORE_OVERVIEW,
+ TanstackKey.HEALTH_SCORE_IMPACT_BREAKDOWN,
params.value.projectSlug,
- params.value.repos,
]);
- const queryFn = computed>(() =>
- this.healthScoreOverviewQueryFn(() => ({
- projectSlug: params.value.projectSlug,
- repos: params.value.repos,
- })),
- );
+ const queryFn: QueryFunction = async () =>
+ await $fetch(`/api/project/${params.value.projectSlug}/overview/health-score-impact`);
- return useQuery({
+ return useQuery({
queryKey,
queryFn,
});
}
- healthScoreOverviewQueryFn(
- query: () => Record,
- ): QueryFunction {
- const { projectSlug, repos } = query();
- return async () =>
- await $fetch(`/api/project/${projectSlug}/overview/health-score-overview`, {
- params: {
- repos,
- },
- });
+ fetchHealthScoreBreakdown(params: ComputedRef<{ projectSlug: string }>) {
+ const queryKey = computed(() => [TanstackKey.HEALTH_SCORE_BREAKDOWN, params.value.projectSlug]);
+ const queryFn: QueryFunction = async () =>
+ await $fetch(`/api/project/${params.value.projectSlug}/overview/health-score-breakdown`);
+
+ return useQuery({
+ queryKey,
+ queryFn,
+ });
}
fetchAssociatedOrganization(params: ComputedRef) {
diff --git a/frontend/app/components/modules/project/views/overview.vue b/frontend/app/components/modules/project/views/overview.vue
index 9cdbefdd5..a1b2dfecc 100644
--- a/frontend/app/components/modules/project/views/overview.vue
+++ b/frontend/app/components/modules/project/views/overview.vue
@@ -5,38 +5,50 @@ SPDX-License-Identifier: MIT
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
@@ -51,104 +63,39 @@ SPDX-License-Identifier: MIT
import { computed, onServerPrefetch } from 'vue';
import { useRoute } from 'nuxt/app';
import { storeToRefs } from 'pinia';
-import { WidgetArea } from '../../widget/types/widget-area';
import LfxProjectAboutSection from '~/components/modules/project/components/overview/about-section.vue';
-import LfxProjectScoreTabs from '~/components/modules/project/components/overview/score-tabs.vue';
-import LfxProjectTrustScore from '~/components/modules/project/components/overview/trust-score.vue';
-import { useProjectStore } from '~~/app/components/modules/project/store/project.store';
+import LfxProjectTrustScoreV2 from '~/components/modules/project/components/overview/trust-score-v2.vue';
+import LfxHealthBreakdownSection from '~/components/modules/project/components/overview/health-breakdown-section.vue';
+import LfxImpactBreakdownSection from '~/components/modules/project/components/overview/impact-breakdown-section.vue';
import { OVERVIEW_API_SERVICE } from '~~/app/components/modules/project/services/overview.api.service';
-import type { TrustScoreSummary } from '~~/types/overview/responses.types';
import LfxCard from '~/components/uikit/card/card.vue';
-import type { HealthScoreResults } from '~~/types/overview/responses.types';
import LfxReposExclusionFooter from '~/components/shared/components/repos-exclusion-footer.vue';
+import { useProjectStore } from '~/components/modules/project/store/project.store';
const route = useRoute();
-const { selectedReposValues, project, allArchived, hasSelectedArchivedRepos, isProjectArchived } =
- storeToRefs(useProjectStore());
+const { hasSelectedArchivedRepos, selectedRepositories, isArchived } = storeToRefs(useProjectStore());
const params = computed(() => ({
projectSlug: route.params.slug as string,
- repos: selectedReposValues.value,
}));
-// Contributors score is only displayed if some contributors widgets are enabled
-const displayContributorsScore = computed(() => isScoreVisible(WidgetArea.CONTRIBUTORS));
-
-// Development score is only displayed if some development widgets are enabled
-const displayDevelopmentScore = computed(() => isScoreVisible(WidgetArea.DEVELOPMENT));
-
-// Popularity score is only displayed if some popularity widgets are enabled
-const displayPopularityScore = computed(() => isScoreVisible(WidgetArea.POPULARITY));
-
-// Security score is only displayed if security data is available
-const displaySecurityScore = computed(() => securityScore.value && securityScore.value.length > 0);
-
-const isArchived = computed(() => allArchived.value || isProjectArchived.value);
-
-const scoreDisplay = computed(() => ({
- overall:
- displayContributorsScore.value &&
- displayDevelopmentScore.value &&
- displayPopularityScore.value &&
- displaySecurityScore.value,
- contributors: displayContributorsScore.value,
- development: displayDevelopmentScore.value,
- popularity: displayPopularityScore.value,
- security: displaySecurityScore.value,
-}));
-
-const { data: overviewData, status, error, suspense } = OVERVIEW_API_SERVICE.fetchHealthScoreOverview(params);
-
-/**
- * TODO: remove this after https://linear.app/lfx/issue/INS-822/periodicly-check-for-widgets-data-and-enabledisable-them
- * is implemented
- *
- * This is a workaround to show/hide the Search Queries from the score.
- * ===============================
- */
-
-// delete the search queries from the overview data
-const data = computed(() => {
- const data = { ...overviewData.value };
- if (overviewData.value?.searchQueries?.value === 0) {
- delete data.searchQueries;
- }
- return data as HealthScoreResults;
-});
-
-/**
- * ===============================
- */
-
-const securityScore = computed(() => data.value?.securityCategoryPercentage || []);
-
-const trustSummary = computed
(() => ({
- overall: data.value?.overallScore || 0,
- popularity: data.value?.popularityPercentage || 0,
- contributors: data.value?.contributorPercentage || 0,
- security: data.value?.securityPercentage || 0,
- development: data.value?.developmentPercentage || 0,
-}));
-
-const isScoreVisible = (widgetArea: WidgetArea) => {
- const widgetKeys = OVERVIEW_API_SERVICE.getOverviewWidgetConfigs(widgetArea);
- return widgetKeys.some((widget) => project.value?.widgets?.includes(widget.key));
-};
+const {
+ data: healthScoreV2Data,
+ status: healthScoreV2Status,
+ suspense,
+} = OVERVIEW_API_SERVICE.fetchHealthScoreV2(params);
-const isRepoSelected = computed(() => selectedReposValues.value.length > 0);
+const {
+ data: impactBreakdownData,
+ status: impactBreakdownStatus,
+ suspense: impactBreakdownSuspense,
+} = OVERVIEW_API_SERVICE.fetchHealthScoreImpactBreakdown(params);
-const isEmpty = computed(() =>
- [
- trustSummary.value?.overall,
- trustSummary.value?.contributors,
- trustSummary.value?.popularity,
- trustSummary.value?.development,
- trustSummary.value?.security,
- ].every((score) => score === 0),
-);
+const { data: healthBreakdownData, suspense: healthBreakdownSuspense } =
+ OVERVIEW_API_SERVICE.fetchHealthScoreBreakdown(params);
onServerPrefetch(async () => {
- await suspense();
+ await Promise.all([suspense(), impactBreakdownSuspense(), healthBreakdownSuspense()]);
});
diff --git a/frontend/app/components/shared/components/health-score.vue b/frontend/app/components/shared/components/health-score.vue
index 60712ffc8..2307fd1ca 100644
--- a/frontend/app/components/shared/components/health-score.vue
+++ b/frontend/app/components/shared/components/health-score.vue
@@ -2,6 +2,13 @@
Copyright (c) 2025 The Linux Foundation and each contributor.
SPDX-License-Identifier: MIT
-->
+
Excellent
Healthy
- Stable
+ Fair
- Unsteady
+ Concerning
Archived repositories are excluded from
- {{ pageContent === 'health-score' ? 'Health Score and Security & Best practices' : 'Security & Best practices' }}.
+ {{ pageContent === 'health-score' ? 'Health Score' : 'Security & Best practices' }}.
diff --git a/frontend/app/components/shared/types/tanstack.ts b/frontend/app/components/shared/types/tanstack.ts
index b8656c8a5..01894ef8f 100644
--- a/frontend/app/components/shared/types/tanstack.ts
+++ b/frontend/app/components/shared/types/tanstack.ts
@@ -58,6 +58,9 @@ export enum TanstackKey {
// Overview
HEALTH_SCORE = 'health-score',
HEALTH_SCORE_OVERVIEW = 'health-score-overview',
+ HEALTH_SCORE_V2 = 'health-score-v2',
+ HEALTH_SCORE_IMPACT_BREAKDOWN = 'health-score-impact-breakdown',
+ HEALTH_SCORE_BREAKDOWN = 'health-score-breakdown',
TRUST_SCORE_SUMMARY = 'trust-score-summary',
SCORE_DATA = 'score-data',
ASSOCIATED_ORGANIZATION = 'associated-organization',
diff --git a/frontend/app/components/uikit/benchmarks/benchmark-icon.vue b/frontend/app/components/uikit/benchmarks/benchmark-icon.vue
index 0ae4f40e2..e2ff71054 100644
--- a/frontend/app/components/uikit/benchmarks/benchmark-icon.vue
+++ b/frontend/app/components/uikit/benchmarks/benchmark-icon.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: MIT
+
@@ -36,10 +42,12 @@ const props = withDefaults(
type: string;
useTriangle?: boolean;
size?: number;
+ circle?: boolean;
}>(),
{
useTriangle: false,
size: 16,
+ circle: false,
},
);
diff --git a/frontend/app/components/uikit/benchmarks/benchmarks.scss b/frontend/app/components/uikit/benchmarks/benchmarks.scss
index 5845f7886..7f4432fa6 100644
--- a/frontend/app/components/uikit/benchmarks/benchmarks.scss
+++ b/frontend/app/components/uikit/benchmarks/benchmarks.scss
@@ -58,6 +58,32 @@
&--negative {
@apply text-negative-500;
}
+
+ &--no-data {
+ @apply text-neutral-400;
+ }
+
+ &--circle {
+ @apply flex items-center justify-center rounded-full shrink-0;
+ width: 24px;
+ height: 24px;
+
+ &.c-benchmarks-icon--positive {
+ background-color: #d0fae5;
+ }
+
+ &.c-benchmarks-icon--warning {
+ background-color: #fef3c7;
+ }
+
+ &.c-benchmarks-icon--negative {
+ background-color: #fee2e2;
+ }
+
+ &.c-benchmarks-icon--no-data {
+ @apply bg-neutral-100;
+ }
+ }
}
.c-benchmarks-wrap {
diff --git a/frontend/app/components/uikit/chart/configs/gauge.chart.ts b/frontend/app/components/uikit/chart/configs/gauge.chart.ts
index 081c74a0c..75066ba51 100644
--- a/frontend/app/components/uikit/chart/configs/gauge.chart.ts
+++ b/frontend/app/components/uikit/chart/configs/gauge.chart.ts
@@ -114,6 +114,15 @@ const fullDataOpts = {
*/
export const getGaugeChartConfig = (data: GaugeData): ECOption => {
const gaugeSeries = { ...(data.gaugeType === 'half' ? halfSeriesStyle : fullSeriesStyle) };
+ if (data.lineWidth !== undefined) {
+ gaugeSeries.axisLine = {
+ ...gaugeSeries.axisLine,
+ lineStyle: {
+ ...gaugeSeries.axisLine?.lineStyle,
+ width: data.lineWidth,
+ },
+ };
+ }
gaugeSeries.detail = {
...(data.gaugeType === 'half' ? halfDetail : fullDetail),
formatter:
diff --git a/frontend/app/components/uikit/chart/types/ChartTypes.ts b/frontend/app/components/uikit/chart/types/ChartTypes.ts
index 9b6b17523..82c6752b5 100644
--- a/frontend/app/components/uikit/chart/types/ChartTypes.ts
+++ b/frontend/app/components/uikit/chart/types/ChartTypes.ts
@@ -40,6 +40,7 @@ export interface GaugeData {
noData?: boolean;
graphOnly?: boolean;
gaugeType: 'half' | 'full';
+ lineWidth?: number;
}
export interface CategoryDataItem {
diff --git a/frontend/app/components/uikit/tag/tag.scss b/frontend/app/components/uikit/tag/tag.scss
index a60f4cd11..99b659ea2 100644
--- a/frontend/app/components/uikit/tag/tag.scss
+++ b/frontend/app/components/uikit/tag/tag.scss
@@ -90,4 +90,13 @@
@apply px-2.5 h-6;
}
}
+
+ /* Dashed (pending state, e.g. a signal still computing) */
+ &--dashed {
+ @apply italic border border-dashed;
+
+ &.c-tag--default {
+ @apply border-neutral-300 bg-neutral-100 text-neutral-400;
+ }
+ }
}
diff --git a/frontend/app/components/uikit/tag/tag.stories.ts b/frontend/app/components/uikit/tag/tag.stories.ts
index 1074b0ac7..11efd63e9 100644
--- a/frontend/app/components/uikit/tag/tag.stories.ts
+++ b/frontend/app/components/uikit/tag/tag.stories.ts
@@ -96,3 +96,12 @@ export const Transparent = {
type: 'transparent',
},
};
+
+export const Dashed = {
+ args: {
+ default: 'Pending',
+ variation: 'default',
+ size: 'medium',
+ type: 'dashed',
+ },
+};
diff --git a/frontend/app/components/uikit/tag/types/tag.types.ts b/frontend/app/components/uikit/tag/types/tag.types.ts
index 7c908b077..50240ad09 100644
--- a/frontend/app/components/uikit/tag/types/tag.types.ts
+++ b/frontend/app/components/uikit/tag/types/tag.types.ts
@@ -12,7 +12,7 @@ export const tagStyles = [
'negative-solid',
] as const;
export const tagSizes = ['small', 'medium'] as const;
-export const tagTypes = ['solid', 'transparent', 'outline'] as const;
+export const tagTypes = ['solid', 'transparent', 'outline', 'dashed'] as const;
export type TagStyle = (typeof tagStyles)[number];
export type TagSize = (typeof tagSizes)[number];
diff --git a/frontend/app/config/styles/colors.ts b/frontend/app/config/styles/colors.ts
index 2c29997bd..7746283cf 100644
--- a/frontend/app/config/styles/colors.ts
+++ b/frontend/app/config/styles/colors.ts
@@ -115,6 +115,7 @@ export const lfxColors = {
// scale. TODO: Verify with Nuno whether these should be folded into an existing scale.
health: {
healthy: '#00bc7d',
+ fair: '#009aff',
concerning: '#fe9a00',
critical: '#fb2c36',
},
diff --git a/frontend/config/health-breakdown-templates.ts b/frontend/config/health-breakdown-templates.ts
new file mode 100644
index 000000000..65ca4cd9f
--- /dev/null
+++ b/frontend/config/health-breakdown-templates.ts
@@ -0,0 +1,623 @@
+// Copyright (c) 2025 The Linux Foundation and each contributor.
+// SPDX-License-Identifier: MIT
+//
+// Content-generation templates for the Health Score v2 breakdown UI (IN-1212).
+// Every function here takes real per-project signal data and returns a generated
+// sentence following the bracket-pattern templates in the content spec
+// (attachments/IN-1212/content-spec.md) — no fixed/fabricated copy.
+import type { HealthBreakdownResults } from '~~/types/overview/responses.types';
+
+// ---------------------------------------------------------------------------
+// Section 2: Lifecycle State descriptions
+// ---------------------------------------------------------------------------
+
+const formatDays = (seconds: number): string => {
+ const days = Math.round(seconds / 86400);
+ return days === 1 ? '1 day' : `${days} days`;
+};
+
+export const getLifecycleDescription = (
+ state: string | null,
+ signals: HealthBreakdownResults | null,
+): string => {
+ switch (state) {
+ case 'active': {
+ return 'Consistent commits, responsive maintainers, regular releases, and healthy issue triage.';
+ }
+ case 'stable': {
+ return 'Mature and deliberately low-activity. The maintainer is reachable and there are no open issues or vulnerabilities that require attention.';
+ }
+ case 'declining': {
+ const responseDetail =
+ signals?.medianIssueResponseS !== null && signals?.medianIssueResponseS !== undefined
+ ? ` Issue response times are now averaging ${formatDays(signals.medianIssueResponseS)}.`
+ : '';
+ const maintainerDetail =
+ signals?.busFactorCount !== null && signals?.busFactorCount !== undefined
+ ? ` Only ${signals.busFactorCount} active maintainer${signals.busFactorCount === 1 ? '' : 's'} remain${signals.busFactorCount === 1 ? 's' : ''}.`
+ : '';
+ return `Maintainer activity has dropped significantly over the past six months.${maintainerDetail}${responseDetail}`;
+ }
+ case 'abandoned': {
+ const lastCommitDetail =
+ signals?.lastCommitAt !== null && signals?.lastCommitAt !== undefined
+ ? ` The last commit was on ${new Date(signals.lastCommitAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}.`
+ : '';
+ const cveDetail =
+ signals?.openCriticals !== null &&
+ signals?.openCriticals !== undefined &&
+ signals.openCriticals > 0
+ ? ` ${signals.openCriticals} critical vulnerabilit${signals.openCriticals === 1 ? 'y remains' : 'ies remain'} unaddressed.`
+ : '';
+ return `No maintainer activity in over 18 months.${lastCommitDetail}${cveDetail}`;
+ }
+ case 'archived': {
+ return 'The repository has been explicitly archived. No further updates are expected and the project is no longer accepting contributions.';
+ }
+ default: {
+ return 'No repository activity has been indexed for this project. Lifecycle state cannot be determined until a supported source platform is connected.';
+ }
+ }
+};
+
+// ---------------------------------------------------------------------------
+// Section 3: Health Score summary
+// ---------------------------------------------------------------------------
+
+const CATEGORY_LABEL: Record<'maintainer' | 'security' | 'development', string> = {
+ maintainer: 'maintainer coverage',
+ security: 'security posture',
+ development: 'development cadence',
+};
+
+export const getHealthScoreDescription = (
+ healthLabel: string | null,
+ maintainerScore: number | null,
+ securityScore: number | null,
+ developmentScore: number | null,
+): string | null => {
+ if (healthLabel === null) {
+ return 'No scoring data is available. This project has no indexed repositories or the connected platform has no supported data pipeline.';
+ }
+ const categoryPercents: { key: 'maintainer' | 'security' | 'development'; percent: number }[] = [
+ { key: 'maintainer', percent: maintainerScore !== null ? maintainerScore / 40 : -1 },
+ { key: 'security', percent: securityScore !== null ? securityScore / 35 : -1 },
+ { key: 'development', percent: developmentScore !== null ? developmentScore / 25 : -1 },
+ ].filter((c) => c.percent >= 0);
+
+ if (categoryPercents.length === 0) {
+ return `Overall project health is ${healthLabel}, based on maintainer activity, security posture, and development cadence.`;
+ }
+
+ const strongest = categoryPercents.reduce((a, b) => (b.percent > a.percent ? b : a));
+ const weakest = categoryPercents.reduce((a, b) => (b.percent < a.percent ? b : a));
+ const strongLabel = CATEGORY_LABEL[strongest.key];
+ const weakLabel = CATEGORY_LABEL[weakest.key];
+
+ if (healthLabel === 'excellent' || healthLabel === 'healthy') {
+ return strongest.key === weakest.key
+ ? `Strong ${strongLabel}, security practices, and development cadence across the board.`
+ : `Strong ${strongLabel}. ${weakLabel.charAt(0).toUpperCase() + weakLabel.slice(1)} is the remaining gap keeping the score from Excellent.`;
+ }
+ if (strongest.key === weakest.key) {
+ return `Overall project health is ${healthLabel}, based on maintainer activity, security posture, and development cadence.`;
+ }
+ return `${weakLabel.charAt(0).toUpperCase() + weakLabel.slice(1)} is dragging the score down, despite stronger ${strongLabel}.`;
+};
+
+// ---------------------------------------------------------------------------
+// Sections 4-6: Category descriptions
+// ---------------------------------------------------------------------------
+
+export type HealthCategory = 'maintainer' | 'security' | 'development';
+
+type CategoryBand = 'success' | 'warning' | 'danger';
+
+const categoryThresholds: Record = {
+ maintainer: { success: 28, warning: 16 },
+ security: { success: 23, warning: 12 },
+ development: { success: 17, warning: 9 },
+};
+
+const getCategoryBand = (category: HealthCategory, score: number): CategoryBand => {
+ const thresholds = categoryThresholds[category];
+ if (score >= thresholds.success) return 'success';
+ if (score >= thresholds.warning) return 'warning';
+ return 'danger';
+};
+
+const bandColor: Record = {
+ success: 'positive',
+ warning: 'warning',
+ danger: 'negative',
+};
+
+export const getCategoryScoreColor = (
+ category: HealthCategory,
+ score: number,
+): 'positive' | 'warning' | 'negative' => bandColor[getCategoryBand(category, score)];
+
+const getMaintainerDescription = (band: CategoryBand, signals: HealthBreakdownResults): string => {
+ const orgCount = signals.orgCount ?? 0;
+ const maintainerCount = signals.busFactorCount ?? 0;
+
+ if (band === 'success') {
+ return `Responsive maintainers, ${maintainerCount} active maintainer${maintainerCount === 1 ? '' : 's'} with merge rights, a stable contributor pipeline, and contributors spanning ${orgCount} organization${orgCount === 1 ? '' : 's'}.`;
+ }
+ if (band === 'warning') {
+ const teamDetail =
+ maintainerCount <= 1
+ ? 'A single active maintainer'
+ : `A small team of ${maintainerCount} active maintainers`;
+ const orgDetail =
+ orgCount <= 1
+ ? 'limited organizational diversity'
+ : `contributors across ${orgCount} organizations`;
+ return `${teamDetail} with slow response times, no contributor growth, and ${orgDetail}.`;
+ }
+ const responseDetail =
+ signals.medianIssueResponseS !== null
+ ? ` No response to issues in over ${formatDays(signals.medianIssueResponseS)}.`
+ : '';
+ return `No active maintainer.${responseDetail} There is no succession plan in place.`;
+};
+
+const getSecurityDescription = (band: CategoryBand, signals: HealthBreakdownResults): string => {
+ if (band === 'success') {
+ const scorecardDetail =
+ signals.scorecardScore !== null
+ ? ` OpenSSF Scorecard is ${signals.scorecardScore.toFixed(1)}/10.`
+ : '';
+ return `No open CVEs and full security practices in place.${scorecardDetail}`;
+ }
+ if (band === 'warning') {
+ const criticalCount = signals.openCriticals ?? 0;
+ const highCount = signals.openHighs ?? 0;
+ const cveText =
+ criticalCount + highCount > 0
+ ? `${criticalCount + highCount} open critical or high vulnerabilit${criticalCount + highCount === 1 ? 'y' : 'ies'}`
+ : 'No open critical or high vulnerabilities';
+ const missing: string[] = [];
+ if (!signals.securityPolicyEnabled) missing.push('no SECURITY.md');
+ if (!signals.branchProtectionEnabled) missing.push('no branch protection');
+ const missingText = missing.length > 0 ? `, but ${missing.join(' and ')}` : '';
+ return `${cveText}${missingText}.`;
+ }
+ const criticalCount = signals.openCriticals ?? 0;
+ const highCount = signals.openHighs ?? 0;
+ const cveDetail =
+ criticalCount + highCount > 0
+ ? `${criticalCount + highCount} open critical or high vulnerabilit${criticalCount + highCount === 1 ? 'y' : 'ies'}.`
+ : 'No open critical or high vulnerabilities.';
+ const practicesMissing: string[] = [];
+ if (!signals.securityPolicyEnabled) practicesMissing.push('no SECURITY.md');
+ if (!signals.branchProtectionEnabled) practicesMissing.push('no branch protection');
+ const practicesDetail =
+ practicesMissing.length > 0
+ ? `Security practices need attention: ${practicesMissing.join(' and ')}.`
+ : 'Dependency health needs attention.';
+ return `${cveDetail} ${practicesDetail}`;
+};
+
+const getDevelopmentDescription = (band: CategoryBand, signals: HealthBreakdownResults): string => {
+ if (band === 'success') {
+ const releaseDetail =
+ signals.daysSinceLatest !== null
+ ? `Active commit stream, with the last release ${Math.round(signals.daysSinceLatest)} days ago.`
+ : 'Active commit stream and a healthy release cadence.';
+ return `${releaseDetail} Issue triage is keeping pace with incoming reports.`;
+ }
+ if (band === 'warning') {
+ const releaseDetail =
+ signals.daysSinceLatest !== null && signals.daysSinceLatest > 365
+ ? `No release in over ${Math.floor(signals.daysSinceLatest / 365)} year${Math.floor(signals.daysSinceLatest / 365) === 1 ? '' : 's'}.`
+ : 'Release cadence has slowed.';
+ return `${releaseDetail} Some commit activity continues but the project shows signs of stagnation.`;
+ }
+ const commitDetail =
+ signals.commitsLast6m !== null && signals.commitsLast6m === 0
+ ? 'No commits in the past six months.'
+ : 'Minimal commit activity in the past six months.';
+ return `${commitDetail} Releases and issue resolution have both stalled.`;
+};
+
+export const getCategoryDescription = (
+ category: HealthCategory,
+ score: number | null,
+ available: boolean,
+ signals: HealthBreakdownResults | null,
+): string => {
+ if (!available || score === null || !signals) {
+ return getCategoryUnavailableDescription(category, signals);
+ }
+ const band = getCategoryBand(category, score);
+ if (category === 'maintainer') return getMaintainerDescription(band, signals);
+ if (category === 'security') return getSecurityDescription(band, signals);
+ return getDevelopmentDescription(band, signals);
+};
+
+const getCategoryUnavailableDescription = (
+ category: HealthCategory,
+ signals: HealthBreakdownResults | null,
+): string => {
+ if (category === 'maintainer') {
+ const availableSignals: string[] = [];
+ if (signals?.responsivenessAvailable) availableSignals.push('maintainer responsiveness');
+ if (signals?.busFactorAvailable) availableSignals.push('bus factor');
+ if (signals?.orgDiversityAvailable) availableSignals.push('org diversity');
+ const namedAvailable =
+ availableSignals.length > 0
+ ? `Only ${availableSignals.join(', ')} ${availableSignals.length === 1 ? 'is' : 'are'} available.`
+ : 'No maintainer signals are available.';
+ return `${namedAvailable} The category has been dropped from the Health Score composite.`;
+ }
+ if (category === 'security') {
+ const availableSignals: string[] = [];
+ if (signals?.scorecardAvailable) availableSignals.push('OpenSSF Scorecard');
+ if (signals?.securityPracticesAvailable) availableSignals.push('security practices');
+ if (signals?.dependencyHealthAvailable) availableSignals.push('dependency health');
+ const reason = signals?.isGerrit ? 'Gerrit-hosted projects' : 'this repository type';
+ const namedAvailable =
+ availableSignals.length > 0
+ ? `Only open vulnerability data and ${availableSignals.join(', ')} ${availableSignals.length === 1 ? 'is' : 'are'} available for ${reason}.`
+ : `Only open vulnerability data is available for ${reason}.`;
+ return `${namedAvailable} The category has been dropped from the Health Score composite.`;
+ }
+ const availableSignals: string[] = [];
+ if (signals?.releaseCadenceAvailable) availableSignals.push('release cadence');
+ if (signals?.issueResolutionAvailable) availableSignals.push('issue resolution');
+ if (signals?.prMergeAvailable) availableSignals.push('PR merge health');
+ const namedAvailable =
+ availableSignals.length > 0
+ ? `${availableSignals.join(' and ')} could not be assessed for this repository type.`
+ : 'Development signals could not be assessed for this repository type.';
+ return `${namedAvailable} The category has been dropped from the Health Score composite.`;
+};
+
+// ---------------------------------------------------------------------------
+// Section 7: Signal Rows
+// ---------------------------------------------------------------------------
+
+export type SignalRowStatus = 'positive' | 'warning' | 'negative' | 'no-data';
+
+export interface SignalRow {
+ status: SignalRowStatus;
+ description: string;
+}
+
+const blockedSignalDescription = (signalKey: string, signals: HealthBreakdownResults): string => {
+ if (signals.isGerrit) {
+ return `Not available for Gerrit-hosted repositories. ${signalKey} currently requires GitHub.`;
+ }
+ if (signals.isExcluded) {
+ return `Not available for this repository. ${signalKey} currently requires GitHub.`;
+ }
+ return `No data available. Repository has not been indexed for ${signalKey.toLowerCase()}.`;
+};
+
+// --- Maintainer signals ---
+
+export const getResponsivenessRow = (signals: HealthBreakdownResults): SignalRow => {
+ if (!signals.responsivenessAvailable) {
+ return {
+ status: 'no-data',
+ description: blockedSignalDescription('Maintainer responsiveness', signals),
+ };
+ }
+ if (signals.medianIssueResponseS === null) {
+ return {
+ status: 'no-data',
+ description: 'No issue or PR response data available for this project.',
+ };
+ }
+ const days = Math.round(signals.medianIssueResponseS / 86400);
+ const responseText = formatDays(signals.medianIssueResponseS);
+ if (days < 7) {
+ return {
+ status: 'positive',
+ description: `Median response time is ${responseText}, well within a week.`,
+ };
+ }
+ if (days < 30) {
+ return { status: 'warning', description: `Median response time is ${responseText}.` };
+ }
+ return {
+ status: 'negative',
+ description: `Median response time is ${responseText}, well over a month.`,
+ };
+};
+
+export const getBusFactorRow = (signals: HealthBreakdownResults): SignalRow => {
+ if (!signals.busFactorAvailable) {
+ return { status: 'no-data', description: blockedSignalDescription('Bus factor', signals) };
+ }
+ const count = signals.busFactorCount ?? 0;
+ if (count >= 3) {
+ return {
+ status: 'positive',
+ description: `${count} active maintainers currently have merge rights.`,
+ };
+ }
+ if (count >= 1) {
+ return {
+ status: 'warning',
+ description: `Only ${count} active maintainer${count === 1 ? '' : 's'} currently ${count === 1 ? 'has' : 'have'} merge rights.`,
+ };
+ }
+ return { status: 'negative', description: 'No active maintainers with merge rights were found.' };
+};
+
+export const getOrgDiversityRow = (signals: HealthBreakdownResults): SignalRow => {
+ if (!signals.orgDiversityAvailable) {
+ return { status: 'no-data', description: blockedSignalDescription('Org diversity', signals) };
+ }
+ const count = signals.orgCount ?? 0;
+ if (count >= 3) {
+ return { status: 'positive', description: `Contributors span ${count} organizations.` };
+ }
+ if (count >= 1) {
+ return {
+ status: 'warning',
+ description: `Contributors come from ${count} organization${count === 1 ? '' : 's'} only.`,
+ };
+ }
+ return {
+ status: 'negative',
+ description: 'No organization affiliation data is available for contributors.',
+ };
+};
+
+// --- Security signals ---
+
+export const getOpenVulnRow = (signals: HealthBreakdownResults): SignalRow => {
+ const criticals = signals.openCriticals ?? 0;
+ const highs = signals.openHighs ?? 0;
+ const moderates = signals.openModerates ?? 0;
+ if (criticals + highs > 0) {
+ return {
+ status: 'negative',
+ description: `${criticals + highs} open critical or high vulnerabilit${criticals + highs === 1 ? 'y' : 'ies'}.`,
+ };
+ }
+ if (moderates > 0) {
+ return {
+ status: 'warning',
+ description: `${moderates} open medium or low severity vulnerabilit${moderates === 1 ? 'y' : 'ies'}, no critical or high issues.`,
+ };
+ }
+ return { status: 'positive', description: 'No open vulnerabilities of any severity.' };
+};
+
+export const getScorecardRow = (signals: HealthBreakdownResults): SignalRow => {
+ if (!signals.scorecardAvailable) {
+ return {
+ status: 'no-data',
+ description: blockedSignalDescription('OpenSSF Scorecard', signals),
+ };
+ }
+ const score = signals.scorecardScore ?? 0;
+ if (score >= 7) {
+ return { status: 'positive', description: `OpenSSF Scorecard is ${score.toFixed(1)}/10.` };
+ }
+ if (score >= 4) {
+ return {
+ status: 'warning',
+ description: `OpenSSF Scorecard is ${score.toFixed(1)}/10, below the recommended baseline.`,
+ };
+ }
+ return { status: 'negative', description: `OpenSSF Scorecard is ${score.toFixed(1)}/10.` };
+};
+
+export const getSecurityPracticesRow = (signals: HealthBreakdownResults): SignalRow => {
+ if (!signals.securityPracticesAvailable) {
+ return {
+ status: 'no-data',
+ description: blockedSignalDescription('Security practices', signals),
+ };
+ }
+ const practices = [
+ signals.securityPolicyEnabled,
+ signals.branchProtectionEnabled,
+ signals.branchProtectionRequiredReviews,
+ signals.branchProtectionRequiresStatusChecks,
+ ];
+ const enabledCount = practices.filter((p) => Boolean(p)).length;
+ if (enabledCount >= 3) {
+ return {
+ status: 'positive',
+ description: `${enabledCount} of 4 tracked security practices are in place.`,
+ };
+ }
+ if (enabledCount >= 1) {
+ const missing: string[] = [];
+ if (!signals.securityPolicyEnabled) missing.push('no SECURITY.md');
+ if (!signals.branchProtectionEnabled) missing.push('no branch protection');
+ return {
+ status: 'warning',
+ description: `${enabledCount} of 4 tracked security practices are in place, ${missing.join(', ') || 'with gaps remaining'}.`,
+ };
+ }
+ return { status: 'negative', description: 'No security practices are in place.' };
+};
+
+export const getDependencyHealthRow = (signals: HealthBreakdownResults): SignalRow => {
+ if (!signals.dependencyHealthAvailable) {
+ return {
+ status: 'no-data',
+ description: blockedSignalDescription('Dependency health', signals),
+ };
+ }
+ const vulnerable = signals.vulnerableDeps ?? 0;
+ if (vulnerable === 0) {
+ return {
+ status: 'positive',
+ description: 'All dependencies are clean of known vulnerabilities.',
+ };
+ }
+ if (vulnerable <= 3) {
+ return {
+ status: 'warning',
+ description: `${vulnerable} dependenc${vulnerable === 1 ? 'y has' : 'ies have'} a known vulnerability.`,
+ };
+ }
+ return {
+ status: 'negative',
+ description: `${vulnerable} dependencies have a known vulnerability.`,
+ };
+};
+
+// --- Development signals ---
+
+export const getReleaseCadenceRow = (signals: HealthBreakdownResults): SignalRow => {
+ if (!signals.releaseCadenceAvailable) {
+ return { status: 'no-data', description: blockedSignalDescription('Release cadence', signals) };
+ }
+ const days = signals.daysSinceLatest;
+ if (days === null) {
+ return { status: 'no-data', description: 'No release history is available for this project.' };
+ }
+ if (days <= 180) {
+ return { status: 'positive', description: `Last release was ${Math.round(days)} days ago.` };
+ }
+ if (days <= 730) {
+ return {
+ status: 'warning',
+ description: `Last release was ${Math.round(days)} days ago, longer than six months.`,
+ };
+ }
+ return { status: 'negative', description: `No release in over ${Math.floor(days / 365)} years.` };
+};
+
+export const getCommitActivityRow = (signals: HealthBreakdownResults): SignalRow => {
+ const commits = signals.commitsLast6m;
+ if (commits === null) {
+ return { status: 'no-data', description: 'No commit history is available for this project.' };
+ }
+ if (commits > 20) {
+ return { status: 'positive', description: `${commits} commits in the past six months.` };
+ }
+ if (commits > 0) {
+ return {
+ status: 'warning',
+ description: `${commits} commit${commits === 1 ? '' : 's'} in the past six months, a slow pace.`,
+ };
+ }
+ return { status: 'negative', description: 'No commits in the past six months.' };
+};
+
+export const getIssueResolutionRow = (signals: HealthBreakdownResults): SignalRow => {
+ if (!signals.issueResolutionAvailable) {
+ return {
+ status: 'no-data',
+ description: blockedSignalDescription('Issue resolution', signals),
+ };
+ }
+ const closed = signals.closed12m ?? 0;
+ const opened = signals.opened12m ?? 0;
+ if (opened === 0) {
+ return { status: 'positive', description: 'No new issues were opened in the past year.' };
+ }
+ if (closed >= opened) {
+ return {
+ status: 'positive',
+ description: `${closed} issues closed against ${opened} opened in the past year, closing faster than opening.`,
+ };
+ }
+ if (closed >= opened * 0.5) {
+ return {
+ status: 'warning',
+ description: `${closed} issues closed against ${opened} opened in the past year, a growing backlog.`,
+ };
+ }
+ return {
+ status: 'negative',
+ description: `${closed} issues closed against ${opened} opened in the past year, the backlog is growing quickly.`,
+ };
+};
+
+export const getPrMergeRow = (signals: HealthBreakdownResults): SignalRow => {
+ if (!signals.prMergeAvailable) {
+ return { status: 'no-data', description: blockedSignalDescription('PR merge health', signals) };
+ }
+ const merged = signals.merged12m ?? 0;
+ const closedUnmerged = signals.closedUnmerged12m ?? 0;
+ const total = merged + closedUnmerged;
+ if (total === 0) {
+ return {
+ status: 'no-data',
+ description: 'No external pull requests were received in the past year.',
+ };
+ }
+ const mergeRate = merged / total;
+ if (mergeRate > 0.5) {
+ return {
+ status: 'positive',
+ description: `${merged} of ${total} pull requests were merged in the past year.`,
+ };
+ }
+ if (mergeRate > 0) {
+ return {
+ status: 'warning',
+ description: `${merged} of ${total} pull requests were merged in the past year, under half.`,
+ };
+ }
+ return {
+ status: 'negative',
+ description: `None of ${total} pull requests were merged in the past year.`,
+ };
+};
+
+// ---------------------------------------------------------------------------
+// Section 9: Impact Breakdown signal row descriptions
+// ---------------------------------------------------------------------------
+
+export const getTransitiveDependentsDescription = (value: number | null): string => {
+ if (value === null) {
+ return 'No transitive dependent data is available for this project.';
+ }
+ return `${value.toLocaleString('en-US')} packages depend on this project directly or indirectly, the primary measure of its blast radius.`;
+};
+
+export const getGraphCentralityDescription = (value: number | null, isPending: boolean): string => {
+ if (isPending) {
+ return 'PageRank-weighted centrality has not yet been computed for this project. The score will update when the weekly batch job completes.';
+ }
+ if (value === null) {
+ return 'No graph centrality data is available for this project.';
+ }
+ return `PageRank-weighted centrality score of ${value.toLocaleString('en-US')} across the dependency graph.`;
+};
+
+export const getDownloadsDescription = (value: number | null): string => {
+ if (value === null) {
+ return 'No package download data is available for this project.';
+ }
+ return `${value.toLocaleString('en-US')} downloads per month across all linked registries.`;
+};
+
+export const getDirectDependentsDescription = (value: number | null): string => {
+ if (value === null) {
+ return 'No direct dependent data is available for this project.';
+ }
+ return `${value.toLocaleString('en-US')} packages depend directly on this project.`;
+};
+
+export const getImpactSummaryDescription = (
+ impactLabel: string | null,
+ transitiveDependents?: number | null,
+): string | null => {
+ if (impactLabel === null) {
+ return 'This project publishes no tracked packages. Impact cannot be computed without a package registry presence.';
+ }
+ const dependentsDetail =
+ transitiveDependents !== null && transitiveDependents !== undefined
+ ? ` ${transitiveDependents.toLocaleString('en-US')} packages depend on it directly or indirectly.`
+ : '';
+ if (impactLabel === 'foundational')
+ return `Near-total blast radius across the dependency graph.${dependentsDetail}`;
+ if (impactLabel === 'major')
+ return `Large blast radius, depended on by many high-importance projects.${dependentsDetail}`;
+ if (impactLabel === 'moderate')
+ return `Moderate blast radius within its dependency graph.${dependentsDetail}`;
+ return `Narrow blast radius, depended on by a small set of projects with limited transitive reach.${dependentsDetail}`;
+};
diff --git a/frontend/config/trust-score.ts b/frontend/config/trust-score.ts
index 9b42d4223..10148774c 100644
--- a/frontend/config/trust-score.ts
+++ b/frontend/config/trust-score.ts
@@ -2,52 +2,7 @@
// SPDX-License-Identifier: MIT
import { useRuntimeConfig } from '#imports';
-
-export interface TrustScoreConfig {
- maxScore: number;
- minScore: number;
- label: string;
- color: string;
- ghBadgeColor: string;
-}
-
-export const lfxTrustScore: TrustScoreConfig[] = [
- {
- maxScore: 100,
- minScore: 80,
- label: 'Excellent',
- color: 'bg-positive-500',
- ghBadgeColor: '#10B981',
- },
- {
- maxScore: 79,
- minScore: 60,
- label: 'Healthy',
- color: 'bg-positive-500',
- ghBadgeColor: '#A7F3D0',
- },
- {
- maxScore: 59,
- minScore: 40,
- label: 'Stable',
- color: 'bg-brand-500',
- ghBadgeColor: '#0094FF',
- },
- {
- maxScore: 39,
- minScore: 20,
- label: 'Unsteady',
- color: 'bg-warning-500',
- ghBadgeColor: '#F59E0B',
- },
- {
- maxScore: 19,
- minScore: 0,
- label: 'Critical',
- color: 'bg-negative-500',
- ghBadgeColor: '#EF4444',
- },
-];
+import { lfxColors } from '~/config/styles/colors';
export const getBadgeUrl = (type: string, projectSlug: string, selectedRepos: string[] = []) => {
const config = useRuntimeConfig();
@@ -55,8 +10,60 @@ export const getBadgeUrl = (type: string, projectSlug: string, selectedRepos: st
selectedRepos.length ? `&repos=${selectedRepos.join(',')}` : ''
}`;
};
-export const getHealthScoreConfig = (score: number) => {
- return (
- lfxTrustScore.find((s) => score <= s.maxScore && score >= s.minScore) || lfxTrustScore.at(-1)!
- );
+
+// v2 health score labels/colors, thresholds 85/70/50/30 (see project_insights_copy.pipe's
+// healthLabel multiIf). Keyed lowercase to match the backend-provided healthLabel string
+// (see collection-health-score-pill.vue).
+export interface HealthScoreV2Config {
+ label: string;
+ ghBadgeColor: string;
+}
+
+export const healthScoreV2Config: Record = {
+ excellent: { label: 'Excellent', ghBadgeColor: lfxColors.health.healthy },
+ healthy: { label: 'Healthy', ghBadgeColor: lfxColors.health.healthy },
+ fair: { label: 'Fair', ghBadgeColor: lfxColors.health.fair },
+ concerning: { label: 'Concerning', ghBadgeColor: lfxColors.health.concerning },
+ critical: { label: 'Critical', ghBadgeColor: lfxColors.health.critical },
+ unavailable: { label: 'Unavailable', ghBadgeColor: lfxColors.neutral[400] },
+};
+
+export const getHealthScoreV2Config = (label: string | null): HealthScoreV2Config => {
+ if (label && healthScoreV2Config[label]) {
+ return healthScoreV2Config[label];
+ }
+ return healthScoreV2Config.unavailable;
+};
+
+// Impact labels come from project_insights_copy.pipe's impactLabel multiIf: foundational (>=85),
+// major (>=60), moderate (>=30), minor (below).
+export const impactLabelConfig: Record = {
+ foundational: 'Foundational',
+ major: 'Major',
+ moderate: 'Moderate',
+ minor: 'Minor',
+};
+
+export const getImpactLabelDisplay = (label: string | null): string => {
+ if (label && impactLabelConfig[label]) {
+ return impactLabelConfig[label];
+ }
+ return 'Unavailable';
+};
+
+// Lifecycle labels come from health_score_v2's lifecycleLabelV2: active, stable, declining,
+// abandoned, archived (best-state-wins across a project's repos).
+export const lifecycleLabelConfig: Record = {
+ active: { label: 'Active', color: 'bg-positive-500' },
+ stable: { label: 'Stable', color: 'bg-accent-500' },
+ declining: { label: 'Declining', color: 'bg-warning-500' },
+ abandoned: { label: 'Abandoned', color: 'bg-negative-500' },
+ archived: { label: 'Archived', color: 'bg-neutral-400' },
+};
+
+export const getLifecycleLabelConfig = (label: string | null): { label: string; color: string } => {
+ if (label && lifecycleLabelConfig[label]) {
+ return lifecycleLabelConfig[label];
+ }
+ return { label: 'Unknown', color: 'bg-neutral-400' };
};
diff --git a/frontend/server/api/badge/health-score.ts b/frontend/server/api/badge/health-score.ts
index 6319a2121..c2b708c14 100644
--- a/frontend/server/api/badge/health-score.ts
+++ b/frontend/server/api/badge/health-score.ts
@@ -1,23 +1,23 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import { fetchFromTinybird } from '~~/server/data/tinybird/tinybird';
-import type { HealthScoreTinybird } from '~~/types/overview/responses.types';
-import { getHealthScoreConfig } from '~~/config/trust-score';
+import type { ProjectInsightsTinybird } from '~~/types/project';
+import { getHealthScoreV2Config } from '~~/config/trust-score';
export default defineEventHandler(async (event): Promise => {
const query = getQuery(event);
const project: string = query?.project as string;
try {
- const res = await fetchFromTinybird(
- '/v0/pipes/health_score_overview.json',
- { project },
+ const res = await fetchFromTinybird(
+ '/v0/pipes/project_insights.json',
+ { slug: project },
);
if (!res.data || res.data.length === 0) {
throw createError({ statusCode: 404, statusMessage: 'Project not found' });
}
- const healthScore = res.data[0].overallScore;
- const config = getHealthScoreConfig(healthScore);
+ const healthLabel = res.data[0].healthLabel;
+ const config = getHealthScoreV2Config(healthLabel);
const message = encodeURIComponent(config.label);
const label = encodeURIComponent('Health Score');
const color = config.ghBadgeColor.replace('#', '');
diff --git a/frontend/server/api/project/[slug]/overview/health-score-breakdown.get.ts b/frontend/server/api/project/[slug]/overview/health-score-breakdown.get.ts
new file mode 100644
index 000000000..aec382d64
--- /dev/null
+++ b/frontend/server/api/project/[slug]/overview/health-score-breakdown.get.ts
@@ -0,0 +1,30 @@
+// Copyright (c) 2025 The Linux Foundation and each contributor.
+// SPDX-License-Identifier: MIT
+import { fetchFromTinybird } from '~~/server/data/tinybird/tinybird';
+import type { HealthBreakdownResults } from '~~/types/overview/responses.types';
+
+export default defineEventHandler(async (event): Promise => {
+ const slug = (event.context.params as { slug: string }).slug;
+
+ try {
+ const res = await fetchFromTinybird(
+ '/v0/pipes/project_insights_health_breakdown.json',
+ {
+ slug,
+ },
+ );
+ if (!res.data || res.data.length === 0) {
+ throw createError({ statusCode: 404, statusMessage: 'Not found' });
+ }
+ return res.data[0];
+ } catch (error: unknown) {
+ if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
+ throw error;
+ }
+ console.error('Error fetching health score breakdown:', error);
+ throw createError({
+ statusCode: 500,
+ statusMessage: 'Failed to fetch health score breakdown',
+ });
+ }
+});
diff --git a/frontend/server/api/project/[slug]/overview/health-score-impact.get.ts b/frontend/server/api/project/[slug]/overview/health-score-impact.get.ts
new file mode 100644
index 000000000..af909a8c5
--- /dev/null
+++ b/frontend/server/api/project/[slug]/overview/health-score-impact.get.ts
@@ -0,0 +1,30 @@
+// Copyright (c) 2025 The Linux Foundation and each contributor.
+// SPDX-License-Identifier: MIT
+import { fetchFromTinybird } from '~~/server/data/tinybird/tinybird';
+import type { ImpactBreakdownResults } from '~~/types/overview/responses.types';
+
+export default defineEventHandler(async (event): Promise => {
+ const slug = (event.context.params as { slug: string }).slug;
+
+ try {
+ const res = await fetchFromTinybird(
+ '/v0/pipes/project_insights_impact_breakdown.json',
+ {
+ slug,
+ },
+ );
+ if (!res.data || res.data.length === 0) {
+ throw createError({ statusCode: 404, statusMessage: 'Not found' });
+ }
+ return res.data[0];
+ } catch (error: unknown) {
+ if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
+ throw error;
+ }
+ console.error('Error fetching health score impact breakdown:', error);
+ throw createError({
+ statusCode: 500,
+ statusMessage: 'Failed to fetch health score impact breakdown',
+ });
+ }
+});
diff --git a/frontend/server/api/project/[slug]/overview/health-score-v2.get.ts b/frontend/server/api/project/[slug]/overview/health-score-v2.get.ts
new file mode 100644
index 000000000..d4071070e
--- /dev/null
+++ b/frontend/server/api/project/[slug]/overview/health-score-v2.get.ts
@@ -0,0 +1,50 @@
+// Copyright (c) 2025 The Linux Foundation and each contributor.
+// SPDX-License-Identifier: MIT
+import { fetchFromTinybird } from '~~/server/data/tinybird/tinybird';
+import type { ProjectInsightsTinybird } from '~~/types/project';
+import type { HealthScoreV2Results } from '~~/types/overview/responses.types';
+
+export default defineEventHandler(async (event): Promise => {
+ const slug = (event.context.params as { slug: string }).slug;
+
+ try {
+ const res = await fetchFromTinybird(
+ '/v0/pipes/project_insights.json',
+ {
+ slug,
+ },
+ );
+ if (!res.data || res.data.length === 0) {
+ throw createError({ statusCode: 404, statusMessage: 'Not found' });
+ }
+ const {
+ healthScoreV2,
+ healthLabel,
+ lifecycleLabel,
+ impactScore,
+ impactLabel,
+ maintainerHealthScoreV2,
+ securitySupplyChainScoreV2,
+ developmentActivityScoreV2,
+ } = res.data[0];
+ return {
+ healthScoreV2,
+ healthLabel,
+ lifecycleLabel,
+ impactScore,
+ impactLabel,
+ maintainerHealthScoreV2,
+ securitySupplyChainScoreV2,
+ developmentActivityScoreV2,
+ };
+ } catch (error: unknown) {
+ if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
+ throw error;
+ }
+ console.error('Error fetching health score v2:', error);
+ throw createError({
+ statusCode: 500,
+ statusMessage: 'Failed to fetch health score v2',
+ });
+ }
+});
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index be8639b3b..badba4012 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -135,6 +135,7 @@ export default {
'!bg-negative-900',
'bg-neutral-50',
'bg-health-healthy',
+ 'bg-health-fair',
'bg-health-concerning',
'bg-health-critical',
],
diff --git a/frontend/types/overview/responses.types.ts b/frontend/types/overview/responses.types.ts
index e4cf83265..57fb9bbf7 100644
--- a/frontend/types/overview/responses.types.ts
+++ b/frontend/types/overview/responses.types.ts
@@ -58,6 +58,90 @@ export interface BenchmarkScoreData {
percentage?: number;
}
+export interface HealthScoreV2Results {
+ healthScoreV2: number | null;
+ healthLabel: string | null;
+ lifecycleLabel: string | null;
+ impactScore: number | null;
+ impactLabel: string | null;
+ maintainerHealthScoreV2: number | null;
+ securitySupplyChainScoreV2: number | null;
+ developmentActivityScoreV2: number | null;
+}
+
+export type ImpactBreakdownBand = 'Top 1%' | 'Top 10%' | 'Top 25%' | 'Top 50%' | 'Bottom 50%';
+
+export interface ImpactBreakdownResults {
+ directDependents: number | null;
+ directDependentsTopPct: number | null;
+ directDependentsBand: ImpactBreakdownBand | null;
+ transitiveDependents: number | null;
+ transitiveDependentsTopPct: number | null;
+ transitiveDependentsBand: ImpactBreakdownBand | null;
+ downloads: number | null;
+ downloadsTopPct: number | null;
+ downloadsBand: ImpactBreakdownBand | null;
+ centrality: number | null;
+ centralityTopPct: number | null;
+ centralityBand: ImpactBreakdownBand | null;
+}
+
+// Flat shape matching the project_insights_health_breakdown.pipe response (mirrors
+// ImpactBreakdownResults' flat style, one row per project after the crowd.dev-side rollup).
+export interface HealthBreakdownResults {
+ // Maintainer
+ busFactorScore: number | null;
+ busFactorAvailable: boolean | null;
+ busFactorCount: number | null;
+ orgDiversityScore: number | null;
+ orgDiversityAvailable: boolean | null;
+ orgCount: number | null;
+ responsivenessScore: number | null;
+ responsivenessAvailable: boolean | null;
+ medianPrResponseS: number | null;
+ medianIssueResponseS: number | null;
+ isGerrit: boolean | null;
+ isExcluded: boolean | null;
+
+ // Security
+ openVulnScore: number | null;
+ openCriticals: number | null;
+ openHighs: number | null;
+ openModerates: number | null;
+ scorecardScorePts: number | null;
+ scorecardAvailable: boolean | null;
+ scorecardScore: number | null;
+ securityPracticesScore: number | null;
+ securityPracticesAvailable: boolean | null;
+ securityPolicyEnabled: 0 | 1 | null;
+ branchProtectionEnabled: 0 | 1 | null;
+ branchProtectionRequiredReviews: 0 | 1 | null;
+ branchProtectionRequiresStatusChecks: 0 | 1 | null;
+ branchProtectionAllowsForcePush: 0 | 1 | null;
+ dependencyHealthScore: number | null;
+ dependencyHealthAvailable: boolean | null;
+ vulnerableDeps: number | null;
+
+ // Development
+ releaseCadenceScore: number | null;
+ releaseCadenceAvailable: boolean | null;
+ daysSinceLatest: number | null;
+ daysBetweenRecent: number | null;
+ commitActivityScore: number | null;
+ commitsLast6m: number | null;
+ lastCommitAt: string | null;
+ issueResolutionScore: number | null;
+ issueResolutionAvailable: boolean | null;
+ closed12m: number | null;
+ opened12m: number | null;
+ medianCloseS: number | null;
+ prMergeScore: number | null;
+ prMergeAvailable: boolean | null;
+ merged12m: number | null;
+ closedUnmerged12m: number | null;
+ medianMergeS: number | null;
+}
+
export interface HealthScoreResults {
activeContributors: BenchmarkScoreData;
contributorDependency: BenchmarkScoreData;
diff --git a/frontend/types/project.ts b/frontend/types/project.ts
index fbc8ed363..da760a770 100644
--- a/frontend/types/project.ts
+++ b/frontend/types/project.ts
@@ -130,6 +130,14 @@ export interface ProjectInsightsTinybird {
popularityHealthScore: number;
developmentHealthScore: number;
securityHealthScore: number;
+ healthScoreV2: number | null;
+ healthLabel: string | null;
+ lifecycleLabel: string | null;
+ impactScore: number | null;
+ impactLabel: string | null;
+ maintainerHealthScoreV2: number | null;
+ securitySupplyChainScoreV2: number | null;
+ developmentActivityScoreV2: number | null;
firstCommit: string;
starsLast365Days: number;
forksLast365Days: number;
@@ -164,6 +172,11 @@ export interface ProjectInsights {
popularityHealthScore: number;
developmentHealthScore: number;
securityHealthScore: number;
+ healthScoreV2: number | null;
+ healthLabel: string | null;
+ lifecycleLabel: string | null;
+ impactScore: number | null;
+ impactLabel: string | null;
firstCommit: string;
starsLast365Days: number;
forksLast365Days: number;