Skip to content

Commit dd15ccd

Browse files
committed
feat: fix maven repo gap
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent daa3c30 commit dd15ccd

6 files changed

Lines changed: 316 additions & 19 deletions

File tree

services/apps/packages_worker/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
"dev:rubygems-worker:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && CROWD_TEMPORAL_TASKQUEUE=rubygems-worker SERVICE=rubygems-worker LOG_LEVEL=trace nodemon --watch src --watch ../../libs --ext ts --exec tsx --inspect=0.0.0.0:9244 src/bin/rubygems-worker.ts",
5555
"backfill:maven": "SERVICE=maven tsx src/bin/maven-backfill.ts",
5656
"backfill:maven:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && SERVICE=maven LOG_LEVEL=info tsx src/bin/maven-backfill.ts",
57+
"backfill:maven-repo-url": "SERVICE=maven tsx src/bin/maven-repo-url-backfill.ts",
58+
"backfill:maven-repo-url:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && SERVICE=maven LOG_LEVEL=info tsx src/bin/maven-repo-url-backfill.ts",
5759
"import:maven-maintainers": "SERVICE=maven tsx src/maven/scripts/importMaintainersFromCsv.ts",
5860
"import:maven-maintainers:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && SERVICE=maven tsx src/maven/scripts/importMaintainersFromCsv.ts",
5961
"import:sonatype-popularity": "SERVICE=maven tsx src/maven/scripts/importSonatypePopularityFromCsv.ts",
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { getServiceLogger } from '@crowd/logging'
2+
3+
import { getPackagesDb } from '../db'
4+
import { backfillMavenRepositoryUrls } from '../maven/backfillRepositoryUrl'
5+
6+
const log = getServiceLogger()
7+
8+
let shuttingDown = false
9+
10+
// Graceful stop: finish the in-flight batch, then exit. Safe to interrupt — every
11+
// write is an idempotent UPDATE recomputed from declared_repository_url, so
12+
// re-running simply reprocesses and skips rows that already match.
13+
const shutdown = () => {
14+
if (shuttingDown) return
15+
shuttingDown = true
16+
log.info('Shutting down maven repo-url backfill (stopping after the current batch)...')
17+
}
18+
19+
process.on('SIGINT', shutdown)
20+
process.on('SIGTERM', shutdown)
21+
22+
const DEFAULT_BATCH_SIZE = 5000
23+
24+
const main = async () => {
25+
const dryRun = process.argv.includes('--dry-run')
26+
const rawBatchSize = process.env.MAVEN_REPO_URL_BACKFILL_BATCH_SIZE
27+
const parsedBatchSize = rawBatchSize === undefined ? DEFAULT_BATCH_SIZE : Number(rawBatchSize)
28+
if (!Number.isInteger(parsedBatchSize) || parsedBatchSize <= 0) {
29+
log.error(
30+
{ MAVEN_REPO_URL_BACKFILL_BATCH_SIZE: rawBatchSize },
31+
'MAVEN_REPO_URL_BACKFILL_BATCH_SIZE must be a positive integer',
32+
)
33+
process.exit(1)
34+
}
35+
const batchSize = parsedBatchSize
36+
37+
log.info(
38+
{ dryRun, batchSize },
39+
'maven repo-url backfill starting (recompute normalizeScmUrl from declared_repository_url, no POM fetch)...',
40+
)
41+
42+
const qx = await getPackagesDb()
43+
await qx.selectOne('SELECT 1')
44+
log.info('Connected to packages-db.')
45+
46+
const totals = await backfillMavenRepositoryUrls(qx, {
47+
batchSize,
48+
dryRun,
49+
isShuttingDown: () => shuttingDown,
50+
})
51+
52+
log.info({ ...totals, dryRun }, 'maven repo-url backfill complete')
53+
process.exit(0)
54+
}
55+
56+
main().catch((err) => {
57+
log.error({ err }, 'maven repo-url backfill fatal error')
58+
process.exit(1)
59+
})

services/apps/packages_worker/src/maven/__tests__/normalize.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,35 @@ describe('normalizeScmUrl', () => {
163163
it('returns null for non-https result', () => {
164164
expect(normalizeScmUrl('svn://svn.apache.org/repos/commons-lang')).toBeNull()
165165
})
166+
167+
// Gap B — recover repository_url from inputs that were previously dropped
168+
it('recovers scm:git: without a scheme', () => {
169+
expect(normalizeScmUrl('scm:git:github.com/lum-ai/nxmlreader')).toBe(
170+
'https://github.com/lum-ai/nxmlreader',
171+
)
172+
})
173+
174+
it('recovers a bare host/owner/repo without a scheme', () => {
175+
expect(normalizeScmUrl('github.com/agiledigital/kamon-play-extensions')).toBe(
176+
'https://github.com/agiledigital/kamon-play-extensions',
177+
)
178+
})
179+
180+
it('upgrades http to https and lower-cases the github path', () => {
181+
expect(normalizeScmUrl('http://github.com/kevemueller/kTLSH/tree/master')).toBe(
182+
'https://github.com/kevemueller/ktlsh',
183+
)
184+
})
185+
186+
// Gap C — reject non-repository URLs so they are never stored
187+
it('returns null for website-only URLs', () => {
188+
expect(normalizeScmUrl('https://meson.ai/')).toBeNull()
189+
expect(normalizeScmUrl('http://source.android.com')).toBeNull()
190+
})
191+
192+
it('returns null for placeholders and free-form text', () => {
193+
expect(normalizeScmUrl('Private')).toBeNull()
194+
expect(normalizeScmUrl('${scm-url}')).toBeNull()
195+
expect(normalizeScmUrl('http://cvs.sourceforge.net/cgi-bin/viewcvs.cgi/foo')).toBeNull()
196+
})
166197
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
listMavenPackagesForRepoUrlRecompute,
3+
updateMavenRepositoryUrls,
4+
} from '@crowd/data-access-layer'
5+
import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
6+
import { getServiceChildLogger } from '@crowd/logging'
7+
8+
import { normalizeScmUrl } from './extract'
9+
10+
const log = getServiceChildLogger('maven-repo-url-backfill')
11+
12+
export type RepoUrlBackfillTotals = {
13+
scanned: number
14+
filled: number // Gap B: NULL → canonical value
15+
cleared: number // Gap C: non-repo value → NULL
16+
rewritten: number // non-canonical value → different canonical value
17+
unchanged: number
18+
}
19+
20+
/**
21+
* Recomputes `repository_url` for every Maven row directly from the stored
22+
* `declared_repository_url`, applying the current `normalizeScmUrl`. No POMs are
23+
* fetched — the raw SCM value is already in the DB. Fills recoverable NULLs
24+
* (Gap B) and clears non-repository values (Gap C) via direct UPDATE.
25+
*
26+
* Idempotent and resumable: the id cursor is derived from the scan, so a
27+
* re-run after an interrupt simply reprocesses from the start and skips rows
28+
* that already match.
29+
*/
30+
export async function backfillMavenRepositoryUrls(
31+
qx: QueryExecutor,
32+
options: { batchSize: number; dryRun: boolean; isShuttingDown: () => boolean },
33+
): Promise<RepoUrlBackfillTotals> {
34+
const { batchSize, dryRun, isShuttingDown } = options
35+
const totals: RepoUrlBackfillTotals = {
36+
scanned: 0,
37+
filled: 0,
38+
cleared: 0,
39+
rewritten: 0,
40+
unchanged: 0,
41+
}
42+
43+
let afterId = 0
44+
for (;;) {
45+
if (isShuttingDown()) {
46+
log.info('Shutdown requested — stopping after the current batch.')
47+
break
48+
}
49+
50+
const rows = await listMavenPackagesForRepoUrlRecompute(qx, { afterId, limit: batchSize })
51+
if (rows.length === 0) break
52+
53+
const updates: { id: number; repositoryUrl: string | null }[] = []
54+
for (const row of rows) {
55+
totals.scanned++
56+
const desired = normalizeScmUrl(row.declaredRepositoryUrl)
57+
if (desired === row.repositoryUrl) {
58+
totals.unchanged++
59+
continue
60+
}
61+
if (row.repositoryUrl === null) totals.filled++
62+
else if (desired === null) totals.cleared++
63+
else totals.rewritten++
64+
updates.push({ id: row.id, repositoryUrl: desired })
65+
}
66+
67+
if (updates.length > 0 && !dryRun) {
68+
await updateMavenRepositoryUrls(qx, updates)
69+
}
70+
71+
afterId = rows[rows.length - 1].id
72+
log.info({ afterId, changes: updates.length, dryRun, ...totals }, 'Backfill progress')
73+
}
74+
75+
return totals
76+
}

services/apps/packages_worker/src/maven/extract.ts

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -453,37 +453,89 @@ export async function extractArtifact(groupId: string, artifactId: string, versi
453453
// ─── SCM URL normalisation ───────────────────────────────────────────────────
454454

455455
/**
456-
* Converts the raw SCM URL from a POM (declared_repository_url) into a clean
457-
* HTTPS repository URL suitable for storage as repository_url.
456+
* Known source-code-hosting hosts. A normalised repository_url is only produced
457+
* when the URL resolves to one of these — anything else (homepages, doc sites,
458+
* placeholders) yields null so it is never stored as a repository link.
459+
*
460+
* TODO(CM): host list pending product confirmation before rollout.
461+
*/
462+
const SCM_HOSTS = new Set([
463+
'github.com',
464+
'gitlab.com',
465+
'bitbucket.org',
466+
'gitee.com',
467+
'codeberg.org',
468+
])
469+
470+
/** Hosts whose owner/repo path is case-insensitive and should be lower-cased. */
471+
const CASE_INSENSITIVE_HOSTS = new Set(['github.com', 'gitlab.com'])
472+
473+
/**
474+
* Converts the raw SCM URL from a POM (declared_repository_url) into a clean,
475+
* canonical `https://<host>/<owner>/<repo>` repository URL suitable for storage
476+
* as repository_url. Returns null when the input does not resolve to a real
477+
* repository on a known SCM host.
458478
*
459479
* Handles common Maven SCM URL forms:
460-
* scm:git:git@github.com:owner/repo.git → https://github.com/owner/repo
461-
* scm:git:https://github.com/owner/repo → https://github.com/owner/repo
462-
* git://github.com/owner/repo.git → https://github.com/owner/repo
463-
* https://github.com/owner/repo/tree/... → https://github.com/owner/repo
480+
* scm:git:git@github.com:owner/repo.git → https://github.com/owner/repo
481+
* scm:git:https://github.com/owner/repo → https://github.com/owner/repo
482+
* scm:git:github.com/owner/repo → https://github.com/owner/repo
483+
* github.com/owner/repo (no scheme) → https://github.com/owner/repo
484+
* git://github.com/owner/repo.git → https://github.com/owner/repo
485+
* http://github.com/owner/repo/tree/... → https://github.com/owner/repo
486+
*
487+
* Rejected (→ null): website-only URLs (https://meson.ai/), non-SCM hosts
488+
* (svn://…, http://source.android.com), placeholders (Private, ${scm-url}).
464489
*/
465490
export function normalizeScmUrl(raw: string | null): string | null {
466491
if (!raw) return null
467-
let url = raw.trim()
492+
let s = raw.trim()
493+
if (!s) return null
468494

469-
// Strip scm:git: or scm: prefix
470-
url = url.replace(/^scm:git:/i, '').replace(/^scm:/i, '')
495+
// Strip Maven scm:git: / scm: prefix
496+
s = s.replace(/^scm:git:/i, '').replace(/^scm:/i, '')
471497

472-
// Convert SSH git@host:owner/repo → https://host/owner/repo
473-
url = url.replace(/^git@([^:]+):(.+)$/, 'https://$1/$2')
498+
// git+https://… → https://
499+
s = s.replace(/^git\+/, '')
474500

475-
// Convert git:// → https://
476-
url = url.replace(/^git:\/\//, 'https://')
501+
// SCP form git@host:owner/repo → https://host/owner/repo
502+
s = s.replace(/^git@([^:/]+):(.+)$/, 'https://$1/$2')
477503

478-
// Strip trailing .git
479-
url = url.replace(/\.git$/, '')
504+
// ssh://git@host/… → https://host/…
505+
s = s.replace(/^ssh:\/\/git@([^/]+)\//, 'https://$1/')
480506

481-
// Strip /tree/... or /blob/... path suffixes (keep only host + owner + repo)
482-
url = url.replace(/\/(tree|blob)(\/.*)?$/, '')
507+
// git:// → https://, and upgrade http:// → https://
508+
s = s.replace(/^git:\/\//, 'https://').replace(/^http:\/\//, 'https://')
483509

484-
if (!url.startsWith('https://')) return null
510+
// No scheme at all (e.g. "github.com/owner/repo") → assume https
511+
if (!s.includes('://')) s = `https://${s}`
512+
513+
let parsed: URL
514+
try {
515+
parsed = new URL(s)
516+
} catch {
517+
return null
518+
}
519+
520+
if (parsed.protocol !== 'https:') return null
521+
522+
const host = parsed.hostname.toLowerCase().replace(/^www\./, '')
523+
if (!SCM_HOSTS.has(host)) return null
524+
525+
// Require at least owner + repo path segments
526+
const segments = parsed.pathname.split('/').filter(Boolean)
527+
if (segments.length < 2) return null
528+
529+
let owner = segments[0]
530+
let name = segments[1].replace(/\.git$/, '')
531+
if (!owner || !name) return null
532+
533+
if (CASE_INSENSITIVE_HOSTS.has(host)) {
534+
owner = owner.toLowerCase()
535+
name = name.toLowerCase()
536+
}
485537

486-
return url.replace(/\/$/, '')
538+
return `https://${host}/${owner}/${name}`
487539
}
488540

489541
// ─── Private helpers ──────────────────────────────────────────────────────────

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,83 @@ export async function listMavenPackagesToSync(
118118
)
119119
}
120120

121+
// ─── repository_url backfill ──────────────────────────────────────────────────
122+
123+
export type MavenRepoUrlRow = {
124+
id: number
125+
declaredRepositoryUrl: string | null
126+
repositoryUrl: string | null
127+
}
128+
129+
/**
130+
* Keyset-paginated scan of Maven rows that carry a repository link (declared or
131+
* canonical). Rows with neither are skipped — there is nothing to recompute.
132+
* Used by the repository_url backfill to re-run the normalizer over stored data
133+
* without re-fetching POMs from the registry.
134+
*/
135+
export async function listMavenPackagesForRepoUrlRecompute(
136+
qx: QueryExecutor,
137+
options: { afterId: number; limit: number },
138+
): Promise<MavenRepoUrlRow[]> {
139+
return qx.select(
140+
`
141+
SELECT
142+
id,
143+
declared_repository_url AS "declaredRepositoryUrl",
144+
repository_url AS "repositoryUrl"
145+
FROM packages
146+
WHERE ecosystem = 'maven'
147+
AND id > $(afterId)
148+
AND (declared_repository_url IS NOT NULL OR repository_url IS NOT NULL)
149+
ORDER BY id ASC
150+
LIMIT $(limit)
151+
`,
152+
{ afterId: options.afterId, limit: options.limit },
153+
)
154+
}
155+
156+
/**
157+
* Applies a batch of recomputed repository_url values via direct UPDATE — the
158+
* only way to clear a stale value, since the enrichment upsert COALESCEs and
159+
* cannot write NULL. Splits clears (→ NULL) from sets to avoid NULLs inside a
160+
* text[] array literal.
161+
*/
162+
export async function updateMavenRepositoryUrls(
163+
qx: QueryExecutor,
164+
updates: { id: number; repositoryUrl: string | null }[],
165+
): Promise<void> {
166+
if (updates.length === 0) return
167+
168+
const toClear = updates.filter((u) => u.repositoryUrl === null).map((u) => u.id)
169+
const toSet = updates.filter(
170+
(u): u is { id: number; repositoryUrl: string } => u.repositoryUrl !== null,
171+
)
172+
173+
if (toClear.length > 0) {
174+
await qx.result(
175+
`UPDATE packages SET repository_url = NULL
176+
WHERE id = ANY($(ids)::bigint[]) AND repository_url IS NOT NULL`,
177+
{ ids: toClear },
178+
)
179+
}
180+
181+
if (toSet.length > 0) {
182+
await qx.result(
183+
`
184+
UPDATE packages p
185+
SET repository_url = v.repository_url
186+
FROM (
187+
SELECT unnest($(ids)::bigint[]) AS id,
188+
unnest($(urls)::text[]) AS repository_url
189+
) v
190+
WHERE p.id = v.id
191+
AND p.repository_url IS DISTINCT FROM v.repository_url
192+
`,
193+
{ ids: toSet.map((u) => u.id), urls: toSet.map((u) => u.repositoryUrl) },
194+
)
195+
}
196+
}
197+
121198
// ─── packages touch ───────────────────────────────────────────────────────────
122199

123200
/**

0 commit comments

Comments
 (0)