Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support self-hosted git repositories #417

Merged
merged 3 commits into from
Oct 31, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 10 additions & 0 deletions docs/configuration/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ Supported hosts:
repository: https://github.com/invertase/melos
```

When using a self-hosted GitHub or GitLab instance, you can specify the repository location like this:

```yaml
repository:
type: gitlab
base: https://gitlab.example.dev
Almighty-Alpaca marked this conversation as resolved.
Show resolved Hide resolved
owner: invertase
name: melos
```

## `sdkPath`

> optional
Expand Down
53 changes: 48 additions & 5 deletions packages/melos/lib/src/common/git_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,14 @@ mixin SupportsManualRelease on HostedGitRepository {
@immutable
class GitHubRepository extends HostedGitRepository with SupportsManualRelease {
const GitHubRepository({
this.base = kDefaultBase,
required this.owner,
required this.name,
});

factory GitHubRepository.fromUrl(Uri uri) {
if (uri.scheme == 'https' && uri.host == 'github.com') {
final match = RegExp(r'^\/(.+)\/(.+)\/?$').firstMatch(uri.path);
final match = RegExp(r'^/(.+)/(.+)/?$').firstMatch(uri.path);
if (match != null) {
return GitHubRepository(
owner: match.group(1)!,
Expand All @@ -89,14 +90,19 @@ class GitHubRepository extends HostedGitRepository with SupportsManualRelease {
throw FormatException('The URL $uri is not a valid GitHub repository URL.');
}

static const kDefaultBase = 'https://github.com';
Almighty-Alpaca marked this conversation as resolved.
Show resolved Hide resolved

/// The base of the GitHub server, defaults to `https://github.com`.
final String base;
Almighty-Alpaca marked this conversation as resolved.
Show resolved Hide resolved

/// The username of the owner of this repository.
final String owner;

@override
final String name;

@override
Uri get url => Uri.parse('https://github.com/$owner/$name/');
Uri get url => Uri.parse('$base/$owner/$name/');

@override
Uri commitUrl(String id) => url.resolve('commit/$id');
Expand Down Expand Up @@ -146,13 +152,14 @@ GitHubRepository(
@immutable
class GitLabRepository extends HostedGitRepository {
GitLabRepository({
String base = kDefaultBase,
required this.owner,
required this.name,
});
}) : base = base.endsWith('/') ? base.substring(0, base.length - 1) : base;

factory GitLabRepository.fromUrl(Uri uri) {
if (uri.scheme == 'https' && uri.host == 'gitlab.com') {
final match = RegExp(r'^\/((?:.+[\/]?))?\/(.+)\/?$').firstMatch(uri.path);
final match = RegExp(r'^/(.+)?/(.+)/?$').firstMatch(uri.path);
if (match != null) {
return GitLabRepository(
owner: match.group(1)!,
Expand All @@ -164,14 +171,19 @@ class GitLabRepository extends HostedGitRepository {
throw FormatException('The URL $uri is not a valid GitLab repository URL.');
}

static const kDefaultBase = 'https://gitlab.com';

/// The base of the GitLab server, defaults to `https://gitlab.com`.
final String base;
Almighty-Alpaca marked this conversation as resolved.
Show resolved Hide resolved

/// The username of the owner of this repository.
final String owner;

@override
final String name;

@override
Uri get url => Uri.parse('https://gitlab.com/$owner/$name/');
Uri get url => Uri.parse('$base/$owner/$name/');

@override
Uri commitUrl(String id) => url.resolve('-/commit/$id');
Expand Down Expand Up @@ -205,6 +217,15 @@ final _hostsToUrlParser = {
'GitLab': (Uri url) => GitLabRepository.fromUrl(url),
};

final _hostsToSpecParser = {
'GitHub': (String base, String owner, String name) {
return GitHubRepository(base: base, owner: owner, name: name);
},
'GitLab': (String base, String owner, String name) {
return GitLabRepository(base: base, owner: owner, name: name);
},
};

/// Tries to parse [url] into a [HostedGitRepository].
///
/// Throws a [FormatException] it the given [url] cannot be parsed into an URL
Expand All @@ -222,3 +243,25 @@ HostedGitRepository parseHostedGitRepositoryUrl(Uri url) {
'hosts: ${_hostsToUrlParser.keys.join(', ')}',
);
}

/// Tries to find a [HostedGitRepository] for [type].
///
/// Throws a [FormatException] it the given [type] is not one of the supported
/// git repository host types.
HostedGitRepository parseHostedGitRepositorySpec(
String type,
String base,
String owner,
String name,
) {
for (final entry in _hostsToSpecParser.entries) {
if (entry.key.toLowerCase() == type.toLowerCase()) {
return entry.value(base, owner, name);
}
}

throw FormatException(
'$type is not a valid type for a repository on any of the supported '
'hosts: ${_hostsToSpecParser.keys.join(', ')}',
);
}
61 changes: 47 additions & 14 deletions packages/melos/lib/src/workspace_configs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -473,22 +473,55 @@ class MelosWorkspaceConfig {
}

HostedGitRepository? repository;
final repositoryUrlString =
assertKeyIsA<String?>(key: 'repository', map: yaml);
if (repositoryUrlString != null) {
Uri repositoryUrl;
try {
repositoryUrl = Uri.parse(repositoryUrlString);
} on FormatException catch (e) {
throw MelosConfigException(
'The repository URL $repositoryUrlString is not a valid URL:\n $e',
if (yaml.containsKey('repository')) {
final repositoryYaml = yaml['repository'];

if (repositoryYaml is Map<Object?, Object?>) {
final type = assertKeyIsA<String>(
key: 'type',
map: repositoryYaml,
path: 'repository',
);
final base = assertKeyIsA<String>(
key: 'base',
map: repositoryYaml,
path: 'repository',
);
final owner = assertKeyIsA<String>(
key: 'owner',
map: repositoryYaml,
path: 'repository',
);
final name = assertKeyIsA<String>(
key: 'name',
map: repositoryYaml,
path: 'repository',
);
}

try {
repository = parseHostedGitRepositoryUrl(repositoryUrl);
} on FormatException catch (e) {
throw MelosConfigException(e.toString());
try {
repository = parseHostedGitRepositorySpec(type, base, owner, name);
} on FormatException catch (e) {
throw MelosConfigException(e.toString());
}
} else if (repositoryYaml is String) {
Uri repositoryUrl;
try {
repositoryUrl = Uri.parse(repositoryYaml);
} on FormatException catch (e) {
throw MelosConfigException(
'The repository URL $repositoryYaml is not a valid URL:\n $e',
);
}

try {
repository = parseHostedGitRepositoryUrl(repositoryUrl);
} on FormatException catch (e) {
throw MelosConfigException(e.toString());
}
} else if (repositoryYaml != null) {
throw MelosConfigException(
'The repository value must be a string or repository spec',
);
}
}

Expand Down
126 changes: 126 additions & 0 deletions packages/melos/test/git_repository_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ void main() {
test('parse GitHub repository URL correctly', () {
final url = Uri.parse('https://github.com/a/b');
final repo = GitHubRepository.fromUrl(url);

expect(repo.base, 'https://github.com');
expect(repo.owner, 'a');
expect(repo.name, 'b');
expect(repo.url, Uri.parse('https://github.com/a/b/'));
Expand All @@ -50,6 +52,34 @@ void main() {
});
});

group('fromSpec', () {
test('parse GitHub repository spec correctly', () {
const repo = GitHubRepository(
base: 'https://github.invertase.dev',
owner: 'a',
name: 'b',
);

expect(repo.base, 'https://github.invertase.dev');
expect(repo.owner, 'a');
expect(repo.name, 'b');
expect(repo.url, Uri.parse('https://github.invertase.dev/a/b/'));
});

test('parse GitHub repository spec with sub-path correctly', () {
const repo = GitHubRepository(
base: 'https://invertase.dev/github',
owner: 'a',
name: 'b',
);

expect(repo.base, 'https://invertase.dev/github');
expect(repo.owner, 'a');
expect(repo.name, 'b');
expect(repo.url, Uri.parse('https://invertase.dev/github/a/b/'));
});
});

test('commitUrl returns correct URL', () {
const repo = GitHubRepository(owner: 'a', name: 'b');
const commitId = 'b2841394a48cd7d84a4966a788842690e543b2ef';
Expand Down Expand Up @@ -78,6 +108,8 @@ void main() {
test('parse GitLab repository URL correctly', () {
final url = Uri.parse('https://gitlab.com/a/b');
final repo = GitLabRepository.fromUrl(url);

expect(repo.base, 'https://gitlab.com');
expect(repo.owner, 'a');
expect(repo.name, 'b');
expect(repo.url, Uri.parse('https://gitlab.com/a/b/'));
Expand All @@ -86,6 +118,8 @@ void main() {
test('parse GitLab repository URL with nested groups correctly', () {
final url = Uri.parse('https://gitlab.com/a/b/c');
final repo = GitLabRepository.fromUrl(url);

expect(repo.base, 'https://gitlab.com');
expect(repo.owner, 'a/b');
expect(repo.name, 'c');
expect(repo.url, Uri.parse('https://gitlab.com/a/b/c/'));
Expand All @@ -112,6 +146,62 @@ void main() {
});
});

group('fromSpec', () {
test('parse GitLab repository spec correctly', () {
final repo = GitLabRepository(
base: 'https://gitlab.invertase.dev',
owner: 'a',
name: 'b',
);

expect(repo.base, 'https://gitlab.invertase.dev');
expect(repo.owner, 'a');
expect(repo.name, 'b');
expect(repo.url, Uri.parse('https://gitlab.invertase.dev/a/b/'));
});

test('parse GitLab repository spec with sub-path correctly', () {
final repo = GitLabRepository(
base: 'https://invertase.dev/gitlab',
owner: 'a',
name: 'b',
);

expect(repo.base, 'https://invertase.dev/gitlab');
expect(repo.owner, 'a');
expect(repo.name, 'b');
expect(repo.url, Uri.parse('https://invertase.dev/gitlab/a/b/'));
});

test('parse GitLab repository spec with nested groups correctly', () {
final repo = GitLabRepository(
base: 'https://gitlab.invertase.dev',
owner: 'a/b',
name: 'c',
);

expect(repo.base, 'https://gitlab.invertase.dev');
expect(repo.owner, 'a/b');
expect(repo.name, 'c');
expect(repo.url, Uri.parse('https://gitlab.invertase.dev/a/b/c/'));
});

test(
'parse GitLab repository spec with sub-path and nested groups '
'correctly', () {
final repo = GitLabRepository(
base: 'https://invertase.dev/gitlab',
owner: 'a/b',
name: 'c',
);

expect(repo.base, 'https://invertase.dev/gitlab');
expect(repo.owner, 'a/b');
expect(repo.name, 'c');
expect(repo.url, Uri.parse('https://invertase.dev/gitlab/a/b/c/'));
});
});

test('commitUrl returns correct URL', () {
final repo = GitLabRepository(owner: 'a', name: 'b');
const commitId = 'b2841394a48cd7d84a4966a788842690e543b2ef';
Expand Down Expand Up @@ -155,4 +245,40 @@ void main() {
);
});
});

group('parseHostedGitRepositorySpec', () {
test('parses GitHub repository spec', () {
final repo = parseHostedGitRepositorySpec(
'github',
'https://github.invertase.com',
'a',
'b',
);

expect(repo, isA<GitHubRepository>());
});

test('parses GitLab repository spec', () {
final repo = parseHostedGitRepositorySpec(
'gitlab',
'https://gitlab.invertase.com',
'a',
'b',
);

expect(repo, isA<GitLabRepository>());
});

test('throws if URL cannot be parsed as URL to one of known hosts', () {
expect(
() => parseHostedGitRepositorySpec(
'example',
'https://example.com',
'a',
'b',
),
throwsFormatException,
);
});
});
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ dev_dependencies:
path: ^1.7.0
yaml: ^3.1.0

# These allows us to use melos on itself during development.
# These allow us to use melos on itself during development.
# If you make a new local package in this repo that the melos package
# will depend on, then make sure to add it here otherwise you'll face
# "'pubspec.yaml' has been modified since".. issues.
Expand Down