diff --git a/packages/nx/src/command-line/release/utils/remote-release-clients/extract-repo-slug.spec.ts b/packages/nx/src/command-line/release/utils/remote-release-clients/extract-repo-slug.spec.ts new file mode 100644 index 00000000000..3c2487989ac --- /dev/null +++ b/packages/nx/src/command-line/release/utils/remote-release-clients/extract-repo-slug.spec.ts @@ -0,0 +1,104 @@ +import { + extractGitHubRepoSlug, + extractGitLabRepoSlug, +} from './extract-repo-slug'; + +describe('extractGitHubRepoSlug', () => { + describe('valid GitHub URLs', () => { + it.each([ + ['https://github.com/user/repo.git', 'user/repo'], + ['https://github.com/user/repo', 'user/repo'], + ['git@github.com:user/repo.git', 'user/repo'], + ['git@github.com:user/repo', 'user/repo'], + ['ssh://git@github.com/user/repo.git', 'user/repo'], + ['ssh://git@ssh.github.com:443/user/repo', 'user/repo'], + ['https://user@github.com/user/repo.git', 'user/repo'], + ['https://github.com/user/repo.git?ref=main', 'user/repo'], + ['https://github.com/user/repo.git#readme', 'user/repo'], + ])('parses %s → %s', (url, expected) => { + expect(extractGitHubRepoSlug(url, 'github.com')).toBe(expected); + }); + }); + + describe('invalid/mismatched GitHub URLs', () => { + it.each([ + ['https://gitlab.com/user/repo.git'], + ['git@gitlab.com:user/repo.git'], + ['not-a-url'], + [''], + ['https://github.com/user'], // only 1 segment + ['https://github.com/'], // no segments + ['https://github.com/.git'], + ])('returns null for %s', (url) => { + expect(extractGitHubRepoSlug(url, 'github.com')).toBeNull(); + }); + }); +}); + +describe('extractGitLabRepoSlug', () => { + describe('valid GitLab URLs with subgroups', () => { + it.each([ + ['https://gitlab.com/user/repo.git', 'user/repo'], + ['https://gitlab.com/user/repo', 'user/repo'], + ['https://gitlab.com/group/subgroup/repo.git', 'group/subgroup/repo'], + ['https://gitlab.com/group/subgroup/repo', 'group/subgroup/repo'], + ['git@gitlab.com:group/subgroup/repo.git', 'group/subgroup/repo'], + ['git@gitlab.com:group/subgroup/repo', 'group/subgroup/repo'], + ['ssh://git@gitlab.com/group/subgroup/repo.git', 'group/subgroup/repo'], + [ + 'ssh://git@gitlab.com:22/group/subgroup/repo.git', + 'group/subgroup/repo', + ], + [ + 'https://user@gitlab.com/group/subgroup/repo.git', + 'group/subgroup/repo', + ], + [ + 'https://gitlab.com/group/subgroup/repo.git?ref=main', + 'group/subgroup/repo', + ], + [ + 'https://gitlab.com/group/subgroup/repo.git#readme', + 'group/subgroup/repo', + ], + ])('parses %s → %s', (url, expected) => { + expect(extractGitLabRepoSlug(url, 'gitlab.com')).toBe(expected); + }); + + it('supports deeply nested slugs', () => { + const url = 'https://gitlab.com/org/team/subteam/subproject/project.git'; + expect(extractGitLabRepoSlug(url, 'gitlab.com')).toBe( + 'org/team/subteam/subproject/project' + ); + }); + }); + + describe('invalid/mismatched GitLab URLs', () => { + it.each([ + ['https://github.com/user/repo.git'], + ['git@github.com:user/repo.git'], + ['not-a-url'], + [''], + ['https://gitlab.com/user'], + ['https://gitlab.com/'], + ['https://gitlab.com/.git'], + ])('returns null for %s', (url) => { + expect(extractGitLabRepoSlug(url, 'gitlab.com')).toBeNull(); + }); + }); + + describe('self-hosted GitLab', () => { + it.each([ + ['https://gitlab.company.com/group/repo.git', 'group/repo'], + ['git@gitlab.company.com:group/repo.git', 'group/repo'], + ['ssh://git@gitlab.company.com/group/repo.git', 'group/repo'], + ])('extracts valid repo from %s', (url, expected) => { + expect(extractGitLabRepoSlug(url, 'gitlab.company.com')).toBe(expected); + }); + + it('returns null on host mismatch', () => { + const url = 'https://gitlab.company-a.com/group/repo.git'; + expect(extractGitLabRepoSlug(url, 'gitlab.company-b.com')).toBeNull(); + }); + }); +}); diff --git a/packages/nx/src/command-line/release/utils/remote-release-clients/extract-repo-slug.ts b/packages/nx/src/command-line/release/utils/remote-release-clients/extract-repo-slug.ts new file mode 100644 index 00000000000..612398345bb --- /dev/null +++ b/packages/nx/src/command-line/release/utils/remote-release-clients/extract-repo-slug.ts @@ -0,0 +1,75 @@ +import type { RemoteRepoSlug } from './remote-release-client'; + +/** + * Extracts a GitHub-style repo slug (user/repo). + */ +export function extractGitHubRepoSlug( + remoteUrl: string, + expectedHostname: string +): RemoteRepoSlug | null { + return extractRepoSlug(remoteUrl, expectedHostname, 2); +} + +/** + * Extracts a GitLab-style repo slug with full nested group path. + */ +export function extractGitLabRepoSlug( + remoteUrl: string, + expectedHostname: string +): RemoteRepoSlug | null { + return extractRepoSlug(remoteUrl, expectedHostname, Infinity); +} + +const SCP_URL_REGEX = /^git@([^:]+):(.+)$/; + +/** + * Extracts a repository slug from a Git remote URL. + * `segmentLimit` = 2 for GitHub (user/repo), `Infinity` for GitLab (with subgroups). + */ +function extractRepoSlug( + remoteUrl: string, + expectedHostname: string, + segmentLimit: number +): RemoteRepoSlug | null { + if (!remoteUrl) return null; + + // SCP-like: git@host:path + const scpMatch = remoteUrl.match(SCP_URL_REGEX); + if (scpMatch) { + const [, host, path] = scpMatch; + if (!isHostMatch(host, expectedHostname)) return null; + + const segments = normalizeRepoPath(path).split('/').filter(Boolean); + if (segments.length < 2) return null; + + return segments.slice(0, segmentLimit).join('/') as RemoteRepoSlug; + } + + // URL-like + try { + const url = new URL(remoteUrl); + if (!isHostMatch(url.hostname, expectedHostname)) return null; + + const segments = normalizeRepoPath(url.pathname).split('/').filter(Boolean); + if (segments.length < 2) return null; + + return segments.slice(0, segmentLimit).join('/') as RemoteRepoSlug; + } catch { + return null; + } +} + +function normalizeRepoPath(s: string): string { + return s.replace(/^\/+|\/+$|\.git$/g, ''); +} + +function normalizeHostname(hostname: string): string { + return hostname + .toLowerCase() + .replace(/^ssh\./, '') + .split(':')[0]; +} + +function isHostMatch(actual: string, expected: string): boolean { + return normalizeHostname(actual) === normalizeHostname(expected); +} diff --git a/packages/nx/src/command-line/release/utils/remote-release-clients/github.ts b/packages/nx/src/command-line/release/utils/remote-release-clients/github.ts index 8b980051c4f..ba5ab7dc55e 100644 --- a/packages/nx/src/command-line/release/utils/remote-release-clients/github.ts +++ b/packages/nx/src/command-line/release/utils/remote-release-clients/github.ts @@ -9,12 +9,12 @@ import type { PostGitTask } from '../../changelog'; import { type ResolvedCreateRemoteReleaseProvider } from '../../config/config'; import { Reference } from '../git'; import { ReleaseVersion } from '../shared'; +import { extractGitHubRepoSlug } from './extract-repo-slug'; import { RemoteReleaseClient, RemoteReleaseOptions, RemoteReleaseResult, RemoteRepoData, - RemoteRepoSlug, } from './remote-release-client'; // axios types and values don't seem to match @@ -66,29 +66,22 @@ export class GithubRemoteReleaseClient extends RemoteReleaseClient