Skip to content

Commit

Permalink
feat: add support for Azure DevOps repository (#681)
Browse files Browse the repository at this point in the history
Co-authored-by: Luca Rampazzo <luca.rampazzo@danfoss.com>
  • Loading branch information
lrampazzo and Luca Rampazzo authored Mar 27, 2024
1 parent 15b1518 commit 16fc890
Show file tree
Hide file tree
Showing 3 changed files with 261 additions and 2 deletions.
5 changes: 3 additions & 2 deletions docs/configuration/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ Supported hosts:
- GitHub
- GitLab (https://gitlab.com)
- Bitbucket (https://bitbucket.org)
- Azure DevOps (https://dev.azure.com)
```yaml
repository: https://github.com/invertase/melos
```
When using a self-hosted GitHub, GitLab or Bitbucket instance, you can specify the
repository location like this:
When using a self-hosted GitHub, GitLab, Bitbucket or Azure DevOps instance,
you can specify the repository location like this:
```yaml
repository:
Expand Down
82 changes: 82 additions & 0 deletions packages/melos/lib/src/common/git_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -262,16 +262,98 @@ class BitbucketRepository extends HostedGitRepository {
Uri get url => Uri.parse('$origin/$owner/$name/');
}

/// A git repository, hosted by Azure DevOps.
@immutable
class AzureDevOpsRepository extends HostedGitRepository {
AzureDevOpsRepository({
required String origin,
required this.owner,
required this.name,
}) : origin = removeTrailingSlash(origin);

factory AzureDevOpsRepository.fromUrl(Uri uri) {
final match = RegExp(r'(https://dev.azure.com/.+)/(.+)/_git/(.+)/?$')
.firstMatch(uri.toString());
if (match != null) {
return AzureDevOpsRepository(
origin: match.group(1)!,
owner: match.group(2)!,
name: match.group(3)!,
);
}

// Azure DevOps has also an older URL format, see
// https://learn.microsoft.com/en-us/azure/devops/extend/develop/work-with-urls
final matchOld = RegExp(r'(https://.+\.visualstudio.com)/(.+)/_git/(.+)/?$')
.firstMatch(uri.toString());
if (matchOld != null) {
return AzureDevOpsRepository(
origin: matchOld.group(1)!,
owner: matchOld.group(2)!,
name: matchOld.group(3)!,
);
}

throw FormatException(
'The URL $uri is not a valid Azure DevOps repository URL.',
);
}

static const defaultOrigin = 'https://dev.azure.com';

/// The origin of the Azure DevOps project.
final String origin;

/// The name of the project.
final String owner;

@override
final String name;

@override
late final Uri url = Uri.parse('$origin/$owner/_git/$name/');

@override
Uri commitUrl(String id) => Uri.parse('$origin/$owner/_git/$name/commit/$id');

@override
Uri issueUrl(String id) => Uri.parse('$origin/$owner/_workitems/edit/$id');

@override
String toString() {
return '''
AzureDevOpsRepository(
origin: $origin,
owner: $owner,
name: $name,
)''';
}

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AzureDevOpsRepository &&
other.runtimeType == runtimeType &&
other.origin == origin &&
other.owner == owner &&
other.name == name;

@override
int get hashCode => origin.hashCode ^ owner.hashCode ^ name.hashCode;
}

final _hostsToUrlParser = {
'GitHub': GitHubRepository.fromUrl,
'GitLab': GitLabRepository.fromUrl,
'Bitbucket': BitbucketRepository.fromUrl,
'AzureDevOps': AzureDevOpsRepository.fromUrl,
};

final _hostsToSpecParser = {
'GitHub': GitHubRepository.new,
'GitLab': GitLabRepository.new,
'Bitbucket': BitbucketRepository.new,
'AzureDevOps': AzureDevOpsRepository.new,
};

/// Tries to parse [url] into a [HostedGitRepository].
Expand Down
176 changes: 176 additions & 0 deletions packages/melos/test/git_repository_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,146 @@ void main() {
});
});

group('AzureDevOpsRepository', () {
group('fromUrl', () {
test('parse Azure DevOps repository URL correctly', () {
final url = Uri.parse('https://dev.azure.com/a/b/_git/c');
final repo = AzureDevOpsRepository.fromUrl(url);

expect(repo.origin, 'https://dev.azure.com/a');
expect(repo.owner, 'b');
expect(repo.name, 'c');
expect(repo.url, Uri.parse('https://dev.azure.com/a/b/_git/c/'));
});

test('parse Azure DevOps (old URL format) repository URL correctly', () {
final url = Uri.parse('https://a.visualstudio.com/b/_git/c');
final repo = AzureDevOpsRepository.fromUrl(url);

expect(repo.origin, 'https://a.visualstudio.com');
expect(repo.owner, 'b');
expect(repo.name, 'c');
expect(repo.url, Uri.parse('https://a.visualstudio.com/b/_git/c/'));
});

test('parse Azure DevOps Server repository URL correctly', () {
final url = Uri.parse('https://a.visualstudio.com/b/_git/c');
final repo = AzureDevOpsRepository.fromUrl(url);

expect(repo.origin, 'https://a.visualstudio.com');
expect(repo.owner, 'b');
expect(repo.name, 'c');
expect(repo.url, Uri.parse('https://a.visualstudio.com/b/_git/c/'));
});

test('throws if URL is not a valid Azure DevOps repository URL', () {
void expectBadUrl(String url) {
final uri = Uri.parse(url);
expect(
() => AzureDevOpsRepository.fromUrl(uri),
throwsFormatException,
reason: url,
);
}

const [
'',
'http://dev.azure.com/a/b/_git/c',
'https://www.visualstudio.com/b/c',
'https://gitlab.com/a/b',
'https://github.com/a/b',
'https://dev.azure.com/a',
'https://dev.azure.com/',
'https://dev.azure.com',
].forEach(expectBadUrl);
});
});

group('fromSpec', () {
test('parse Azure DevOps repository spec correctly', () {
final repo = AzureDevOpsRepository(
origin: 'https://dev.azure.com/a',
owner: 'b',
name: 'c',
);

expect(repo.origin, 'https://dev.azure.com/a');
expect(repo.owner, 'b');
expect(repo.name, 'c');
expect(repo.url, Uri.parse('https://dev.azure.com/a/b/_git/c/'));
});

test('parse Azure DevOps (old URL format) repository spec correctly', () {
final repo = AzureDevOpsRepository(
origin: 'https://a.visualstudio.com/',
owner: 'b',
name: 'c',
);

expect(repo.origin, 'https://a.visualstudio.com');
expect(repo.owner, 'b');
expect(repo.name, 'c');
expect(repo.url, Uri.parse('https://a.visualstudio.com/b/_git/c/'));
});
});

test('commitUrl returns correct URL', () {
final repo = AzureDevOpsRepository(
origin: 'https://dev.azure.com/a',
owner: 'b',
name: 'c',
);
const commitId = 'b6849381';

expect(
repo.commitUrl(commitId),
Uri.parse('https://dev.azure.com/a/b/_git/c/commit/b6849381'),
);
});

test('commitUrl returns correct URL (old format)', () {
final repo = AzureDevOpsRepository(
origin: 'https://a.visualstudio.com',
owner: 'b',
name: 'c',
);
const commitId = 'b6849381';

expect(
repo.commitUrl(commitId),
Uri.parse('https://a.visualstudio.com/b/_git/c/commit/b6849381'),
);
});

test('issueUrl returns correct URL', () {
final repo = AzureDevOpsRepository(
origin: 'https://dev.azure.com/a',
owner: 'b',
name: 'c',
);
const issueId = '123';

expect(
repo.issueUrl(issueId),
Uri.parse('https://dev.azure.com/a/b/_workitems/edit/123'),
);
});

test('issueUrl returns correct URL (old format)', () {
final repo = AzureDevOpsRepository(
origin: 'https://a.visualstudio.com',
owner: 'b',
name: 'c',
);
const issueId = '123';

expect(
repo.issueUrl(issueId),
Uri.parse('https://a.visualstudio.com/b/_workitems/edit/123'),
);
});
});

group('parseHostedGitRepositoryUrl', () {
test('parses GitHub repository URL', () {
final repo =
Expand All @@ -338,6 +478,20 @@ void main() {
expect(repo, isA<GitLabRepository>());
});

test('parses Azure DevOps repository URL', () {
final repo = parseHostedGitRepositoryUrl(
Uri.parse('https://dev.azure.com/a/b/_git/c'),
);
expect(repo, isA<AzureDevOpsRepository>());
});

test('parses Azure DevOps repository URL (old format)', () {
final repo = parseHostedGitRepositoryUrl(
Uri.parse('https://a.visualstudio.com/b/_git/c'),
);
expect(repo, isA<AzureDevOpsRepository>());
});

test('throws if URL cannot be parsed as URL to one of known hosts', () {
expect(
() => parseHostedGitRepositoryUrl(Uri.parse('https://example.com')),
Expand Down Expand Up @@ -369,6 +523,28 @@ void main() {
expect(repo, isA<GitLabRepository>());
});

test('parses Azure DevOps repository spec', () {
final repo = parseHostedGitRepositorySpec(
'azuredevops',
'https://dev.azure.com/a',
'b',
'c',
);

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

test('parses Azure DevOps repository spec (old URL format)', () {
final repo = parseHostedGitRepositorySpec(
'azuredevops',
'https://a.visualstudio.com/',
'b',
'c',
);

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

test('throws if URL cannot be parsed as URL to one of known hosts', () {
expect(
() => parseHostedGitRepositorySpec(
Expand Down

0 comments on commit 16fc890

Please sign in to comment.