diff --git a/src/SourceLink.AzureRepos.Git/build/Microsoft.SourceLink.AzureRepos.Git.targets b/src/SourceLink.AzureRepos.Git/build/Microsoft.SourceLink.AzureRepos.Git.targets index a647ffcb..e30145ec 100644 --- a/src/SourceLink.AzureRepos.Git/build/Microsoft.SourceLink.AzureRepos.Git.targets +++ b/src/SourceLink.AzureRepos.Git/build/Microsoft.SourceLink.AzureRepos.Git.targets @@ -21,7 +21,7 @@ output property is set to the content URL corresponding to the domain, otherwise it is set to string "N/A". Recognized domains are specified via Hosts (initialized from SourceLinkAzureReposGitHost item group). - In addition SourceLinkHasSingleProvider is true an iplicit host is parsed from RepositoryUrl. + In addition, if SourceLinkHasSingleProvider is true an implicit host is parsed from RepositoryUrl. ContentUrl is optional. If not specified it defaults to "https://{domain}" or "http://{domain}", based on the scheme of SourceRoot.RepositoryUrl. --> diff --git a/src/SourceLink.Bitbucket.Git.UnitTests/GetSourceLinkUrlTests.cs b/src/SourceLink.Bitbucket.Git.UnitTests/GetSourceLinkUrlTests.cs index e8bcc015..363a8db0 100644 --- a/src/SourceLink.Bitbucket.Git.UnitTests/GetSourceLinkUrlTests.cs +++ b/src/SourceLink.Bitbucket.Git.UnitTests/GetSourceLinkUrlTests.cs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using Microsoft.Build.Tasks.SourceControl; using TestUtilities; using Xunit; @@ -8,11 +9,6 @@ namespace Microsoft.SourceLink.Bitbucket.Git.UnitTests { public class GetSourceLinkUrlTests { - private const string ExpectedUrlForCloudEdition = - "https://api.domain.com/x/y/2.0/repositories/a/b/src/0123456789abcdefABCDEF000000000000000000/*"; - private const string ExpectedUrlForEnterpriseEditionOldVersion = "https://bitbucket.domain.com/projects/a/repos/b/browse/*?at=0123456789abcdefABCDEF000000000000000000&raw"; - private const string ExpectedUrlForEnterpriseEditionNewVersion = "https://bitbucket.domain.com/projects/a/repos/b/raw/*?at=0123456789abcdefABCDEF000000000000000000"; - [Fact] public void EmptyHosts() { @@ -32,6 +28,33 @@ public void EmptyHosts() Assert.False(result); } + [Theory] + [InlineData("a/b", "", "a", "b")] + [InlineData("/a/b", "", "a", "b")] + [InlineData("/a/b/", "", "a", "b")] + [InlineData("scm/a", "", "scm", "a")] + [InlineData("scm/a/b", "", "a", "b")] + [InlineData("/r/scm/a/b", "r", "a", "b")] + [InlineData("/r/s/scm/a/b", "r/s", "a", "b")] + [InlineData("/r/s/a/b", "r/s", "a", "b")] + [InlineData("/r/s/scm/b", "r/s", "scm", "b")] + public void TryParseEnterpriseUrl(string relativeUrl, string expectedBaseUrl, string expectedProjectName, string expectedRepositoryName) + { + Assert.True(GetSourceLinkUrl.TryParseEnterpriseUrl(relativeUrl, out var baseUrl, out var projectName, out var repositoryName)); + Assert.Equal(expectedBaseUrl, baseUrl); + Assert.Equal(expectedProjectName, projectName); + Assert.Equal(expectedRepositoryName, repositoryName); + } + + [Theory] + [InlineData("")] + [InlineData("/")] + [InlineData("x")] + public void TryParseEnterpriseUrl_Errors(string relativeUrl) + { + Assert.False(GetSourceLinkUrl.TryParseEnterpriseUrl(relativeUrl, out _, out _, out _)); + } + [Theory] [InlineData("", "")] [InlineData("", "/")] @@ -39,7 +62,6 @@ public void EmptyHosts() [InlineData("/", "/")] public void BuildSourceLinkUrl_BitbucketCloud(string s1, string s2) { - var isEnterpriseEditionSetting = KVP("EnterpriseEdition", "false"); var engine = new MockEngine(); var task = new GetSourceLinkUrl() { @@ -47,16 +69,38 @@ public void BuildSourceLinkUrl_BitbucketCloud(string s1, string s2) SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", "http://subdomain.mybitbucket.org:100/a/b" + s1), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), Hosts = new[] { - new MockItem("mybitbucket.org", KVP("ContentUrl", "https://domain.com/x/y" + s2), isEnterpriseEditionSetting), + new MockItem("mybitbucket.org", KVP("ContentUrl", "https://domain.com/x/y" + s2), KVP("EnterpriseEdition", "false")), } }; bool result = task.Execute(); AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual(ExpectedUrlForCloudEdition, task.SourceLinkUrl); + AssertEx.AreEqual("https://api.domain.com/x/y/2.0/repositories/a/b/src/0123456789abcdefABCDEF000000000000000000/*", task.SourceLinkUrl); Assert.True(result); } + [Fact] + public void BuildSourceLinkUrl_BitbucketEnterprise_InvalidUrl() + { + var engine = new MockEngine(); + var task = new GetSourceLinkUrl() + { + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", "http://subdomain.mybitbucket.org/a"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] + { + new MockItem("mybitbucket.org", KVP("ContentUrl", "https://domain.com/x/y")), + } + }; + + bool result = task.Execute(); + + AssertEx.AssertEqualToleratingWhitespaceDifferences( + "ERROR : " + string.Format(CommonResources.ValueOfWithIdentityIsInvalid, "SourceRoot.RepositoryUrl", "/src/", "http://subdomain.mybitbucket.org/a"), engine.Log); + + Assert.False(result); + } + [Fact] public void BuildSourceLinkUrl_MetadataWithEnterpriseEditionButWithoutVersion_UseNewVersionAsDefauld() { @@ -74,7 +118,7 @@ public void BuildSourceLinkUrl_MetadataWithEnterpriseEditionButWithoutVersion_Us bool result = task.Execute(); AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual(ExpectedUrlForEnterpriseEditionNewVersion, task.SourceLinkUrl); + AssertEx.AreEqual("https://bitbucket.domain.com/projects/a/repos/b/raw/*?at=0123456789abcdefABCDEF000000000000000000", task.SourceLinkUrl); Assert.True(result); } @@ -104,7 +148,7 @@ public void BuildSourceLinkUrl_BitbucketEnterpriseOldVersionSsh(string s1, strin bool result = task.Execute(); AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual(ExpectedUrlForEnterpriseEditionOldVersion, task.SourceLinkUrl); + AssertEx.AreEqual("https://bitbucket.domain.com/projects/a/repos/b/browse/*?at=0123456789abcdefABCDEF000000000000000000&raw", task.SourceLinkUrl); Assert.True(result); } @@ -125,7 +169,7 @@ public void BuildSourceLinkUrl_BitbucketEnterpriseOldVersionHttps(string s1, str var task = new GetSourceLinkUrl() { BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", "http://bitbucket.domain.com:100/scm/a/b" + s1), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", "http://bitbucket.domain.com:100/base/scm/a/b" + s1), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), Hosts = new[] { new MockItem("domain.com", KVP("ContentUrl", "https://bitbucket.domain.com" + s2), isEnterpriseEditionSetting, version), @@ -134,7 +178,7 @@ public void BuildSourceLinkUrl_BitbucketEnterpriseOldVersionHttps(string s1, str bool result = task.Execute(); AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual(ExpectedUrlForEnterpriseEditionOldVersion, task.SourceLinkUrl); + AssertEx.AreEqual("https://bitbucket.domain.com/base/projects/a/repos/b/browse/*?at=0123456789abcdefABCDEF000000000000000000&raw", task.SourceLinkUrl); Assert.True(result); } @@ -165,7 +209,7 @@ public void BuildSourceLinkUrl_BitbucketEnterpriseNewVersionSsh(string s1, strin bool result = task.Execute(); AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual(ExpectedUrlForEnterpriseEditionNewVersion, task.SourceLinkUrl); + AssertEx.AreEqual("https://bitbucket.domain.com/projects/a/repos/b/raw/*?at=0123456789abcdefABCDEF000000000000000000", task.SourceLinkUrl); Assert.True(result); } @@ -196,7 +240,7 @@ public void BuildSourceLinkUrl_BitbucketEnterpriseNewVersionHttps(string s1, str bool result = task.Execute(); AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual(ExpectedUrlForEnterpriseEditionNewVersion, task.SourceLinkUrl); + AssertEx.AreEqual("https://bitbucket.domain.com/projects/a/repos/b/raw/*?at=0123456789abcdefABCDEF000000000000000000", task.SourceLinkUrl); Assert.True(result); } diff --git a/src/SourceLink.Bitbucket.Git/GetSourceLinkUrl.cs b/src/SourceLink.Bitbucket.Git/GetSourceLinkUrl.cs index 3818eee8..3a5673ff 100644 --- a/src/SourceLink.Bitbucket.Git/GetSourceLinkUrl.cs +++ b/src/SourceLink.Bitbucket.Git/GetSourceLinkUrl.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Diagnostics; using Microsoft.Build.Framework; using Microsoft.Build.Tasks.SourceControl; @@ -18,33 +19,68 @@ public sealed class GetSourceLinkUrl : GetSourceLinkUrlGitTask private const string IsEnterpriseEditionMetadataName = "EnterpriseEdition"; private const string VersionMetadataName = "Version"; - private const string VersionWithNewUrlFormat = "4.7"; + private static readonly Version s_versionWithNewUrlFormat = new Version(4, 7); protected override string BuildSourceLinkUrl(Uri contentUri, Uri gitUri, string relativeUrl, string revisionId, ITaskItem hostItem) { - return - bool.TryParse(hostItem?.GetMetadata(IsEnterpriseEditionMetadataName), out var isEnterpriseEdition) && !isEnterpriseEdition - ? BuildSourceLinkUrlForCloudEdition(contentUri, relativeUrl, revisionId) - : BuildSourceLinkUrlForEnterpriseEdition(contentUri, relativeUrl, revisionId, hostItem); + // The SourceLinkBitbucketGitHost item for bitbucket.org specifies EnterpriseEdition="false". + // Other items that may be specified by the project default to EnterpriseEdition="true" without specifying it. + bool isCloud = bool.TryParse(hostItem?.GetMetadata(IsEnterpriseEditionMetadataName), out var isEnterpriseEdition) && !isEnterpriseEdition; + + if (isCloud) + { + return BuildSourceLinkUrlForCloudEdition(contentUri, relativeUrl, revisionId); + } + + if (TryParseEnterpriseUrl(relativeUrl, out var relativeBaseUrl, out var projectName, out var repositoryName)) + { + var version = GetBitbucketEnterpriseVersion(hostItem); + return BuildSourceLinkUrlForEnterpriseEdition(contentUri, relativeBaseUrl, projectName, repositoryName, revisionId, version); + } + + Log.LogError(CommonResources.ValueOfWithIdentityIsInvalid, Names.SourceRoot.RepositoryUrlFullName, SourceRoot.ItemSpec, gitUri); + return null; } - private string BuildSourceLinkUrlForEnterpriseEdition(Uri contentUri, string relativeUrl, string revisionId, - ITaskItem hostItem) + internal static string BuildSourceLinkUrlForEnterpriseEdition( + Uri contentUri, + string relativeBaseUrl, + string projectName, + string repositoryName, + string commitSha, + Version version) { - var bitbucketEnterpriseVersion = GetBitbucketEnterpriseVersion(hostItem); + var relativeUrl = (version >= s_versionWithNewUrlFormat) ? + $"projects/{projectName}/repos/{repositoryName}/raw/*?at={commitSha}" : + $"projects/{projectName}/repos/{repositoryName}/browse/*?at={commitSha}&raw"; - var splits = relativeUrl.Split(new[] {'/'}, StringSplitOptions.RemoveEmptyEntries); - var isSshRepoUri = !(splits.Length == 3 && splits[0] == "scm"); - var projectName = isSshRepoUri ? splits[0] : splits[1]; - var repositoryName = isSshRepoUri ? splits[1] : splits[2]; + return UriUtilities.Combine(contentUri.ToString(), UriUtilities.Combine(relativeBaseUrl, relativeUrl)); + } + + internal static bool TryParseEnterpriseUrl(string relativeUrl, out string relativeBaseUrl, out string projectName, out string repositoryName) + { + // HTTP: {baseUrl}/scm/{projectName}/{repositoryName} + // SSH: {baseUrl}/{projectName}/{repositoryName} + + if (!UriUtilities.TrySplitRelativeUrl(relativeUrl, out var parts) || parts.Length < 2) + { + relativeBaseUrl = projectName = repositoryName = null; + return false; + } - var relativeUrlForBitbucketEnterprise = - GetRelativeUrlForBitbucketEnterprise(projectName, repositoryName, revisionId, - bitbucketEnterpriseVersion); + var i = parts.Length - 1; - var result = UriUtilities.Combine(contentUri.ToString(), relativeUrlForBitbucketEnterprise); + repositoryName = parts[i--]; + projectName = parts[i--]; - return result; + if (i >= 0 && parts[i] == "scm") + { + i--; + } + + Debug.Assert(i >= -1); + relativeBaseUrl = string.Join("/", parts, 0, i + 1); + return true; } private Version GetBitbucketEnterpriseVersion(ITaskItem hostItem) @@ -63,7 +99,7 @@ private Version GetBitbucketEnterpriseVersion(ITaskItem hostItem) } else { - bitbucketEnterpriseVersion = Version.Parse(VersionWithNewUrlFormat); + bitbucketEnterpriseVersion = s_versionWithNewUrlFormat; } return bitbucketEnterpriseVersion; @@ -79,20 +115,5 @@ private static string BuildSourceLinkUrlForCloudEdition(Uri contentUri, string r return UriUtilities.Combine(apiUriBuilder.Uri.ToString(), relativeApiUrl); } - - private static string GetRelativeUrlForBitbucketEnterprise(string projectName, string repositoryName, string commitId, Version bitbucketVersion) - { - string relativeUrl; - if (bitbucketVersion >= Version.Parse(VersionWithNewUrlFormat)) - { - relativeUrl = $"projects/{projectName}/repos/{repositoryName}/raw/*?at={commitId}"; - } - else - { - relativeUrl = $"projects/{projectName}/repos/{repositoryName}/browse/*?at={commitId}&raw"; - } - - return relativeUrl; - } } } diff --git a/src/SourceLink.Bitbucket.Git/build/Microsoft.SourceLink.Bitbucket.Git.targets b/src/SourceLink.Bitbucket.Git/build/Microsoft.SourceLink.Bitbucket.Git.targets index 4e35472b..16576b73 100644 --- a/src/SourceLink.Bitbucket.Git/build/Microsoft.SourceLink.Bitbucket.Git.targets +++ b/src/SourceLink.Bitbucket.Git/build/Microsoft.SourceLink.Bitbucket.Git.targets @@ -21,7 +21,7 @@ output property is set to the content URL corresponding to the domain, otherwise it is set to string "N/A". Recognized domains are specified via Hosts (initialized from SourceLinkBitbucketGitHost item group). - In addition SourceLinkHasSingleProvider is true an iplicit host is parsed from RepositoryUrl. + In addition, if SourceLinkHasSingleProvider is true an implicit host is parsed from RepositoryUrl. Example of SourceLinkBitbucketGitHost items: diff --git a/src/SourceLink.GitHub/build/Microsoft.SourceLink.GitHub.targets b/src/SourceLink.GitHub/build/Microsoft.SourceLink.GitHub.targets index 796d0994..f0a0c1d9 100644 --- a/src/SourceLink.GitHub/build/Microsoft.SourceLink.GitHub.targets +++ b/src/SourceLink.GitHub/build/Microsoft.SourceLink.GitHub.targets @@ -21,7 +21,7 @@ output property is set to the content URL corresponding to the domain, otherwise it is set to string "N/A". Recognized domains are specified via Hosts (initialized from SourceLinkGitHubHost item group). - In addition SourceLinkHasSingleProvider is true an iplicit host is parsed from RepositoryUrl. + In addition, if SourceLinkHasSingleProvider is true an implicit host is parsed from RepositoryUrl. Example of SourceLinkGitHubHost items: diff --git a/src/SourceLink.GitLab/build/Microsoft.SourceLink.GitLab.targets b/src/SourceLink.GitLab/build/Microsoft.SourceLink.GitLab.targets index cfa533fc..ed8d16b3 100644 --- a/src/SourceLink.GitLab/build/Microsoft.SourceLink.GitLab.targets +++ b/src/SourceLink.GitLab/build/Microsoft.SourceLink.GitLab.targets @@ -21,7 +21,7 @@ output property is set to the content URL corresponding to the domain, otherwise it is set to string "N/A". Recognized domains are specified via Hosts (initialized from SourceLinkGitLabHost item group). - In addition SourceLinkHasSingleProvider is true an iplicit host is parsed from RepositoryUrl. + In addition, if SourceLinkHasSingleProvider is true an implicit host is parsed from RepositoryUrl. Example of SourceLinkGitLabHost items: