Skip to content

Commit

Permalink
Feature: GitWeb Source Link provider
Browse files Browse the repository at this point in the history
GitWeb only currently supports SSH repository URLs.

#505
  • Loading branch information
Glen-Nicol-Garmin committed Jan 31, 2020
1 parent 39c946a commit 1b424fc
Show file tree
Hide file tree
Showing 28 changed files with 844 additions and 10 deletions.
12 changes: 12 additions & 0 deletions SourceLink.sln
Expand Up @@ -54,6 +54,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.SourceLink.AzureD
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.SourceLink.AzureDevOpsServer.Git.UnitTests", "src\SourceLink.AzureDevOpsServer.Git.UnitTests\Microsoft.SourceLink.AzureDevOpsServer.Git.UnitTests.csproj", "{79371F26-FB84-408D-A4A1-B142B247C288}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.SourceLink.GitWeb", "src\SourceLink.GitWeb\Microsoft.SourceLink.GitWeb.csproj", "{C78DD3EF-9D20-4E00-8237-E871BB53F840}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.SourceLink.GitWeb.UnitTests", "src\SourceLink.GitWeb.UnitTests\Microsoft.SourceLink.GitWeb.UnitTests.csproj", "{50503A43-08C0-493B-B8CC-F368983644C1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -136,6 +140,14 @@ Global
{79371F26-FB84-408D-A4A1-B142B247C288}.Debug|Any CPU.Build.0 = Debug|Any CPU
{79371F26-FB84-408D-A4A1-B142B247C288}.Release|Any CPU.ActiveCfg = Release|Any CPU
{79371F26-FB84-408D-A4A1-B142B247C288}.Release|Any CPU.Build.0 = Release|Any CPU
{C78DD3EF-9D20-4E00-8237-E871BB53F840}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C78DD3EF-9D20-4E00-8237-E871BB53F840}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C78DD3EF-9D20-4E00-8237-E871BB53F840}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C78DD3EF-9D20-4E00-8237-E871BB53F840}.Release|Any CPU.Build.0 = Release|Any CPU
{50503A43-08C0-493B-B8CC-F368983644C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{50503A43-08C0-493B-B8CC-F368983644C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{50503A43-08C0-493B-B8CC-F368983644C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{50503A43-08C0-493B-B8CC-F368983644C1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
41 changes: 31 additions & 10 deletions src/Common/TranslateRepositoryUrlGitTask.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.
// 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.Collections.Generic;
Expand Down Expand Up @@ -48,13 +49,13 @@ private void ExecuteImpl()
}

static bool isMatchingHostUri(Uri hostUri, Uri uri)
=> uri.GetHost().Equals(hostUri.GetHost(), StringComparison.OrdinalIgnoreCase) ||
=> uri.GetHost().Equals(hostUri.GetHost(), StringComparison.OrdinalIgnoreCase) ||
uri.GetHost().EndsWith("." + hostUri.GetHost(), StringComparison.OrdinalIgnoreCase);

// only need to translate valid ssh URLs that match one of our hosts:
string? translate(string? url)
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
hostUris.Any(h => isMatchingHostUri(h, uri)))
{
return (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ? TranslateHttpUrl(uri) :
Expand All @@ -66,7 +67,16 @@ static bool isMatchingHostUri(Uri hostUri, Uri uri)
return url;
}

TranslatedRepositoryUrl = translate(RepositoryUrl);
try
{
TranslatedRepositoryUrl = translate(RepositoryUrl);
}
catch (NotSupportedException e)
{
Log.LogError(e.Message);
return;
}

TranslatedSourceRoots = SourceRoots;

if (TranslatedSourceRoots != null)
Expand All @@ -78,12 +88,23 @@ static bool isMatchingHostUri(Uri hostUri, Uri uri)
continue;
}

// Item metadata are stored msbuild-escaped. GetMetadata unescapes, SetMetadata stores the value as specified.
// When initializing the URL metadata from git information we msbuild-escaped the URL to preserve any URL escapes in it.
// Here, GetMetadata unescapes the msbuild escapes, then we translate the URL and finally msbuild-escape
// the resulting URL to preserve any URL escapes.
sourceRoot.SetMetadata(Names.SourceRoot.ScmRepositoryUrl,
Evaluation.ProjectCollection.Escape(translate(sourceRoot.GetMetadata(Names.SourceRoot.ScmRepositoryUrl))));
string? translatedUrl;
try
{
translatedUrl = translate(sourceRoot.GetMetadata(Names.SourceRoot.ScmRepositoryUrl));
}
catch (NotSupportedException e)
{
Log.LogError(e.Message);
continue;
}

// Item metadata are stored msbuild-escaped. GetMetadata unescapes, SetMetadata
// stores the value as specified. When initializing the URL metadata from git
// information we msbuild-escaped the URL to preserve any URL escapes in it.
// Here, GetMetadata unescapes the msbuild escapes, then we translate the URL
// and finally msbuild-escape the resulting URL to preserve any URL escapes.
sourceRoot.SetMetadata(Names.SourceRoot.ScmRepositoryUrl, Evaluation.ProjectCollection.Escape(translatedUrl));
}
}
}
Expand Down
130 changes: 130 additions & 0 deletions src/SourceLink.Git.IntegrationTests/GitWebTests.cs
@@ -0,0 +1,130 @@
// 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 Microsoft.Build.Tasks.SourceControl;
using System;
using System.IO;
using TestUtilities;

namespace Microsoft.SourceLink.IntegrationTests
{
public class GitWebTests : DotNetSdkTestBase
{
public GitWebTests()
: base("Microsoft.SourceLink.GitWeb")
{
}

[ConditionalFact(typeof(DotNetSdkAvailable))]
public void FullValidation_Ssh()
{
// Test non-ascii characters and escapes in the URL. Escaped URI reserved characters
// should remain escaped, non-reserved characters unescaped in the results.
var repoUrl = $"ssh://git@噸.com/test-%72epo\u1234%24%2572%2F.git";
var repoName = "test-repo\u1234%24%2572%2F.git";

var repo = GitUtilities.CreateGitRepositoryWithSingleCommit(ProjectDir.Path, new[] { ProjectFileName }, repoUrl);
var commitSha = repo.Head.Tip.Sha;

VerifyValues(
customProps: @"
<PropertyGroup>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
</PropertyGroup>
<ItemGroup>
<SourceLinkGitWebHost Include='噸.com' ContentUrl='https://噸.com/gitweb'/>
</ItemGroup>
",
customTargets: "",
targets: new[]
{
"Build", "Pack"
},
expressions: new[]
{
"@(SourceRoot)",
"@(SourceRoot->'%(SourceLinkUrl)')",
"$(SourceLink)",
"$(PrivateRepositoryUrl)",
"$(RepositoryUrl)"
},
expectedResults: new[]
{
ProjectSourceRoot,
$"https://噸.com/gitweb/?p={repoName};a=blob_plain;hb={commitSha};f=*",
s_relativeSourceLinkJsonPath,
$"ssh://git@噸.com/{repoName}",
$"ssh://git@噸.com/{repoName}"
});

AssertEx.AreEqual(
$@"{{""documents"":{{""{ProjectSourceRoot.Replace(@"\", @"\\")}*"":""https://噸.com/gitweb/?p={repoName};a=blob_plain;hb={commitSha};f=*""}}}}",
File.ReadAllText(Path.Combine(ProjectDir.Path, s_relativeSourceLinkJsonPath)));

TestUtilities.ValidateAssemblyInformationalVersion(
Path.Combine(ProjectDir.Path, s_relativeOutputFilePath),
"1.0.0+" + commitSha);

TestUtilities.ValidateNuSpecRepository(
Path.Combine(ProjectDir.Path, s_relativePackagePath),
type: "git",
commit: commitSha,
url: $"ssh://git@噸.com/{repoName}");
}

[ConditionalFact(typeof(DotNetSdkAvailable))]
public void Issues_error_on_git_url()
{
var repoUrl = "git://噸.com/invalid_url_protocol.git";
var repo = GitUtilities.CreateGitRepositoryWithSingleCommit(ProjectDir.Path, new[] { ProjectFileName }, repoUrl);
var commitSha = repo.Head.Tip.Sha;

VerifyValues(
customProps: @"
<PropertyGroup>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
</PropertyGroup>
<ItemGroup>
<SourceLinkGitWebHost Include='噸.com' ContentUrl='https://噸.com/gitweb'/>
</ItemGroup>
",
customTargets: "",
targets: new[]
{
"Build", "Pack"
},
expressions: Array.Empty<string>(),
expectedErrors: new[]{
string.Format(GitWeb.Resources.RepositoryUrlIsNotSupportedByProvider, "GIT")
});
}

[ConditionalFact(typeof(DotNetSdkAvailable))]
public void Issues_error_on_https_url()
{
var repoUrl = "https://噸.com/invalid_url_protocol.git";
var repo = GitUtilities.CreateGitRepositoryWithSingleCommit(ProjectDir.Path, new[] { ProjectFileName }, repoUrl);
var commitSha = repo.Head.Tip.Sha;

VerifyValues(
customProps: @"
<PropertyGroup>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
</PropertyGroup>
<ItemGroup>
<SourceLinkGitWebHost Include='噸.com' ContentUrl='https://噸.com/gitweb'/>
</ItemGroup>
",
customTargets: "",
targets: new[]
{
"Build", "Pack"
},
expressions: Array.Empty<string>(),
expectedErrors: new[]
{
string.Format(GitWeb.Resources.RepositoryUrlIsNotSupportedByProvider, "HTTP")
});
}
}
}
Expand Up @@ -7,6 +7,7 @@
<ProjectReference Include="..\Microsoft.Build.Tasks.Git\Microsoft.Build.Tasks.Git.csproj" />
<ProjectReference Include="..\SourceLink.Common\Microsoft.SourceLink.Common.csproj" />
<ProjectReference Include="..\SourceLink.GitHub\Microsoft.SourceLink.GitHub.csproj" />
<ProjectReference Include="..\SourceLink.GitWeb\Microsoft.SourceLink.GitWeb.csproj" />
<ProjectReference Include="..\TestUtilities\TestUtilities.csproj" />
</ItemGroup>
</Project>
58 changes: 58 additions & 0 deletions src/SourceLink.GitWeb.UnitTests/GetSourceLinkUrlTests.cs
@@ -0,0 +1,58 @@
// 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 Microsoft.Build.Tasks.SourceControl;
using TestUtilities;
using Xunit;
using static TestUtilities.KeyValuePairUtils;

namespace Microsoft.SourceLink.GitWeb.UnitTests
{
public class GetSourceLinkUrlTests
{
[Fact]
public void EmptyHosts()
{
var engine = new MockEngine();

var task = new GetSourceLinkUrl()
{
BuildEngine = engine,
SourceRoot = new MockItem("x", KVP("RepositoryUrl", "http://abc.com"), KVP("SourceControl", "git")),
};

bool result = task.Execute();

AssertEx.AssertEqualToleratingWhitespaceDifferences(
"ERROR : " + string.Format(CommonResources.AtLeastOneRepositoryHostIsRequired, "SourceLinkGitWebHost", "GitWeb"), engine.Log);

Assert.False(result);
}

[Theory]
[InlineData("", "")]
[InlineData("", "/")]
[InlineData("/", "")]
[InlineData("/", "/")]
public void BuildSourceLinkUrl(string s1, string s2)
{
var engine = new MockEngine();

var task = new GetSourceLinkUrl()
{
BuildEngine = engine,
SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", "ssh://git@src.intranet.company.com/root_dir_name/sub_dirs/reponame.git" + s1), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")),
Hosts = new[]
{
// NOTE: i don't know what the spec parameter is for. but for all the other
// tests like this for various providers it is the domain of the Repo URL.
new MockItem("src.intranet.company.com", KVP("ContentUrl", "https://src.intranet.company.com/gitweb" + s2)),
}
};

bool result = task.Execute();
AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log);
AssertEx.AreEqual("https://src.intranet.company.com/gitweb/?p=root_dir_name/sub_dirs/reponame.git;a=blob_plain;hb=0123456789abcdefABCDEF000000000000000000;f=*", task.SourceLinkUrl);
Assert.True(result);
}
}
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net461;netcoreapp2.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SourceLink.GitWeb\Microsoft.SourceLink.GitWeb.csproj" />
<ProjectReference Include="..\TestUtilities\TestUtilities.csproj" />
</ItemGroup>
</Project>
51 changes: 51 additions & 0 deletions src/SourceLink.GitWeb.UnitTests/TranslateRepositoryUrlsTests.cs
@@ -0,0 +1,51 @@
// 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.Linq;
using TestUtilities;
using Xunit;
using static TestUtilities.KeyValuePairUtils;

namespace Microsoft.SourceLink.GitWeb.UnitTests
{
public class TranslateRepositoryUrlsTests
{
[Fact]
public void Translate()
{
var engine = new MockEngine();

var task = new TranslateRepositoryUrls()
{
BuildEngine = engine,
RepositoryUrl = "ssh://git@src.intranet.company.com/root_dir_name/sub_dirs/reponame.git",
IsSingleProvider = true,
SourceRoots = new[]
{
new MockItem("/1/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://git@src.intranet.company.com/root_dir_name/sub_dirs/reponame.git")),
new MockItem("/2/", KVP("SourceControl", "tfvc"), KVP("ScmRepositoryUrl", "ssh://git@src.intranet.company1.com/root_dir_name/sub_dirs/reponame.git")),
new MockItem("/2/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://git@src.intranet.company1.com/root_dir_name/sub_dirs/reponame.git")),
new MockItem("/2/", KVP("SourceControl", "tfvc"), KVP("ScmRepositoryUrl", "ssh://git@src.intranet.company2.com/root_dir_name/sub_dirs/reponame.git")),
},
Hosts = new[]
{
new MockItem("src.intranet.company1.com")
}
};

bool result = task.Execute();
AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log);

AssertEx.AreEqual("ssh://git@src.intranet.company.com/root_dir_name/sub_dirs/reponame.git", task.TranslatedRepositoryUrl);

AssertEx.Equal(new[]
{
"ssh://git@src.intranet.company.com/root_dir_name/sub_dirs/reponame.git",
"ssh://git@src.intranet.company1.com/root_dir_name/sub_dirs/reponame.git",
"ssh://git@src.intranet.company1.com/root_dir_name/sub_dirs/reponame.git",
"ssh://git@src.intranet.company2.com/root_dir_name/sub_dirs/reponame.git",
}, task.TranslatedSourceRoots.Select(r => r.GetMetadata("ScmRepositoryUrl")));

Assert.True(result);
}
}
}
37 changes: 37 additions & 0 deletions src/SourceLink.GitWeb/GetSourceLinkUrl.cs
@@ -0,0 +1,37 @@
// 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.Framework;
using Microsoft.Build.Tasks.SourceControl;

namespace Microsoft.SourceLink.GitWeb
{
/// <summary>
/// The task calculates SourceLink URL for a given SourceRoot. If the SourceRoot is associated
/// with a git repository with a recognized domain the <see cref="SourceLinkUrl"/> output
/// property is set to the content URL corresponding to the domain, otherwise it is set to
/// string "N/A".
/// </summary>
public sealed class GetSourceLinkUrl : GetSourceLinkUrlGitTask
{
protected override string HostsItemGroupName => "SourceLinkGitWebHost";
protected override string ProviderDisplayName => "GitWeb";

protected override Uri GetDefaultContentUriFromHostUri(string authority, Uri gitUri)
=> new Uri($"https://{authority}/gitweb", UriKind.Absolute);

protected override string BuildSourceLinkUrl(Uri contentUri, Uri gitUri, string relativeUrl, string revisionId, ITaskItem? hostItem)
{
var trimLeadingSlash = relativeUrl.TrimStart('/');
var trimmedContentUrl = contentUri.ToString().TrimEnd('/', '\\');

// p = project/path
// a = action
// hb = SHA/revision
// f = repo file path
var gitwebRawUrl = UriUtilities.Combine(trimmedContentUrl, $"?p={trimLeadingSlash}.git;a=blob_plain;hb={revisionId};f=*");
return gitwebRawUrl;
}
}
}

0 comments on commit 1b424fc

Please sign in to comment.