| author | |
| committer | |
| log | 7ce4194707e2a883ccd6a44fd2e07eba08f5369a |
| tree | 5d3a3a5e817182faddd694a7246987d93e328bbc |
| parent | dc0ff8f13eaa9753fdf0de60203c9e819f1cee27 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 72 insertions(+), 16 deletions(-)
apps/server/src/git/GitManager.ts+2-2| ... | @@ -42,6 +42,7 @@ import { | ... | @@ -42,6 +42,7 @@ import { |
| 42 | } from "@t3tools/shared/git"; | 42 | } from "@t3tools/shared/git"; |
| 43 | import { | 43 | import { |
| 44 | getChangeRequestTerminologyForKind, | 44 | getChangeRequestTerminologyForKind, |
| 45 | isSshRemoteUrl, | ||
| 45 | type ChangeRequestTerminology, | 46 | type ChangeRequestTerminology, |
| 46 | } from "@t3tools/shared/sourceControl"; | 47 | } from "@t3tools/shared/sourceControl"; |
| 47 | 48 | ||
| ... | @@ -574,8 +575,7 @@ function toResolvedPullRequest(pr: { | ... | @@ -574,8 +575,7 @@ function toResolvedPullRequest(pr: { |
| 574 | 575 | ||
| 575 | function shouldPreferSshRemote(url: string | null): boolean { | 576 | function shouldPreferSshRemote(url: string | null): boolean { |
| 576 | if (!url) return false; | 577 | if (!url) return false; |
| 577 | const trimmed = url.trim(); | 578 | return isSshRemoteUrl(url); |
| 578 | return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); | ||
| 579 | } | 579 | } |
| 580 | 580 | ||
| 581 | function toPullRequestHeadRemoteInfo(pr: { | 581 | function toPullRequestHeadRemoteInfo(pr: { |
apps/server/src/sourceControl/BitbucketApi.ts+9-6| ... | @@ -14,7 +14,10 @@ import { | ... | @@ -14,7 +14,10 @@ import { |
| 14 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 15 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | 15 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 16 | import { sanitizeBranchFragment } from "@t3tools/shared/git"; | 16 | import { sanitizeBranchFragment } from "@t3tools/shared/git"; |
| 17 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | 17 | import { |
| 18 | detectSourceControlProviderFromRemoteUrl, | ||
| 19 | isSshRemoteUrl, | ||
| 20 | } from "@t3tools/shared/sourceControl"; | ||
| 18 | 21 | ||
| 19 | import { | 22 | import { |
| 20 | BitbucketPullRequestListSchema, | 23 | BitbucketPullRequestListSchema, |
| ... | @@ -448,9 +451,9 @@ function requireRepositoryLocator( | ... | @@ -448,9 +451,9 @@ function requireRepositoryLocator( |
| 448 | 451 | ||
| 449 | function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { | 452 | function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { |
| 450 | const trimmed = remoteUrl.trim(); | 453 | const trimmed = remoteUrl.trim(); |
| 451 | if (trimmed.startsWith("git@")) { | 454 | const scpMatch = /^[a-zA-Z0-9._-]+@[^:/]+:(.+)$/.exec(trimmed); |
| 452 | const pathStart = trimmed.indexOf(":"); | 455 | if (scpMatch?.[1]) { |
| 453 | return pathStart < 0 ? null : parseBitbucketRepositorySlug(trimmed.slice(pathStart + 1)); | 456 | return parseBitbucketRepositorySlug(scpMatch[1]); |
| 454 | } | 457 | } |
| 455 | 458 | ||
| 456 | try { | 459 | try { |
| ... | @@ -494,8 +497,8 @@ function defaultChangeRequestTargetBranch(input: { | ... | @@ -494,8 +497,8 @@ function defaultChangeRequestTargetBranch(input: { |
| 494 | } | 497 | } |
| 495 | 498 | ||
| 496 | function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { | 499 | function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { |
| 497 | const trimmed = originRemoteUrl?.trim() ?? ""; | 500 | if (!originRemoteUrl) return false; |
| 498 | return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); | 501 | return isSshRemoteUrl(originRemoteUrl); |
| 499 | } | 502 | } |
| 500 | 503 | ||
| 501 | function selectCloneUrl(input: { | 504 | function selectCloneUrl(input: { |
packages/shared/src/git.test.ts+9| ... | @@ -40,6 +40,15 @@ describe("normalizeGitRemoteUrl", () => { | ... | @@ -40,6 +40,15 @@ describe("normalizeGitRemoteUrl", () => { |
| 40 | "gitlab.company.com/team/project", | 40 | "gitlab.company.com/team/project", |
| 41 | ); | 41 | ); |
| 42 | }); | 42 | }); |
| 43 | |||
| 44 | it("normalizes SCP-like remotes with non-git SSH users", () => { | ||
| 45 | expect(normalizeGitRemoteUrl("gitlab@gitlab.example.com:group/project.git")).toBe( | ||
| 46 | "gitlab.example.com/group/project", | ||
| 47 | ); | ||
| 48 | expect(normalizeGitRemoteUrl("deploy@bitbucket.org:workspace/repo.git")).toBe( | ||
| 49 | "bitbucket.org/workspace/repo", | ||
| 50 | ); | ||
| 51 | }); | ||
| 43 | }); | 52 | }); |
| 44 | 53 | ||
| 45 | describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { | 54 | describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { |
packages/shared/src/git.ts+3-1| ... | @@ -133,7 +133,9 @@ export function normalizeGitRemoteUrl(value: string): string { | ... | @@ -133,7 +133,9 @@ export function normalizeGitRemoteUrl(value: string): string { |
| 133 | } | 133 | } |
| 134 | } | 134 | } |
| 135 | 135 | ||
| 136 | const scpStyleHostAndPath = /^git@([^:/\s]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); | 136 | const scpStyleHostAndPath = /^[a-zA-Z0-9._-]+@([^:/\s]+):([^/\s]+(?:\/[^/\s]+)+)$/i.exec( |
| 137 | normalized, | ||
| 138 | ); | ||
| 137 | if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { | 139 | if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { |
| 138 | return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; | 140 | return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; |
| 139 | } | 141 | } |
packages/shared/src/sourceControl.test.ts+39| ... | @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { | 3 | import { |
| 4 | detectSourceControlProviderFromRemoteUrl, | 4 | detectSourceControlProviderFromRemoteUrl, |
| 5 | getChangeRequestTerminologyForKind, | 5 | getChangeRequestTerminologyForKind, |
| 6 | isSshRemoteUrl, | ||
| 6 | resolveChangeRequestPresentation, | 7 | resolveChangeRequestPresentation, |
| 7 | } from "./sourceControl.ts"; | 8 | } from "./sourceControl.ts"; |
| 8 | 9 | ||
| ... | @@ -120,4 +121,42 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { | ... | @@ -120,4 +121,42 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { |
| 120 | )?.kind, | 121 | )?.kind, |
| 121 | ).toBe("unknown"); | 122 | ).toBe("unknown"); |
| 122 | }); | 123 | }); |
| 124 | |||
| 125 | it("detects SSH remotes with non-git SSH users (e.g. gitlab@, deploy@)", () => { | ||
| 126 | expect( | ||
| 127 | detectSourceControlProviderFromRemoteUrl("gitlab@gitlab.example.com:group/project.git")?.kind, | ||
| 128 | ).toBe("gitlab"); | ||
| 129 | expect( | ||
| 130 | detectSourceControlProviderFromRemoteUrl("gitlab@gitlab.example.com:group/project.git") | ||
| 131 | ?.baseUrl, | ||
| 132 | ).toBe("https://gitlab.example.com"); | ||
| 133 | expect(detectSourceControlProviderFromRemoteUrl("deploy@github.com:owner/repo.git")?.kind).toBe( | ||
| 134 | "github", | ||
| 135 | ); | ||
| 136 | expect( | ||
| 137 | detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind, | ||
| 138 | ).toBe("bitbucket"); | ||
| 139 | }); | ||
| 140 | }); | ||
| 141 | |||
| 142 | describe("isSshRemoteUrl", () => { | ||
| 143 | it("recognises SCP-like SSH URLs with any SSH user prefix", () => { | ||
| 144 | expect(isSshRemoteUrl("git@github.com:owner/repo.git")).toBe(true); | ||
| 145 | expect(isSshRemoteUrl("gitlab@gitlab.example.com:group/project.git")).toBe(true); | ||
| 146 | expect(isSshRemoteUrl("deploy@bitbucket.org:workspace/repo.git")).toBe(true); | ||
| 147 | }); | ||
| 148 | |||
| 149 | it("recognises ssh:// URLs with any case", () => { | ||
| 150 | expect(isSshRemoteUrl("ssh://git@gitlab.example.com/group/project.git")).toBe(true); | ||
| 151 | expect(isSshRemoteUrl("ssh://git@gitlab.example.com:22/group/project.git")).toBe(true); | ||
| 152 | expect(isSshRemoteUrl("SSH://git@gitlab.example.com/group/project.git")).toBe(true); | ||
| 153 | expect(isSshRemoteUrl("SsH://git@gitlab.example.com/group/project.git")).toBe(true); | ||
| 154 | }); | ||
| 155 | |||
| 156 | it("returns false for HTTPS, local paths, and SCP-like paths without a colon", () => { | ||
| 157 | expect(isSshRemoteUrl("https://gitlab.example.com/group/project.git")).toBe(false); | ||
| 158 | expect(isSshRemoteUrl("/home/user/repos/project")).toBe(false); | ||
| 159 | expect(isSshRemoteUrl("")).toBe(false); | ||
| 160 | expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false); | ||
| 161 | }); | ||
| 123 | }); | 162 | }); |
packages/shared/src/sourceControl.ts+10-7| ... | @@ -133,19 +133,22 @@ export function getChangeRequestTerminologyForKind( | ... | @@ -133,19 +133,22 @@ export function getChangeRequestTerminologyForKind( |
| 133 | }; | 133 | }; |
| 134 | } | 134 | } |
| 135 | 135 | ||
| 136 | const SCP_SSH_REMOTE_PATTERN = /^[a-zA-Z0-9._-]+@([^:/]+):/; | ||
| 137 | |||
| 138 | export function isSshRemoteUrl(remoteUrl: string): boolean { | ||
| 139 | const trimmed = remoteUrl.trim(); | ||
| 140 | return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://"); | ||
| 141 | } | ||
| 142 | |||
| 136 | function parseRemoteHost(remoteUrl: string): string | null { | 143 | function parseRemoteHost(remoteUrl: string): string | null { |
| 137 | const trimmed = remoteUrl.trim(); | 144 | const trimmed = remoteUrl.trim(); |
| 138 | if (trimmed.length === 0) { | 145 | if (trimmed.length === 0) { |
| 139 | return null; | 146 | return null; |
| 140 | } | 147 | } |
| 141 | 148 | ||
| 142 | if (trimmed.startsWith("git@")) { | 149 | const scpMatch = SCP_SSH_REMOTE_PATTERN.exec(trimmed); |
| 143 | const hostWithPath = trimmed.slice("git@".length); | 150 | if (scpMatch?.[1]) { |
| 144 | const separatorIndex = hostWithPath.search(/[:/]/); | 151 | return scpMatch[1].toLowerCase(); |
| 145 | if (separatorIndex <= 0) { | ||
| 146 | return null; | ||
| 147 | } | ||
| 148 | return hostWithPath.slice(0, separatorIndex).toLowerCase(); | ||
| 149 | } | 152 | } |
| 150 | 153 | ||
| 151 | try { | 154 | try { |