|
| 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 | +}) |
0 commit comments