Skip to content

feat: fix maven repo gap (CM-1305)#4311

Open
ulemons wants to merge 8 commits into
mainfrom
fix/maven-repo-gap
Open

feat: fix maven repo gap (CM-1305)#4311
ulemons wants to merge 8 commits into
mainfrom
fix/maven-repo-gap

Conversation

@ulemons

@ulemons ulemons commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the Maven half of the repository_url normalization problem: packages.repository_url must always hold a canonical https://<host>/<owner>/<repo> link or NULL, never a website, placeholder, or free-form string. The Maven normalizer (normalizeScmUrl) had two defects:

  • Gap B — recoverable inputs dropped to NULL. A regex chain gated on startsWith('https://') discarded inputs whose repo was clearly reconstructable: scm:git: prefixes without a scheme, bare host/owner/repo values, and http:// URLs that were never upgraded to https://. This left repository_url NULL for ~18.8k critical Maven rows where the declared value was a valid GitHub URL.
  • Gap C — non-repository URLs written as repos. After the https gate there was no shape validation, so homepages (e.g. https://meson.ai/) passed straight through and were stored as if they were repository links.

Since the public API falls back to declared_repository_url when repository_url is NULL, these gaps meant clients received either nothing or raw, non-normalized registry values.

This PR rewrites normalizeScmUrl to parse and validate instead of regex-guess, and adds a DB-only backfill to correct existing rows.

Scope: Maven only. Cargo/npm/NuGet gaps, the shared normalizer + ADR, and the API fallback removal are tracked separately.

Changes

  • Rewrote normalizeScmUrl (services/apps/packages_worker/src/maven/extract.ts) to normalize via URL() parsing rather than a regex chain. It now: strips scm:git:/scm:/git+ prefixes and SCP/ssh:///git:// forms; prepends https:// to schemeless inputs and upgrades http://https:// (Gap B); requires a known SCM host and an owner/repo path shape, returning NULL otherwise (Gap C); and lower-cases the path on case-insensitive hosts (github/gitlab).
  • The SCM host allowlist is provisionalSCM_HOSTS/CASE_INSENSITIVE_HOSTS are marked TODO(CM) pending product confirmation of the final host list. The trade-off: an allowlist reliably rejects doc-sites but also drops self-hosted git (e.g. gitbox.apache.org); needs a decision before rollout.
  • Added tests for the ticket's Gap B/Gap C cases (maven/__tests__/normalize.test.ts). All pre-existing cases still pass, including svn:// → NULL.
  • Added a recompute-from-DB backfill rather than reusing backfill:maven. The existing backfill re-fetches every POM over the network (~18.8k+ HTTP calls) and — critically — cannot clear Gap C junk, because the enrichment upsert COALESCEs and can never write NULL. The new path re-runs the normalizer over the already-stored declared_repository_url (zero network) and applies a direct UPDATE, so it both fills recoverable NULLs (Gap B) and clears non-repo values (Gap C).
    • services/apps/packages_worker/src/maven/backfillRepositoryUrl.ts — keyset-paginated, idempotent, resumable orchestration; counts filled/cleared/rewritten/unchanged/linked/pruned; supports --dry-run.
  • Keeps the repos / package_repos link tables consistent with the recomputed repository_url. This matters because consumers like the security-contacts pipeline read the repository through repos ⋈ package_repos, not packages.repository_url — so a fill that only touched packages would stay invisible to them. On rewrites and clears the stale source='declared' link is pruned; on fills and rewrites the correct link is (re)written via the enrichment loop's writeRepoLink (now exported from runMavenEnrichmentLoop.ts). This is deliberately stricter than the incremental enrichment path, which is upsert-only and never prunes — so the backfill also cleans up pre-existing stale links rather than leaving zombies.
    • services/apps/packages_worker/src/bin/maven-repo-url-backfill.ts — bin entrypoint with graceful shutdown and positive-integer validation of the batch-size env var.
    • services/libs/data-access-layer/src/osspckgs/packages.ts — new listMavenPackagesForRepoUrlRecompute + updateMavenRepositoryUrls (splits clears from sets to avoid NULLs inside a text[] literal).
    • services/libs/data-access-layer/src/osspckgs/repos.ts — new deleteMavenPackageRepoLinks (removes only source='declared' links; never deletes shared repos rows).
    • package.json — new backfill:maven-repo-url[:local] scripts.
  • The backfill does not bump last_synced_at, so it doesn't disturb the enrichment freshness window.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Performance improvement
  • Chore / dependency update
  • Documentation

JIRA ticket

CM-1305


Note

Medium Risk
Bulk updates to packages.repository_url and package_repos affect security-contacts and other repo join consumers; host allowlist is marked provisional (TODO) and may drop valid self-hosted URLs until confirmed.

Overview
Maven repository_url is tightened so stored values are canonical https://…/owner/repo links or NULL, fixing recoverable SCM strings that were nulled and junk URLs that were kept.

normalizeScmUrl is rewritten around URL() parsing: SCM host allowlisting, owner/repo shape checks, Apache gitweb and Eclipse cgit handling, SCP colon forms, placeholder rejection, and http→https / schemeless recovery. interpolateProperties resolves ${…} in SCM URLs during POM extraction using merged parent-chain <properties> and built-in project.* coordinates; parent walks now treat unresolved ${ in SCM as missing.

Operational backfills: backfill:maven-repo-url recomputes repository_url from declared_repository_url with no POM fetch, uses direct UPDATEs (including NULL clears), and in one transaction updates package_repos (deleteMavenPackageRepoLinks + exported writeRepoLink). backfill:maven --force runs runMavenCriticalForceBackfill (keyset by package id, full POM re-extraction) when staleness-based backfill cannot drain the queue.

DAL adds listMavenCriticalPackagesById, listMavenPackagesForRepoUrlRecompute, and updateMavenRepositoryUrls. Extensive tests cover Gap B/C normalization and interpolation.

Reviewed by Cursor Bugbot for commit f8c9e2f. Bugbot is set up for automated code reviews on this repo. Configure here.

Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 7, 2026 07:37

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conventional Commits FTW!

@ulemons ulemons self-assigned this Jul 7, 2026
@ulemons ulemons added the Bug Created by Linear-GitHub Sync label Jul 7, 2026
}

return url.replace(/\/$/, '')
return `https://${host}/${owner}/${name}`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitLab nested paths truncated wrongly

Medium Severity

The updated normalizeScmUrl always keeps only the first two URL path segments as owner and repo. GitLab projects under nested groups (three or more path segments) are shortened to the wrong repository URL, and the repo-url backfill will persist that incorrect value wherever the declared SCM URL was complete.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit aa4ad3e. Configure here.

Comment thread services/apps/packages_worker/src/maven/backfillRepositoryUrl.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR closes a gap in how Maven package repository links are derived. It rewrites normalizeScmUrl into a stricter, host-validated canonicalizer that only emits https://<host>/<owner>/<repo> for known SCM hosts (returning null for homepages, placeholders, and non-SCM hosts), and adds a one-shot backfill that re-runs this normalizer over already-stored declared_repository_url values — without re-fetching POMs — to fill previously-dropped repository_url values (Gap B) and clear non-repository values (Gap C).

Changes:

  • Reworked normalizeScmUrl to canonicalize SCM URLs against an allow-list of hosts (github, gitlab, bitbucket, gitee, codeberg), handling scheme-less, git+, SCP, and ssh:// forms, with case-folding for GitHub/GitLab.
  • Added DAL helpers (listMavenPackagesForRepoUrlRecompute, updateMavenRepositoryUrls) plus a resumable, dry-run-capable backfill (backfillMavenRepositoryUrls) and CLI entrypoint.
  • Extended unit tests for the new normalizer behavior and added npm scripts for the backfill.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
services/apps/packages_worker/src/maven/extract.ts Rewrites normalizeScmUrl into a host-validated canonicalizer; core of the fix.
services/libs/data-access-layer/src/osspckgs/packages.ts Adds keyset scan + batched clear/set UPDATE helpers used by the backfill.
services/apps/packages_worker/src/maven/backfillRepositoryUrl.ts New resumable/idempotent backfill loop recomputing repository_url from stored data.
services/apps/packages_worker/src/bin/maven-repo-url-backfill.ts CLI entrypoint with arg parsing, graceful shutdown, and DB connect.
services/apps/packages_worker/src/maven/tests/normalize.test.ts Adds Gap B/Gap C test cases for the new normalizer.
services/apps/packages_worker/package.json Adds backfill:maven-repo-url npm scripts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +67 to +69
if (updates.length > 0 && !dryRun) {
await updateMavenRepositoryUrls(qx, updates)
}
Comment on lines +526 to +531
const segments = parsed.pathname.split('/').filter(Boolean)
if (segments.length < 2) return null

let owner = segments[0]
let name = segments[1].replace(/\.git$/, '')
if (!owner || !name) return null
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
@ulemons ulemons changed the title feat: fix maven repo gap feat: fix maven repo gap (CM-1305) Jul 7, 2026
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 7, 2026 08:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.

Comment on lines +98 to +114
if (updates.length > 0 && !dryRun) {
// Atomic per batch: the repository_url UPDATE, the stale-link prune, and the
// relink must commit together. Otherwise an interrupt between them leaves
// packages.repository_url updated but package_repos out of sync — and on a
// re-run the row is skipped (its repository_url already matches `desired`),
// so the inconsistency would never be repaired. On rollback the row stays
// unchanged and is reprocessed on the next run.
await qx.tx(async (t) => {
await updateMavenRepositoryUrls(t, updates)
await deleteMavenPackageRepoLinks(t, pruneTargets)
for (const target of linkTargets) {
await writeRepoLink(t, target.id, target.repositoryUrl)
}
})
totals.pruned += pruneTargets.length
totals.linked += linkTargets.length
}
Comment on lines +105 to +111
await qx.tx(async (t) => {
await updateMavenRepositoryUrls(t, updates)
await deleteMavenPackageRepoLinks(t, pruneTargets)
for (const target of linkTargets) {
await writeRepoLink(t, target.id, target.repositoryUrl)
}
})
ulemons added 2 commits July 7, 2026 14:56
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 7, 2026 13:25
for (const target of linkTargets) {
await writeRepoLink(t, target.id, target.repositoryUrl)
}
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backfill lacks deadlock retry

Medium Severity

The repo-url backfill wraps batch UPDATE/DELETE/writeRepoLink work in a single qx.tx without the withDeadlockRetry wrapper used by the Maven enrichment path for the same shared repos and package_repos upserts. If the Maven worker runs concurrently, Postgres 40P01 deadlocks can abort the whole batch and exit the job via process.exit(1) even though the writes are idempotent and safe to retry.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4535615. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.

Comment on lines +471 to +472
* - ambiguous `git.*` hosts (git.iem.at, git.i-novus.ru, git.oschina.net) and the
* internal git.corp.adobe.com: pending path/reachability confirmation.
Comment on lines +105 to +111
await qx.tx(async (t) => {
await updateMavenRepositoryUrls(t, updates)
await deleteMavenPackageRepoLinks(t, pruneTargets)
for (const target of linkTargets) {
await writeRepoLink(t, target.id, target.repositoryUrl)
}
})
Comment on lines +111 to +114
})
totals.pruned += pruneTargets.length
totals.linked += linkTargets.length
}
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 8, 2026 11:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 9 changed files in this pull request and generated 8 comments.

Comment on lines +565 to +566
// ssh://git@host:owner/repo → https://host/owner/repo (SCP colon under ssh)
s = s.replace(/^ssh:\/\/git@([^:/]+):(?=\D)/, 'https://$1/')
Comment on lines +575 to +577
// scheme://host:owner/repo → scheme://host/owner/repo — the colon is an SCP path
// separator, not a port (guarded by \D so real numeric ports are left intact).
s = s.replace(/^(https?):\/\/([^:/]+):(?=\D)/, '$1://$2/')
Comment on lines +580 to +584
if (!s.includes('://')) {
// Bare SCP form "host:owner/repo" → "host/owner/repo" before assuming https.
s = s.replace(/^([^/:]+):(?=\D)/, '$1/')
s = `https://${s}`
}
Comment on lines +571 to +573
// git:// → https://, and upgrade http:// → https:// — done before the SCP-colon
// rule below so that git://host:owner/repo is normalised too.
s = s.replace(/^git:\/\//, 'https://').replace(/^http:\/\//, 'https://')
Comment on lines 282 to 286
const missingLicense = licenses.length === 0
const missingScm = !scmUrl
// An unresolved ${...} placeholder counts as missing: the property that defines it
// may live in a parent POM, so we still need to walk the chain to collect it.
const missingScm = !scmUrl || scmUrl.includes('${')
const missingDevelopers = developers.length === 0 || contributors.length === 0
Comment on lines +98 to +114
if (updates.length > 0 && !dryRun) {
// Atomic per batch: the repository_url UPDATE, the stale-link prune, and the
// relink must commit together. Otherwise an interrupt between them leaves
// packages.repository_url updated but package_repos out of sync — and on a
// re-run the row is skipped (its repository_url already matches `desired`),
// so the inconsistency would never be repaired. On rollback the row stays
// unchanged and is reprocessed on the next run.
await qx.tx(async (t) => {
await updateMavenRepositoryUrls(t, updates)
await deleteMavenPackageRepoLinks(t, pruneTargets)
for (const target of linkTargets) {
await writeRepoLink(t, target.id, target.repositoryUrl)
}
})
totals.pruned += pruneTargets.length
totals.linked += linkTargets.length
}
Comment on lines +158 to +176
export type MavenRepoUrlRow = {
id: number
declaredRepositoryUrl: string | null
repositoryUrl: string | null
}

/**
* Keyset-paginated scan of Maven rows that carry a repository link (declared or
* canonical). Rows with neither are skipped — there is nothing to recompute.
* Used by the repository_url backfill to re-run the normalizer over stored data
* without re-fetching POMs from the registry.
*
* `criticalOnly` restricts the scan to is_critical rows (index-backed by the
* partial index on is_critical) — used for a fast, consumer-facing first pass.
*/
export async function listMavenPackagesForRepoUrlRecompute(
qx: QueryExecutor,
options: { afterId: number; limit: number; criticalOnly?: boolean },
): Promise<MavenRepoUrlRow[]> {
Comment on lines +63 to +81
let afterId = 0
for (;;) {
if (isShuttingDown()) {
log.info('Shutdown requested — stopping after the current batch.')
break
}

const rows = await listMavenPackagesForRepoUrlRecompute(qx, {
afterId,
limit: batchSize,
criticalOnly,
})
if (rows.length === 0) break

const updates: { id: number; repositoryUrl: string | null }[] = []
// Rows that had a link and now change/clear — their stale 'declared' link is pruned.
const pruneTargets: number[] = []
// Rows that gained a canonical URL — their repo link is (re)written after the update.
const linkTargets: { id: number; repositoryUrl: string }[] = []
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 9, 2026 08:51
confidence: 0.8,
})
repoChanged.forEach((f) => changed.add(f))
repoChanged.forEach((f) => changed?.add(f))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apache URLs misparsed for repo links

Medium Severity

New Apache gitweb canonical URLs like https://gitbox.apache.org/repos/asf/commons-lang are written to repository_url and passed through writeRepoLink, but parseRepoUrl only reads the first two path segments, so owner/name become repos/asf instead of the actual project. repos and package_repos end up inconsistent with the stored URL.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 970ad5e. Configure here.

totals.linked += linkTargets.length
}

afterId = rows[rows.length - 1].id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repo backfill uses unsafe id cursor

Low Severity

The repo-url backfill paginates with a numeric afterId and MavenRepoUrlRow.id typed as number, while the same PR’s force Maven backfill documents that package ids are Postgres bigint values that lose precision when coerced to JavaScript numbers, which can skip rows during keyset scans.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 970ad5e. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 9 changed files in this pull request and generated 3 comments.

Comment on lines +105 to +111
await qx.tx(async (t) => {
await updateMavenRepositoryUrls(t, updates)
await deleteMavenPackageRepoLinks(t, pruneTargets)
for (const target of linkTargets) {
await writeRepoLink(t, target.id, target.repositoryUrl)
}
})
Comment on lines +111 to +114
})
totals.pruned += pruneTargets.length
totals.linked += linkTargets.length
}
// ─── repository_url backfill ──────────────────────────────────────────────────

export type MavenRepoUrlRow = {
id: number
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 9, 2026 09:36

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 5 total unresolved issues (including 4 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f8c9e2f. Configure here.

const missingScm = !scmUrl
// An unresolved ${...} placeholder counts as missing: the property that defines it
// may live in a parent POM, so we still need to walk the chain to collect it.
const missingScm = !scmUrl || scmUrl.includes('${')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unresolved SCM keeps parent URL

Medium Severity

Parent-chain walks now treat a child SCM value containing ${...} as missing, but the merge still prefers any non-null child scmUrl over the parent’s concrete URL. When placeholders cannot be resolved from merged properties, enrichment keeps the literal template, normalizeScmUrl returns null, and a valid inherited parent SCM URL is discarded.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f8c9e2f. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 9 changed files in this pull request and generated 2 comments.

Comment on lines +158 to +162
export type MavenRepoUrlRow = {
id: number
declaredRepositoryUrl: string | null
repositoryUrl: string | null
}
Comment on lines +105 to +111
await qx.tx(async (t) => {
await updateMavenRepositoryUrls(t, updates)
await deleteMavenPackageRepoLinks(t, pruneTargets)
for (const target of linkTargets) {
await writeRepoLink(t, target.id, target.repositoryUrl)
}
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Created by Linear-GitHub Sync

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants