diff --git a/services/apps/packages_worker/src/maven/__tests__/normalize.test.ts b/services/apps/packages_worker/src/maven/__tests__/normalize.test.ts index 0dfd3e74e0..89eac726c4 100644 --- a/services/apps/packages_worker/src/maven/__tests__/normalize.test.ts +++ b/services/apps/packages_worker/src/maven/__tests__/normalize.test.ts @@ -103,6 +103,14 @@ describe('parseRepoUrl', () => { expect(result?.host).toBe('other') }) + it('keeps the full path as name (no owner) for known SVN hosts', () => { + expect(parseRepoUrl('https://svn.apache.org/repos/asf/geronimo/server')).toEqual({ + host: 'svn', + owner: null, + name: 'repos/asf/geronimo/server', + }) + }) + it('returns null for invalid URLs', () => { expect(parseRepoUrl('not-a-url')).toBeNull() }) @@ -111,6 +119,22 @@ describe('parseRepoUrl', () => { const result = parseRepoUrl('https://github.com/') expect(result).toEqual({ host: 'github', owner: null, name: null }) }) + + // The canonical OpenDaylight link keeps repo identity in ?p=, not the (fixed) + // pathname — a generic parts[0]/parts[1] split would collapse every OpenDaylight + // repo to the same owner='gerrit', name='gitweb'. + it('derives owner=null and name from the ?p= query for OpenDaylight Gerrit links', () => { + expect(parseRepoUrl('https://git.opendaylight.org/gerrit/gitweb?p=netconf.git')).toEqual({ + host: 'gerrit', + owner: null, + name: 'netconf', + }) + expect(parseRepoUrl('https://git.opendaylight.org/gerrit/gitweb?p=aaa.git')).toEqual({ + host: 'gerrit', + owner: null, + name: 'aaa', + }) + }) }) describe('normalizeScmUrl', () => { @@ -160,8 +184,57 @@ describe('normalizeScmUrl', () => { ) }) - it('returns null for non-https result', () => { - expect(normalizeScmUrl('svn://svn.apache.org/repos/commons-lang')).toBeNull() + it('returns null for non-https, non-SVN schemes', () => { + expect(normalizeScmUrl('ftp://ftp.apache.org/repos/commons-lang')).toBeNull() + }) + + // SVN has no owner/repo shape and is mapped even though the hosts are largely + // unreachable today — see normalizeSvnUrl / normalizeSvnScmUrl docstrings. + it('maps svn:// URLs on known SVN hosts to a canonical link', () => { + expect(normalizeScmUrl('svn://svn.apache.org/repos/asf/commons-lang/trunk')).toBe( + 'https://svn.apache.org/repos/asf/commons-lang', + ) + }) + + it('maps scm:svn: connection strings, unifying viewvc/repos-asf to the same link', () => { + expect( + normalizeScmUrl( + 'scm:svn:http://svn.apache.org/repos/asf/geronimo/server/tags/geronimo-3.0.1', + ), + ).toBe('https://svn.apache.org/repos/asf/geronimo/server') + expect( + normalizeScmUrl('http://svn.apache.org/viewvc/geronimo/server/tags/geronimo-3.0.1'), + ).toBe('https://svn.apache.org/repos/asf/geronimo/server') + }) + + it('maps svn+ssh: with an embedded user and SourceForge /p/.../code paths', () => { + expect( + normalizeScmUrl('scm:svn:svn+ssh://albertil@svn.forge.objectweb.org/svnroot/myproj/trunk'), + ).toBe('https://svn.forge.objectweb.org/svnroot/myproj') + expect(normalizeScmUrl('http://svn.code.sf.net/p/myproj/code/trunk/foo')).toBe( + 'https://svn.code.sf.net/p/myproj/code', + ) + }) + + it('returns null for a marker-only SVN root with no project segment', () => { + // No trailing slash after "repos/asf" — must not fall through to being + // mis-parsed as project segments ["repos", "asf"] and duplicated. + expect(normalizeScmUrl('https://svn.apache.org/repos/asf')).toBeNull() + }) + + // java.net requires an explicit SVN marker because the bare host is shared with + // non-SVN content (forums, wikis, JIRA) — this must not accidentally widen to + // treat every java.net URL as SVN. + it('treats java.net as SVN only with an explicit scm:svn:/svn:// marker', () => { + expect(normalizeScmUrl('https://java.net/projects/foo/sources/svn')).toBeNull() + expect(normalizeScmUrl('scm:svn:https://java.net/svn/foo/trunk')).toBe( + 'https://java.net/svn/foo', + ) + expect(normalizeScmUrl('svn://java.net/svn/foo/trunk')).toBe('https://java.net/svn/foo') + }) + + it('does not map dead services that incidentally match an "svn" text filter', () => { + expect(normalizeScmUrl('http://specs.googlecode.com/svn/trunk')).toBeNull() }) // Gap B — recover repository_url from inputs that were previously dropped @@ -362,6 +435,21 @@ describe('normalizeScmUrl', () => { expect(normalizeScmUrl('https://android.googlesource.com/')).toBeNull() }) + // cs.android.com is the Android Code Search UI, backed by the same Gitiles + // repos as android.googlesource.com — same identity passthrough applies. + it('passes through cs.android.com urls unchanged, mirroring the googlesource identity form', () => { + expect(normalizeScmUrl('https://cs.android.com/androidx/platform/frameworks/support')).toBe( + 'https://cs.android.com/androidx/platform/frameworks/support', + ) + }) + + it('returns null for a bare OpenDaylight Gerrit root with no repo path', () => { + // /gerrit/ alone (as opposed to /gerrit/gitweb?p=...) has nothing to extract — + // same "no derivable identity" case as the bare gerrit.onosproject.org root + // tested above, confirmed against real declared_repository_url values. + expect(normalizeScmUrl('https://git.opendaylight.org/gerrit/')).toBeNull() + }) + it('recovers GitHub Pages project-page urls (owner.github.io/repo)', () => { expect(normalizeScmUrl('https://silentbalanceyh.github.io/vertx-zero/')).toBe( 'https://github.com/silentbalanceyh/vertx-zero', @@ -480,9 +568,19 @@ describe('normalizeScmUrl', () => { expect(normalizeScmUrl('https://git.iem.at/owner/repo')).toBe('https://git.iem.at/owner/repo') }) - it('still returns null for internal or non-allowlisted hosts', () => { - expect(normalizeScmUrl('https://git.corp.adobe.com/team/project')).toBeNull() - expect(normalizeScmUrl('https://gitlab.alibaba-inc.com/team/project')).toBeNull() + // Internal-only hosts are mapped despite being unreachable for external + // consumers — see the SCM_HOSTS docstring. + it('maps internal-only hosts (git.corp.adobe.com, gitlab.alibaba-inc.com)', () => { + expect(normalizeScmUrl('https://git.corp.adobe.com/team/project')).toBe( + 'https://git.corp.adobe.com/team/project', + ) + expect(normalizeScmUrl('https://gitlab.alibaba-inc.com/team/project')).toBe( + 'https://gitlab.alibaba-inc.com/team/project', + ) + }) + + it('still returns null for genuinely non-allowlisted hosts', () => { + expect(normalizeScmUrl('https://git.some-random-unlisted-host.example/team/project')).toBeNull() }) it('preserves nested GitLab group namespaces instead of truncating to the group', () => { @@ -515,6 +613,33 @@ describe('normalizeScmUrl', () => { it('returns null for a GitLab namespace with no project segment', () => { expect(normalizeScmUrl('https://gitlab.com/onlygroup')).toBeNull() }) + + // OpenDaylight's Gerrit: both the gitweb browse query and the SSH clone form + // (port, no git@ user) unify to the same gitweb browse link. + it('maps OpenDaylight Gerrit gitweb query and SSH clone URLs to the same link', () => { + expect( + normalizeScmUrl('https://git.opendaylight.org/gerrit/gitweb?p=netconf.git;a=summary'), + ).toBe('https://git.opendaylight.org/gerrit/gitweb?p=netconf.git') + expect(normalizeScmUrl('scm:git:ssh://git.opendaylight.org:29418/aaa.git')).toBe( + 'https://git.opendaylight.org/gerrit/gitweb?p=aaa.git', + ) + }) + + it('returns null for Gerrit shapes with no derivable repo identity', () => { + // Bare Gerrit root — no path at all, nothing to extract. + expect(normalizeScmUrl('http://gerrit.onosproject.org/')).toBeNull() + // Gerrit admin-UI fragment path — not a clone shape. + expect( + normalizeScmUrl('https://gerrit.wikimedia.org/r/#/admin/projects/wikidata/query/rdf'), + ).toBeNull() + }) + + it('returns null for non-SCM OpenDaylight URLs instead of mistaking them for a repo', () => { + // A `p` query on some unrelated path is not a gitweb browse link. + expect(normalizeScmUrl('https://git.opendaylight.org/somewhere?p=netconf.git')).toBeNull() + // Single path segment with no .git suffix — e.g. a static asset, not a clone URL. + expect(normalizeScmUrl('https://git.opendaylight.org/favicon.ico')).toBeNull() + }) }) describe('interpolateProperties', () => { diff --git a/services/apps/packages_worker/src/maven/extract.ts b/services/apps/packages_worker/src/maven/extract.ts index 51439f8d5f..2d163b558d 100644 --- a/services/apps/packages_worker/src/maven/extract.ts +++ b/services/apps/packages_worker/src/maven/extract.ts @@ -487,15 +487,23 @@ export async function extractArtifact(groupId: string, artifactId: string, versi * declared_repository_url that need more than a flat owner/repo allowlist are handled * by their own dedicated normalizers below rather than here: * - git.eclipse.org: cgit `/c//` paths → normalizeEclipseCgitUrl. - * - android.googlesource.com: Gitiles identity paths → normalizeGooglesourceUrl. + * - android.googlesource.com / cs.android.com: Gitiles identity paths (the Android + * Code Search UI mirrors the same repo naming as the Gitiles source itself) → + * normalizeGooglesourceUrl. * - ec.europa.eu: Bitbucket-Server `/projects/x/repos/y` → normalizeBitbucketServerUrl. * - gitbox.apache.org / git.apache.org / git-wip-us.apache.org: gitweb * `/repos/asf/` or `?p=` → normalizeApacheGitwebUrl. * * Still NOT handled (need more than a host allowlist, or are unreachable): * - eclipse.gerrithub.io: Gerrit admin-UI paths (`/admin/repos/...`), not a clone shape. - * - internal-only hosts (git.corp.adobe.com, gitlab.alibaba-inc.com): unreachable - * for consumers, so intentionally excluded. + * - gerrit.onosproject.org: every occurrence observed is the bare Gerrit root + * (`http://gerrit.onosproject.org/`) with no path at all — nothing to extract, + * unlike git.opendaylight.org which has resolvable gitweb/SSH forms. + * + * Internal-only hosts (git.corp.adobe.com, fit.corp.adobe.com, gitlab.alibaba-inc.com) + * are included below even though they're unreachable for external consumers — per + * product decision, an internal SCM URL should still resolve to a canonical link + * rather than being dropped to null. */ const SCM_HOSTS = new Set([ 'github.com', @@ -505,10 +513,12 @@ const SCM_HOSTS = new Set([ 'codeberg.org', // Self-hosted GitLab / Gitea instances seen in Maven POMs with clean // // paths (same shape as gitlab.com — handled by the generic logic). - // Internal-only hosts (git.corp.adobe.com, gitlab.alibaba-inc.com) are excluded: - // their links are unreachable for consumers. The ≥2-segment owner/repo requirement - // acts as a safety net so a mis-classified host yields NULL, never a junk link. + // The ≥2-segment owner/repo requirement acts as a safety net so a mis-classified + // host yields NULL, never a junk link. 'gitlab.smartb.city', + 'git.corp.adobe.com', + 'fit.corp.adobe.com', + 'gitlab.alibaba-inc.com', 'gitlab.ow2.org', 'gitlab.nuiton.org', 'gitlab.inria.fr', @@ -759,8 +769,12 @@ function normalizeAliyunCodeupUrl(host: string, pathname: string): string | null * Android's Gerrit/Gitiles mirror already uses its full path as the canonical, * resolvable repo name (e.g. /platform/tools/base) — nested repo naming is normal * here, so this is an identity passthrough rather than an owner/repo split. + * + * cs.android.com (Android Code Search) is included too: it's a browsing UI over + * the same Gitiles-backed repos and mirrors the identical path naming (e.g. + * /androidx/platform/frameworks/support) — same passthrough applies. */ -const GOOGLESOURCE_IDENTITY_HOSTS = new Set(['android.googlesource.com']) +const GOOGLESOURCE_IDENTITY_HOSTS = new Set(['android.googlesource.com', 'cs.android.com']) function normalizeGooglesourceUrl(host: string, pathname: string): string | null { const path = pathname.replace(/\/+$/, '').replace(/\.git$/, '') @@ -769,6 +783,33 @@ function normalizeGooglesourceUrl(host: string, pathname: string): string | null return `https://${host}${path}` } +/** + * OpenDaylight's self-hosted Gerrit serves the same flat (no-owner) repo naming as + * ASF's gitweb, in two forms seen in the wild: a gitweb browse query + * (`/gerrit/gitweb?p=.git;a=summary`) and an SSH clone URL with an explicit + * Gerrit port and no `git@` user (`ssh://git.opendaylight.org:29418/.git`). + * Both are unified to the gitweb browse form so the stored link is always + * resolvable in a browser, not just via `git clone`. + */ +export const OPENDALIGHT_GERRIT_HOST = 'git.opendaylight.org' +export const OPENDALIGHT_GERRIT_GITWEB_PATH = '/gerrit/gitweb' + +function normalizeOpendaylightGerritUrl(pathname: string, search: string): string | null { + // Query extraction only applies to the actual gitweb browse script — a `p` query + // on some unrelated path isn't a repo reference. + const queryRepo = + pathname === OPENDALIGHT_GERRIT_GITWEB_PATH ? new URLSearchParams(search).get('p') : null + const segments = pathname.split('/').filter(Boolean) + // The one-segment SSH-clone form must end in .git, otherwise any arbitrary + // single-segment path (e.g. /favicon.ico) would be mistaken for a repo. + const rawName = + queryRepo ?? (segments.length === 1 && segments[0].endsWith('.git') ? segments[0] : null) + const name = rawName ? extractGitwebRepoName(rawName) : null + if (!name) return null + + return `https://${OPENDALIGHT_GERRIT_HOST}/gerrit/gitweb?p=${name}.git` +} + /** * GitLab — gitlab.com and self-hosted instances — supports nested group * namespaces (group/subgroup/.../project), so the repository path is NOT limited @@ -796,6 +837,136 @@ function normalizeGitlabUrl(host: string, pathname: string): string | null { return `https://${host}/${host === 'gitlab.com' ? path.toLowerCase() : path}` } +/** + * Subversion hosts unambiguously identifiable by hostname alone (the "svn" in the + * name), so a bare `http://svn.apache.org/...` URL is recognised as SVN even without + * an `scm:svn:` connection-string prefix or `svn(+ssh)://` scheme. + */ +const SVN_HOSTS = new Set([ + 'svn.apache.org', + 'svn.eu.apache.org', + 'svn.sonatype.org', + 'svn.forge.objectweb.org', + 'svn.code.sf.net', + 'svn.codehaus.org', + 'svn.java.net', + 'websvn.ow2.org', +]) + +/** + * Hosts that only count as SVN when explicitly marked (`scm:svn:` prefix or + * `svn(+ssh)://` scheme) — the bare hostname is shared with non-SVN content + * (java.net hosted forums, wikis, JIRA, etc. alongside its SVN forge). + */ +const SVN_HOSTS_REQUIRE_EXPLICIT_MARKER = new Set(['java.net']) + +function isSvnSourceforgeHost(host: string): boolean { + return host.endsWith('.svn.sourceforge.net') +} + +const APACHE_SVN_HOSTS = new Set(['svn.apache.org', 'svn.eu.apache.org']) + +/** + * Whether `host` (lowercased, no `www.`) is one of the SVN hosts this module + * normalizes. Exported so callers persisting the normalized URL elsewhere + * (e.g. `parseRepoUrl` in `./normalize`) can special-case SVN's owner/repo-less + * path shape instead of applying a generic two-segment split. + */ +export function isSvnHost(host: string): boolean { + return ( + SVN_HOSTS.has(host) || isSvnSourceforgeHost(host) || SVN_HOSTS_REQUIRE_EXPLICIT_MARKER.has(host) + ) +} + +/** + * Subversion has no owner/repo shape — its near-universal convention is + * [/]/{trunk|tags|branches}/... . The canonical link keeps + * everything up to (but not including) that trunk/tags/branches marker, the closest + * SVN equivalent of a repo root. Apache's three script fronts (viewvc, viewcvs.cgi, + * repos/asf) all serve the same repos, so they're unified to the /repos/asf/ form + * regardless of which one the declared URL used — mirrors wrapAsfRepo for git. + * + * Kept even though these hosts are largely dead/unreachable today (Apache, Sonatype + * and ObjectWeb's SVN forges have moved to Git) per explicit product decision: an SVN + * declared_repository_url should still resolve to a canonical link, not stay null. + */ +function normalizeSvnUrl(host: string, pathname: string): string | null { + const rest = pathname.replace(/^\/+/, '') + // The trailing group is optional so a marker-only root (e.g. "/repos/asf" with + // no trailing slash) still matches, leaving an empty remainder instead of + // falling through to be mis-parsed as project segments "repos"/"asf". + const markerMatch = rest.match(/^(viewvc|viewcvs\.cgi|repos\/asf|svnroot|svn|p)(?:\/(.*))?$/) + const scriptPrefix = markerMatch?.[1] ?? null + const afterPrefix = markerMatch ? (markerMatch[2] ?? '') : rest + + const segments = afterPrefix.split('/').filter(Boolean) + if (segments.some((s) => /\$\{|%7B/i.test(s))) return null + + if (scriptPrefix === 'p') { + // SourceForge /p//code/... — "code" is a fixed marker, not part of the path. + return segments[0] ? `https://${host}/p/${segments[0]}/code` : null + } + + const branchMarkerIndex = segments.findIndex((s) => /^(trunk|tags|branches)$/i.test(s)) + const projectSegments = branchMarkerIndex === -1 ? segments : segments.slice(0, branchMarkerIndex) + + if (projectSegments.length === 0) { + // Legacy SourceForge subdomain form (.svn.sourceforge.net) with no + // path beyond trunk/tags/branches — the project name is the subdomain itself. + if (isSvnSourceforgeHost(host)) { + const project = host.replace(/\.svn\.sourceforge\.net$/, '') + return project ? `https://${host}/svnroot/${project}` : null + } + return null + } + + if (APACHE_SVN_HOSTS.has(host)) return `https://${host}/repos/asf/${projectSegments.join('/')}` + + const prefix = scriptPrefix && scriptPrefix !== 'repos/asf' ? `${scriptPrefix}/` : '' + return `https://${host}${prefix ? `/${prefix}` : '/'}${projectSegments.join('/')}` +} + +/** + * Detects and normalizes Maven `scm:svn:` connection strings and bare SVN URLs. + * Returns `undefined` when the input isn't SVN at all (falls through to the + * git-oriented logic below); `null` when it's SVN but not on a known SVN host or + * not resolvable; otherwise the canonical link. + */ +function normalizeSvnScmUrl(raw: string): string | null | undefined { + const hadSvnScmPrefix = /^scm:svn:/i.test(raw) + let s = raw.replace(/^scm:svn:/i, '') + + const hasSvnScheme = /^svn(\+ssh)?:\/\//i.test(s) + s = s.replace(/^svn\+ssh:\/\//i, 'https://').replace(/^svn:\/\//i, 'https://') + + if (!/^https?:\/\//i.test(s)) { + if (!hadSvnScmPrefix && !hasSvnScheme) return undefined + s = `https://${s.replace(/^https?:\/\//i, '')}` + } + + // Strip an embedded user@ (svn+ssh://user@host/... form) + s = s.replace(/^(https?:\/\/)[^@/]+@/, '$1') + + let parsed: URL + try { + parsed = new URL(s) + } catch { + return hadSvnScmPrefix || hasSvnScheme ? null : undefined + } + + const host = parsed.hostname.toLowerCase().replace(/^www\./, '') + const isUnambiguousSvnHost = SVN_HOSTS.has(host) || isSvnSourceforgeHost(host) + + if (!hadSvnScmPrefix && !hasSvnScheme && !isUnambiguousSvnHost) return undefined + + const isSvnHost = + isUnambiguousSvnHost || + (SVN_HOSTS_REQUIRE_EXPLICIT_MARKER.has(host) && (hadSvnScmPrefix || hasSvnScheme)) + if (!isSvnHost) return null + + return normalizeSvnUrl(host, parsed.pathname) +} + /** * Converts the raw SCM URL from a POM (declared_repository_url) into a clean, * canonical `https:////` repository URL suitable for storage @@ -813,14 +984,21 @@ function normalizeGitlabUrl(host: string, pathname: string): string | null { * https://github.com:owner/repo → https://github.com/owner/repo * ssh://git@github.com:owner/repo.git → https://github.com/owner/repo * + * SVN connection strings (`scm:svn:...`, `svn(+ssh)://...`) are handled separately + * by normalizeSvnScmUrl, which has no owner/repo shape and is dispatched before any + * of the git-oriented rewriting below — see its docstring for the SVN conventions. + * * Rejected (→ null): website-only URLs (https://meson.ai/), non-SCM hosts - * (svn://…, http://source.android.com), placeholders (Private, ${scm-url}). + * (http://source.android.com), placeholders (Private, ${scm-url}). */ export function normalizeScmUrl(raw: string | null): string | null { if (!raw) return null let s = raw.trim() if (!s) return null + const svnResult = normalizeSvnScmUrl(s) + if (svnResult !== undefined) return svnResult + // Strip Maven scm:git: / scm: prefix s = s.replace(/^scm:git:/i, '').replace(/^scm:/i, '') @@ -836,6 +1014,9 @@ export function normalizeScmUrl(raw: string | null): string | null { // ssh://git@host/… → https://host/… s = s.replace(/^ssh:\/\/git@([^/]+)\//, 'https://$1/') + // ssh://host:port/… (no git@ user, real numeric Gerrit-style port) → https://host/… + s = s.replace(/^ssh:\/\/([^@/:]+):\d+\//, 'https://$1/') + // 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://') @@ -879,6 +1060,10 @@ export function normalizeScmUrl(raw: string | null): string | null { return normalizeGitwebQueryUrl(host, parsed.search) } + if (host === OPENDALIGHT_GERRIT_HOST) { + return normalizeOpendaylightGerritUrl(parsed.pathname, parsed.search) + } + if (ECLIPSE_CGIT_HOSTS.has(host)) { return normalizeEclipseCgitUrl(host, parsed.pathname) } diff --git a/services/apps/packages_worker/src/maven/normalize.ts b/services/apps/packages_worker/src/maven/normalize.ts index c256b2d11d..4e58d52baf 100644 --- a/services/apps/packages_worker/src/maven/normalize.ts +++ b/services/apps/packages_worker/src/maven/normalize.ts @@ -1,3 +1,5 @@ +import { OPENDALIGHT_GERRIT_GITWEB_PATH, OPENDALIGHT_GERRIT_HOST, isSvnHost } from './extract' + export function isPrerelease(version: string): boolean { return /-(SNAPSHOT|alpha|beta|rc|cr|m\d+|dev)/i.test(version) } @@ -8,12 +10,29 @@ export function parseRepoUrl( try { const parsed = new URL(url) const h = parsed.hostname.toLowerCase() + const parts = parsed.pathname.split('/').filter(Boolean) + + // SVN has no owner/repo concept — its canonical path is a single project + // identifier (e.g. /repos/asf/geronimo/server), so splitting it into + // parts[0]/parts[1] like a git host would produce garbage (owner='repos', + // name='asf'). Keep the whole path as `name` instead. + if (isSvnHost(h)) return { host: 'svn', owner: null, name: parts.join('/') || null } + + // OpenDaylight's canonical link keeps the actual repo identity only in the + // ?p= query — the pathname is always the fixed /gerrit/gitweb script, so a + // generic parts[0]/parts[1] split would store owner='gerrit', name='gitweb' + // for every single OpenDaylight repo, indistinguishable from one another. + if (h === OPENDALIGHT_GERRIT_HOST && parsed.pathname === OPENDALIGHT_GERRIT_GITWEB_PATH) { + const p = parsed.searchParams.get('p') + const name = p ? p.replace(/\.git$/, '') : null + return { host: 'gerrit', owner: null, name } + } + let host: string if (h === 'github.com' || h.endsWith('.github.com')) host = 'github' else if (h === 'gitlab.com' || h.includes('gitlab')) host = 'gitlab' else if (h === 'bitbucket.org') host = 'bitbucket' else host = 'other' - const parts = parsed.pathname.split('/').filter(Boolean) return { host, owner: parts[0] ?? null, name: parts[1] ?? null } } catch { return null