@@ -48,6 +48,7 @@ export async function enrichPackages(qx: QueryExecutor): Promise<EnrichPackagesR
4848 tx . selectOne (
4949 `WITH snap AS (
5050 SELECT p.id, p.status, p.description, p.homepage, p.declared_repository_url,
51+ p.repository_url,
5152 p.licenses, p.licenses_raw, p.keywords, p.versions_count, p.latest_version,
5253 p.first_release_at, p.latest_release_at, p.dependent_count,
5354 p.dependent_repos_count, p.downloads_last_30d
@@ -60,6 +61,8 @@ export async function enrichPackages(qx: QueryExecutor): Promise<EnrichPackagesR
6061 description = COALESCE(e.description, p.description),
6162 homepage = COALESCE(e.homepage, p.homepage),
6263 declared_repository_url = COALESCE(e.declared_repository_url, p.declared_repository_url),
64+ repository_url = CASE WHEN e.declared_repository_url IS NOT NULL
65+ THEN rn.repository_url ELSE p.repository_url END,
6366 licenses = COALESCE(e.licenses, p.licenses),
6467 licenses_raw = COALESCE(e.licenses_raw, p.licenses_raw),
6568 keywords = COALESCE(e.keywords, p.keywords),
@@ -73,18 +76,22 @@ export async function enrichPackages(qx: QueryExecutor): Promise<EnrichPackagesR
7376 ingestion_source = $(ingestionSource),
7477 last_synced_at = NOW()
7578 FROM ${ STAGING_SCHEMA } .enrich_packages e
79+ LEFT JOIN ${ STAGING_SCHEMA } .repo_norm rn ON rn.declared = e.declared_repository_url
7680 WHERE p.id = e.package_id
7781 RETURNING p.id
7882 ),
7983 diff AS (
8084 SELECT s.id AS package_id, f.field
8185 FROM snap s
8286 JOIN ${ STAGING_SCHEMA } .enrich_packages e ON e.package_id = s.id
87+ LEFT JOIN ${ STAGING_SCHEMA } .repo_norm rn ON rn.declared = e.declared_repository_url
8388 CROSS JOIN LATERAL (VALUES
8489 ('packages.status', s.status IS DISTINCT FROM e.status),
8590 ('packages.description', s.description IS DISTINCT FROM COALESCE(e.description, s.description)),
8691 ('packages.homepage', s.homepage IS DISTINCT FROM COALESCE(e.homepage, s.homepage)),
8792 ('packages.declared_repository_url', s.declared_repository_url IS DISTINCT FROM COALESCE(e.declared_repository_url, s.declared_repository_url)),
93+ ('packages.repository_url', s.repository_url IS DISTINCT FROM
94+ CASE WHEN e.declared_repository_url IS NOT NULL THEN rn.repository_url ELSE s.repository_url END),
8895 ('packages.licenses', s.licenses IS DISTINCT FROM COALESCE(e.licenses, s.licenses)),
8996 ('packages.licenses_raw', s.licenses_raw IS DISTINCT FROM COALESCE(e.licenses_raw, s.licenses_raw)),
9097 ('packages.keywords', s.keywords IS DISTINCT FROM COALESCE(e.keywords, s.keywords)),
@@ -159,36 +166,65 @@ export async function enrichVersions(qx: QueryExecutor): Promise<EnrichVersionsR
159166 return { upserted : row . upserted }
160167}
161168
162- // Writes only url + host — other repo fields belong to the GitHub enricher.
169+ // Writes only url + host — other repo fields belong to the GitHub enricher. Uses
170+ // repo_norm (built by normalizeRepos) so repos.url/package_repos always agree with
171+ // the canonical packages.repository_url — never the raw declared_repository_url.
163172export async function enrichRepos ( qx : QueryExecutor ) : Promise < EnrichReposResult > {
164173 return withTunedSession ( qx , 'repos' , async ( tx ) => {
165174 const repoRow = await tx . selectOne (
166175 `WITH new_repos AS (
167176 INSERT INTO repos (url, host, updated_at)
168- SELECT DISTINCT e.declared_repository_url,
169- CASE
170- WHEN e.declared_repository_url ~* '://([^/]+\\.)?github\\.com(/|$)' THEN 'github'
171- WHEN e.declared_repository_url ~* '://[^/]*gitlab' THEN 'gitlab'
172- WHEN e.declared_repository_url ~* '://([^/]+\\.)?bitbucket\\.org(/|$)' THEN 'bitbucket'
173- ELSE 'other'
174- END,
175- NOW()
177+ SELECT DISTINCT rn.repository_url, rn.host, NOW()
176178 FROM ${ STAGING_SCHEMA } .enrich_packages e
177- WHERE e.declared_repository_url IS NOT NULL AND e.declared_repository_url LIKE 'http%'
179+ JOIN ${ STAGING_SCHEMA } .repo_norm rn ON rn.declared = e.declared_repository_url
178180 ON CONFLICT (url) DO NOTHING
179181 RETURNING url
180182 ),
181183 ins_audit AS (
182184 INSERT INTO ${ STAGING_SCHEMA } .audit_changes (package_id, field)
183185 SELECT e.package_id, f.field
184186 FROM ${ STAGING_SCHEMA } .enrich_packages e
185- JOIN new_repos nr ON nr.url = e.declared_repository_url
187+ JOIN ${ STAGING_SCHEMA } .repo_norm rn ON rn.declared = e.declared_repository_url
188+ JOIN new_repos nr ON nr.url = rn.repository_url
186189 CROSS JOIN LATERAL (VALUES ('repos.url'), ('repos.host')) AS f(field)
187190 RETURNING 1
188191 )
189192 SELECT (SELECT COUNT(*) FROM new_repos)::int AS repos` ,
190193 )
191194
195+ // Prunes stale 'declared' links before relinking — covers junk/unparseable declared
196+ // values, rewrites (declared URL now maps elsewhere), and removals (this dump's
197+ // declared_repository_url is NULL, meaning the crate no longer declares a repo at
198+ // all — loadDump.ts stages every matched crate every run, so NULL here is
199+ // authoritative, not "no data this run"). Safe to always prune on that signal because
200+ // the DELETE is scoped to source = 'declared' — cargo only ever removes links it owns.
201+ // Without this, package_repos would accumulate a link to a repo no crate declares
202+ // anymore, and consumers such as security-contacts (which join through
203+ // repos ⋈ package_repos, not packages.repository_url) would keep reading it.
204+ const pruneRow = await tx . selectOne (
205+ `WITH targets AS (
206+ SELECT e.package_id, r.id AS repo_id
207+ FROM ${ STAGING_SCHEMA } .enrich_packages e
208+ LEFT JOIN ${ STAGING_SCHEMA } .repo_norm rn ON rn.declared = e.declared_repository_url
209+ LEFT JOIN repos r ON r.url = rn.repository_url
210+ ),
211+ del AS (
212+ DELETE FROM package_repos pr
213+ USING targets t
214+ WHERE pr.package_id = t.package_id
215+ AND pr.source = $(source)
216+ AND (t.repo_id IS NULL OR pr.repo_id IS DISTINCT FROM t.repo_id)
217+ RETURNING pr.package_id
218+ ),
219+ ins_audit AS (
220+ INSERT INTO ${ STAGING_SCHEMA } .audit_changes (package_id, field)
221+ SELECT package_id, 'package_repos.repo_id' FROM del
222+ RETURNING 1
223+ )
224+ SELECT (SELECT COUNT(*) FROM del)::int AS pruned` ,
225+ { source : REPO_LINK_SOURCE } ,
226+ )
227+
192228 const linkRow = await tx . selectOne (
193229 `WITH old AS (
194230 SELECT pr.package_id, pr.repo_id, pr.source, pr.confidence
@@ -201,11 +237,13 @@ export async function enrichRepos(qx: QueryExecutor): Promise<EnrichReposResult>
201237 INSERT INTO package_repos (package_id, repo_id, source, confidence, created_at, verified_at)
202238 SELECT e.package_id, r.id, $(source), $(confidence), NOW(), NOW()
203239 FROM ${ STAGING_SCHEMA } .enrich_packages e
204- JOIN repos r ON r.url = e.declared_repository_url
205- WHERE e.declared_repository_url IS NOT NULL
240+ JOIN ${ STAGING_SCHEMA } .repo_norm rn ON rn.declared = e.declared_repository_url
241+ JOIN repos r ON r.url = rn.repository_url
242+ -- Leaves source untouched on conflict — a link another enricher already owns for
243+ -- this (package_id, repo_id) keeps its provenance instead of being reassigned to
244+ -- 'declared', matching upsertMavenPackageRepo's confidence-only merge.
206245 ON CONFLICT (package_id, repo_id) DO UPDATE SET
207- source = EXCLUDED.source,
208- confidence = EXCLUDED.confidence,
246+ confidence = GREATEST(EXCLUDED.confidence, package_repos.confidence),
209247 verified_at = NOW()
210248 RETURNING package_id, repo_id, source, confidence
211249 ),
@@ -228,7 +266,7 @@ export async function enrichRepos(qx: QueryExecutor): Promise<EnrichReposResult>
228266 { source : REPO_LINK_SOURCE , confidence : REPO_LINK_CONFIDENCE } ,
229267 )
230268
231- return { repos : repoRow . repos , links : linkRow . links }
269+ return { repos : repoRow . repos , links : linkRow . links , pruned : pruneRow . pruned }
232270 } )
233271}
234272
0 commit comments