diff --git a/.gitignore b/.gitignore index 2bab6d1d..dedbc770 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ !**/.gitignore !/.commitlintrc.js +!/.eslint.config.js !/.eslintrc.js !/.eslintrc.local.* !/.git-blame-ignore-revs diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6331e904..529b133f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "8.0.2" + ".": "8.1.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index ea84dacc..f2a1030b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [8.1.0](https://github.com/npm/hosted-git-info/compare/v8.0.2...v8.1.0) (2025-04-14) +### Features +* [`ef0865c`](https://github.com/npm/hosted-git-info/commit/ef0865cc5c28700f990bf25d919e2520c944cf55) [#288](https://github.com/npm/hosted-git-info/pull/288) add `HostedGitInfo.fromManifest` (#288) (@ljharb) +### Chores +* [`ac08fe8`](https://github.com/npm/hosted-git-info/commit/ac08fe89153d19d1fecbd1e5ce5014fad833134c) [#296](https://github.com/npm/hosted-git-info/pull/296) bump @npmcli/template-oss from 4.23.6 to 4.24.3 (#296) (@dependabot[bot], @npm-cli-bot) + ## [8.0.2](https://github.com/npm/hosted-git-info/compare/v8.0.1...v8.0.2) (2024-11-21) ### Bug Fixes * [`cc004ba`](https://github.com/npm/hosted-git-info/commit/cc004bae62d17b90c2fc889fcde5afbcac2fc508) [#280](https://github.com/npm/hosted-git-info/pull/280) even better regex for host fragment (#280) (@wraithgar) diff --git a/README.md b/README.md index 498e3d28..c5a537ec 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 0c9d0b08..2a7100dc 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/package.json b/package.json index 78356159..a9bb26be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hosted-git-info", - "version": "8.0.2", + "version": "8.1.0", "description": "Provides metadata and conversions from repository urls for GitHub, Bitbucket and GitLab", "main": "./lib/index.js", "repository": { @@ -35,7 +35,7 @@ }, "devDependencies": { "@npmcli/eslint-config": "^5.0.0", - "@npmcli/template-oss": "4.23.4", + "@npmcli/template-oss": "4.24.3", "tap": "^16.0.1" }, "files": [ @@ -55,7 +55,7 @@ }, "templateOSS": { "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.", - "version": "4.23.4", + "version": "4.24.3", "publish": "true" } } diff --git a/release-please-config.json b/release-please-config.json index a1676b9c..c56fd1d8 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -33,5 +33,5 @@ "package-name": "" } }, - "prerelease-type": "pre" + "prerelease-type": "pre.0" } diff --git a/test/file.js b/test/file.js new file mode 100644 index 00000000..2cd2de02 --- /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 6c010682..7d7739b5 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 685bd06a..ffa080cc 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() +})