Skip to content

Commit ff77df6

Browse files
committed
feat: add sonatype script
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 5ab88b0 commit ff77df6

3 files changed

Lines changed: 301 additions & 1 deletion

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/**
2+
* One-shot import of the Sonatype popularity signal into the osspckgs `packages`
3+
* table.
4+
*
5+
* Sonatype could not deliver raw Maven download counts, so instead they provide a
6+
* popularity score derived from their SCA telemetry: normalized 0-100 per
7+
* ecosystem, tiered P0-P3. First delivery is Maven P0 only (top ~500 components).
8+
*
9+
* Safe to run multiple times — every write goes through upsertSonatypePopularity,
10+
* keyed on the composite identity (ecosystem, namespace, name). Rows not yet in
11+
* `packages` are inserted with ingestion_source='sonatype' (deps.dev / Maven
12+
* enrichment backfills the rest later); existing rows get only their sonatype_*
13+
* fields refreshed and keep their original ingestion_source.
14+
*
15+
* Expected CSV header (exact): format,namespace,name,component_key,popularity_score,rank,tier
16+
* - namespace = Maven groupId, name = Maven artifactId
17+
* - component_key = groupId:artifactId (used only as a sanity check)
18+
*
19+
* Flags:
20+
* --dry-run Parse + validate, print what would happen, no DB writes
21+
* --snapshot-date=YYYY-MM-DD Snapshot timestamp; derived from the filename if omitted
22+
*
23+
* Usage:
24+
* tsx src/maven/scripts/importSonatypePopularityFromCsv.ts <input.csv> [--snapshot-date=YYYY-MM-DD] [--dry-run]
25+
*/
26+
import * as fs from 'fs'
27+
import * as path from 'path'
28+
29+
import { upsertSonatypePopularity } from '@crowd/data-access-layer/src/osspckgs/packages'
30+
import { pgpQx } from '@crowd/data-access-layer/src/queryExecutor'
31+
32+
import { getPackagesDbConnection } from '../../db'
33+
34+
import { parseCsv } from './csv'
35+
36+
const EXPECTED_HEADER = [
37+
'format',
38+
'namespace',
39+
'name',
40+
'component_key',
41+
'popularity_score',
42+
'rank',
43+
'tier',
44+
]
45+
const VALID_TIERS = new Set(['P0', 'P1', 'P2', 'P3'])
46+
47+
// packages.purl is stored version-stripped for Maven (one row per groupId:artifactId).
48+
// Mirror that exact format for insert-if-missing rows so a later deps.dev / Maven
49+
// enrichment upsert (keyed on purl) resolves to the same row instead of duplicating it.
50+
function buildMavenPurl(groupId: string, artifactId: string): string {
51+
return `pkg:maven/${groupId}/${artifactId}`
52+
}
53+
54+
function resolveSnapshotDate(args: string[], inputPath: string): Date {
55+
const flag = args.find((a) => a.startsWith('--snapshot-date='))
56+
const explicit = flag?.split('=')[1]
57+
const raw = explicit ?? path.basename(inputPath).match(/(\d{4}-\d{2}-\d{2})/)?.[1]
58+
59+
if (!raw) {
60+
throw new Error(
61+
'Could not determine snapshot date: pass --snapshot-date=YYYY-MM-DD or include a YYYY-MM-DD in the filename',
62+
)
63+
}
64+
const date = new Date(`${raw}T00:00:00Z`)
65+
if (isNaN(date.getTime())) {
66+
throw new Error(`Invalid snapshot date: "${raw}" (expected YYYY-MM-DD)`)
67+
}
68+
return date
69+
}
70+
71+
type SonatypeRow = {
72+
namespace: string
73+
name: string
74+
score: number
75+
rank: number
76+
tier: string
77+
}
78+
79+
// Parse + validate one CSV record. Returns null for rows to skip (non-maven or
80+
// malformed), with the reason logged so nothing is dropped silently.
81+
function parseRow(r: Record<string, string>, lineNo: number): SonatypeRow | null {
82+
if (r.format !== 'maven') return null // defensive: first delivery is maven-only
83+
84+
const namespace = r.namespace
85+
const name = r.name
86+
const score = Number(r.popularity_score)
87+
const rank = Number(r.rank)
88+
const tier = r.tier
89+
90+
if (!namespace || !name) {
91+
console.warn(` line ${lineNo}: missing namespace/name — skipped`)
92+
return null
93+
}
94+
if (!Number.isFinite(score) || score < 0 || score > 100) {
95+
console.warn(` line ${lineNo}: invalid popularity_score "${r.popularity_score}" — skipped`)
96+
return null
97+
}
98+
if (!Number.isInteger(rank) || rank <= 0) {
99+
console.warn(` line ${lineNo}: invalid rank "${r.rank}" — skipped`)
100+
return null
101+
}
102+
if (!VALID_TIERS.has(tier)) {
103+
console.warn(` line ${lineNo}: invalid tier "${tier}" — skipped`)
104+
return null
105+
}
106+
// component_key is groupId:artifactId — cross-check against namespace/name.
107+
if (r.component_key && r.component_key !== `${namespace}:${name}`) {
108+
console.warn(
109+
` line ${lineNo}: component_key "${r.component_key}" != "${namespace}:${name}" — skipped`,
110+
)
111+
return null
112+
}
113+
114+
return { namespace, name, score, rank, tier }
115+
}
116+
117+
async function main() {
118+
const args = process.argv.slice(2)
119+
const dryRun = args.includes('--dry-run')
120+
const inputPath = args.find((a) => !a.startsWith('--'))
121+
122+
if (!inputPath) {
123+
console.error(
124+
'Usage: tsx src/maven/scripts/importSonatypePopularityFromCsv.ts <input.csv> [--snapshot-date=YYYY-MM-DD] [--dry-run]',
125+
)
126+
process.exit(1)
127+
}
128+
129+
const snapshotAt = resolveSnapshotDate(args, inputPath)
130+
131+
console.log(`Input: ${inputPath}`)
132+
console.log(`Snapshot: ${snapshotAt.toISOString()}`)
133+
console.log(`Mode: ${dryRun ? 'DRY RUN (no DB writes)' : 'LIVE'}`)
134+
console.log()
135+
136+
const content = fs.readFileSync(inputPath, 'utf-8')
137+
const records = parseCsv(content)
138+
139+
if (records.length === 0) {
140+
throw new Error('CSV is empty (no data rows)')
141+
}
142+
143+
// Validate the header exactly — order-independent, no missing/extra columns.
144+
const header = Object.keys(records[0])
145+
const missing = EXPECTED_HEADER.filter((h) => !header.includes(h))
146+
const extra = header.filter((h) => !EXPECTED_HEADER.includes(h))
147+
if (missing.length > 0 || extra.length > 0) {
148+
throw new Error(
149+
`Unexpected CSV header.\n expected: ${EXPECTED_HEADER.join(', ')}\n got: ${header.join(', ')}` +
150+
(missing.length ? `\n missing: ${missing.join(', ')}` : '') +
151+
(extra.length ? `\n extra: ${extra.join(', ')}` : ''),
152+
)
153+
}
154+
155+
let skipped = 0
156+
const rows: SonatypeRow[] = []
157+
records.forEach((r, i) => {
158+
const parsed = parseRow(r, i + 2) // +2: 1-based, and header is line 1
159+
if (parsed) rows.push(parsed)
160+
else skipped++
161+
})
162+
163+
console.log(`Parsed ${records.length} data rows — ${rows.length} valid maven, ${skipped} skipped`)
164+
165+
if (dryRun) {
166+
rows.slice(0, 10).forEach((r) => {
167+
console.log(` ${r.namespace}:${r.name} score=${r.score} rank=${r.rank} tier=${r.tier}`)
168+
})
169+
if (rows.length > 10) console.log(` ... (${rows.length - 10} more)`)
170+
console.log('\nDry run complete — no changes made.')
171+
return
172+
}
173+
174+
const conn = await getPackagesDbConnection()
175+
176+
const insertedRows: SonatypeRow[] = []
177+
let updated = 0
178+
179+
// Single transaction: the dataset is small (hundreds of rows) and a one-shot
180+
// import should be all-or-nothing.
181+
await conn.tx(async (t) => {
182+
const tqx = pgpQx(t)
183+
for (const r of rows) {
184+
const { inserted: wasInserted } = await upsertSonatypePopularity(tqx, {
185+
purl: buildMavenPurl(r.namespace, r.name),
186+
ecosystem: 'maven',
187+
namespace: r.namespace,
188+
name: r.name,
189+
sonatypePopularityScore: r.score,
190+
sonatypeRank: r.rank,
191+
sonatypeTier: r.tier,
192+
sonatypeSnapshotAt: snapshotAt,
193+
})
194+
if (wasInserted) insertedRows.push(r)
195+
else updated++
196+
}
197+
})
198+
199+
await (conn as unknown as { $pool: { end: () => Promise<void> } }).$pool.end()
200+
201+
console.log(`\n✓ Done.`)
202+
console.log(` Inserted (new packages): ${insertedRows.length}`)
203+
console.log(` Updated (existing): ${updated}`)
204+
console.log(` Skipped (non-maven / invalid): ${skipped}`)
205+
206+
// List the newly-inserted packages — these are the Sonatype rows that were not
207+
// in `packages` yet (deps.dev hasn't backfilled them). Small set, worth an audit
208+
// trail so the overlap can be reported without re-querying the DB.
209+
if (insertedRows.length > 0) {
210+
console.log(`\n New packages inserted (ingestion_source='sonatype'):`)
211+
insertedRows
212+
.sort((a, b) => a.rank - b.rank)
213+
.forEach((r) =>
214+
console.log(` rank ${r.rank}\t${r.tier}\tscore ${r.score}\t${r.namespace}:${r.name}`),
215+
)
216+
}
217+
}
218+
219+
main().catch((err) => {
220+
console.error(err)
221+
process.exit(1)
222+
})

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { QueryExecutor } from '../queryExecutor'
22

3-
import { IDbPackageUniverse, IDbPackageUpsert } from './types'
3+
import { IDbPackageUniverse, IDbPackageUpsert, IDbSonatypePopularityUpsert } from './types'
44

55
export async function findPackageIdsByPurl(
66
qx: QueryExecutor,
@@ -241,3 +241,63 @@ export async function upsertPackage(
241241
)
242242
return { id: row.id as number, changedFields: row.changed_fields as string[] }
243243
}
244+
245+
// ─── sonatype popularity upsert ────────────────────────────────────────────────
246+
247+
/**
248+
* Upserts the Sonatype popularity signal for a Maven component, keyed by the
249+
* composite identity (ecosystem, namespace, name) — not purl — so it is immune
250+
* to purl format and matches the granularity of the unique index.
251+
*
252+
* Insert-if-missing: Sonatype's list is "what industry actually uses", so some
253+
* rows may not exist in `packages` yet. Those are inserted with
254+
* ingestion_source='sonatype' and only the identity + sonatype_* columns; the
255+
* rest is backfilled later by deps.dev / Maven enrichment.
256+
*
257+
* On conflict only the sonatype_* fields are updated — ingestion_source is
258+
* deliberately preserved so we never clobber how an existing row was ingested.
259+
*
260+
* Returns whether the row was inserted (true) or updated (false).
261+
*/
262+
export async function upsertSonatypePopularity(
263+
qx: QueryExecutor,
264+
item: IDbSonatypePopularityUpsert,
265+
): Promise<{ id: number; inserted: boolean }> {
266+
const row = await qx.selectOne(
267+
`
268+
WITH existing AS (
269+
-- Evaluated against the pre-INSERT snapshot, so it reflects whether the row
270+
-- already existed. More robust than the xmax=0 trick for insert-vs-update.
271+
SELECT id FROM packages
272+
WHERE ecosystem = $(ecosystem)
273+
AND COALESCE(namespace, '') = COALESCE($(namespace), '')
274+
AND name = $(name)
275+
),
276+
ins AS (
277+
INSERT INTO packages (
278+
purl, ecosystem, namespace, name,
279+
sonatype_popularity_score, sonatype_rank, sonatype_tier,
280+
sonatype_snapshot_at, sonatype_updated_at,
281+
ingestion_source
282+
) VALUES (
283+
$(purl), $(ecosystem), $(namespace), $(name),
284+
$(sonatypePopularityScore), $(sonatypeRank), $(sonatypeTier),
285+
$(sonatypeSnapshotAt), NOW(),
286+
'sonatype'
287+
)
288+
ON CONFLICT (ecosystem, COALESCE(namespace, ''), name) DO UPDATE SET
289+
sonatype_popularity_score = EXCLUDED.sonatype_popularity_score,
290+
sonatype_rank = EXCLUDED.sonatype_rank,
291+
sonatype_tier = EXCLUDED.sonatype_tier,
292+
sonatype_snapshot_at = EXCLUDED.sonatype_snapshot_at,
293+
sonatype_updated_at = NOW()
294+
-- ingestion_source intentionally left untouched on update
295+
RETURNING id
296+
)
297+
SELECT ins.id, NOT EXISTS (SELECT 1 FROM existing) AS inserted
298+
FROM ins
299+
`,
300+
item,
301+
)
302+
return { id: row.id as number, inserted: row.inserted as boolean }
303+
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ export type IDbPackageUpsert = {
3535
repositoryUrl?: string | null
3636
}
3737

38+
// ─── sonatype popularity ──────────────────────────────────────────────────────
39+
40+
/**
41+
* Sonatype popularity signal for a single Maven component (one row per
42+
* groupId:artifactId). Only the sonatype_* fields plus the identity columns are
43+
* carried — deps.dev / Maven enrichment backfills everything else later.
44+
*/
45+
export type IDbSonatypePopularityUpsert = {
46+
purl: string
47+
ecosystem: string
48+
namespace: string // Maven groupId
49+
name: string // Maven artifactId
50+
sonatypePopularityScore: number
51+
sonatypeRank: number
52+
sonatypeTier: string
53+
sonatypeSnapshotAt: Date
54+
}
55+
3856
// ─── maintainers ──────────────────────────────────────────────────────────────
3957

4058
export type IDbMaintainerUpsert = {

0 commit comments

Comments
 (0)