-
Notifications
You must be signed in to change notification settings - Fork 731
Expand file tree
/
Copy pathcanonicalizeRepoUrl.ts
More file actions
96 lines (82 loc) · 3.33 KB
/
Copy pathcanonicalizeRepoUrl.ts
File metadata and controls
96 lines (82 loc) · 3.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
export type RepoHost = 'github' | 'gitlab' | 'bitbucket' | 'other'
export interface CanonicalRepo {
// Canonical https://<host>/<owner>/<name>
url: string
// Coarse host classification stored in repos.host.
host: RepoHost
}
const SHORTHAND_HOSTS: Record<string, string> = {
github: 'github.com',
gitlab: 'gitlab.com',
bitbucket: 'bitbucket.org',
gist: 'gist.github.com',
}
const HOST_ENUM: Record<string, RepoHost> = {
'github.com': 'github',
'gitlab.com': 'gitlab',
'bitbucket.org': 'bitbucket',
}
// GitHub/GitLab repo paths are case-insensitive — lowercase owner/name so the
// same repo never produces two distinct repos.url keys.
const CASE_INSENSITIVE_HOSTS = new Set(['github.com', 'gitlab.com'])
/**
* Canonicalize a source-repository URL to `{ url, host }` where url is
* `https://<host>/<owner>/<name>` and host is the coarse classification stored
* in `repos.host`.
*
* Shared across the registry sub-workers (npm, Maven, …) and the GitHub
* enricher so `repos.url` keys never diverge per ADR 0001. Handles npm
* shorthand (`github:owner/repo`, bare `owner/repo`), SSH scp form, `ssh://`,
* `git+`, `git://`, `www.`, and monorepo `/tree/<branch>/<path>` deep-links
* (only the first two path segments are kept). Returns null when the input
* cannot be reduced to an owner/name pair.
*/
export function canonicalizeRepoUrl(raw: string): CanonicalRepo | null {
let s = raw.trim().replace(/#.*$/, '')
if (!s) return null
const sh = s.match(/^(github|gitlab|bitbucket|gist):(.+)$/)
if (sh) {
s = `https://${SHORTHAND_HOSTS[sh[1]]}/${sh[2]}`
} else if (!s.includes('://') && !s.includes('@') && /^[\w.-]+\/[\w.-]+$/.test(s)) {
s = `https://github.com/${s}`
}
s = s.replace(/^git\+/, '')
const scp = s.match(/^git@([^:]+):(.+)$/)
if (scp) {
s = `https://${scp[1]}/${scp[2]}`
}
// ssh:// with an scp-style `host:path` (colon instead of slash) is not valid URL
// syntax — the part after `:` looks like a port to the URL parser and throws.
// Rewrite it before the generic ssh://git@host/path case below. A numeric-only
// segment before the next `/` is a real port (e.g. `ssh://git@host:2222/owner/repo`),
// not an scp-style owner — leave those for the generic case, which URL parses fine.
const sshScp = s.match(/^ssh:\/\/git@([^/:]+):(.+)$/)
const sshScpIsPort = sshScp ? /^\d+$/.test(sshScp[2].split('/')[0]) : false
if (sshScp && !sshScpIsPort) {
s = `https://${sshScp[1]}/${sshScp[2]}`
} else {
s = s.replace(/^ssh:\/\/git@([^/]+)\//, 'https://$1/')
}
s = s.replace(/^git:\/\//, 'https://')
let u: URL
try {
u = new URL(s)
} catch {
return null
}
const hostname = u.hostname.toLowerCase().replace(/^www\./, '')
const segments = u.pathname.split('/').filter(Boolean)
if (segments.length < 2) return null
const isKnownHost = hostname in HOST_ENUM
let ownerPath = isKnownHost ? [segments[0]] : segments.slice(0, -1)
let name = (isKnownHost ? segments[1] : segments[segments.length - 1]).replace(/\.git$/, '')
if (!name || ownerPath.length === 0 || ownerPath.some((seg) => !seg)) return null
if (CASE_INSENSITIVE_HOSTS.has(hostname)) {
ownerPath = ownerPath.map((seg) => seg.toLowerCase())
name = name.toLowerCase()
}
return {
url: `https://${hostname}/${[...ownerPath, name].join('/')}`,
host: HOST_ENUM[hostname] ?? 'other',
}
}