Skip to content

Commit 730d5cf

Browse files
ulemonsclaude
andauthored
feat: stewardship api unmock (CM-1218) (#4195)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent be11a5d commit 730d5cf

10 files changed

Lines changed: 435 additions & 68 deletions

File tree

backend/config/custom-environment-variables.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@
4949
"password": "PRODUCT_DB_PASSWORD",
5050
"database": "PRODUCT_DB_DATABASE"
5151
},
52+
"packagesDb": {
53+
"host": "CROWD_PACKAGES_DB_WRITE_HOST",
54+
"port": "CROWD_PACKAGES_DB_PORT",
55+
"user": "CROWD_PACKAGES_DB_USERNAME",
56+
"password": "CROWD_PACKAGES_DB_PASSWORD",
57+
"database": "CROWD_PACKAGES_DB_DATABASE"
58+
},
5259
"segment": {
5360
"writeKey": "CROWD_SEGMENT_WRITE_KEY"
5461
},

backend/src/api/public/v1/packages/batchGetStewardship.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import type { Request, Response } from 'express'
22
import { z } from 'zod'
33

4+
import { getPackagesByStewardshipPurls } from '@crowd/data-access-layer'
5+
6+
import { getPackagesQx } from '@/db/packagesDb'
47
import { ok } from '@/utils/api'
58
import { validateOrThrow } from '@/utils/validation'
69

7-
import { MOCK_DETAILS } from './mockData'
8-
import type { OpenVulns, StewardshipSummary } from './types'
10+
import type { StewardshipSummary } from './types'
911

1012
const MAX_PURLS = 100
1113

@@ -16,29 +18,30 @@ const bodySchema = z.object({
1618
.max(MAX_PURLS, `Maximum ${MAX_PURLS} purls per request`),
1719
})
1820

19-
// TODO: replace with real DB queries once stewardship tables land
2021
export async function batchGetStewardship(req: Request, res: Response): Promise<void> {
2122
const { purls } = validateOrThrow(bodySchema, req.body)
2223

24+
const qx = await getPackagesQx()
25+
const rows = await getPackagesByStewardshipPurls(qx, purls)
26+
27+
const byPurl = new Map(rows.map((r) => [r.purl, r]))
28+
2329
const packages: Record<string, StewardshipSummary | null> = {}
2430
for (const purl of purls) {
25-
const detail = MOCK_DETAILS[purl]
26-
if (!detail) {
31+
const row = byPurl.get(purl)
32+
if (!row) {
2733
packages[purl] = null
2834
} else {
29-
const openVulns: OpenVulns = { low: 0, medium: 0, high: 0, critical: 0 }
30-
for (const advisory of detail.security.advisories) {
31-
openVulns[advisory.severity] += 1
32-
}
3335
packages[purl] = {
34-
name: detail.name,
35-
ecosystem: detail.ecosystem,
36-
lifecycle: detail.general.riskSignals.lifecycle,
37-
health: detail.general.healthScore.total,
38-
impact: detail.general.impact.impactScore,
39-
openVulns,
40-
stewardship: detail.stewardship.status,
41-
stewards: detail.stewardship.stewards,
36+
name: row.name,
37+
ecosystem: row.ecosystem,
38+
lifecycle: null,
39+
health: null,
40+
impact:
41+
row.criticalityScore != null ? Math.round(Number(row.criticalityScore) * 100) : null,
42+
openVulns: null,
43+
stewardship: (row.stewardshipStatus ?? 'unassigned') as StewardshipSummary['stewardship'],
44+
stewards: null,
4245
lastActivityAt: null,
4346
lastActivityDescription: null,
4447
}
Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,90 @@
11
import type { Request, Response } from 'express'
22
import { z } from 'zod'
33

4-
import { BadRequestError, NotFoundError } from '@crowd/common'
4+
import { NotFoundError } from '@crowd/common'
5+
import { getAdvisoriesByPackageId, getPackageDetailByPurl } from '@crowd/data-access-layer'
56

7+
import { getPackagesQx } from '@/db/packagesDb'
68
import { ok } from '@/utils/api'
79
import { validateOrThrow } from '@/utils/validation'
810

9-
import { MOCK_DETAILS } from './mockData'
11+
import type { StewardshipStatus } from './types'
1012

1113
const querySchema = z.object({
12-
purl: z.string().trim().min(1),
14+
purl: z
15+
.string()
16+
.trim()
17+
.min(1)
18+
.refine((v) => v.startsWith('pkg:'), { message: 'purl must start with pkg:' }),
1319
})
1420

15-
// TODO: replace with real DB queries once packages DB is wired into the backend
1621
export async function getPackage(req: Request, res: Response): Promise<void> {
1722
const { purl } = validateOrThrow(querySchema, req.query)
1823

19-
if (!purl.startsWith('pkg:')) {
20-
throw new BadRequestError('Invalid purl format: must start with pkg:')
21-
}
24+
const qx = await getPackagesQx()
25+
const pkg = await getPackageDetailByPurl(qx, purl)
2226

23-
const detail = MOCK_DETAILS[purl]
24-
if (!detail) {
27+
if (!pkg) {
2528
throw new NotFoundError()
2629
}
2730

28-
ok(res, detail)
31+
const advisories = await getAdvisoriesByPackageId(qx, pkg.id)
32+
33+
ok(res, {
34+
purl: pkg.purl,
35+
name: pkg.name,
36+
ecosystem: pkg.ecosystem,
37+
general: {
38+
healthScore: null,
39+
impact: {
40+
impactScore:
41+
pkg.criticalityScore != null ? Math.round(Number(pkg.criticalityScore) * 100) : null,
42+
downloadsLastMonth:
43+
pkg.downloadsLast30d != null ? parseInt(pkg.downloadsLast30d, 10) : null,
44+
dependentPackages: pkg.dependentPackagesCount ?? null,
45+
dependentRepos: pkg.dependentReposCount ?? null,
46+
transitiveReach: pkg.transitiveReach,
47+
},
48+
riskSignals: {
49+
lifecycle: null,
50+
maintainerBusFactor: pkg.maintainerCount,
51+
lastRelease: pkg.latestReleaseAt ? pkg.latestReleaseAt.toISOString() : null,
52+
hasSecurityFile: pkg.hasSecurityFile,
53+
openSSFScorecard: pkg.scorecardScore != null ? Number(pkg.scorecardScore) : null,
54+
},
55+
},
56+
assessment: {},
57+
security: {
58+
securityContacts: null,
59+
advisories: advisories.map((a) => ({
60+
osvId: a.osvId,
61+
severity: a.severity,
62+
resolution: a.resolution,
63+
})),
64+
cvd: {
65+
isPvrEnabled: null,
66+
hasSecurityPolicyEnabled: pkg.branchProtectionEnabled,
67+
tier0Steward: null,
68+
criticalVulnerabilityFlag: pkg.hasCriticalVulnerability,
69+
},
70+
},
71+
provenance: {
72+
repositoryMapping: {
73+
declaredRepo: pkg.repoUrl ?? pkg.repositoryUrl ?? pkg.declaredRepositoryUrl ?? null,
74+
mappingConfidence:
75+
pkg.repoMappingConfidence != null ? Number(pkg.repoMappingConfidence) : null,
76+
lastCommitAt: pkg.repoLastCommitAt ? pkg.repoLastCommitAt.toISOString() : null,
77+
},
78+
supplyChainIntegrity: {
79+
buildProvenance: null,
80+
signedReleases: null,
81+
},
82+
},
83+
stewardship: {
84+
status: (pkg.stewardshipStatus ?? 'unassigned') as StewardshipStatus,
85+
stewards: null,
86+
lastActivityAt: null,
87+
},
88+
history: {},
89+
})
2990
}
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { Request, Response } from 'express'
22

3-
import { ok } from '@/utils/api'
3+
import { getPackageMetrics } from '@crowd/data-access-layer'
44

5-
import { MOCK_METRICS } from './mockData'
5+
import { getPackagesQx } from '@/db/packagesDb'
6+
import { ok } from '@/utils/api'
67

7-
// TODO: replace with real DB queries once packages DB is wired into the backend
88
export async function getPackagesMetrics(req: Request, res: Response): Promise<void> {
9-
ok(res, MOCK_METRICS)
9+
const qx = await getPackagesQx()
10+
const metrics = await getPackageMetrics(qx)
11+
ok(res, metrics)
1012
}
Lines changed: 30 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import type { Request, Response } from 'express'
22
import { z } from 'zod'
33

4+
import { listPackagesForApi } from '@crowd/data-access-layer'
5+
6+
import { getPackagesQx } from '@/db/packagesDb'
47
import { ok } from '@/utils/api'
58
import { validateOrThrow } from '@/utils/validation'
69

7-
import { MOCK_DETAILS, MOCK_PACKAGES } from './mockData'
10+
import type { StewardshipStatus } from './types'
811

912
const DEFAULT_PAGE_SIZE = 20
1013
const MAX_PAGE_SIZE = 100
11-
const STALE_MONTHS = 18
1214

1315
const booleanQueryParam = z.preprocess((v) => v === 'true', z.boolean()).default(false)
1416

@@ -18,15 +20,14 @@ const querySchema = z.object({
1820
page: z.coerce.number().int().min(1).default(1),
1921
pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
2022
ecosystem: z.string().trim().optional(),
21-
lifecycle: z.enum(lifecycleValues).optional(),
22-
busFactor1Only: booleanQueryParam,
23+
lifecycle: z.enum(lifecycleValues).optional(), // TODO: filter not yet implemented in DAL
24+
busFactor1Only: booleanQueryParam, // TODO: filter not yet implemented in DAL
2325
staleOnly: booleanQueryParam,
2426
unstewardedOnly: booleanQueryParam,
2527
sortBy: z.enum(['name', 'health', 'impact', 'openVulns']).default('name'),
2628
sortDir: z.enum(['asc', 'desc']).default('asc'),
2729
})
2830

29-
// TODO: replace with real DB queries once packages DB is wired into the backend
3031
export async function listPackages(req: Request, res: Response): Promise<void> {
3132
const {
3233
page,
@@ -40,40 +41,32 @@ export async function listPackages(req: Request, res: Response): Promise<void> {
4041
sortDir,
4142
} = validateOrThrow(querySchema, req.query)
4243

43-
const staleThreshold = new Date()
44-
staleThreshold.setMonth(staleThreshold.getMonth() - STALE_MONTHS)
45-
46-
let filtered = MOCK_PACKAGES.filter((p) => {
47-
if (ecosystem && p.ecosystem !== ecosystem) return false
48-
if (lifecycle && p.lifecycle !== lifecycle) return false
49-
if (busFactor1Only && p.maintainerBusFactor !== 1) return false
50-
if (unstewardedOnly && p.stewardship !== null && p.stewardship !== 'unassigned') return false
51-
if (staleOnly) {
52-
const lastRelease = MOCK_DETAILS[p.purl]?.general.riskSignals.lastRelease
53-
if (!lastRelease || new Date(lastRelease) >= staleThreshold) return false
54-
}
55-
return true
56-
})
44+
// health is a v2 field with no backing column yet — fall back to name sort
45+
const effectiveSortBy = sortBy === 'health' ? 'name' : sortBy
5746

58-
filtered = filtered.sort((a, b) => {
59-
let cmp = 0
60-
if (sortBy === 'name') {
61-
cmp = a.name.localeCompare(b.name)
62-
} else if (sortBy === 'health') {
63-
cmp = (a.health ?? 0) - (b.health ?? 0)
64-
} else if (sortBy === 'impact') {
65-
cmp = (a.impact ?? 0) - (b.impact ?? 0)
66-
} else if (sortBy === 'openVulns') {
67-
const sumA = a.openVulns.low + a.openVulns.medium + a.openVulns.high + a.openVulns.critical
68-
const sumB = b.openVulns.low + b.openVulns.medium + b.openVulns.high + b.openVulns.critical
69-
cmp = sumA - sumB
70-
}
71-
return sortDir === 'desc' ? -cmp : cmp
47+
const qx = await getPackagesQx()
48+
const { rows, total } = await listPackagesForApi(qx, {
49+
page,
50+
pageSize,
51+
ecosystem,
52+
staleOnly,
53+
unstewardedOnly,
54+
sortBy: effectiveSortBy,
55+
sortDir,
7256
})
7357

74-
const total = filtered.length
75-
const start = (page - 1) * pageSize
76-
const packages = filtered.slice(start, start + pageSize)
58+
const packages = rows.map((r) => ({
59+
purl: r.purl,
60+
name: r.name,
61+
ecosystem: r.ecosystem,
62+
health: null,
63+
impact: r.criticalityScore != null ? Math.round(Number(r.criticalityScore) * 100) : null,
64+
lifecycle: null,
65+
maintainerBusFactor: r.maintainerCount,
66+
openVulns: r.openVulns,
67+
stewardship: (r.stewardshipStatus ?? 'unassigned') as StewardshipStatus,
68+
stewards: null,
69+
}))
7770

7871
ok(res, {
7972
page,
@@ -86,7 +79,7 @@ export async function listPackages(req: Request, res: Response): Promise<void> {
8679
staleOnly,
8780
unstewardedOnly,
8881
},
89-
sort: { by: sortBy, dir: sortDir },
82+
sort: { by: effectiveSortBy, dir: sortDir },
9083
packages,
9184
})
9285
}

backend/src/conf/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ export const PRODUCT_DB_CONFIG: IDatabaseConfig = config.has('productDb')
8282
? config.get<IDatabaseConfig>('productDb')
8383
: undefined
8484

85+
export const PACKAGES_DB_CONFIG: IDatabaseConfig | undefined = config.has('packagesDb')
86+
? config.get<IDatabaseConfig>('packagesDb')
87+
: undefined
88+
8589
export const SEGMENT_CONFIG: SegmentConfiguration = config.get<SegmentConfiguration>('segment')
8690

8791
export const COMPREHEND_CONFIG: ComprehendConfiguration =

backend/src/db/packagesDb.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { getDbConnection } from '@crowd/data-access-layer/src/database'
2+
import { QueryExecutor, pgpQx } from '@crowd/data-access-layer/src/queryExecutor'
3+
4+
import { PACKAGES_DB_CONFIG } from '@/conf'
5+
6+
let _init: Promise<QueryExecutor> | undefined
7+
8+
export function getPackagesQx(): Promise<QueryExecutor> {
9+
if (!_init) {
10+
if (!PACKAGES_DB_CONFIG) {
11+
throw new Error(
12+
'Packages DB is not configured — set CROWD_PACKAGES_DB_* environment variables',
13+
)
14+
}
15+
_init = getDbConnection(PACKAGES_DB_CONFIG)
16+
.then(pgpQx)
17+
.catch((err) => {
18+
_init = undefined
19+
throw err
20+
})
21+
}
22+
return _init
23+
}

services/libs/data-access-layer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ export * from './osspckgs/packages'
2222
export * from './osspckgs/repos'
2323
export * from './osspckgs/stewardships'
2424
export * from './osspckgs/versions'
25+
export * from './osspckgs/api'

0 commit comments

Comments
 (0)