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
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ SPDX-License-Identifier: MIT
:class="healthScoreDotClass"
/>
<span class="font-semibold text-neutral-900">{{ healthScoreLabel }}</span>
<span class="text-neutral-500">({{ props.score }}/100)</span>
<span class="text-neutral-500">({{ props.score }}/{{ props.healthMaxScore ?? 100 }})</span>
</div>
<lfx-progress-bar
:values="[props.score]"
:values="[progressBarValue]"
:color="progressBarColor"
size="small"
/>
Expand Down Expand Up @@ -85,6 +85,7 @@ import LfxPopover from '~/components/uikit/popover/popover.vue';
import LfxIcon from '~/components/uikit/icon/icon.vue';
import LfxProgressBar from '~/components/uikit/progress-bar/progress-bar.vue';
import { getHealthScoreDescription } from '~~/config/health-breakdown-templates';
import { getHealthScoreV2Config, isPartialHealthScore } from '~~/config/trust-score';

const props = defineProps<{
score: number;
Expand All @@ -93,6 +94,7 @@ const props = defineProps<{
maintainerHealthScoreV2?: number | null;
securitySupplyChainScoreV2?: number | null;
developmentActivityScoreV2?: number | null;
healthMaxScore?: number | null;
}>();

// Akrites v2 bands (PRD): excellent 85-100, healthy 70-84, fair 50-69, concerning 30-49, critical 0-29.
Expand All @@ -108,16 +110,9 @@ const bandFromScore = (score: number) => {

const band = computed(() => (props.healthLabel ?? bandFromScore(props.score)).toLowerCase());

const healthScoreLabel = computed(() => {
const labels: Record<string, string> = {
excellent: 'Excellent',
healthy: 'Healthy',
fair: 'Fair',
concerning: 'Concerning',
critical: 'Critical',
};
return labels[band.value] ?? band.value;
});
const healthScoreLabel = computed(
() => getHealthScoreV2Config(band.value, isPartialHealthScore(props.healthMaxScore ?? null)).label,
);

const healthScoreDotClass = computed(() => {
const classes: Record<string, string> = {
Expand All @@ -130,6 +125,10 @@ const healthScoreDotClass = computed(() => {
return classes[band.value] ?? 'bg-health-critical';
});

// The progress bar renders `values` as a raw 0-100 fill percentage, so a capped score (e.g. 45
// out of a 65 max) needs rescaling - otherwise the bar under-fills relative to the displayed total.
const progressBarValue = computed(() => (props.score / (props.healthMaxScore ?? 100)) * 100);

const progressBarColor = computed(() => {
if (band.value === 'excellent' || band.value === 'healthy') return 'positive';
if (band.value === 'fair') return 'accent';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ SPDX-License-Identifier: MIT
:maintainer-health-score-v2="project.maintainerHealthScoreV2"
:security-supply-chain-score-v2="project.securitySupplyChainScoreV2"
:development-activity-score-v2="project.developmentActivityScoreV2"
:health-max-score="project.healthMaxScore"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gaspergrom worth fixing

/>
</td>
<!-- TEMPORARILY HIDDEN (IN-1243): Impact column disabled until underlying data quality issue is fixed. Re-enable by uncommenting.
Expand Down Expand Up @@ -182,6 +183,7 @@ SPDX-License-Identifier: MIT
:maintainer-health-score-v2="project.maintainerHealthScoreV2"
:security-supply-chain-score-v2="project.securitySupplyChainScoreV2"
:development-activity-score-v2="project.developmentActivityScoreV2"
:health-max-score="project.healthMaxScore"
/>
<!-- TEMPORARILY HIDDEN (IN-1243): Impact section disabled until underlying data quality issue is fixed. Re-enable by uncommenting.
<lfx-collection-impact-score-pill
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ SPDX-License-Identifier: MIT
:class="scoreDotColorClass"
/>
<span class="font-semibold text-neutral-900">{{ scoreLabel }}</span>
<span class="text-neutral-500">({{ props.healthScoreV2 }}/100)</span>
<span class="text-neutral-500">({{ props.healthScoreV2 }}/{{ props.healthMaxScore ?? 100 }})</span>
</lfx-chip>
</div>
<p class="text-xs text-neutral-500 mb-4">
Expand Down Expand Up @@ -111,7 +111,7 @@ import LfxChip from '~/components/uikit/chip/chip.vue';
import LfxBenchmarkIcon from '~/components/uikit/benchmarks/benchmark-icon.vue';
import LfxEmptyState from '~/components/shared/components/empty-state.vue';
import { LfxRoutes } from '~/components/shared/types/routes';
import { getHealthScoreV2Config, healthScoreFilterEmptyState } from '~~/config/trust-score';
import { getHealthScoreV2Config, isPartialHealthScore, healthScoreFilterEmptyState } from '~~/config/trust-score';
import {
getCategoryDescription,
getCategoryScoreColor,
Expand All @@ -135,6 +135,7 @@ const props = defineProps<{
maintainerHealthScoreV2: number | null;
securitySupplyChainScoreV2: number | null;
developmentActivityScoreV2: number | null;
healthMaxScore: number | null;
signals: HealthBreakdownResults | null;
selectedReposAllArchivedOrExcluded: boolean;
isRepoSelected: boolean;
Expand All @@ -156,7 +157,9 @@ const isLowSignalCoverage = computed(
() => props.healthScoreV2 === null && !props.isRepoSelected && allCategoriesEmpty.value,
);

const scoreLabel = computed(() => getHealthScoreV2Config(props.healthLabel).label);
const scoreLabel = computed(
() => getHealthScoreV2Config(props.healthLabel, isPartialHealthScore(props.healthMaxScore)).label,
);

const scoreDotColorClass = computed(() => {
const label = props.healthLabel;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,35 @@ SPDX-License-Identifier: MIT
</template>
</lfx-tooltip>
</span>
<span
class="text-lg font-semibold"
:class="isEmpty ? 'text-neutral-400' : scoreTextColorClass"
>{{ scoreLabel }}</span
>
<span class="flex items-center gap-1">
<span
class="text-lg font-semibold"
:class="isEmpty ? 'text-neutral-400' : scoreTextColorClass"
>{{ scoreLabel }}</span
>
<lfx-tooltip
v-if="isPartial"
placement="top"
>
<lfx-icon
Comment thread
joanagmaia marked this conversation as resolved.
name="circle-question"
:size="11"
class="cursor-help text-neutral-400"
/>
<template #content>
<div class="max-w-xs text-xs leading-relaxed">
This Health Score is partial because the {{ missingCategoryLabel }} category is missing data
for this project. The score is computed from the remaining categories only.
</div>
</template>
</lfx-tooltip>
</span>
</div>
<lfx-health-score-ring
:score="healthScoreV2 ?? 0"
:color="scoreColorHex"
:unavailable="isEmpty"
:max-score="healthMaxScore ?? 100"
/>
</div>
<div class="flex-grow" />
Expand Down Expand Up @@ -172,6 +191,7 @@ import LfxProjectTrustScoreShareBadge from './trust-score/share-badge.vue';
import LfxHealthScoreRing from './trust-score/health-score-ring.vue';
import {
getHealthScoreV2Config,
isPartialHealthScore,
// TEMPORARILY HIDDEN (IN-1243): Impact section disabled until underlying data quality issue is fixed. Re-enable by uncommenting.
// getImpactLabelDisplay,
getLifecycleLabelConfig,
Expand Down Expand Up @@ -202,6 +222,7 @@ const props = defineProps<{
maintainerHealthScoreV2: number | null;
securitySupplyChainScoreV2: number | null;
developmentActivityScoreV2: number | null;
healthMaxScore: number | null;
status: AsyncDataRequestStatus;
isRepoSelected: boolean;
signals: HealthBreakdownResults | null;
Expand All @@ -225,7 +246,16 @@ const showShareBadge = computed(
props.status === 'success' && selectedRepositories.value.length <= 1 && (props.isRepoSelected || !isEmpty.value),
);

const scoreLabel = computed(() => getHealthScoreV2Config(props.healthLabel).label);
const isPartial = computed(() => isPartialHealthScore(props.healthMaxScore));

const missingCategoryLabel = computed(() => {
if (props.maintainerHealthScoreV2 === null) return 'Maintainer Health';
if (props.securitySupplyChainScoreV2 === null) return 'Security & Supply Chain';
if (props.developmentActivityScoreV2 === null) return 'Development Activity';
return null;
});

const scoreLabel = computed(() => getHealthScoreV2Config(props.healthLabel, isPartial.value).label);

const scoreColorHex = computed(() => {
const label = props.healthLabel;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ SPDX-License-Identifier: MIT
<span
v-if="!props.unavailable"
class="text-xs text-neutral-500 mt-1"
>out of 100</span
>out of {{ props.maxScore }}</span
>
</div>
</div>
Expand All @@ -36,17 +36,19 @@ const props = withDefaults(
score: number;
color?: string;
unavailable?: boolean;
maxScore?: number;
}>(),
{
color: undefined,
unavailable: false,
maxScore: 100,
},
);

const gaugeConfig = computed(() =>
getGaugeChartConfig({
value: props.score,
maxValue: 100,
maxValue: props.maxScore,
gaugeType: 'full',
name: '',
graphOnly: true,
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/components/modules/project/views/overview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ SPDX-License-Identifier: MIT
:maintainer-health-score-v2="healthScoreV2Data?.maintainerHealthScoreV2 ?? null"
:security-supply-chain-score-v2="healthScoreV2Data?.securitySupplyChainScoreV2 ?? null"
:development-activity-score-v2="healthScoreV2Data?.developmentActivityScoreV2 ?? null"
:health-max-score="healthScoreV2Data?.healthMaxScore ?? null"
:status="healthScoreV2Status"
:is-repo-selected="isRepoFilterActive"
:signals="healthBreakdownData ?? null"
Expand All @@ -33,6 +34,7 @@ SPDX-License-Identifier: MIT
:maintainer-health-score-v2="healthScoreV2Data?.maintainerHealthScoreV2 ?? null"
:security-supply-chain-score-v2="healthScoreV2Data?.securitySupplyChainScoreV2 ?? null"
:development-activity-score-v2="healthScoreV2Data?.developmentActivityScoreV2 ?? null"
:health-max-score="healthScoreV2Data?.healthMaxScore ?? null"
:signals="healthBreakdownData ?? null"
:selected-repos-all-archived-or-excluded="selectedReposAllArchivedOrExcluded"
:is-repo-selected="isRepoFilterActive"
Expand Down
1 change: 1 addition & 0 deletions frontend/app/components/uikit/chart/configs/gauge.chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const fullDataOpts = {
*/
export const getGaugeChartConfig = (data: GaugeData): ECOption => {
const gaugeSeries = { ...(data.gaugeType === 'half' ? halfSeriesStyle : fullSeriesStyle) };
gaugeSeries.max = data.maxValue || 100;
if (data.lineWidth !== undefined) {
gaugeSeries.axisLine = {
...gaugeSeries.axisLine,
Expand Down
55 changes: 54 additions & 1 deletion frontend/config/trust-score.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import { describe, test, expect } from 'vitest';
import { getLifecycleLabelConfig } from './trust-score';
import {
getLifecycleLabelConfig,
getHealthScoreV2Config,
isPartialHealthScore,
} from './trust-score';

describe('getLifecycleLabelConfig', () => {
test('should return the inert label and color when lifecycle state is "inert"', () => {
Expand All @@ -24,3 +28,52 @@ describe('getLifecycleLabelConfig', () => {
expect(result).toEqual({ label: 'Unknown', color: 'bg-neutral-400' });
});
});

describe('isPartialHealthScore', () => {
test('should return false when healthMaxScore is null (fewer than 2 categories covered)', () => {
expect(isPartialHealthScore(null)).toBe(false);
});

test('should return false when healthMaxScore is undefined (field omitted by an older response)', () => {
expect(isPartialHealthScore(undefined)).toBe(false);
});

test('should return false when healthMaxScore is 100 (all 3 categories covered)', () => {
expect(isPartialHealthScore(100)).toBe(false);
});

test.each([60, 65, 75])(
'should return true when healthMaxScore is %i (exactly 1 category missing)',
(max) => {
expect(isPartialHealthScore(max)).toBe(true);
},
);
});

describe('getHealthScoreV2Config', () => {
test('should not append a partial suffix by default', () => {
const result = getHealthScoreV2Config('healthy');
expect(result.label).toBe('Healthy');
});

test('should append " - Partial" to the label when isPartial is true', () => {
const result = getHealthScoreV2Config('healthy', true);
expect(result.label).toBe('Healthy - Partial');
});

test('should not append a partial suffix when isPartial is false', () => {
const result = getHealthScoreV2Config('healthy', false);
expect(result.label).toBe('Healthy');
});

test('should append the partial suffix to the unavailable fallback when label is null', () => {
const result = getHealthScoreV2Config(null, true);
expect(result.label).toBe('Unavailable - Partial');
});

test('should preserve the badge color when appending the partial suffix', () => {
const withoutPartial = getHealthScoreV2Config('critical');
const withPartial = getHealthScoreV2Config('critical', true);
expect(withPartial.ghBadgeColor).toBe(withoutPartial.ghBadgeColor);
});
});
23 changes: 19 additions & 4 deletions frontend/config/trust-score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,28 @@ export const healthScoreV2Config: Record<string, HealthScoreV2Config> = {
unavailable: { label: 'Unavailable', ghBadgeColor: lfxColors.neutral[400] },
};

export const getHealthScoreV2Config = (label: string | null): HealthScoreV2Config => {
if (label && healthScoreV2Config[label]) {
return healthScoreV2Config[label];
export const getHealthScoreV2Config = (
label: string | null,
isPartial = false,
): HealthScoreV2Config => {
const config =
label && healthScoreV2Config[label]
? healthScoreV2Config[label]
: healthScoreV2Config.unavailable;
if (isPartial) {
return { ...config, label: `${config.label} - Partial` };
}
return healthScoreV2Config.unavailable;
return config;
};

// A Health Score is partial when exactly 1 of the 3 v2 categories (Maintainer Health,
// Security & Supply Chain, Development Activity) is missing data. project_insights.pipe already
// encodes this via healthMaxScore: 100 when all 3 categories are covered, null when fewer than 2
// are covered, and the capped denominator (60/65/75) when exactly one is missing - so this never
// needs to recompute category coverage itself.
export const isPartialHealthScore = (healthMaxScore: number | null | undefined): boolean =>
healthMaxScore != null && healthMaxScore !== 100;

// Health Score empty-state copy for the repo-selector states.
export const healthScoreFilterEmptyState = {
stateSelectAll: {
Expand Down
6 changes: 3 additions & 3 deletions frontend/docs/metrics/health-score/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,11 @@ Not every signal is available for every project. Insights uses two layers of han

A category is marked unavailable when the repository platform does not support any of its sub-signals — for example, a Gerrit project with no package data may have no computable Security sub-signals. Once a category is unavailable it is dropped from the composite entirely.

- If **all 3 categories** are available, the Health Score is computed normally.
- If **exactly 1 category** is unavailable, the score is computed from the 2 available categories and shown with a **partial** indicator to signal that not all signals were observed.
- If **all 3 categories** are available, the Health Score is computed normally, out of a maximum of 100.
- If **exactly 1 category** is unavailable, the score is computed from the 2 available categories and shown out of a reduced maximum: 60 if Maintainer Health (40 pts) is missing, 65 if Security and Supply Chain (35 pts) is missing, or 75 if Development Activity (25 pts) is missing. The rating label carries a **" - Partial"** suffix (for example, "Healthy - Partial") to signal that not all categories were observed, and a tooltip next to the score explains which category is missing.
Comment thread
joanagmaia marked this conversation as resolved.
- If **2 or more categories** are unavailable, the Health Score is marked `unavailable` rather than computed from insufficient evidence.

The Health Score is always either a number (full or partial) or explicitly `unavailable`, never a silent zero or blank. This way you can always distinguish "we could not measure this" from "this project scored poorly."
The Health Score is always either a number out of 100 (full), a number out of a reduced maximum with a partial indicator, or explicitly `unavailable` never a silent zero or blank. This way you can always distinguish "we could not measure this" from "this project scored poorly."

<!-- TEMPORARILY HIDDEN (IN-1243): Impact Score documentation section disabled until underlying data quality issue is fixed. Re-enable by uncommenting.
## Impact Score (0–100)
Expand Down
5 changes: 3 additions & 2 deletions frontend/server/api/badge/health-score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: MIT
import { fetchFromTinybird } from '~~/server/data/tinybird/tinybird';
import type { ProjectInsightsTinybird } from '~~/types/project';
import { getHealthScoreV2Config } from '~~/config/trust-score';
import { getHealthScoreV2Config, isPartialHealthScore } from '~~/config/trust-score';

export default defineEventHandler(async (event): Promise<void> => {
const query = getQuery(event);
Expand All @@ -17,7 +17,8 @@ export default defineEventHandler(async (event): Promise<void> => {
throw createError({ statusCode: 404, statusMessage: 'Project not found' });
}
const healthLabel = res.data[0].healthLabel;
const config = getHealthScoreV2Config(healthLabel);
const healthMaxScore = res.data[0].healthMaxScore;
const config = getHealthScoreV2Config(healthLabel, isPartialHealthScore(healthMaxScore));
const message = encodeURIComponent(config.label);
const label = encodeURIComponent('Health Score');
const color = config.ghBadgeColor.replace('#', '');
Expand Down
Loading
Loading