Skip to content

Commit 106298a

Browse files
authored
fix: stweardship bug fixes (CM-1218) (#4202)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 90f5681 commit 106298a

5 files changed

Lines changed: 62 additions & 10 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getPackagesQx } from '@/db/packagesDb'
77
import { ok } from '@/utils/api'
88
import { validateOrThrow } from '@/utils/validation'
99

10+
import { normalizePurl } from './purl'
1011
import type { StewardshipSummary } from './types'
1112

1213
const MAX_PURLS = 100
@@ -26,7 +27,9 @@ const bodySchema = z.object({
2627

2728
export async function batchGetStewardship(req: Request, res: Response): Promise<void> {
2829
const { purls: rawPurls } = validateOrThrow(bodySchema, req.body)
29-
const normalizedPurls = rawPurls.map((p) => p.replace(/@/g, '%40'))
30+
// Normalize after parsing (not in the schema) so rawPurls keeps the client's
31+
// original form — used as the response key so clients can look up their input.
32+
const normalizedPurls = rawPurls.map(normalizePurl)
3033

3134
const qx = await getPackagesQx()
3235
const rows = await getPackagesByStewardshipPurls(qx, normalizedPurls)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getPackagesQx } from '@/db/packagesDb'
88
import { ok } from '@/utils/api'
99
import { validateOrThrow } from '@/utils/validation'
1010

11+
import { normalizePurl } from './purl'
1112
import type { StewardshipStatus } from './types'
1213

1314
const querySchema = z.object({
@@ -16,7 +17,7 @@ const querySchema = z.object({
1617
.trim()
1718
.min(1)
1819
.refine((v) => v.startsWith('pkg:'), { message: 'purl must start with pkg:' })
19-
.transform((v) => v.replace(/@/g, '%40')),
20+
.transform(normalizePurl),
2021
})
2122

2223
export async function getPackage(req: Request, res: Response): Promise<void> {

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const querySchema = z.object({
2121
pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
2222
ecosystem: z.string().trim().optional(),
2323
lifecycle: z.enum(lifecycleValues).optional(), // TODO: filter not yet implemented in DAL
24-
busFactor1Only: booleanQueryParam, // TODO: filter not yet implemented in DAL
24+
busFactor1Only: booleanQueryParam,
2525
staleOnly: booleanQueryParam,
2626
unstewardedOnly: booleanQueryParam,
2727
sortBy: z.enum(['name', 'health', 'impact', 'openVulns']).default('name'),
@@ -33,7 +33,7 @@ export async function listPackages(req: Request, res: Response): Promise<void> {
3333
page,
3434
pageSize,
3535
ecosystem,
36-
lifecycle,
36+
lifecycle: _lifecycle,
3737
busFactor1Only,
3838
staleOnly,
3939
unstewardedOnly,
@@ -51,6 +51,7 @@ export async function listPackages(req: Request, res: Response): Promise<void> {
5151
ecosystem,
5252
staleOnly,
5353
unstewardedOnly,
54+
busFactor1Only,
5455
sortBy: effectiveSortBy,
5556
sortDir,
5657
})
@@ -74,7 +75,7 @@ export async function listPackages(req: Request, res: Response): Promise<void> {
7475
total,
7576
filters: {
7677
ecosystem: ecosystem ?? null,
77-
lifecycle: lifecycle ?? null,
78+
lifecycle: null, // TODO: filter not yet implemented in DAL
7879
busFactor1Only,
7980
staleOnly,
8081
unstewardedOnly,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Normalize a PURL for lookup against the packages table.
3+
*
4+
* The DB stores versionless PURLs with npm scope @ encoded as %40.
5+
* Clients may send purls with versions (pkg:npm/lodash@4.17.21) and qualifiers.
6+
*
7+
* Transform order:
8+
* 1. strip ?qualifiers and #subpath — not stored in DB
9+
* 2. strip @version suffix — DB stores versionless PURLs
10+
* 3. encode @ in namespace/scope (e.g. npm @babel → %40babel)
11+
*
12+
* The version regex matches @ followed by non-/ non-@ chars at end of string.
13+
* This is always the version separator, not an npm scope (pkg:npm/@babel/core
14+
* has @babel followed by /core, so it never matches the end-of-string pattern).
15+
*/
16+
export function normalizePurl(purl: string): string {
17+
const withoutQualifiers = purl.replace(/[?#].*$/, '')
18+
const withoutVersion = withoutQualifiers.replace(/@[^/@]+$/, '')
19+
return withoutVersion.replace(/@/g, '%40')
20+
}

services/libs/data-access-layer/src/osspckgs/api.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export interface ListPackagesOptions {
6666
ecosystem?: string
6767
staleOnly: boolean
6868
unstewardedOnly: boolean
69+
busFactor1Only: boolean
6970
sortBy: 'name' | 'impact' | 'openVulns'
7071
sortDir: 'asc' | 'desc'
7172
}
@@ -94,6 +95,11 @@ export async function listPackagesForApi(
9495
conditions.push(`(s.status = 'unassigned' OR s.id IS NULL)`)
9596
}
9697

98+
if (opts.busFactor1Only) {
99+
// References pm_counts LATERAL join below — computed once, used in both WHERE and SELECT
100+
conditions.push(`pm_counts.cnt = 1`)
101+
}
102+
97103
const where = `WHERE ${conditions.join(' AND ')}`
98104

99105
// health is a v2 field — fall back to name sort
@@ -103,8 +109,8 @@ export async function listPackagesForApi(
103109
else sortExpr = 'LOWER(p.name)'
104110
const sortDir = opts.sortDir === 'desc' ? 'DESC' : 'ASC'
105111

106-
params.limit = opts.pageSize
107-
params.offset = (opts.page - 1) * opts.pageSize
112+
// Separate paginated params from filter-only params used by the fallback COUNT query
113+
const queryParams = { ...params, limit: opts.pageSize, offset: (opts.page - 1) * opts.pageSize }
108114

109115
const rows: PackageListRow[] = await qx.select(
110116
`
@@ -115,21 +121,42 @@ export async function listPackagesForApi(
115121
p.impact AS "criticalityScore",
116122
s.status AS "stewardshipStatus",
117123
COALESCE(ap_counts.cnt, 0) AS "openVulns",
118-
(SELECT COUNT(*)::int FROM package_maintainers pm WHERE pm.package_id = p.id) AS "maintainerCount",
124+
pm_counts.cnt AS "maintainerCount",
119125
COUNT(*) OVER() AS total
120126
FROM packages p
121127
LEFT JOIN stewardships s ON s.package_id = p.id
122128
LEFT JOIN LATERAL (
123129
SELECT COUNT(*)::int AS cnt FROM advisory_packages WHERE package_id = p.id
124130
) ap_counts ON true
131+
LEFT JOIN LATERAL (
132+
SELECT COUNT(*)::int AS cnt FROM package_maintainers pm WHERE pm.package_id = p.id
133+
) pm_counts ON true
125134
${where}
126135
ORDER BY ${sortExpr} ${sortDir} NULLS LAST, p.purl ${sortDir}
127136
LIMIT $(limit) OFFSET $(offset)
128137
`,
129-
params,
138+
queryParams,
130139
)
131140

132-
const total = rows.length > 0 ? parseInt(rows[0].total, 10) : 0
141+
let total: number
142+
if (rows.length > 0) {
143+
total = parseInt(rows[0].total, 10)
144+
} else {
145+
// Window function returns no rows when the page is beyond the result set.
146+
// Fall back to a separate COUNT so the caller always gets the real total.
147+
const countRow: { count: string } = await qx.selectOne(
148+
`SELECT COUNT(*)::text AS count
149+
FROM packages p
150+
LEFT JOIN stewardships s ON s.package_id = p.id
151+
LEFT JOIN LATERAL (
152+
SELECT COUNT(*)::int AS cnt FROM package_maintainers pm WHERE pm.package_id = p.id
153+
) pm_counts ON true
154+
${where}`,
155+
params,
156+
)
157+
total = parseInt(countRow.count, 10)
158+
}
159+
133160
return { rows, total }
134161
}
135162

0 commit comments

Comments
 (0)