Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,29 +66,22 @@ export class GithubRemoteReleaseClient extends RemoteReleaseClient<GithubRemoteR
createReleaseConfig !== false &&
typeof createReleaseConfig !== 'string'
) {
hostname = createReleaseConfig.hostname;
hostname = createReleaseConfig.hostname || hostname;
apiBaseUrl = createReleaseConfig.apiBaseUrl;
}

// Extract the 'user/repo' part from the URL
const escapedHostname = hostname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regexString = `${escapedHostname}[/:]([\\w.-]+/[\\w.-]+)(\\.git)?`;
const regex = new RegExp(regexString);
const match = remoteUrl.match(regex);

if (match && match[1]) {
return {
hostname,
apiBaseUrl,
// Ensure any trailing .git is stripped
slug: match[1].replace(/\.git$/, '') as RemoteRepoSlug,
};
const slug = extractGitHubRepoSlug(remoteUrl, hostname);
if (slug) {
return { hostname, apiBaseUrl, slug };
} else {
throw new Error(
`Could not extract "user/repo" data from the resolved remote URL: ${remoteUrl}`
);
}
} catch (error) {
if (process.env.NX_VERBOSE_LOGGING === 'true') {
console.error(error);
}
return null;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import type { PostGitTask } from '../../changelog';
import type { ResolvedCreateRemoteReleaseProvider } from '../../config/config';
import type { Reference } from '../git';
import { ReleaseVersion } from '../shared';
import { extractGitLabRepoSlug } from './extract-repo-slug';
import {
RemoteReleaseClient,
RemoteReleaseOptions,
RemoteReleaseResult,
RemoteRepoData,
RemoteRepoSlug,
} from './remote-release-client';

export interface GitLabRepoData extends RemoteRepoData {
Expand Down Expand Up @@ -64,28 +64,18 @@ export class GitLabRemoteReleaseClient extends RemoteReleaseClient<GitLabRelease
// Use the default provider if custom one is not specified or releases are disabled
let hostname = defaultCreateReleaseProvider.hostname;
let apiBaseUrl = defaultCreateReleaseProvider.apiBaseUrl;

if (
createReleaseConfig !== false &&
typeof createReleaseConfig !== 'string'
) {
hostname = createReleaseConfig.hostname || hostname;
apiBaseUrl = createReleaseConfig.apiBaseUrl || apiBaseUrl;
apiBaseUrl = createReleaseConfig.apiBaseUrl;
}

// Extract the project path from the URL
const escapedHostname = hostname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regexString = `${escapedHostname}[/:]([\\w.-]+/[\\w.-]+(?:/[\\w.-]+)*)(\\.git)?`;
const regex = new RegExp(regexString);
const match = remoteUrl.match(regex);

if (match && match[1]) {
// Remove trailing .git if present
const slug = match[1].replace(/\.git$/, '') as RemoteRepoSlug;

const slug = extractGitLabRepoSlug(remoteUrl, hostname);
if (slug) {
// Encode the project path for use in API URLs
const projectId = encodeURIComponent(slug);

return {
hostname,
apiBaseUrl,
Expand Down
Loading