diff --git a/app/package.json b/app/package.json index 59d49e5c6..57eb5790e 100644 --- a/app/package.json +++ b/app/package.json @@ -58,6 +58,7 @@ "@types/diff": "^5.2.1", "@types/diff-match-patch": "^1.0.36", "@types/eslint__js": "^8.42.3", + "@types/git-url-parse": "^9.0.3", "@types/lscache": "^1.3.4", "@types/marked": "^5.0.2", "@typescript-eslint/parser": "^7.13.1", @@ -72,6 +73,7 @@ "eslint-plugin-import-x": "^0.5.1", "eslint-plugin-square-svelte-store": "^1.0.0", "eslint-plugin-svelte": "2.40.0", + "git-url-parse": "^14.0.0", "globals": "^15.6.0", "inter-ui": "^4.0.2", "leven": "^4.0.0", diff --git a/app/src/lib/github/service.test.ts b/app/src/lib/github/service.test.ts new file mode 100644 index 000000000..93a7a99bd --- /dev/null +++ b/app/src/lib/github/service.test.ts @@ -0,0 +1,42 @@ +import { GitHubService } from './service'; +import { BehaviorSubject } from 'rxjs'; +import { expect, test, describe } from 'vitest'; + +const exampleRemoteUrls = [ + 'ssh://user@host.xz:123/org/repo.git/', + 'ssh://user@host.xz/org/repo.git/', + 'ssh://host.xz:123/org/repo.git/', + 'ssh://host.xz:123/org/repo', + 'ssh://host.xz/org/repo.git/', + 'ssh://host.xz/org/repo.git', + 'ssh://host.xz/org/repo', + 'ssh://user@host.xz/org/repo.git/', + 'ssh://user@host.xz/org/repo.git', + 'ssh://user@host.xz/org/repo', + 'host.xz:org/repo.git/', + 'host.xz:org/repo.git', + 'host.xz:org/repo', + 'user@host.xz:org/repo.git/', + 'user@host.xz:org/repo.git', + 'user@host.xz:org/repo', + 'git@github.com:org/repo.git/', + 'git@github.com:org/repo.git', + 'git@github.com:org/repo', + 'https://github.com/org/repo.git/', + 'https://github.com/org/repo.git', + 'https://github.com/org/repo' +]; + +describe.concurrent('GitHubService', () => { + describe.concurrent('parse GitHub remote URL', () => { + test.each(exampleRemoteUrls)('%s', (remoteUrl) => { + const accessToken$ = new BehaviorSubject('token'); + const remoteUrl$ = new BehaviorSubject(remoteUrl); + + const githubService = new GitHubService(accessToken$, remoteUrl$); + + expect(githubService.owner).toBe('org'); + expect(githubService.repo).toBe('repo'); + }); + }); +}); diff --git a/app/src/lib/github/service.ts b/app/src/lib/github/service.ts index 6630bb248..34c57ce74 100644 --- a/app/src/lib/github/service.ts +++ b/app/src/lib/github/service.ts @@ -12,6 +12,7 @@ import { showError, showToast, type Toast } from '$lib/notifications/toasts'; import { sleep } from '$lib/utils/sleep'; import * as toasts from '$lib/utils/toasts'; import { Octokit } from '@octokit/rest'; +import GitUrlParse from 'git-url-parse'; import lscache from 'lscache'; import posthog from 'posthog-js'; import { @@ -78,8 +79,8 @@ export class GitHubService { baseUrl: 'https://api.github.com' }); if (remoteUrl) { - const [owner, repo] = remoteUrl.split(/.git$/)[0].split(/\/|:/).slice(-2); - this._repo = repo; + const { owner, name } = GitUrlParse(remoteUrl); + this._repo = name; this._owner = owner; } }), diff --git a/app/src/lib/utils/url.test.ts b/app/src/lib/utils/url.test.ts index ec5ae7cc7..47c4ddb9e 100644 --- a/app/src/lib/utils/url.test.ts +++ b/app/src/lib/utils/url.test.ts @@ -1,4 +1,4 @@ -import { convertRemoteToWebUrl } from '$lib/utils/url'; +import { remoteUrlIsHttp, convertRemoteToWebUrl } from '$lib/utils/url'; import { describe, expect, test } from 'vitest'; describe.concurrent('cleanUrl', () => { @@ -25,4 +25,20 @@ describe.concurrent('cleanUrl', () => { 'https://github.com/user/repo' ); }); + + const httpRemoteUrls = ['https://github.com/user/repo.git', 'http://192.168.1.1/user/repo.git']; + + test.each(httpRemoteUrls)('HTTP Remote - %s', (remoteUrl) => { + expect(remoteUrlIsHttp(remoteUrl)).toBe(true); + }); + + const nonHttpRemoteUrls = [ + 'git@github.com:user/repo.git', + 'ssh://git@github.com:22/user/repo.git', + 'git://github.com/user/repo.git' + ]; + + test.each(nonHttpRemoteUrls)('Non HTTP Remote - %s', (remoteUrl) => { + expect(remoteUrlIsHttp(remoteUrl)).toBe(false); + }); }); diff --git a/app/src/lib/utils/url.ts b/app/src/lib/utils/url.ts index 5c9725b5b..c41c0adc2 100644 --- a/app/src/lib/utils/url.ts +++ b/app/src/lib/utils/url.ts @@ -1,5 +1,6 @@ import { showToast } from '$lib/notifications/toasts'; import { open } from '@tauri-apps/api/shell'; +import GitUrlParse from 'git-url-parse'; import { posthog } from 'posthog-js'; export function openExternalUrl(href: string) { @@ -25,20 +26,16 @@ export function openExternalUrl(href: string) { // turn a git remote url into a web url (github, gitlab, bitbucket, etc) export function convertRemoteToWebUrl(url: string): string { - if (url.startsWith('http')) { - return url.replace('.git', '').trim(); - } else if (url.startsWith('ssh')) { - url = url.replace('ssh://git@', ''); - const [host, ...paths] = url.split('/'); - const path = paths.join('/').replace('.git', ''); - const protocol = /\d+\.\d+\.\d+\.\d+/.test(host) ? 'http' : 'https'; - const [hostname, _port] = host.split(':'); - return `${protocol}://${hostname}/${path}`; - } else { - return url.replace(':', '/').replace('git@', 'https://').replace('.git', '').trim(); - } + const gitRemote = GitUrlParse(url); + const ipv4Regex = new RegExp(/^([0-9]+(\.|$)){4}/); + const protocol = ipv4Regex.test(gitRemote.resource) ? 'http' : 'https'; + + return `${protocol}://${gitRemote.resource}/${gitRemote.owner}/${gitRemote.name}`; } export function remoteUrlIsHttp(url: string): boolean { - return url.startsWith('http'); + const httpProtocols = ['http', 'https']; + const gitRemote = GitUrlParse(url); + + return httpProtocols.includes(gitRemote.protocol); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6487d7f28..a4ad1c82a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: '@types/eslint__js': specifier: ^8.42.3 version: 8.42.3 + '@types/git-url-parse': + specifier: ^9.0.3 + version: 9.0.3 '@types/lscache': specifier: ^1.3.4 version: 1.3.4 @@ -156,6 +159,9 @@ importers: eslint-plugin-svelte: specifier: 2.40.0 version: 2.40.0(eslint@9.5.0)(svelte@5.0.0-next.149) + git-url-parse: + specifier: ^14.0.0 + version: 14.0.0 globals: specifier: ^15.6.0 version: 15.6.0 @@ -1312,6 +1318,9 @@ packages: '@types/express@4.17.21': resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + '@types/git-url-parse@9.0.3': + resolution: {integrity: sha512-Wrb8zeghhpKbYuqAOg203g+9YSNlrZWNZYvwxJuDF4dTmerijqpnGbI79yCuPtHSXHPEwv1pAFUB4zsSqn82Og==} + '@types/http-assert@1.5.5': resolution: {integrity: sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g==} @@ -2091,6 +2100,12 @@ packages: get-tsconfig@4.7.5: resolution: {integrity: sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==} + git-up@7.0.0: + resolution: {integrity: sha512-ONdIrbBCFusq1Oy0sC71F5azx8bVkvtZtMJAsv+a6lz5YAmbNnLD6HAB4gptHZVLPR8S2/kVN6Gab7lryq5+lQ==} + + git-url-parse@14.0.0: + resolution: {integrity: sha512-NnLweV+2A4nCvn4U/m2AoYu0pPKlsmhK9cknG7IMwsjFY1S2jxM+mAhsDxyxfCIGfGaD+dozsyX4b6vkYc83yQ==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2318,6 +2333,9 @@ packages: resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} engines: {node: '>= 0.4'} + is-ssh@1.4.0: + resolution: {integrity: sha512-x7+VxdxOdlV3CYpjvRLBv5Lo9OJerlYanjwFrPR9fuGPjCiNiCzFgAWpiLAohSbsnH4ZAys3SBh+hq5rJosxUQ==} + is-string@1.0.7: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} @@ -2625,6 +2643,12 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-path@7.0.0: + resolution: {integrity: sha512-Euf9GG8WT9CdqwuWJGdf3RkUcTBArppHABkO7Lm8IzRQp0e2r/kkFnmhu4TSK30Wcu5rVAZLmfPKSBBi9tWFog==} + + parse-url@8.1.0: + resolution: {integrity: sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2789,6 +2813,9 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + protocols@2.0.1: + resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -4621,6 +4648,8 @@ snapshots: '@types/qs': 6.9.15 '@types/serve-static': 1.15.7 + '@types/git-url-parse@9.0.3': {} + '@types/http-assert@1.5.5': {} '@types/http-errors@2.0.4': {} @@ -5590,6 +5619,15 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + git-up@7.0.0: + dependencies: + is-ssh: 1.4.0 + parse-url: 8.1.0 + + git-url-parse@14.0.0: + dependencies: + git-up: 7.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5822,6 +5860,10 @@ snapshots: dependencies: call-bind: 1.0.7 + is-ssh@1.4.0: + dependencies: + protocols: 2.0.1 + is-string@1.0.7: dependencies: has-tostringtag: 1.0.2 @@ -6099,6 +6141,14 @@ snapshots: dependencies: callsites: 3.1.0 + parse-path@7.0.0: + dependencies: + protocols: 2.0.1 + + parse-url@8.1.0: + dependencies: + parse-path: 7.0.0 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -6223,6 +6273,8 @@ snapshots: progress@2.0.3: {} + protocols@2.0.1: {} + proxy-from-env@1.1.0: {} punycode@2.3.0: {}