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

Feature: GitWeb Source Link provider #505

Merged
merged 3 commits into from Feb 16, 2020
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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;
}
Copy link

@dfev77 dfev77 Nov 29, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it ok to just log and return? I'm thinking that the error might be observer only a few week/months later when one debugs the code and sees that source linking doesn't work anymore.
Why not let the exception propagate?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am fairly hazy on the specifics now, but I think logging the error results in an MsBuild error that can't be suppressed. Is the concern that since it is a post build action that error may not be noticed since the files will be on disk? I think the exception being thrown would have the same limitation.

I don't remember the specific use case where the NotSupportedException was thrown to say whether or not the message is helpful enough to tell the user that their URL scheme/format is not supported by Sourcelink. But I thought those were the errors being thrown by the individual translators when they didn't support the repo url.


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)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new MockItem("src.intranet.company.com" [](start = 20, length = 39)

It is the host domain that needs to match the domain in the SourceRoot.

}
};

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;
}
}
}