From bfc0aa0cda33f9ae20727e8e7ee46d08ee3a6de9 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 24 Jun 2026 17:50:51 +0200 Subject: [PATCH 1/3] feat: add metrics in packages api Signed-off-by: Umberto Sgueglia --- .../api/public/v1/packages/listPackages.ts | 13 ++++++++--- .../src/api/public/v1/packages/openapi.yaml | 22 ++++++++++++++----- .../data-access-layer/src/osspckgs/api.ts | 4 ++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/backend/src/api/public/v1/packages/listPackages.ts b/backend/src/api/public/v1/packages/listPackages.ts index 56342085cd..5202d536c9 100644 --- a/backend/src/api/public/v1/packages/listPackages.ts +++ b/backend/src/api/public/v1/packages/listPackages.ts @@ -1,7 +1,11 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { getPackageStatusCounts, listPackagesForApi } from '@crowd/data-access-layer' +import { + computeHealthBand, + getPackageStatusCounts, + listPackagesForApi, +} from '@crowd/data-access-layer' import { getPackagesQx } from '@/db/packagesDb' import { ok } from '@/utils/api' @@ -84,9 +88,12 @@ export async function listPackages(req: Request, res: Response): Promise { purl: r.purl, name: r.name, ecosystem: r.ecosystem, - health: r.scorecardScore != null ? Math.round(Number(r.scorecardScore) * 10) : null, + health: { + score: r.scorecardScore != null ? Math.round(Number(r.scorecardScore) * 10) : null, + label: r.healthLabel ?? computeHealthBand(r.scorecardScore), + }, impact: r.criticalityScore != null ? Math.round(Number(r.criticalityScore) * 100) : null, - lifecycle: null, + lifecycle: r.lifecycleLabel, maintainerBusFactor: r.maintainerCount, openVulns: r.openVulns, stewardshipId: r.stewardshipId ?? null, diff --git a/backend/src/api/public/v1/packages/openapi.yaml b/backend/src/api/public/v1/packages/openapi.yaml index cd5776693f..bb36aaa015 100644 --- a/backend/src/api/public/v1/packages/openapi.yaml +++ b/backend/src/api/public/v1/packages/openapi.yaml @@ -180,10 +180,20 @@ components: type: string example: npm health: - type: - - integer - - 'null' - example: 18 + type: object + required: [score, label] + properties: + score: + type: + - integer + - 'null' + description: Health score (0–100) derived from OpenSSF Scorecard. Null if no repo is linked. + example: 18 + label: + type: string + enum: [healthy, fair, concerning, critical] + description: 'Derived from score: healthy ≥70, fair 50–69, concerning 30–49, critical <30 or null.' + example: critical impact: type: - integer @@ -814,7 +824,9 @@ paths: name: lodash ecosystem: npm lifecycle: declining - health: 18 + health: + score: 18 + label: critical impact: 71 openVulns: low: 0 diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 0d1e3fdd6c..5d7c52d69f 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -135,7 +135,9 @@ export interface PackageListRow { maxVulnSeverity: 'critical' | 'high' | 'medium' | 'low' | null maintainerCount: number scorecardScore: number | null + healthLabel: string | null latestReleaseAt: Date | null + lifecycleLabel: string | null lastActivityType?: string | null lastActivityContent?: string | null lastActivityMetadata?: Record | null @@ -551,7 +553,9 @@ export async function listPackagesForApi( END AS "maxVulnSeverity", pm_counts.cnt AS "maintainerCount", r_sc.scorecard_score AS "scorecardScore", + p.health_label AS "healthLabel", p.latest_release_at AS "latestReleaseAt", + p.lifecycle_label AS "lifecycleLabel", ${opts.includeLastActivity === true ? `last_act.activity_type AS "lastActivityType", last_act.content AS "lastActivityContent", last_act.metadata AS "lastActivityMetadata", last_act.created_at AS "lastActivityAt",` : ''} ${opts.includeStewards === true ? 'ss_agg.stewards AS stewards,' : ''} COUNT(*) OVER() AS total From 6b4b1201801b9fb6d98ce896a3a9e8953ca0d6b6 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 24 Jun 2026 18:25:07 +0200 Subject: [PATCH 2/3] feat: add metrics in akrites packages api Signed-off-by: Umberto Sgueglia --- .../src/api/public/v1/ossprey/openapi.yaml | 65 ++++++++++++---- .../src/api/public/v1/ossprey/packageList.ts | 15 +++- .../api/public/v1/packages/listPackages.ts | 15 ++-- .../src/api/public/v1/packages/openapi.yaml | 16 ++-- backend/src/api/public/v1/packages/types.ts | 4 +- .../data-access-layer/src/osspckgs/api.ts | 75 ++++++++----------- 6 files changed, 112 insertions(+), 78 deletions(-) diff --git a/backend/src/api/public/v1/ossprey/openapi.yaml b/backend/src/api/public/v1/ossprey/openapi.yaml index 808532bfb2..a1bfe4f22f 100644 --- a/backend/src/api/public/v1/ossprey/openapi.yaml +++ b/backend/src/api/public/v1/ossprey/openapi.yaml @@ -50,10 +50,11 @@ components: HealthBand: type: string - enum: [healthy, fair, concerning, critical] + enum: [excellent, healthy, fair, concerning, critical] description: > - Server-derived from `scorecardScore` thresholds: - `null or < 3.0` → critical · `< 5.0` → concerning · `< 7.0` → fair · `≥ 7.0` → healthy + Tinybird band when enriched (excellent ≥85, healthy 70–84, fair 50–69, + concerning 30–49, critical <30). Falls back to scorecard thresholds: + `null or < 3.0` → critical · `< 5.0` → concerning · `< 7.0` → fair · `≥ 7.0` → healthy. StewardshipStatus: type: string @@ -90,7 +91,7 @@ components: - ecosystem - openVulns - maintainerCount - - healthBand + - health - stewards properties: purl: @@ -102,12 +103,6 @@ components: ecosystem: type: string example: npm - criticalityScore: - type: - - number - - 'null' - description: packages.impact (0–1 float). Multiply × 100 for display score. - example: 0.94 stewardshipId: type: - string @@ -131,14 +126,52 @@ components: type: integer description: Count of package_maintainers rows. Bus factor proxy. example: 2 + criticalityScore: + type: + - number + - 'null' + description: Raw criticality score (0–1 float). Use `impact` for display. + example: 0.94 + impact: + type: + - integer + - 'null' + description: Display score (0–100). criticalityScore × 100, rounded. Null if no score. + example: 94 scorecardScore: type: - number - 'null' description: OpenSSF Scorecard score (0–10). Null if no repo mapped. example: 5.2 - healthBand: - $ref: '#/components/schemas/HealthBand' + health: + type: object + required: [score, label] + properties: + score: + type: + - integer + - 'null' + description: > + Health score (0–100). Tinybird composite score when enriched, + OpenSSF Scorecard × 10 otherwise. Null if neither is available. + example: 52 + label: + type: + - string + - 'null' + enum: [excellent, healthy, fair, concerning, critical, null] + description: > + Tinybird band when enriched (excellent ≥85, healthy 70–84, + fair 50–69, concerning 30–49, critical <30), scorecard band otherwise. + example: fair + lifecycle: + type: + - string + - 'null' + enum: [active, stable, declining, abandoned, archived, null] + description: Tinybird-enriched lifecycle label. Null if not yet enriched. + example: active latestReleaseAt: type: - string @@ -545,7 +578,7 @@ paths: in: query schema: type: string - enum: [active, stable, declining, abandoned] + enum: [active, stable, declining, abandoned, archived] - name: status in: query description: > @@ -625,13 +658,17 @@ paths: name: slf4j-api ecosystem: maven criticalityScore: 0.998 + impact: 100 stewardshipId: '101' stewardshipStatus: active openVulns: 0 maxVulnSeverity: null maintainerCount: 2 scorecardScore: 7.5 - healthBand: healthy + health: + score: 75 + label: healthy + lifecycle: active latestReleaseAt: '2026-04-10T00:00:00Z' lastActivity: type: state_changed diff --git a/backend/src/api/public/v1/ossprey/packageList.ts b/backend/src/api/public/v1/ossprey/packageList.ts index fdfa82c94a..777e498eb6 100644 --- a/backend/src/api/public/v1/ossprey/packageList.ts +++ b/backend/src/api/public/v1/ossprey/packageList.ts @@ -13,8 +13,10 @@ import { ok } from '@/utils/api' import { validateOrThrow } from '@/utils/validation' import { purlFilterSchema } from '../packages/purl' +import { LIFECYCLE_VALUES } from '../packages/types' const MAX_PAGE_SIZE = 250 +const LIFECYCLE_SET = new Set(LIFECYCLE_VALUES) const boolParam = z.preprocess((v) => v === 'true', z.boolean()).default(false) @@ -22,7 +24,7 @@ const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(25), ecosystem: z.string().trim().optional(), - lifecycle: z.enum(['active', 'stable', 'declining', 'abandoned']).optional(), + lifecycle: z.enum(LIFECYCLE_VALUES).optional(), name: z.string().trim().optional(), purl: purlFilterSchema, status: z @@ -37,7 +39,7 @@ const querySchema = z.object({ 'inactive', ]) .optional(), - healthBand: z.enum(['healthy', 'fair', 'concerning', 'critical']).optional(), + healthBand: z.enum(['excellent', 'healthy', 'fair', 'concerning', 'critical']).optional(), vulnSeverity: z.enum(['any', 'high', 'critical', 'none']).optional(), staleOnly: boolParam, unstewardedOnly: boolParam, @@ -73,13 +75,20 @@ export async function packageListHandler(req: Request, res: Response): Promise v === 'true', z.boolean()).default(false) -const lifecycleValues = ['active', 'stable', 'declining', 'abandoned'] as const -const healthBandValues = ['healthy', 'fair', 'concerning', 'critical'] as const +const LIFECYCLE_SET = new Set(LIFECYCLE_VALUES) +const healthBandValues = ['excellent', 'healthy', 'fair', 'concerning', 'critical'] as const const vulnSeverityValues = ['any', 'high', 'critical', 'none'] as const const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE), ecosystem: z.string().trim().optional(), - lifecycle: z.enum(lifecycleValues).optional(), + lifecycle: z.enum(LIFECYCLE_VALUES).optional(), name: z.string().trim().optional(), purl: purlFilterSchema, status: z.enum(STEWARDSHIP_STATUS_VALUES).optional(), @@ -89,11 +89,12 @@ export async function listPackages(req: Request, res: Response): Promise { name: r.name, ecosystem: r.ecosystem, health: { - score: r.scorecardScore != null ? Math.round(Number(r.scorecardScore) * 10) : null, + score: r.healthScore ?? (r.scorecardScore != null ? Math.round(r.scorecardScore * 10) : null), label: r.healthLabel ?? computeHealthBand(r.scorecardScore), }, - impact: r.criticalityScore != null ? Math.round(Number(r.criticalityScore) * 100) : null, - lifecycle: r.lifecycleLabel, + impact: r.criticalityScore != null ? Math.round(r.criticalityScore * 100) : null, + lifecycle: + r.lifecycleLabel != null && LIFECYCLE_SET.has(r.lifecycleLabel) ? r.lifecycleLabel : null, maintainerBusFactor: r.maintainerCount, openVulns: r.openVulns, stewardshipId: r.stewardshipId ?? null, diff --git a/backend/src/api/public/v1/packages/openapi.yaml b/backend/src/api/public/v1/packages/openapi.yaml index bb36aaa015..e03bab2db6 100644 --- a/backend/src/api/public/v1/packages/openapi.yaml +++ b/backend/src/api/public/v1/packages/openapi.yaml @@ -127,7 +127,7 @@ components: type: - string - 'null' - enum: [active, stable, declining, abandoned, null] + enum: [active, stable, declining, abandoned, archived, null] health: type: - integer @@ -187,12 +187,12 @@ components: type: - integer - 'null' - description: Health score (0–100) derived from OpenSSF Scorecard. Null if no repo is linked. + description: Health score (0–100). Tinybird composite score when enriched, OpenSSF Scorecard × 10 otherwise. Null if neither is available. example: 18 label: type: string - enum: [healthy, fair, concerning, critical] - description: 'Derived from score: healthy ≥70, fair 50–69, concerning 30–49, critical <30 or null.' + enum: [excellent, healthy, fair, concerning, critical] + description: 'Tinybird band when enriched (excellent ≥85, healthy 70–84, fair 50–69, concerning 30–49, critical <30), scorecard band otherwise.' example: critical impact: type: @@ -203,7 +203,7 @@ components: type: - string - 'null' - enum: [active, stable, declining, abandoned, null] + enum: [active, stable, declining, abandoned, archived, null] maintainerBusFactor: type: - integer @@ -317,7 +317,7 @@ components: type: - string - 'null' - enum: [healthy, fair, concerning, critical, null] + enum: [excellent, healthy, fair, concerning, critical, null] description: Derived from Tinybird health score when available, OpenSSF Scorecard otherwise. example: concerning impact: @@ -360,7 +360,7 @@ components: type: - string - 'null' - enum: [active, stable, declining, abandoned, null] + enum: [active, stable, declining, abandoned, archived, null] maintainerBusFactor: type: - integer @@ -551,7 +551,7 @@ paths: in: query schema: type: string - enum: [active, stable, declining, abandoned] + enum: [active, stable, declining, abandoned, archived] - name: busFactor1Only in: query schema: diff --git a/backend/src/api/public/v1/packages/types.ts b/backend/src/api/public/v1/packages/types.ts index 6b4f542af3..673d7186cf 100644 --- a/backend/src/api/public/v1/packages/types.ts +++ b/backend/src/api/public/v1/packages/types.ts @@ -11,7 +11,9 @@ export const STEWARDSHIP_STATUS_VALUES = [ export type StewardshipStatus = (typeof STEWARDSHIP_STATUS_VALUES)[number] -export type Lifecycle = 'active' | 'stable' | 'declining' | 'abandoned' +export const LIFECYCLE_VALUES = ['active', 'stable', 'declining', 'abandoned', 'archived'] as const + +export type Lifecycle = (typeof LIFECYCLE_VALUES)[number] export type SeverityLevel = 'critical' | 'high' | 'medium' | 'low' diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 5d7c52d69f..03b5f7d928 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -11,7 +11,7 @@ export interface PackageMetrics { criticalPackages: number } -export async function getPackageMetrics(qx: QueryExecutor): Promise { +export async function getPackageMetrics(qx: QueryExecutor): Promise { const row: { total: string; critical: string } = await qx.selectOne(` SELECT COUNT(*) AS total, @@ -34,10 +34,7 @@ export interface PackageStewardshipRow { stewardshipStatus: string | null } -export async function getPackagesByStewardshipPurls( - qx: QueryExecutor, - purls: string[], -): Promise { +export async function getPackagesByStewardshipPurls(qx: QueryExecutor, purls: string[]): Promise { if (purls.length === 0) return [] return qx.select( ` @@ -68,7 +65,7 @@ export interface OsspreyMetrics { } // TODO[deprecate]: rename to getAkritesMetrics once /v1/ossprey is removed -export async function getOsspreyMetrics(qx: QueryExecutor): Promise { +export async function getOsspreyMetrics(qx: QueryExecutor): Promise { const [counts, stewardRow]: [ { totalPackages: string @@ -135,18 +132,19 @@ export interface PackageListRow { maxVulnSeverity: 'critical' | 'high' | 'medium' | 'low' | null maintainerCount: number scorecardScore: number | null + healthScore: number | null healthLabel: string | null latestReleaseAt: Date | null lifecycleLabel: string | null lastActivityType?: string | null lastActivityContent?: string | null - lastActivityMetadata?: Record | null + lastActivityMetadata?: Record | null lastActivityAt?: Date | null stewards?: StewardEntry[] total: string } -export type HealthBand = 'healthy' | 'fair' | 'concerning' | 'critical' +export type HealthBand = 'excellent' | 'healthy' | 'fair' | 'concerning' | 'critical' export type VulnSeverityFilter = 'any' | 'high' | 'critical' | 'none' export function computeHealthBand(scorecardScore: number | null): HealthBand { @@ -198,10 +196,7 @@ export interface PackageStatusCounts { inactive: number } -export type StatusCountsOptions = Omit< - ListPackagesOptions, - 'status' | 'page' | 'pageSize' | 'sortBy' | 'sortDir' -> +export type StatusCountsOptions = Omit const ALL_STEWARDSHIP_STATUSES = [ 'unassigned', @@ -221,9 +216,9 @@ const ALL_STEWARDSHIP_STATUSES = [ export async function getPackageStatusCounts( qx: QueryExecutor, opts: StatusCountsOptions, -): Promise { +): Promise { const conditions: string[] = ['p.is_critical = true'] - const params: Record = {} + const params: Record = {} if (opts.ecosystem) { conditions.push('p.ecosystem = $(ecosystem)') @@ -241,11 +236,14 @@ export async function getPackageStatusCounts( } if (opts.lifecycle) { - conditions.push('p.status IS NOT NULL') + conditions.push('p.lifecycle_label = $(lifecycle)') + params.lifecycle = opts.lifecycle } if (opts.healthBand) { - if (opts.healthBand === 'healthy') { + if (opts.healthBand === 'excellent') { + conditions.push("p.health_label = 'excellent'") + } else if (opts.healthBand === 'healthy') { conditions.push('r_sc.scorecard_score >= 7.0') } else if (opts.healthBand === 'fair') { conditions.push('r_sc.scorecard_score >= 5.0 AND r_sc.scorecard_score < 7.0') @@ -317,7 +315,7 @@ export async function getPackageStatusCounts( params, ) - const countsMap: Record = {} + const countsMap: Record = {} let all = 0 for (const row of rows) { countsMap[row.status] = row.count @@ -341,12 +339,9 @@ export async function getPackageStatusCounts( return result } -export async function listPackagesForApi( - qx: QueryExecutor, - opts: ListPackagesOptions, -): Promise<{ rows: PackageListRow[]; total: number }> { +export async function listPackagesForApi(qx: QueryExecutor, opts: ListPackagesOptions): Promise { const conditions: string[] = ['p.is_critical = true'] - const params: Record = {} + const params: Record = {} if (opts.ecosystem) { conditions.push('p.ecosystem = $(ecosystem)') @@ -363,11 +358,9 @@ export async function listPackagesForApi( params.purl = `%${opts.purl}%` } - // Exclude packages with no registry status when a lifecycle filter is active. - // Full lifecycle column support is pending; this prevents null-lifecycle rows - // from leaking into filtered results. if (opts.lifecycle) { - conditions.push('p.status IS NOT NULL') + conditions.push('p.lifecycle_label = $(lifecycle)') + params.lifecycle = opts.lifecycle } if (opts.status) { @@ -383,7 +376,10 @@ export async function listPackagesForApi( if (opts.healthBand) { // scorecard_score is 0–10; multiply by 10 to get 0–100 health score. // Packages with no linked repo (scorecard_score IS NULL) fall into 'critical'. - if (opts.healthBand === 'healthy') { + // 'excellent' is Tinybird-enriched (health_score ≥ 85); filter on the stored label directly. + if (opts.healthBand === 'excellent') { + conditions.push("p.health_label = 'excellent'") + } else if (opts.healthBand === 'healthy') { conditions.push('r_sc.scorecard_score >= 7.0') } else if (opts.healthBand === 'fair') { conditions.push('r_sc.scorecard_score >= 5.0 AND r_sc.scorecard_score < 7.0') @@ -448,7 +444,7 @@ export async function listPackagesForApi( const sortDir = opts.sortDir === 'desc' ? 'DESC' : 'ASC' // Separate paginated params from filter-only params used by the fallback COUNT query - const queryParams: Record = { + const queryParams: Record = { ...params, limit: opts.pageSize, offset: (opts.page - 1) * opts.pageSize, @@ -553,6 +549,7 @@ export async function listPackagesForApi( END AS "maxVulnSeverity", pm_counts.cnt AS "maintainerCount", r_sc.scorecard_score AS "scorecardScore", + p.health_score AS "healthScore", p.health_label AS "healthLabel", p.latest_release_at AS "latestReleaseAt", p.lifecycle_label AS "lifecycleLabel", @@ -629,7 +626,7 @@ export interface PackageDetailRow { securitySupplyChainScore: number | null developmentActivityScore: number | null lifecycleLabel: string | null - signalCoverageHealth: Record | null + signalCoverageHealth: Record | null } export interface AdvisoryRow { @@ -639,10 +636,7 @@ export interface AdvisoryRow { isCritical: boolean } -export async function getPackageDetailByPurl( - qx: QueryExecutor, - purl: string, -): Promise { +export async function getPackageDetailByPurl(qx: QueryExecutor, purl: string): Promise { return qx.selectOneOrNone( ` SELECT @@ -733,7 +727,7 @@ export interface ScatterPoint { export async function listPackagesForScatter( qx: QueryExecutor, options: { status?: string[]; ecosystem?: string } = {}, -): Promise { +): Promise { const { status, ecosystem } = options // 'unassigned' covers packages with no stewardship row (s.id IS NULL) in addition @@ -745,16 +739,7 @@ export async function listPackagesForScatter( : '' const ecosystemFilter = ecosystem ? `AND p.ecosystem = $(ecosystem)` : '' - const rows: Array<{ - purl: string - name: string - criticalityScore: number - healthScore: number - scorecardScoreRaw: number | null - stewardshipId: string | null - stewardshipStatus: string | null - openVulns: number - }> = await qx.select( + const rows: Array = await qx.select( ` SELECT p.purl, @@ -810,7 +795,7 @@ export async function getAdvisoriesByPackageId( resolutions?: ('open' | 'patched')[] critical?: boolean }, -): Promise<{ rows: AdvisoryRow[]; total: number }> { +): Promise { const cte = ` WITH advisory_data AS ( SELECT From 3079df4075891e94f0c1cc7821c375da054c960f Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 24 Jun 2026 18:51:07 +0200 Subject: [PATCH 3/3] fix: lint and consistency Signed-off-by: Umberto Sgueglia --- .../src/api/public/v1/ossprey/openapi.yaml | 6 +- .../src/api/public/v1/ossprey/packageList.ts | 9 ++- .../src/api/public/v1/packages/getPackage.ts | 21 ++++--- .../api/public/v1/packages/listPackages.ts | 16 ++++-- backend/src/api/public/v1/packages/types.ts | 12 ++++ .../data-access-layer/src/osspckgs/api.ts | 55 +++++++++++++------ 6 files changed, 87 insertions(+), 32 deletions(-) diff --git a/backend/src/api/public/v1/ossprey/openapi.yaml b/backend/src/api/public/v1/ossprey/openapi.yaml index a1bfe4f22f..9acac302f8 100644 --- a/backend/src/api/public/v1/ossprey/openapi.yaml +++ b/backend/src/api/public/v1/ossprey/openapi.yaml @@ -263,7 +263,11 @@ components: description: ROUND(scorecard_score × 10). X-axis position (0–100). 0 if no repo. example: 52 healthBand: - $ref: '#/components/schemas/HealthBand' + type: string + enum: [healthy, fair, concerning, critical] + description: > + Falls back to scorecard thresholds — never 'excellent' (scatter uses + scorecard score only, not Tinybird health_label). stewardshipStatus: oneOf: - $ref: '#/components/schemas/StewardshipStatus' diff --git a/backend/src/api/public/v1/ossprey/packageList.ts b/backend/src/api/public/v1/ossprey/packageList.ts index 777e498eb6..ee8a1b1fd3 100644 --- a/backend/src/api/public/v1/ossprey/packageList.ts +++ b/backend/src/api/public/v1/ossprey/packageList.ts @@ -13,7 +13,7 @@ import { ok } from '@/utils/api' import { validateOrThrow } from '@/utils/validation' import { purlFilterSchema } from '../packages/purl' -import { LIFECYCLE_VALUES } from '../packages/types' +import { HEALTH_BAND_SET, HEALTH_BAND_VALUES, LIFECYCLE_VALUES } from '../packages/types' const MAX_PAGE_SIZE = 250 const LIFECYCLE_SET = new Set(LIFECYCLE_VALUES) @@ -39,7 +39,7 @@ const querySchema = z.object({ 'inactive', ]) .optional(), - healthBand: z.enum(['excellent', 'healthy', 'fair', 'concerning', 'critical']).optional(), + healthBand: z.enum(HEALTH_BAND_VALUES).optional(), vulnSeverity: z.enum(['any', 'high', 'critical', 'none']).optional(), staleOnly: boolParam, unstewardedOnly: boolParam, @@ -85,7 +85,10 @@ export async function packageListHandler(req: Request, res: Response): Promise(LIFECYCLE_VALUES) function snakeToCamelKeys(obj: Record | null): Record | null { if (obj === null) return null @@ -47,7 +49,6 @@ export async function getPackage(req: Request, res: Response): Promise { const scorecardScore = pkg.scorecardScore != null ? Number(pkg.scorecardScore) : null const mappingConfidence = pkg.repoMappingConfidence != null ? Number(pkg.repoMappingConfidence) : null - const healthBandScore = pkg.healthScore !== null ? pkg.healthScore / 10 : scorecardScore ok(res, { purl: pkg.purl, @@ -58,22 +59,28 @@ export async function getPackage(req: Request, res: Response): Promise { healthScore: pkg.healthScore, healthScoreDetails: { total: pkg.healthScore, - label: pkg.healthLabel, + label: + pkg.healthLabel != null && HEALTH_BAND_SET.has(pkg.healthLabel) ? pkg.healthLabel : null, maintainerHealth: pkg.maintainerHealthScore, securitySupplyChain: pkg.securitySupplyChainScore, developmentActivity: pkg.developmentActivityScore, }, - healthBand: computeHealthBand(healthBandScore), + healthBand: + pkg.healthLabel != null && HEALTH_BAND_SET.has(pkg.healthLabel) + ? pkg.healthLabel + : computeHealthBand(scorecardScore), impact: { - impactScore: - pkg.criticalityScore != null ? Math.round(Number(pkg.criticalityScore) * 100) : null, + impactScore: pkg.criticalityScore != null ? Math.round(pkg.criticalityScore * 100) : null, downloadsLastMonth: pkg.downloadsLast30d ?? null, dependentPackages: pkg.dependentPackagesCount ?? null, dependentRepos: pkg.dependentReposCount ?? null, transitiveReach: pkg.transitiveReach, }, riskSignals: { - lifecycle: pkg.lifecycleLabel, + lifecycle: + pkg.lifecycleLabel != null && LIFECYCLE_SET.has(pkg.lifecycleLabel) + ? pkg.lifecycleLabel + : null, maintainerBusFactor: pkg.maintainerCount, lastRelease: pkg.latestReleaseAt ? pkg.latestReleaseAt.toISOString() : null, hasSecurityFile: pkg.hasSecurityFile, diff --git a/backend/src/api/public/v1/packages/listPackages.ts b/backend/src/api/public/v1/packages/listPackages.ts index 9b8c21cd7d..9d99ddd1be 100644 --- a/backend/src/api/public/v1/packages/listPackages.ts +++ b/backend/src/api/public/v1/packages/listPackages.ts @@ -12,7 +12,13 @@ import { ok } from '@/utils/api' import { validateOrThrow } from '@/utils/validation' import { purlFilterSchema } from './purl' -import { LIFECYCLE_VALUES, STEWARDSHIP_STATUS_VALUES, type StewardshipStatus } from './types' +import { + HEALTH_BAND_SET, + HEALTH_BAND_VALUES, + LIFECYCLE_VALUES, + STEWARDSHIP_STATUS_VALUES, + type StewardshipStatus, +} from './types' const DEFAULT_PAGE_SIZE = 20 const MAX_PAGE_SIZE = 100 @@ -20,7 +26,6 @@ const MAX_PAGE_SIZE = 100 const booleanQueryParam = z.preprocess((v) => v === 'true', z.boolean()).default(false) const LIFECYCLE_SET = new Set(LIFECYCLE_VALUES) -const healthBandValues = ['excellent', 'healthy', 'fair', 'concerning', 'critical'] as const const vulnSeverityValues = ['any', 'high', 'critical', 'none'] as const const querySchema = z.object({ @@ -31,7 +36,7 @@ const querySchema = z.object({ name: z.string().trim().optional(), purl: purlFilterSchema, status: z.enum(STEWARDSHIP_STATUS_VALUES).optional(), - healthBand: z.enum(healthBandValues).optional(), + healthBand: z.enum(HEALTH_BAND_VALUES).optional(), vulnSeverity: z.enum(vulnSeverityValues).optional(), busFactor1Only: booleanQueryParam, staleOnly: booleanQueryParam, @@ -90,7 +95,10 @@ export async function listPackages(req: Request, res: Response): Promise { ecosystem: r.ecosystem, health: { score: r.healthScore ?? (r.scorecardScore != null ? Math.round(r.scorecardScore * 10) : null), - label: r.healthLabel ?? computeHealthBand(r.scorecardScore), + label: + r.healthLabel != null && HEALTH_BAND_SET.has(r.healthLabel) + ? r.healthLabel + : computeHealthBand(r.scorecardScore), }, impact: r.criticalityScore != null ? Math.round(r.criticalityScore * 100) : null, lifecycle: diff --git a/backend/src/api/public/v1/packages/types.ts b/backend/src/api/public/v1/packages/types.ts index 673d7186cf..623c423dbc 100644 --- a/backend/src/api/public/v1/packages/types.ts +++ b/backend/src/api/public/v1/packages/types.ts @@ -15,6 +15,18 @@ export const LIFECYCLE_VALUES = ['active', 'stable', 'declining', 'abandoned', ' export type Lifecycle = (typeof LIFECYCLE_VALUES)[number] +export const HEALTH_BAND_VALUES = [ + 'excellent', + 'healthy', + 'fair', + 'concerning', + 'critical', +] as const + +export type HealthBand = (typeof HEALTH_BAND_VALUES)[number] + +export const HEALTH_BAND_SET = new Set(HEALTH_BAND_VALUES) + export type SeverityLevel = 'critical' | 'high' | 'medium' | 'low' export interface OpenVulns { diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 03b5f7d928..24850b4b68 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -11,7 +11,7 @@ export interface PackageMetrics { criticalPackages: number } -export async function getPackageMetrics(qx: QueryExecutor): Promise { +export async function getPackageMetrics(qx: QueryExecutor): Promise { const row: { total: string; critical: string } = await qx.selectOne(` SELECT COUNT(*) AS total, @@ -34,7 +34,10 @@ export interface PackageStewardshipRow { stewardshipStatus: string | null } -export async function getPackagesByStewardshipPurls(qx: QueryExecutor, purls: string[]): Promise { +export async function getPackagesByStewardshipPurls( + qx: QueryExecutor, + purls: string[], +): Promise { if (purls.length === 0) return [] return qx.select( ` @@ -65,7 +68,7 @@ export interface OsspreyMetrics { } // TODO[deprecate]: rename to getAkritesMetrics once /v1/ossprey is removed -export async function getOsspreyMetrics(qx: QueryExecutor): Promise { +export async function getOsspreyMetrics(qx: QueryExecutor): Promise { const [counts, stewardRow]: [ { totalPackages: string @@ -138,7 +141,7 @@ export interface PackageListRow { lifecycleLabel: string | null lastActivityType?: string | null lastActivityContent?: string | null - lastActivityMetadata?: Record | null + lastActivityMetadata?: Record | null lastActivityAt?: Date | null stewards?: StewardEntry[] total: string @@ -196,7 +199,10 @@ export interface PackageStatusCounts { inactive: number } -export type StatusCountsOptions = Omit +export type StatusCountsOptions = Omit< + ListPackagesOptions, + 'status' | 'page' | 'pageSize' | 'sortBy' | 'sortDir' +> const ALL_STEWARDSHIP_STATUSES = [ 'unassigned', @@ -216,9 +222,9 @@ const ALL_STEWARDSHIP_STATUSES = [ export async function getPackageStatusCounts( qx: QueryExecutor, opts: StatusCountsOptions, -): Promise { +): Promise { const conditions: string[] = ['p.is_critical = true'] - const params: Record = {} + const params: Record = {} if (opts.ecosystem) { conditions.push('p.ecosystem = $(ecosystem)') @@ -315,7 +321,7 @@ export async function getPackageStatusCounts( params, ) - const countsMap: Record = {} + const countsMap: Record = {} let all = 0 for (const row of rows) { countsMap[row.status] = row.count @@ -339,9 +345,12 @@ export async function getPackageStatusCounts( return result } -export async function listPackagesForApi(qx: QueryExecutor, opts: ListPackagesOptions): Promise { +export async function listPackagesForApi( + qx: QueryExecutor, + opts: ListPackagesOptions, +): Promise<{ rows: PackageListRow[]; total: number }> { const conditions: string[] = ['p.is_critical = true'] - const params: Record = {} + const params: Record = {} if (opts.ecosystem) { conditions.push('p.ecosystem = $(ecosystem)') @@ -444,7 +453,7 @@ export async function listPackagesForApi(qx: QueryExecutor, opts: ListPackagesOp const sortDir = opts.sortDir === 'desc' ? 'DESC' : 'ASC' // Separate paginated params from filter-only params used by the fallback COUNT query - const queryParams: Record = { + const queryParams: Record = { ...params, limit: opts.pageSize, offset: (opts.page - 1) * opts.pageSize, @@ -551,8 +560,8 @@ export async function listPackagesForApi(qx: QueryExecutor, opts: ListPackagesOp r_sc.scorecard_score AS "scorecardScore", p.health_score AS "healthScore", p.health_label AS "healthLabel", - p.latest_release_at AS "latestReleaseAt", p.lifecycle_label AS "lifecycleLabel", + p.latest_release_at AS "latestReleaseAt", ${opts.includeLastActivity === true ? `last_act.activity_type AS "lastActivityType", last_act.content AS "lastActivityContent", last_act.metadata AS "lastActivityMetadata", last_act.created_at AS "lastActivityAt",` : ''} ${opts.includeStewards === true ? 'ss_agg.stewards AS stewards,' : ''} COUNT(*) OVER() AS total @@ -626,7 +635,7 @@ export interface PackageDetailRow { securitySupplyChainScore: number | null developmentActivityScore: number | null lifecycleLabel: string | null - signalCoverageHealth: Record | null + signalCoverageHealth: Record | null } export interface AdvisoryRow { @@ -636,7 +645,10 @@ export interface AdvisoryRow { isCritical: boolean } -export async function getPackageDetailByPurl(qx: QueryExecutor, purl: string): Promise { +export async function getPackageDetailByPurl( + qx: QueryExecutor, + purl: string, +): Promise { return qx.selectOneOrNone( ` SELECT @@ -727,7 +739,7 @@ export interface ScatterPoint { export async function listPackagesForScatter( qx: QueryExecutor, options: { status?: string[]; ecosystem?: string } = {}, -): Promise { +): Promise { const { status, ecosystem } = options // 'unassigned' covers packages with no stewardship row (s.id IS NULL) in addition @@ -739,7 +751,16 @@ export async function listPackagesForScatter( : '' const ecosystemFilter = ecosystem ? `AND p.ecosystem = $(ecosystem)` : '' - const rows: Array = await qx.select( + const rows: Array<{ + purl: string + name: string + criticalityScore: number + healthScore: number + scorecardScoreRaw: number | null + stewardshipId: string | null + stewardshipStatus: string | null + openVulns: number + }> = await qx.select( ` SELECT p.purl, @@ -795,7 +816,7 @@ export async function getAdvisoriesByPackageId( resolutions?: ('open' | 'patched')[] critical?: boolean }, -): Promise { +): Promise<{ rows: AdvisoryRow[]; total: number }> { const cte = ` WITH advisory_data AS ( SELECT