diff --git a/api/src/lib/collections.ts b/api/src/lib/collections.ts index 2266acde9..f260cb341 100644 --- a/api/src/lib/collections.ts +++ b/api/src/lib/collections.ts @@ -4,7 +4,7 @@ import { Type, type Static, type TSchema } from '@sinclair/typebox'; import { nullableEnum, nullableNumber, nullableString } from '../schemas/common.js'; import { LeaderboardType } from './leaderboards.js'; -import { HealthLabel, ImpactLabel, LifecycleLabel } from './overview.js'; +import { HealthLabel, ImpactLabel, isHealthScore, LifecycleLabel } from './overview.js'; import { dataEnum, enumGuard, isCount, isString } from './security.js'; // OpenAPI 3.0 has no type 'null', so a nullable object carries `nullable` like the scalars in schemas/common.ts. @@ -267,15 +267,12 @@ export interface CollectionProjectRow { const isProjectType = enumGuard(CollectionProjectType); const isStatus = enumGuard(ProjectStatus); -const isHealthLabel = enumGuard(HealthLabel); const isLifecycleLabel = enumGuard(LifecycleLabel); const isImpactLabel = enumGuard(ImpactLabel); const isLeaderboardType = enumGuard(LeaderboardType); const upTo = (value: unknown, max: number) => value === null || (isCount(value) && (value as number) <= max); -// The maxima the pipe derives from the covered categories. -const healthMaxScores = new Set([60, 65, 75, 100, null]); const isPercentage = (value: unknown) => Number.isFinite(value); const isAchievement = (value: unknown) => @@ -310,12 +307,7 @@ export const isCollectionProjectRow = (row: unknown): row is CollectionProjectRo isPercentage(candidate.organizationDependencyPercentage) && Array.isArray(candidate.achievements) && candidate.achievements.every(isAchievement) && - upTo(candidate.healthScoreV2, 100) && - isHealthLabel(candidate.healthLabel) && - healthMaxScores.has(candidate.healthMaxScore) && - (candidate.healthScoreV2 === null || - candidate.healthMaxScore === null || - (candidate.healthScoreV2 as number) <= (candidate.healthMaxScore as number)) && + isHealthScore(candidate.healthScoreV2, candidate.healthLabel, candidate.healthMaxScore) && upTo(candidate.maintainerHealthScoreV2, 40) && upTo(candidate.securitySupplyChainScoreV2, 35) && upTo(candidate.developmentActivityScoreV2, 25) && diff --git a/api/src/lib/oss-index.ts b/api/src/lib/oss-index.ts new file mode 100644 index 000000000..42ff316a2 --- /dev/null +++ b/api/src/lib/oss-index.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import { Type, type Static } from '@sinclair/typebox'; + +import { nullableNumber } from '../schemas/common.js'; +import { HealthLabel, isHealthScore } from './overview.js'; +import { dataEnum, inRange, isCount, isString } from './security.js'; + +// The pipes return positional tuples, so each slot is type-checked before the route names it. Project +// tuples keep the v1 health score, which the API drops, and end in the nullable v2 health fields. +export type TopCollectionTuple = [ + id: string, + count: number, + name: string, + softwareValue: number, + avgScore: number, +]; + +export type TopProjectTuple = [ + id: string, + count: number, + name: string, + logo: string, + softwareValue: number, + avgScore: number, + v1HealthScore: number, + description: string, + status: string, + healthScore: number | null, + healthLabel: string | null, + scaledHealthScore: number | null, + healthMaxScore: number | null, +]; + +// Counts and the UInt64 software value must be non-negative safe integers. The 0-1 averages use +// inRange; the dropped v1 score only needs a finite number. +const isNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value); +const isNullableNumber = (value: unknown) => value === null || isNumber(value); + +export const isTopCollectionTuple = (value: unknown): value is TopCollectionTuple => + Array.isArray(value) && + value.length === 5 && + isString(value[0]) && + isCount(value[1]) && + isString(value[2]) && + isCount(value[3]) && + inRange(value[4], 1); + +export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => + Array.isArray(value) && + value.length >= 13 && + isString(value[0]) && + isCount(value[1]) && + isString(value[2]) && + isString(value[3]) && + isCount(value[4]) && + inRange(value[5], 1) && + isNumber(value[6]) && + isString(value[7]) && + isString(value[8]) && + isHealthScore(value[9], value[10], value[12]) && + isNullableNumber(value[11]); + +export const TopCollection = Type.Object( + { + id: Type.String({ description: 'Identifier of the collection.' }), + count: Type.Integer({ minimum: 0, description: 'Contributors in the collection.' }), + name: Type.String({ description: 'Display name of the collection.' }), + softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the collection.' }), + avgScore: Type.Number({ + minimum: 0, + maximum: 1, + description: 'Average health score of the collection, from 0 to 1.', + }), + }, + { title: 'TopCollection' }, +); +export type TopCollection = Static; + +export const TopProject = Type.Object( + { + id: Type.String({ description: 'Identifier of the project.' }), + count: Type.Integer({ minimum: 0, description: 'Contributors to the project.' }), + name: Type.String({ description: 'Display name of the project.' }), + logoUrl: Type.String({ description: 'URL of the project logo.' }), + description: Type.String({ description: 'Short description of the project.' }), + softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the project.' }), + avgScore: Type.Number({ + minimum: 0, + maximum: 1, + description: 'Average score of the project, from 0 to 1.', + }), + healthScore: nullableNumber( + 'Health score, the sum of the three category scores, from 0 to `healthMaxScore`. Null when fewer than two categories have data.', + ), + healthLabel: HealthLabel, + healthMaxScore: nullableNumber( + 'Highest score the covered categories allow: 60, 65, 75 or 100. Null when unknown.', + ), + status: Type.String({ description: 'Lifecycle status of the project, for example `active`.' }), + }, + { title: 'TopProject' }, +); +export type TopProject = Static; + +export const toTopCollection = ([ + id, + count, + name, + softwareValue, + avgScore, +]: TopCollectionTuple): TopCollection => ({ id, count, name, softwareValue, avgScore }); + +export const toTopProject = ([ + id, + count, + name, + logo, + softwareValue, + avgScore, + , + description, + status, + healthScore, + healthLabel, + , + healthMaxScore, +]: TopProjectTuple): TopProject => ({ + id, + count, + name, + logoUrl: logo, + description, + softwareValue, + avgScore, + healthScore, + healthLabel: healthLabel as TopProject['healthLabel'], + healthMaxScore, + status, +}); + +export const sortValues = ['totalContributors', 'softwareValue'] as const; + +export const sortQuery = Type.Object({ + sort: Type.Optional( + dataEnum( + sortValues, + 'Orders the results, highest first. Defaults to `totalContributors`; `softwareValue` orders by software value.', + ), + ), +}); diff --git a/api/src/lib/overview.ts b/api/src/lib/overview.ts index 181f1784c..e5d29f959 100644 --- a/api/src/lib/overview.ts +++ b/api/src/lib/overview.ts @@ -3,6 +3,7 @@ import { Type, type TObject } from '@sinclair/typebox'; import { nullableEnum } from '../schemas/common.js'; +import { enumGuard, isCount } from './security.js'; // Values are exposed as the data holds them, matching the Overview tab. export const HealthLabel = nullableEnum( @@ -10,6 +11,22 @@ export const HealthLabel = nullableEnum( 'Band of the health score. Null when the score is null.', ); +const isHealthLabel = enumGuard(HealthLabel); +// The maxima the pipes derive from the covered categories. +const healthMaxScores = new Set([60, 65, 75, 100, null]); + +// The v2 health score, band and maximum. The band is null exactly when the score is, and repo +// records carry a score without a maximum. +export const isHealthScore = (score: unknown, label: unknown, maxScore: unknown) => + score === null + ? label === null && maxScore === null + : isCount(score) && + (score as number) <= 100 && + label !== null && + isHealthLabel(label) && + healthMaxScores.has(maxScore) && + (maxScore === null || (score as number) <= (maxScore as number)); + export const LifecycleLabel = nullableEnum( ['active', 'stable', 'declining', 'inert', 'abandoned', 'archived'] as const, 'Activity state of the project or selected repositories. Null when unknown.', diff --git a/api/src/versions/v1-alpha/oss-index/groups.ts b/api/src/versions/v1-alpha/oss-index/groups.ts new file mode 100644 index 000000000..eebd7b09e --- /dev/null +++ b/api/src/versions/v1-alpha/oss-index/groups.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; +import { Type } from '@sinclair/typebox'; + +import { fetchPipe } from '../../../clients/tinybird.js'; +import { + isTopCollectionTuple, + isTopProjectTuple, + sortQuery, + TopCollection, + toTopCollection, + TopProject, + toTopProject, + type TopCollectionTuple, + type TopProjectTuple, +} from '../../../lib/oss-index.js'; +import { inRange, isCount, isString } from '../../../lib/security.js'; + +const pipePath = '/v0/pipes/category_groups_oss_index.json'; + +interface Row { + id: string; + name: string; + type: string; + slug: string; + totalContributors: number; + softwareValue: number; + avgScore: number; + projectCount: number; + topCollections: TopCollectionTuple[]; + topProjects: TopProjectTuple[]; +} + +const isRow = (row: Row) => + isString(row.id) && + isString(row.name) && + isString(row.type) && + isString(row.slug) && + isCount(row.totalContributors) && + isCount(row.softwareValue) && + inRange(row.avgScore, 1) && + isCount(row.projectCount) && + Array.isArray(row.topCollections) && + row.topCollections.every(isTopCollectionTuple) && + Array.isArray(row.topProjects) && + row.topProjects.every(isTopProjectTuple); + +const Query = Type.Object({ + type: Type.Optional( + Type.String({ + description: 'Keeps the groups of this type, for example `vertical` or `horizontal`.', + }), + ), + ...sortQuery.properties, +}); + +const OssIndexGroup = Type.Object( + { + id: Type.String({ description: 'Identifier of the category group.' }), + name: Type.String({ description: 'Display name of the category group.' }), + type: Type.String({ + description: 'Type of the group, for example `vertical` or `horizontal`.', + }), + slug: Type.String({ description: 'URL slug of the category group.' }), + totalContributors: Type.Integer({ minimum: 0, description: 'Contributors across the group.' }), + softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the group.' }), + avgScore: Type.Number({ + minimum: 0, + maximum: 1, + description: 'Average health score of the group, from 0 to 1.', + }), + projectCount: Type.Integer({ minimum: 0, description: 'Projects in the group.' }), + topCollections: Type.Array(TopCollection, { + description: 'Leading collections of the group, ordered by contributors.', + }), + topProjects: Type.Array(TopProject, { + description: 'Leading projects of the group, ordered by contributors.', + }), + }, + { title: 'OssIndexGroup' }, +); + +const Groups = Type.Object({ + data: Type.Array(OssIndexGroup, { + description: 'Category groups in the requested order. Empty when none match `type`.', + }), +}); + +const groupRoutes: FastifyPluginAsyncTypebox = async (scope) => { + scope.get( + '/oss-index/groups', + { + schema: { + tags: ['OSS Index'], + summary: 'List the OSS Index category groups', + description: + 'Returns every category group with its totals and its leading collections and projects. ' + + '`type` keeps one group type, and `sort` orders the groups by contributors or by software value.', + querystring: Query, + response: { 200: Groups }, + }, + }, + async (request) => { + const { type, sort } = request.query; + const rows = await fetchPipe( + request, + pipePath, + { type: type || undefined, orderBy: sort ?? 'totalContributors' }, + isRow, + ); + return { + data: rows.map((row) => ({ + ...row, + topCollections: row.topCollections.map(toTopCollection), + topProjects: row.topProjects.map(toTopProject), + })), + }; + }, + ); +}; + +export default groupRoutes; diff --git a/api/tests/collections-projects.test.ts b/api/tests/collections-projects.test.ts index d4e5c22d6..7392be5bc 100644 --- a/api/tests/collections-projects.test.ts +++ b/api/tests/collections-projects.test.ts @@ -229,6 +229,33 @@ describe('response', () => { expect(body.data[0]).not.toHaveProperty('healthScoreV2'); }); + it.each([ + ['a score with a null label', { healthScoreV2: 77, healthLabel: null }], + [ + 'a label with a null score', + { healthScoreV2: null, healthLabel: 'healthy', healthMaxScore: null }, + ], + [ + 'a maximum with a null score', + { healthScoreV2: null, healthLabel: null, healthMaxScore: 100 }, + ], + ])('answers 503 for %s', async (_label, patch) => { + all = [{ ...row(1), ...patch }]; + const res = await get(route); + expect(res.statusCode).toBe(503); + }); + + it('accepts a repo row with a score and no maximum', async () => { + all = [{ ...row(1, 'repo'), healthScoreV2: 61, healthLabel: 'fair', healthMaxScore: null }]; + const body = await bodyOf(); + expect(body.data[0]).toMatchObject({ + type: 'repo', + healthScore: 61, + healthLabel: 'fair', + healthMaxScore: null, + }); + }); + it('answers 503 for rows of an unexpected shape', async () => { mockFetch.mockImplementation(async () => tinybirdResponse([{ id: 'x' }])); const res = await get(route); diff --git a/api/tests/oss-index-groups.test.ts b/api/tests/oss-index-groups.test.ts new file mode 100644 index 000000000..0b0133ff6 --- /dev/null +++ b/api/tests/oss-index-groups.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + callsTo, + mockFetch, + pipeCalls, + tinybirdStub, + useApp, + resolveSchema, + type OpenApiDoc, +} from './helpers/tinybird.js'; + +const route = '/v1-alpha/oss-index/groups'; +const pipePath = '/v0/pipes/category_groups_oss_index.json'; + +const row = (overrides: Record = {}) => ({ + id: 'g1', + name: 'Programming Languages', + type: 'horizontal', + slug: 'programming-languages-horizontal', + totalContributors: 9305223, + softwareValue: 135666024614, + avgScore: 0.41, + projectCount: 5279, + topCollections: [['c1', 726549, 'Frontend', 1917065599, 0.45]], + topProjects: [ + [ + 'p1', + 315948, + 'Node.js', + 'https://logo.test/n.png', + 24048486264, + 0, + 86, + 'Runtime', + 'active', + 74, + 'healthy', + 74, + 100, + ], + ], + ...overrides, +}); + +let rows: unknown[] = []; +const { get } = useApp(); + +beforeEach(() => { + rows = [row()]; + mockFetch.mockImplementation(tinybirdStub(() => rows)); +}); + +describe('GET /v1-alpha/oss-index/groups', () => { + it('maps group fields and converts both tuple lists', async () => { + const res = await get(route); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + data: [ + { + id: 'g1', + name: 'Programming Languages', + type: 'horizontal', + slug: 'programming-languages-horizontal', + totalContributors: 9305223, + softwareValue: 135666024614, + avgScore: 0.41, + projectCount: 5279, + topCollections: [ + { + id: 'c1', + count: 726549, + name: 'Frontend', + softwareValue: 1917065599, + avgScore: 0.45, + }, + ], + topProjects: [ + { + id: 'p1', + count: 315948, + name: 'Node.js', + logoUrl: 'https://logo.test/n.png', + description: 'Runtime', + softwareValue: 24048486264, + avgScore: 0, + healthScore: 74, + healthLabel: 'healthy', + healthMaxScore: 100, + status: 'active', + }, + ], + }, + ], + }); + }); + + it('answers an empty list', async () => { + rows = []; + const res = await get(route); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ data: [] }); + }); + + it('forwards totalContributors as orderBy by default and omits type', async () => { + await get(route); + expect(pipeCalls()).toHaveLength(1); + const [call] = callsTo(pipePath); + expect(call?.searchParams.get('orderBy')).toBe('totalContributors'); + expect(call?.searchParams.has('type')).toBe(false); + }); + + it('forwards an explicit sort as orderBy', async () => { + await get(`${route}?sort=softwareValue`); + expect(callsTo(pipePath)[0]?.searchParams.get('orderBy')).toBe('softwareValue'); + }); + + it('forwards type when given', async () => { + await get(`${route}?type=vertical`); + expect(callsTo(pipePath)[0]?.searchParams.get('type')).toBe('vertical'); + }); + + it('omits an empty type', async () => { + await get(`${route}?type=`); + expect(callsTo(pipePath)[0]?.searchParams.has('type')).toBe(false); + }); + + it('rejects an unknown sort with 400 before calling the pipe', async () => { + const res = await get(`${route}?sort=bogus`); + expect(res.statusCode).toBe(400); + expect(pipeCalls()).toHaveLength(0); + }); + + it.each([ + ['a missing group id', row({ id: undefined })], + ['a non-array topProjects', row({ topProjects: 'x' })], + ['a malformed collection tuple', row({ topCollections: [['c1', 1, null, 1, 1]] })], + ['a negative totalContributors', row({ totalContributors: -1 })], + ['a fractional projectCount', row({ projectCount: 1.5 })], + ['a fractional softwareValue', row({ softwareValue: 10.5 })], + ['a negative collection tuple count', row({ topCollections: [['c1', -1, 'n', 1, 1]] })], + [ + 'a fractional project tuple count', + row({ topProjects: [['p1', 1.5, 'n', 'l', 1, 1, 1, 'd', 's', null, null, null, null]] }), + ], + ['a short project tuple', row({ topProjects: [['p1', 1, 'n']] })], + [ + 'a project tuple without health fields', + row({ topProjects: [['p1', 1, 'n', 'l', 1, 1, 1, 'd', 's']] }), + ], + ['a negative avgScore', row({ avgScore: -0.1 })], + ['an avgScore above 1', row({ avgScore: 1.1 })], + [ + 'a project tuple with a score and no label', + row({ topProjects: [['p1', 1, 'n', 'l', 1, 1, 1, 'd', 's', 74, null, 74, 100]] }), + ], + [ + 'a null project logo', + row({ topProjects: [['p1', 1, 'n', null, 1, 1, 1, 'd', 's', null, null, null, null]] }), + ], + ])('answers 503 upstream_unavailable for %s', async (_label, bad) => { + rows = [bad]; + const res = await get(route); + expect(res.statusCode).toBe(503); + expect(res.body).toContain('upstream_unavailable'); + }); + + it('is listed in the OpenAPI spec under the OSS Index tag', async () => { + const spec = (await get('/v1-alpha/openapi.json')).json(); + const op = spec.paths[route]?.get as { tags?: string[] } | undefined; + expect(op?.tags).toEqual(['OSS Index']); + }); + + it('publishes the 0 to 1 bounds the row guard enforces on every avgScore', async () => { + const spec = (await get('/v1-alpha/openapi.json')).json(); + const ok = spec.paths[route]?.get?.responses['200']?.content['application/json']?.schema; + const group = resolveSchema(spec, resolveSchema(spec, ok)?.properties?.data?.items)?.properties; + const bounds = { minimum: 0, maximum: 1 }; + expect(group?.avgScore).toMatchObject(bounds); + const nested = (key: string) => + resolveSchema(spec, resolveSchema(spec, group?.[key]?.items)?.properties?.avgScore); + expect(nested('topCollections')).toMatchObject(bounds); + expect(nested('topProjects')).toMatchObject(bounds); + }); +}); diff --git a/api/tests/oss-index.test.ts b/api/tests/oss-index.test.ts new file mode 100644 index 000000000..9e0b3f8b0 --- /dev/null +++ b/api/tests/oss-index.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; + +import { + isTopCollectionTuple, + isTopProjectTuple, + toTopCollection, + toTopProject, +} from '../src/lib/oss-index.js'; + +const collectionTuple = ['c1', 12, 'Web Frameworks', 1500, 0.45]; +const projectTuple = [ + 'p1', + 30, + 'Node.js', + 'https://logo.test/n.png', + 2400, + 0.8, + 86, + 'Runtime', + 'active', + 74, + 'healthy', + 74, + 100, +]; +const withSlot = (index: number, value: unknown) => + projectTuple.map((slot, i) => (i === index ? value : slot)); + +describe('toTopCollection', () => { + it('names each tuple slot', () => { + expect(toTopCollection(collectionTuple as never)).toEqual({ + id: 'c1', + count: 12, + name: 'Web Frameworks', + softwareValue: 1500, + avgScore: 0.45, + }); + }); +}); + +describe('toTopProject', () => { + it('names each tuple slot, renames logo to logoUrl and takes the v2 health fields', () => { + expect(toTopProject(projectTuple as never)).toEqual({ + id: 'p1', + count: 30, + name: 'Node.js', + logoUrl: 'https://logo.test/n.png', + description: 'Runtime', + softwareValue: 2400, + avgScore: 0.8, + healthScore: 74, + healthLabel: 'healthy', + healthMaxScore: 100, + status: 'active', + }); + }); + + it('keeps null v2 health fields for an unscored project', () => { + const unscored = [...projectTuple.slice(0, 9), null, null, null, null]; + expect(toTopProject(unscored as never)).toMatchObject({ + healthScore: null, + healthLabel: null, + healthMaxScore: null, + }); + }); +}); + +describe('tuple guards', () => { + it('accept well-formed tuples', () => { + expect(isTopCollectionTuple(collectionTuple)).toBe(true); + expect(isTopProjectTuple(projectTuple)).toBe(true); + expect(isTopProjectTuple([...projectTuple.slice(0, 9), null, null, null, null])).toBe(true); + expect(isTopProjectTuple([...projectTuple.slice(0, 9), 64, 'excellent', 85, 75])).toBe(true); + expect(isTopProjectTuple([...projectTuple.slice(0, 9), 61, 'fair', 61, null])).toBe(true); + }); + + it.each([ + ['not an array', 'x'], + ['too short', collectionTuple.slice(0, 4)], + ['too long', [...collectionTuple, 1]], + ['a null slot', ['c1', 12, null, 1500, 0.45]], + ['a string count', ['c1', '12', 'n', 1500, 0.45]], + ['a non-finite score', ['c1', 12, 'n', 1500, Number.NaN]], + ['a negative count', ['c1', -1, 'n', 1500, 0.45]], + ['a fractional count', ['c1', 1.5, 'n', 1500, 0.45]], + ['a fractional software value', ['c1', 12, 'n', 1500.5, 0.45]], + ['a negative score', ['c1', 12, 'n', 1500, -0.1]], + ['a score above 1', ['c1', 12, 'n', 1500, 1.1]], + ])('reject a collection tuple that is %s', (_label, value) => { + expect(isTopCollectionTuple(value)).toBe(false); + }); + + it.each([ + ['too short', projectTuple.slice(0, 12)], + ['without the health fields', projectTuple.slice(0, 9)], + ['a null logo', withSlot(3, null)], + ['a numeric status', withSlot(8, 1)], + ['a string v1 health score', withSlot(6, '86')], + ['a negative count', withSlot(1, -30)], + ['a fractional count', withSlot(1, 30.5)], + ['a negative software value', withSlot(4, -2400)], + ['an unknown health label', withSlot(10, 'great')], + ['a health score above its maximum', [...projectTuple.slice(0, 9), 80, 'healthy', 100, 75]], + ['a health maximum no category set gives', withSlot(12, 90)], + ['a string scaled health score', withSlot(11, '74')], + ['a negative average score', withSlot(5, -0.1)], + ['an average score above 1', withSlot(5, 1.1)], + ['a health score with a null label', [...projectTuple.slice(0, 9), 74, null, 74, 100]], + [ + 'a null health score with a label', + [...projectTuple.slice(0, 9), null, 'healthy', null, null], + ], + ['a null health score with a maximum', [...projectTuple.slice(0, 9), null, null, null, 100]], + ])('reject a project tuple that is %s', (_label, value) => { + expect(isTopProjectTuple(value)).toBe(false); + }); +});