@@ -494,8 +494,11 @@ export async function extractArtifact(groupId: string, artifactId: string, versi
494494 *
495495 * Still NOT handled (need more than a host allowlist, or are unreachable):
496496 * - eclipse.gerrithub.io: Gerrit admin-UI paths (`/admin/repos/...`), not a clone shape.
497- * - internal-only hosts (git.corp.adobe.com, gitlab.alibaba-inc.com): unreachable
498- * for consumers, so intentionally excluded.
497+ *
498+ * Internal-only hosts (git.corp.adobe.com, fit.corp.adobe.com, gitlab.alibaba-inc.com)
499+ * are included below even though they're unreachable for external consumers — per
500+ * product decision, an internal SCM URL should still resolve to a canonical link
501+ * rather than being dropped to null.
499502 */
500503const SCM_HOSTS = new Set ( [
501504 'github.com' ,
@@ -505,10 +508,12 @@ const SCM_HOSTS = new Set([
505508 'codeberg.org' ,
506509 // Self-hosted GitLab / Gitea instances seen in Maven POMs with clean
507510 // /<owner>/<repo> paths (same shape as gitlab.com — handled by the generic logic).
508- // Internal-only hosts (git.corp.adobe.com, gitlab.alibaba-inc.com) are excluded:
509- // their links are unreachable for consumers. The ≥2-segment owner/repo requirement
510- // acts as a safety net so a mis-classified host yields NULL, never a junk link.
511+ // The ≥2-segment owner/repo requirement acts as a safety net so a mis-classified
512+ // host yields NULL, never a junk link.
511513 'gitlab.smartb.city' ,
514+ 'git.corp.adobe.com' ,
515+ 'fit.corp.adobe.com' ,
516+ 'gitlab.alibaba-inc.com' ,
512517 'gitlab.ow2.org' ,
513518 'gitlab.nuiton.org' ,
514519 'gitlab.inria.fr' ,
@@ -769,6 +774,26 @@ function normalizeGooglesourceUrl(host: string, pathname: string): string | null
769774 return `https://${ host } ${ path } `
770775}
771776
777+ /**
778+ * OpenDaylight's self-hosted Gerrit serves the same flat (no-owner) repo naming as
779+ * ASF's gitweb, in two forms seen in the wild: a gitweb browse query
780+ * (`/gerrit/gitweb?p=<repo>.git;a=summary`) and an SSH clone URL with an explicit
781+ * Gerrit port and no `git@` user (`ssh://git.opendaylight.org:29418/<repo>.git`).
782+ * Both are unified to the gitweb browse form so the stored link is always
783+ * resolvable in a browser, not just via `git clone`.
784+ */
785+ const OPENDALIGHT_GERRIT_HOST = 'git.opendaylight.org'
786+
787+ function normalizeOpendaylightGerritUrl ( pathname : string , search : string ) : string | null {
788+ const queryRepo = new URLSearchParams ( search ) . get ( 'p' )
789+ const segments = pathname . split ( '/' ) . filter ( Boolean )
790+ const rawName = queryRepo ?? ( segments . length === 1 ? segments [ 0 ] : null )
791+ const name = rawName ? extractGitwebRepoName ( rawName ) : null
792+ if ( ! name ) return null
793+
794+ return `https://${ OPENDALIGHT_GERRIT_HOST } /gerrit/gitweb?p=${ name } .git`
795+ }
796+
772797/**
773798 * GitLab — gitlab.com and self-hosted instances — supports nested group
774799 * namespaces (group/subgroup/.../project), so the repository path is NOT limited
@@ -796,6 +821,133 @@ function normalizeGitlabUrl(host: string, pathname: string): string | null {
796821 return `https://${ host } /${ host === 'gitlab.com' ? path . toLowerCase ( ) : path } `
797822}
798823
824+ /**
825+ * Subversion hosts unambiguously identifiable by hostname alone (the "svn" in the
826+ * name), so a bare `http://svn.apache.org/...` URL is recognised as SVN even without
827+ * an `scm:svn:` connection-string prefix or `svn(+ssh)://` scheme.
828+ */
829+ const SVN_HOSTS = new Set ( [
830+ 'svn.apache.org' ,
831+ 'svn.eu.apache.org' ,
832+ 'svn.sonatype.org' ,
833+ 'svn.forge.objectweb.org' ,
834+ 'svn.code.sf.net' ,
835+ 'svn.codehaus.org' ,
836+ 'svn.java.net' ,
837+ 'websvn.ow2.org' ,
838+ ] )
839+
840+ /**
841+ * Hosts that only count as SVN when explicitly marked (`scm:svn:` prefix or
842+ * `svn(+ssh)://` scheme) — the bare hostname is shared with non-SVN content
843+ * (java.net hosted forums, wikis, JIRA, etc. alongside its SVN forge).
844+ */
845+ const SVN_HOSTS_REQUIRE_EXPLICIT_MARKER = new Set ( [ 'java.net' ] )
846+
847+ function isSvnSourceforgeHost ( host : string ) : boolean {
848+ return host . endsWith ( '.svn.sourceforge.net' )
849+ }
850+
851+ const APACHE_SVN_HOSTS = new Set ( [ 'svn.apache.org' , 'svn.eu.apache.org' ] )
852+
853+ /**
854+ * Whether `host` (lowercased, no `www.`) is one of the SVN hosts this module
855+ * normalizes. Exported so callers persisting the normalized URL elsewhere
856+ * (e.g. `parseRepoUrl` in `./normalize`) can special-case SVN's owner/repo-less
857+ * path shape instead of applying a generic two-segment split.
858+ */
859+ export function isSvnHost ( host : string ) : boolean {
860+ return (
861+ SVN_HOSTS . has ( host ) || isSvnSourceforgeHost ( host ) || SVN_HOSTS_REQUIRE_EXPLICIT_MARKER . has ( host )
862+ )
863+ }
864+
865+ /**
866+ * Subversion has no owner/repo shape — its near-universal convention is
867+ * <project>[/<module>]/{trunk|tags|branches}/... . The canonical link keeps
868+ * everything up to (but not including) that trunk/tags/branches marker, the closest
869+ * SVN equivalent of a repo root. Apache's three script fronts (viewvc, viewcvs.cgi,
870+ * repos/asf) all serve the same repos, so they're unified to the /repos/asf/ form
871+ * regardless of which one the declared URL used — mirrors wrapAsfRepo for git.
872+ *
873+ * Kept even though these hosts are largely dead/unreachable today (Apache, Sonatype
874+ * and ObjectWeb's SVN forges have moved to Git) per explicit product decision: an SVN
875+ * declared_repository_url should still resolve to a canonical link, not stay null.
876+ */
877+ function normalizeSvnUrl ( host : string , pathname : string ) : string | null {
878+ const rest = pathname . replace ( / ^ \/ + / , '' )
879+ const markerMatch = rest . match ( / ^ ( v i e w v c | v i e w c v s \. c g i | r e p o s \/ a s f | s v n r o o t | s v n | p ) \/ ( .* ) $ / )
880+ const scriptPrefix = markerMatch ?. [ 1 ] ?? null
881+ const afterPrefix = markerMatch ? markerMatch [ 2 ] : rest
882+
883+ const segments = afterPrefix . split ( '/' ) . filter ( Boolean )
884+ if ( segments . some ( ( s ) => / \$ \{ | % 7 B / i. test ( s ) ) ) return null
885+
886+ if ( scriptPrefix === 'p' ) {
887+ // SourceForge /p/<project>/code/... — "code" is a fixed marker, not part of the path.
888+ return segments [ 0 ] ? `https://${ host } /p/${ segments [ 0 ] } /code` : null
889+ }
890+
891+ const branchMarkerIndex = segments . findIndex ( ( s ) => / ^ ( t r u n k | t a g s | b r a n c h e s ) $ / i. test ( s ) )
892+ const projectSegments = branchMarkerIndex === - 1 ? segments : segments . slice ( 0 , branchMarkerIndex )
893+
894+ if ( projectSegments . length === 0 ) {
895+ // Legacy SourceForge subdomain form (<project>.svn.sourceforge.net) with no
896+ // path beyond trunk/tags/branches — the project name is the subdomain itself.
897+ if ( isSvnSourceforgeHost ( host ) ) {
898+ const project = host . replace ( / \. s v n \. s o u r c e f o r g e \. n e t $ / , '' )
899+ return project ? `https://${ host } /svnroot/${ project } ` : null
900+ }
901+ return null
902+ }
903+
904+ if ( APACHE_SVN_HOSTS . has ( host ) ) return `https://${ host } /repos/asf/${ projectSegments . join ( '/' ) } `
905+
906+ const prefix = scriptPrefix && scriptPrefix !== 'repos/asf' ? `${ scriptPrefix } /` : ''
907+ return `https://${ host } ${ prefix ? `/${ prefix } ` : '/' } ${ projectSegments . join ( '/' ) } `
908+ }
909+
910+ /**
911+ * Detects and normalizes Maven `scm:svn:` connection strings and bare SVN URLs.
912+ * Returns `undefined` when the input isn't SVN at all (falls through to the
913+ * git-oriented logic below); `null` when it's SVN but not on a known SVN host or
914+ * not resolvable; otherwise the canonical link.
915+ */
916+ function normalizeSvnScmUrl ( raw : string ) : string | null | undefined {
917+ const hadSvnScmPrefix = / ^ s c m : s v n : / i. test ( raw )
918+ let s = raw . replace ( / ^ s c m : s v n : / i, '' )
919+
920+ const hasSvnScheme = / ^ s v n ( \+ s s h ) ? : \/ \/ / i. test ( s )
921+ s = s . replace ( / ^ s v n \+ s s h : \/ \/ / i, 'https://' ) . replace ( / ^ s v n : \/ \/ / i, 'https://' )
922+
923+ if ( ! / ^ h t t p s ? : \/ \/ / i. test ( s ) ) {
924+ if ( ! hadSvnScmPrefix && ! hasSvnScheme ) return undefined
925+ s = `https://${ s . replace ( / ^ h t t p s ? : \/ \/ / i, '' ) } `
926+ }
927+
928+ // Strip an embedded user@ (svn+ssh://user@host/... form)
929+ s = s . replace ( / ^ ( h t t p s ? : \/ \/ ) [ ^ @ / ] + @ / , '$1' )
930+
931+ let parsed : URL
932+ try {
933+ parsed = new URL ( s )
934+ } catch {
935+ return hadSvnScmPrefix || hasSvnScheme ? null : undefined
936+ }
937+
938+ const host = parsed . hostname . toLowerCase ( ) . replace ( / ^ w w w \. / , '' )
939+ const isUnambiguousSvnHost = SVN_HOSTS . has ( host ) || isSvnSourceforgeHost ( host )
940+
941+ if ( ! hadSvnScmPrefix && ! hasSvnScheme && ! isUnambiguousSvnHost ) return undefined
942+
943+ const isSvnHost =
944+ isUnambiguousSvnHost ||
945+ ( SVN_HOSTS_REQUIRE_EXPLICIT_MARKER . has ( host ) && ( hadSvnScmPrefix || hasSvnScheme ) )
946+ if ( ! isSvnHost ) return null
947+
948+ return normalizeSvnUrl ( host , parsed . pathname )
949+ }
950+
799951/**
800952 * Converts the raw SCM URL from a POM (declared_repository_url) into a clean,
801953 * canonical `https://<host>/<owner>/<repo>` repository URL suitable for storage
@@ -813,14 +965,21 @@ function normalizeGitlabUrl(host: string, pathname: string): string | null {
813965 * https://github.com:owner/repo → https://github.com/owner/repo
814966 * ssh://git@github.com:owner/repo.git → https://github.com/owner/repo
815967 *
968+ * SVN connection strings (`scm:svn:...`, `svn(+ssh)://...`) are handled separately
969+ * by normalizeSvnScmUrl, which has no owner/repo shape and is dispatched before any
970+ * of the git-oriented rewriting below — see its docstring for the SVN conventions.
971+ *
816972 * Rejected (→ null): website-only URLs (https://meson.ai/), non-SCM hosts
817- * (svn://…, http://source.android.com), placeholders (Private, ${scm-url}).
973+ * (http://source.android.com), placeholders (Private, ${scm-url}).
818974 */
819975export function normalizeScmUrl ( raw : string | null ) : string | null {
820976 if ( ! raw ) return null
821977 let s = raw . trim ( )
822978 if ( ! s ) return null
823979
980+ const svnResult = normalizeSvnScmUrl ( s )
981+ if ( svnResult !== undefined ) return svnResult
982+
824983 // Strip Maven scm:git: / scm: prefix
825984 s = s . replace ( / ^ s c m : g i t : / i, '' ) . replace ( / ^ s c m : / i, '' )
826985
@@ -836,6 +995,9 @@ export function normalizeScmUrl(raw: string | null): string | null {
836995 // ssh://git@host /… → https://host/…
837996 s = s . replace ( / ^ s s h : \/ \/ g i t @ ( [ ^ / ] + ) \/ / , 'https://$1/' )
838997
998+ // ssh://host:port/… (no git@ user, real numeric Gerrit-style port) → https://host/…
999+ s = s . replace ( / ^ s s h : \/ \/ ( [ ^ @ / : ] + ) : \d + \/ / , 'https://$1/' )
1000+
8391001 // git:// → https://, and upgrade http:// → https:// — done before the SCP-colon
8401002 // rule below so that git://host:owner/repo is normalised too.
8411003 s = s . replace ( / ^ g i t : \/ \/ / , 'https://' ) . replace ( / ^ h t t p : \/ \/ / , 'https://' )
@@ -879,6 +1041,10 @@ export function normalizeScmUrl(raw: string | null): string | null {
8791041 return normalizeGitwebQueryUrl ( host , parsed . search )
8801042 }
8811043
1044+ if ( host === OPENDALIGHT_GERRIT_HOST ) {
1045+ return normalizeOpendaylightGerritUrl ( parsed . pathname , parsed . search )
1046+ }
1047+
8821048 if ( ECLIPSE_CGIT_HOSTS . has ( host ) ) {
8831049 return normalizeEclipseCgitUrl ( host , parsed . pathname )
8841050 }
0 commit comments