@@ -484,15 +484,17 @@ export async function extractArtifact(groupId: string, artifactId: string, versi
484484 *
485485 * Candidate additions found in Maven declared_repository_url (critical rows) but
486486 * intentionally NOT added yet, because they need more than a host allowlist:
487- * - gitbox.apache.org / git.apache.org / git-wip-us.apache.org (~1.3k rows):
488- * paths are `/repos/asf/<repo>`, so the generic first-two-segments logic would
489- * collapse every Apache repo to `repos/asf`. Needs path-aware handling (skip
490- * the `/repos/asf/` prefix) or mapping to the github.com/apache mirror.
491- * - git.eclipse.org (~180 rows): Gerrit paths, likewise not owner/repo.
487+ * - git.eclipse.org (~180 rows): Gerrit `/c/<repo>` paths, not owner/repo.
488+ * - eclipse.gerrithub.io (~10 rows): Gerrit admin-UI paths (`/admin/repos/...`),
489+ * not a clone URL shape.
492490 * - android.googlesource.com, ec.europa.eu (Bitbucket-Server /projects/x/repos/y):
493491 * multi-segment paths, not owner/repo.
494492 * - ambiguous `git.*` hosts (git.iem.at, git.i-novus.ru, git.oschina.net) and the
495493 * internal git.corp.adobe.com: pending path/reachability confirmation.
494+ *
495+ * gitbox.apache.org / git.apache.org / git-wip-us.apache.org are handled separately
496+ * below (normalizeApacheGitwebUrl) rather than through this allowlist — their paths
497+ * are `/repos/asf/<repo>` or `?p=<repo>` (classic gitweb query form), not owner/repo.
496498 */
497499const SCM_HOSTS = new Set ( [
498500 'github.com' ,
@@ -522,6 +524,33 @@ const SCM_HOSTS = new Set([
522524/** Hosts whose owner/repo path is case-insensitive and should be lower-cased. */
523525const CASE_INSENSITIVE_HOSTS = new Set ( [ 'github.com' , 'gitlab.com' ] )
524526
527+ /**
528+ * Apache's gitweb-based hosts serve every repo under a fixed /repos/asf/ prefix,
529+ * in two equivalent forms: /repos/asf/<repo>.git (path) or /repos/asf?p=<repo>.git
530+ * (classic gitweb query-string). Neither is an owner/repo shape — the generic path
531+ * logic would either reject the query form (no second segment) or collapse every
532+ * repo to the literal segments "repos"/"asf" for the path form. Handled on the same
533+ * host rather than mapped to a github.com/apache mirror, since that mapping isn't
534+ * guaranteed to hold for every repo.
535+ */
536+ const APACHE_GITWEB_HOSTS = new Set ( [
537+ 'gitbox.apache.org' ,
538+ 'git.apache.org' ,
539+ 'git-wip-us.apache.org' ,
540+ ] )
541+
542+ function normalizeApacheGitwebUrl ( host : string , pathname : string , search : string ) : string | null {
543+ const queryRepo = new URLSearchParams ( search ) . get ( 'p' )
544+ const raw =
545+ queryRepo ?? ( pathname . startsWith ( '/repos/asf/' ) ? pathname . slice ( '/repos/asf/' . length ) : null )
546+ if ( ! raw ) return null
547+
548+ const name = raw . replace ( / \. g i t $ / , '' ) . replace ( / \/ + $ / , '' )
549+ if ( ! name || name . includes ( '/' ) || / \$ \{ | % 7 B / i. test ( name ) ) return null
550+
551+ return `https://${ host } /repos/asf/${ name } `
552+ }
553+
525554/**
526555 * Converts the raw SCM URL from a POM (declared_repository_url) into a clean,
527556 * canonical `https://<host>/<owner>/<repo>` repository URL suitable for storage
@@ -547,12 +576,6 @@ export function normalizeScmUrl(raw: string | null): string | null {
547576 let s = raw . trim ( )
548577 if ( ! s ) return null
549578
550- // A leftover ${...} means best-effort interpolation upstream could not resolve it.
551- // Reject outright: a placeholder embedded in the path (e.g. github.com/owner/${x})
552- // otherwise survives URL parsing (percent-encoded to %7B…%7D) and would yield a junk
553- // repository_url instead of null.
554- if ( s . includes ( '${' ) ) return null
555-
556579 // Strip Maven scm:git: / scm: prefix
557580 s = s . replace ( / ^ s c m : g i t : / i, '' ) . replace ( / ^ s c m : / i, '' )
558581
@@ -593,6 +616,11 @@ export function normalizeScmUrl(raw: string | null): string | null {
593616 if ( parsed . protocol !== 'https:' ) return null
594617
595618 const host = parsed . hostname . toLowerCase ( ) . replace ( / ^ w w w \. / , '' )
619+
620+ if ( APACHE_GITWEB_HOSTS . has ( host ) ) {
621+ return normalizeApacheGitwebUrl ( host , parsed . pathname , parsed . search )
622+ }
623+
596624 if ( ! SCM_HOSTS . has ( host ) ) return null
597625
598626 // Require at least owner + repo path segments
@@ -603,6 +631,14 @@ export function normalizeScmUrl(raw: string | null): string | null {
603631 let name = segments [ 1 ] . replace ( / \. g i t $ / , '' )
604632 if ( ! owner || ! name ) return null
605633
634+ // A leftover ${...} in owner/repo means best-effort interpolation upstream could
635+ // not resolve it — reject rather than store a junk link. The URL parser
636+ // percent-encodes { and } in the path, so check both forms. Placeholders in a
637+ // trailing suffix (e.g. /tree/${project.scm.tag}) are irrelevant here since only
638+ // segments[0]/[1] are inspected — that suffix is simply never read.
639+ const hasPlaceholder = ( seg : string ) => / \$ \{ | % 7 B / i. test ( seg )
640+ if ( hasPlaceholder ( owner ) || hasPlaceholder ( name ) ) return null
641+
606642 if ( CASE_INSENSITIVE_HOSTS . has ( host ) ) {
607643 owner = owner . toLowerCase ( )
608644 name = name . toLowerCase ( )
0 commit comments