From 4450004494c684d0f120b43809ca82f86affeda0 Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Mon, 16 Dec 2024 15:34:07 -0800 Subject: [PATCH] feat: add `HostedGitInfo.fromManifest` This encapsulates the logic used in `npm repo` --- README.md | 7 ++++- lib/index.js | 48 +++++++++++++++++++++++++++++++ test/file.js | 14 +++++++++ test/github.js | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++ test/gitlab.js | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 test/file.js diff --git a/README.md b/README.md index 498e3d2..c5a537e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ const info = hostedGitInfo.fromUrl("git@github.com:npm/hosted-git-info.git", opt */ ``` -If the URL can't be matched with a git host, `null` will be returned. We +If the URL can't be matched with a git host, `null` will be returned. We can match git, ssh and https urls. Additionally, we can match ssh connect strings (`git@github.com:npm/hosted-git-info`) and shortcuts (eg, `github:npm/hosted-git-info`). GitHub specifically, is detected in the case @@ -59,6 +59,11 @@ Implications: * *noCommittish* — If true then committishes won't be included in generated URLs. * *noGitPlus* — If true then `git+` won't be prefixed on URLs. +### const infoOrURL = hostedGitInfo.fromManifest(manifest[, options]) + +* *manifest* is a package manifest, such as that returned by [`pacote.manifest()`](https://npmjs.com/pacote) +* *options* is an optional object. It can have the same properties as `fromUrl` above. + ## Methods All of the methods take the same options as the `fromUrl` factory. Options diff --git a/lib/index.js b/lib/index.js index 0c9d0b0..2a7100d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -7,6 +7,26 @@ const parseUrl = require('./parse-url.js') const cache = new LRUCache({ max: 1000 }) +function unknownHostedUrl (url) { + try { + const { + protocol, + hostname, + pathname, + } = new URL(url) + + if (!hostname) { + return null + } + + const proto = /(?:git\+)http:$/.test(protocol) ? 'http:' : 'https:' + const path = pathname.replace(/\.git$/, '') + return `${proto}//${hostname}${path}` + } catch { + return null + } +} + class GitHost { constructor (type, user, auth, project, committish, defaultRepresentation, opts = {}) { Object.assign(this, GitHost.#gitHosts[type], { @@ -56,6 +76,34 @@ class GitHost { return cache.get(key) } + static fromManifest (manifest, opts = {}) { + if (!manifest || typeof manifest !== 'object') { + return + } + + const r = manifest.repository + // TODO: look into also checking the `bugs`/`homepage` URLs + + const rurl = r && ( + typeof r === 'string' + ? r + : typeof r === 'object' && typeof r.url === 'string' + ? r.url + : null + ) + + if (!rurl) { + throw new Error('no repository') + } + + const info = (rurl && GitHost.fromUrl(rurl.replace(/^git\+/, ''), opts)) || null + if (info) { + return info + } + const unk = unknownHostedUrl(rurl) + return GitHost.fromUrl(unk, opts) || unk + } + static parseUrl (url) { return parseUrl(url) } diff --git a/test/file.js b/test/file.js new file mode 100644 index 0000000..2cd2de0 --- /dev/null +++ b/test/file.js @@ -0,0 +1,14 @@ +const HostedGit = require('..') +const t = require('tap') + +t.test('file:// URLs', t => { + const fileRepo = { + name: 'foo', + repository: { + url: 'file:///path/dot.git', + }, + } + t.equal(HostedGit.fromManifest(fileRepo), null) + + t.end() +}) diff --git a/test/github.js b/test/github.js index 6c01068..7d7739b 100644 --- a/test/github.js +++ b/test/github.js @@ -270,3 +270,80 @@ t.test('string methods populate correctly', t => { t.end() }) + +t.test('from manifest', t => { + t.equal(HostedGit.fromManifest(), undefined, 'no manifest returns undefined') + t.equal(HostedGit.fromManifest(), undefined, 'no manifest returns undefined') + t.equal(HostedGit.fromManifest(false), undefined, 'false manifest returns undefined') + t.equal(HostedGit.fromManifest(() => {}), undefined, 'function manifest returns undefined') + + const unknownHostRepo = { + name: 'foo', + repository: { + url: 'https://nope.com', + }, + } + t.same(HostedGit.fromManifest(unknownHostRepo), 'https://nope.com/') + + const insecureUnknownHostRepo = { + name: 'foo', + repository: { + url: 'http://nope.com', + }, + } + t.same(HostedGit.fromManifest(insecureUnknownHostRepo), 'https://nope.com/') + + const insecureGitUnknownHostRepo = { + name: 'foo', + repository: { + url: 'git+http://nope.com', + }, + } + t.same(HostedGit.fromManifest(insecureGitUnknownHostRepo), 'http://nope.com') + + const badRepo = { + name: 'foo', + repository: { + url: '#', + }, + } + t.equal(HostedGit.fromManifest(badRepo), null) + + const manifest = { + name: 'foo', + repository: { + type: 'git', + url: 'git+ssh://github.com/foo/bar.git', + }, + } + + const parsed = HostedGit.fromManifest(manifest) + t.same(parsed.browse(), 'https://github.com/foo/bar') + + const monorepo = { + name: 'clowncar', + repository: { + type: 'git', + url: 'git+ssh://github.com/foo/bar.git', + directory: 'packages/foo', + }, + } + + const honk = HostedGit.fromManifest(monorepo) + t.same(honk.browse(monorepo.repository.directory), 'https://github.com/foo/bar/tree/HEAD/packages/foo') + + const stringRepo = { + name: 'foo', + repository: 'git+ssh://github.com/foo/bar.git', + } + const stringRepoParsed = HostedGit.fromManifest(stringRepo) + t.same(stringRepoParsed.browse(), 'https://github.com/foo/bar') + + const nonStringRepo = { + name: 'foo', + repository: 42, + } + t.throws(() => HostedGit.fromManifest(nonStringRepo)) + + t.end() +}) diff --git a/test/gitlab.js b/test/gitlab.js index 685bd06..ffa080c 100644 --- a/test/gitlab.js +++ b/test/gitlab.js @@ -321,3 +321,80 @@ t.test('string methods populate correctly', t => { t.end() }) + +t.test('from manifest', t => { + t.equal(HostedGit.fromManifest(), undefined, 'no manifest returns undefined') + t.equal(HostedGit.fromManifest(), undefined, 'no manifest returns undefined') + t.equal(HostedGit.fromManifest(false), undefined, 'false manifest returns undefined') + t.equal(HostedGit.fromManifest(() => {}), undefined, 'function manifest returns undefined') + + const unknownHostRepo = { + name: 'foo', + repository: { + url: 'https://nope.com', + }, + } + t.same(HostedGit.fromManifest(unknownHostRepo), 'https://nope.com/') + + const insecureUnknownHostRepo = { + name: 'foo', + repository: { + url: 'http://nope.com', + }, + } + t.same(HostedGit.fromManifest(insecureUnknownHostRepo), 'https://nope.com/') + + const insecureGitUnknownHostRepo = { + name: 'foo', + repository: { + url: 'git+http://nope.com', + }, + } + t.same(HostedGit.fromManifest(insecureGitUnknownHostRepo), 'http://nope.com') + + const badRepo = { + name: 'foo', + repository: { + url: '#', + }, + } + t.equal(HostedGit.fromManifest(badRepo), null) + + const manifest = { + name: 'foo', + repository: { + type: 'git', + url: 'git+ssh://gitlab.com/foo/bar.git', + }, + } + + const parsed = HostedGit.fromManifest(manifest) + t.same(parsed.browse(), 'https://gitlab.com/foo/bar') + + const monorepo = { + name: 'clowncar', + repository: { + type: 'git', + url: 'git+ssh://gitlab.com/foo/bar.git', + directory: 'packages/foo', + }, + } + + const honk = HostedGit.fromManifest(monorepo) + t.same(honk.browse(monorepo.repository.directory), 'https://gitlab.com/foo/bar/tree/HEAD/packages/foo') + + const stringRepo = { + name: 'foo', + repository: 'git+ssh://gitlab.com/foo/bar.git', + } + const stringRepoParsed = HostedGit.fromManifest(stringRepo) + t.same(stringRepoParsed.browse(), 'https://gitlab.com/foo/bar') + + const nonStringRepo = { + name: 'foo', + repository: 42, + } + t.throws(() => HostedGit.fromManifest(nonStringRepo)) + + t.end() +})