From 16fc890d1d5ee40d47be6f9dfd565de927f1b32c Mon Sep 17 00:00:00 2001 From: Luca Rampazzo Date: Wed, 27 Mar 2024 21:36:13 +0100 Subject: [PATCH] feat: add support for Azure DevOps repository (#681) Co-authored-by: Luca Rampazzo --- docs/configuration/overview.mdx | 5 +- .../melos/lib/src/common/git_repository.dart | 82 ++++++++ packages/melos/test/git_repository_test.dart | 176 ++++++++++++++++++ 3 files changed, 261 insertions(+), 2 deletions(-) diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index b5a2b115d..e29c0ee69 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -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: diff --git a/packages/melos/lib/src/common/git_repository.dart b/packages/melos/lib/src/common/git_repository.dart index 15363ed97..aa0a2c89b 100644 --- a/packages/melos/lib/src/common/git_repository.dart +++ b/packages/melos/lib/src/common/git_repository.dart @@ -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]. diff --git a/packages/melos/test/git_repository_test.dart b/packages/melos/test/git_repository_test.dart index b24a6cf7e..7980e93bd 100644 --- a/packages/melos/test/git_repository_test.dart +++ b/packages/melos/test/git_repository_test.dart @@ -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 = @@ -338,6 +478,20 @@ void main() { expect(repo, isA()); }); + test('parses Azure DevOps repository URL', () { + final repo = parseHostedGitRepositoryUrl( + Uri.parse('https://dev.azure.com/a/b/_git/c'), + ); + expect(repo, isA()); + }); + + test('parses Azure DevOps repository URL (old format)', () { + final repo = parseHostedGitRepositoryUrl( + Uri.parse('https://a.visualstudio.com/b/_git/c'), + ); + expect(repo, isA()); + }); + test('throws if URL cannot be parsed as URL to one of known hosts', () { expect( () => parseHostedGitRepositoryUrl(Uri.parse('https://example.com')), @@ -369,6 +523,28 @@ void main() { expect(repo, isA()); }); + test('parses Azure DevOps repository spec', () { + final repo = parseHostedGitRepositorySpec( + 'azuredevops', + 'https://dev.azure.com/a', + 'b', + 'c', + ); + + expect(repo, isA()); + }); + + test('parses Azure DevOps repository spec (old URL format)', () { + final repo = parseHostedGitRepositorySpec( + 'azuredevops', + 'https://a.visualstudio.com/', + 'b', + 'c', + ); + + expect(repo, isA()); + }); + test('throws if URL cannot be parsed as URL to one of known hosts', () { expect( () => parseHostedGitRepositorySpec(