From 8e8ad92cf9ae99339843a5994e7ddc11172bdba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Gr=C3=BCnwald?= Date: Fri, 6 Jan 2023 18:24:22 +0100 Subject: [PATCH 1/2] Convert footer values that are relative paths to files in the GitHub repository to links When the GitHub integration is enabled and a footer value is a relative path to a file that exists in the repository (in the default branch), convert the footer into a link to that file in the GitHub Web UI --- .../GitHubFileReferenceTextElementTest.cs | 71 +++++++ .../Integrations/GitHub/GitHubLinkTaskTest.cs | 200 ++++++++++++++++++ .../GitHub/_Helpers/GitHubClientMock.cs | 4 +- .../GitHub/_Helpers/OctokitMockExtensions.cs | 28 ++- .../GitHub/_Helpers/TestRepositoryContent.cs | 25 +++ .../GitHub/GitHubFileReferenceTextElement.cs | 42 ++++ .../Integrations/GitHub/GitHubLinkTask.cs | 39 +++- 7 files changed, 406 insertions(+), 3 deletions(-) create mode 100644 src/ChangeLog.Test/Integrations/GitHub/GitHubFileReferenceTextElementTest.cs create mode 100644 src/ChangeLog.Test/Integrations/GitHub/_Helpers/TestRepositoryContent.cs create mode 100644 src/ChangeLog/Integrations/GitHub/GitHubFileReferenceTextElement.cs diff --git a/src/ChangeLog.Test/Integrations/GitHub/GitHubFileReferenceTextElementTest.cs b/src/ChangeLog.Test/Integrations/GitHub/GitHubFileReferenceTextElementTest.cs new file mode 100644 index 00000000..6c9a9b73 --- /dev/null +++ b/src/ChangeLog.Test/Integrations/GitHub/GitHubFileReferenceTextElementTest.cs @@ -0,0 +1,71 @@ +using System; +using Grynwald.ChangeLog.Integrations.GitHub; +using Xunit; + +namespace Grynwald.ChangeLog.Test.Integrations.GitHub +{ + /// + /// Tests for + /// + public class GitHubFileReferenceTextElementTest + { + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Text_must_not_be_null_or_whitespace(string text) + { + // ARRANGE + + // ACT + var ex = Record.Exception(() => new GitHubFileReferenceTextElement( + text: text, + uri: new("http://example.com"), + relativePath: "relativePath" + )); + + // ASSERT + var argumentException = Assert.IsType(ex); + Assert.Equal("text", argumentException.ParamName); + } + + [Fact] + public void Uri_must_not_be_null() + { + // ARRANGE + + // ACT + var ex = Record.Exception(() => new GitHubFileReferenceTextElement( + text: "some text", + uri: null!, + relativePath: "relativePath" + )); + + // ASSERT + var argumentNullException = Assert.IsType(ex); + Assert.Equal("uri", argumentNullException.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void RelativePath_must_not_be_null_or_whitespace(string relativePath) + { + // ARRANGE + + // ACT + var ex = Record.Exception(() => new GitHubFileReferenceTextElement( + text: "Some Text", + uri: new("http://example.com"), + relativePath: relativePath + )); + + // ASSERT + var argumentException = Assert.IsType(ex); + Assert.Equal("relativePath", argumentException.ParamName); + } + } +} diff --git a/src/ChangeLog.Test/Integrations/GitHub/GitHubLinkTaskTest.cs b/src/ChangeLog.Test/Integrations/GitHub/GitHubLinkTaskTest.cs index 7c82fdc2..0f179135 100644 --- a/src/ChangeLog.Test/Integrations/GitHub/GitHubLinkTaskTest.cs +++ b/src/ChangeLog.Test/Integrations/GitHub/GitHubLinkTaskTest.cs @@ -585,6 +585,10 @@ public async Task Run_ignores_footers_which_cannot_be_parsed(string footerText) .SetupGet() .ReturnsTestCommit(); + m_GithubClientMock.Repository.Content + .SetupGetAllContents() + .ThrowsNotFound(); + var sut = new GitHubLinkTask(m_Logger, m_DefaultConfiguration, repoMock.Object, m_GitHubClientFactoryMock.Object); var changeLog = new ApplicationChangeLog() @@ -674,6 +678,202 @@ public async Task Run_does_not_add_a_links_to_footers_if_no_issue_or_pull_reques m_GithubClientMock.PullRequest.Verify(x => x.Get(owner, repo, It.IsAny()), Times.Once); } + [Theory] + [InlineData("some-file.md", "https://example.com/owner/repo/default-branch/some-file.md", "owner", "repo")] + [InlineData("dir/some-file.md", "https://example.com/owner/repo/default-branch/dir/some-file.md", "owner", "repo")] + [InlineData("dir/../some-file.md", "https://example.com/owner/repo/default-branch/some-file.md", "owner", "repo")] + public async Task Run_adds_link_to_repository_file_references(string footerText, string expectedLink, string owner, string repo) + { + // ARRANGE + var repoMock = new Mock(MockBehavior.Strict); + repoMock.SetupRemotes("origin", $"http://github.com/{owner}/{repo}.git"); + + m_GithubClientMock.Repository.Content + .Setup(x => x.GetAllContents(owner, repo, footerText)) + .ReturnsTestRepositoryContent( + new TestRepositoryContent(expectedLink, "File Content") + ); + + var sut = new GitHubLinkTask(m_Logger, m_DefaultConfiguration, repoMock.Object, m_GitHubClientFactoryMock.Object); + + var changeLog = new ApplicationChangeLog() + { + GetSingleVersionChangeLog( + "1.2.3", + null, + GetChangeLogEntry(summary: "Entry1", commit: TestGitIds.Id1, footers: new [] + { + new ChangeLogEntryFooter(new CommitMessageFooterName("See-Also"), new PlainTextElement(footerText)) + }) + ) + }; + + // ACT + var result = await sut.RunAsync(changeLog); + + // ASSERT + Assert.Equal(ChangeLogTaskResult.Success, result); + + var entries = changeLog.ChangeLogs.SelectMany(x => x.AllEntries).ToArray(); + Assert.All(entries, entry => + { + Assert.All(entry.Footers.Where(x => x.Name == new CommitMessageFooterName("See-Also")), footer => + { + var fileReferenceElement = Assert.IsType(footer.Value); + Assert.Equal(footerText, fileReferenceElement.Text); + Assert.Equal(expectedLink, fileReferenceElement.Uri.ToString()); + }); + + }); + + m_GithubClientMock.Repository.Content.Verify(x => x.GetAllContents(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + m_GithubClientMock.Repository.Content.Verify(x => x.GetAllContents(owner, repo, footerText), Times.Once); + } + + [Theory] + [InlineData("https://example.com/some-file.md")] + public async Task Run_does_not_add_link_to_absolute_paths(string footerText) + { + // ARRANGE + var repoMock = new Mock(MockBehavior.Strict); + repoMock.SetupRemotes("origin", "http://github.com/owner/repo.git"); + + m_GithubClientMock.Repository.Content + .Setup(x => x.GetAllContents("owner", "repo", footerText)) + .ThrowsNotFound(); + + var sut = new GitHubLinkTask(m_Logger, m_DefaultConfiguration, repoMock.Object, m_GitHubClientFactoryMock.Object); + + var originalFooterValue = new PlainTextElement(footerText); + var changeLog = new ApplicationChangeLog() + { + GetSingleVersionChangeLog( + "1.2.3", + null, + GetChangeLogEntry(summary: "Entry1", commit: TestGitIds.Id1, footers: new [] + { + new ChangeLogEntryFooter(new CommitMessageFooterName("See-Also"), originalFooterValue) + }) + ) + }; + + // ACT + var result = await sut.RunAsync(changeLog); + + // ASSERT + Assert.Equal(ChangeLogTaskResult.Success, result); + + var entries = changeLog.ChangeLogs.SelectMany(x => x.AllEntries).ToArray(); + Assert.All(entries, entry => + { + Assert.All(entry.Footers.Where(x => x.Name == new CommitMessageFooterName("See-Also")), footer => + { + Assert.Same(originalFooterValue, footer.Value); + }); + + }); + + m_GithubClientMock.Repository.Content.Verify(x => x.GetAllContents(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("some-file.md")] + public async Task Run_does_not_add_link_to_repository_file_references_if_file_does_not_exist(string footerText) + { + // ARRANGE + var repoMock = new Mock(MockBehavior.Strict); + repoMock.SetupRemotes("origin", "http://github.com/owner/repo.git"); + + m_GithubClientMock.Repository.Content + .Setup(x => x.GetAllContents("owner", "repo", footerText)) + .ThrowsNotFound(); + + var sut = new GitHubLinkTask(m_Logger, m_DefaultConfiguration, repoMock.Object, m_GitHubClientFactoryMock.Object); + + var originalFooterValue = new PlainTextElement(footerText); + var changeLog = new ApplicationChangeLog() + { + GetSingleVersionChangeLog( + "1.2.3", + null, + GetChangeLogEntry(summary: "Entry1", commit: TestGitIds.Id1, footers: new [] + { + new ChangeLogEntryFooter(new CommitMessageFooterName("See-Also"), originalFooterValue) + }) + ) + }; + + // ACT + var result = await sut.RunAsync(changeLog); + + // ASSERT + Assert.Equal(ChangeLogTaskResult.Success, result); + + var entries = changeLog.ChangeLogs.SelectMany(x => x.AllEntries).ToArray(); + Assert.All(entries, entry => + { + Assert.All(entry.Footers.Where(x => x.Name == new CommitMessageFooterName("See-Also")), footer => + { + Assert.Same(originalFooterValue, footer.Value); + }); + + }); + + m_GithubClientMock.Repository.Content.Verify(x => x.GetAllContents(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + m_GithubClientMock.Repository.Content.Verify(x => x.GetAllContents("owner", "repo", footerText), Times.Once); + } + + [Theory] + [InlineData("some-directory", 0)] + [InlineData("some-directory", 1)] + [InlineData("some-directory", 5)] + public async Task Run_does_not_add_link_to_repository_file_references_if_path_is_a_directory(string footerText, int fileCount) + { + // ARRANGE + var repoMock = new Mock(MockBehavior.Strict); + repoMock.SetupRemotes("origin", "http://github.com/owner/repo.git"); + + m_GithubClientMock.Repository.Content + .Setup(x => x.GetAllContents("owner", "repo", footerText)) + .ReturnsTestRepositoryContent( + Enumerable.Range(0, fileCount).Select(x => new TestRepositoryContent("https://example.com", content: null)) + ); + + var sut = new GitHubLinkTask(m_Logger, m_DefaultConfiguration, repoMock.Object, m_GitHubClientFactoryMock.Object); + + var originalFooterValue = new PlainTextElement(footerText); + var changeLog = new ApplicationChangeLog() + { + GetSingleVersionChangeLog( + "1.2.3", + null, + GetChangeLogEntry(summary: "Entry1", commit: TestGitIds.Id1, footers: new [] + { + new ChangeLogEntryFooter(new CommitMessageFooterName("See-Also"), originalFooterValue) + }) + ) + }; + + // ACT + var result = await sut.RunAsync(changeLog); + + // ASSERT + Assert.Equal(ChangeLogTaskResult.Success, result); + + var entries = changeLog.ChangeLogs.SelectMany(x => x.AllEntries).ToArray(); + Assert.All(entries, entry => + { + Assert.All(entry.Footers.Where(x => x.Name == new CommitMessageFooterName("See-Also")), footer => + { + Assert.Same(originalFooterValue, footer.Value); + }); + + }); + + m_GithubClientMock.Repository.Content.Verify(x => x.GetAllContents(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + m_GithubClientMock.Repository.Content.Verify(x => x.GetAllContents("owner", "repo", footerText), Times.Once); + } + [Theory] [InlineData("github.com")] [InlineData("github.example.com")] diff --git a/src/ChangeLog.Test/Integrations/GitHub/_Helpers/GitHubClientMock.cs b/src/ChangeLog.Test/Integrations/GitHub/_Helpers/GitHubClientMock.cs index 1b4dedbc..d69ec58b 100644 --- a/src/ChangeLog.Test/Integrations/GitHub/_Helpers/GitHubClientMock.cs +++ b/src/ChangeLog.Test/Integrations/GitHub/_Helpers/GitHubClientMock.cs @@ -13,11 +13,13 @@ public class RepositoriesClientMock public Mock Commit { get; } = new(MockBehavior.Strict); + public Mock Content { get; } = new(MockBehavior.Strict); + public RepositoriesClientMock() { m_Mock.Setup(x => x.Commit).Returns(Commit.Object); - + m_Mock.Setup(x => x.Content).Returns(Content.Object); } } diff --git a/src/ChangeLog.Test/Integrations/GitHub/_Helpers/OctokitMockExtensions.cs b/src/ChangeLog.Test/Integrations/GitHub/_Helpers/OctokitMockExtensions.cs index fd17d8a9..73f08fbd 100644 --- a/src/ChangeLog.Test/Integrations/GitHub/_Helpers/OctokitMockExtensions.cs +++ b/src/ChangeLog.Test/Integrations/GitHub/_Helpers/OctokitMockExtensions.cs @@ -1,4 +1,6 @@ -using System.Net; +using System.Collections.Generic; +using System.Linq; +using System.Net; using System.Threading.Tasks; using Moq; using Moq.Language.Flow; @@ -57,5 +59,29 @@ public static IReturnsResult ThrowsNotFound(this ISetup>> SetupGetAllContents(this Mock mock) + { + return mock.Setup(x => x.GetAllContents(It.IsAny(), It.IsAny(), It.IsAny())); + } + + public static IReturnsResult ThrowsNotFound(this ISetup>> setup) + { + return setup.ThrowsAsync(new NotFoundException("Not found", HttpStatusCode.NotFound)); + } + + public static IReturnsResult ReturnsTestRepositoryContent( + this ISetup>> setup, + params TestRepositoryContent[] files) + { + return setup.ReturnsAsync((string _, string _, string _) => files); + } + + public static IReturnsResult ReturnsTestRepositoryContent( + this ISetup>> setup, + IEnumerable files) + { + return setup.ReturnsAsync((string _, string _, string _) => files.ToList()); + } } } diff --git a/src/ChangeLog.Test/Integrations/GitHub/_Helpers/TestRepositoryContent.cs b/src/ChangeLog.Test/Integrations/GitHub/_Helpers/TestRepositoryContent.cs new file mode 100644 index 00000000..d240e269 --- /dev/null +++ b/src/ChangeLog.Test/Integrations/GitHub/_Helpers/TestRepositoryContent.cs @@ -0,0 +1,25 @@ +using System; +using Octokit; + +namespace Grynwald.ChangeLog.Test.Integrations.GitHub +{ + public class TestRepositoryContent : RepositoryContent + { + public Uri HtmlUri => new Uri(HtmlUrl); + + public TestRepositoryContent(string htmlUrl, string? content) + { + HtmlUrl = htmlUrl; + + var encodedContent = content is null + ? null + : Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content)); ; + + // No really a good idea but probably okay for test code + typeof(RepositoryContent) + .GetProperty(nameof(EncodedContent))! + .SetMethod! + .Invoke(this, new[] { encodedContent }); + } + } +} diff --git a/src/ChangeLog/Integrations/GitHub/GitHubFileReferenceTextElement.cs b/src/ChangeLog/Integrations/GitHub/GitHubFileReferenceTextElement.cs new file mode 100644 index 00000000..7e894da6 --- /dev/null +++ b/src/ChangeLog/Integrations/GitHub/GitHubFileReferenceTextElement.cs @@ -0,0 +1,42 @@ +using System; +using Grynwald.ChangeLog.Model.Text; + +namespace Grynwald.ChangeLog.Integrations.GitHub +{ + /// + /// A that references a file in a GitHub repository + /// + internal sealed class GitHubFileReferenceTextElement : ITextElement, IWebLinkTextElement + { + /// + public string Text { get; } + + /// + public TextStyle Style => TextStyle.None; + + /// + public Uri Uri { get; } + + /// + /// Gets the relative path of the file within the GitHub repository. + /// + public string RelativePath { get; } + + + /// + /// Initializes a new instance of . + /// + public GitHubFileReferenceTextElement(string text, Uri uri, string relativePath) + { + if (String.IsNullOrWhiteSpace(text)) + throw new ArgumentException("Value must not be null or whitespace", nameof(text)); + + if (String.IsNullOrWhiteSpace(relativePath)) + throw new ArgumentException("Value must not be null or whitespace", nameof(relativePath)); + + Text = text; + Uri = uri ?? throw new ArgumentNullException(nameof(uri)); + RelativePath = relativePath; + } + } +} diff --git a/src/ChangeLog/Integrations/GitHub/GitHubLinkTask.cs b/src/ChangeLog/Integrations/GitHub/GitHubLinkTask.cs index 1a6e4c63..6470cbdb 100644 --- a/src/ChangeLog/Integrations/GitHub/GitHubLinkTask.cs +++ b/src/ChangeLog/Integrations/GitHub/GitHubLinkTask.cs @@ -18,7 +18,7 @@ namespace Grynwald.ChangeLog.Integrations.GitHub /// [BeforeTask(typeof(RenderTemplateTask))] [AfterTask(typeof(ParseCommitsTask))] - // AddCommitFooterTask must run before eGitHubLinkTask so a web link can be added to the "Commit" footer + // AddCommitFooterTask must run before GitHubLinkTask so a web link can be added to the "Commit" footer [AfterTask(typeof(AddCommitFooterTask))] internal sealed class GitHubLinkTask : IChangeLogTask { @@ -185,6 +185,18 @@ private async Task ProcessEntryAsync(GitHubProjectInfo projectInfo, IGitHubClien m_Logger.LogWarning($"Failed to determine web uri for reference '{reference}'"); } } + // Detect references to files from the reppository. + // Inspired by GiLab's "repository file references", see https://docs.gitlab.com/ee/user/markdown.html#gitlab-specific-references + else if (footer.Value is PlainTextElement && Uri.TryCreate(footer.Value.Text, UriKind.Relative, out var relativeUri)) + { + var relativePath = relativeUri.ToString(); + var uri = await TryGetRepositoryFileLink(githubClient, projectInfo, relativePath); + if (uri is not null) + { + footer.Value = new GitHubFileReferenceTextElement(footer.Value.Text, uri, relativeUri.ToString()); + } + } + // TODO: Also detect file references in Markdown links (like [Description](./docs/some-docs.md) } } @@ -245,5 +257,30 @@ private async Task ProcessEntryAsync(GitHubProjectInfo projectInfo, IGitHubClien } } + private async Task TryGetRepositoryFileLink(IGitHubClient gitHubClient, GitHubProjectInfo project, string relativePath) + { + //TODO: Recognize links to file numbers + //TODO: Normalize file paths? + try + { + var files = await gitHubClient.Repository.Content.GetAllContents(project.Owner, project.Repository, relativePath); + + // relativePath can be a path to either a file or a directory in the GitHub repository + // - If the path is a file, GetAllContents() will return a single item with a non-null Content + // - If there are multiple items in the response, relativePath is a directory path => ignore + // - If the response is a single item and Content is null, relativePath is path to a directory that contains a single file => ignore + if (files is [{ Content: not null } file]) + { + return new Uri(file.HtmlUrl); + } + } + // Not found => ignore link + catch (NotFoundException) + { + return null; + } + + return null; + } } } From 12d9ae5e151003b7f9b044a36a4de634194b2cc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Gr=C3=BCnwald?= Date: Fri, 6 Jan 2023 19:10:11 +0100 Subject: [PATCH 2/2] Recoginize repository file references in Markdown links When a footer value is a Markdown link with a relative path to a file that exists in the repository (in the default branch), convert the footer into a link to that file in the GitHub Web UI --- .../Integrations/GitHub/GitHubLinkTaskTest.cs | 23 +++++++---- .../Integrations/GitHub/GitHubLinkTask.cs | 31 +++++++++++--- .../Tasks/ParseMarkdownWebLinksTask.cs | 20 ++-------- src/ChangeLog/Utilities/MarkdownLinkParser.cs | 40 +++++++++++++++++++ 4 files changed, 84 insertions(+), 30 deletions(-) create mode 100644 src/ChangeLog/Utilities/MarkdownLinkParser.cs diff --git a/src/ChangeLog.Test/Integrations/GitHub/GitHubLinkTaskTest.cs b/src/ChangeLog.Test/Integrations/GitHub/GitHubLinkTaskTest.cs index 0f179135..b9c3690b 100644 --- a/src/ChangeLog.Test/Integrations/GitHub/GitHubLinkTaskTest.cs +++ b/src/ChangeLog.Test/Integrations/GitHub/GitHubLinkTaskTest.cs @@ -679,17 +679,24 @@ public async Task Run_does_not_add_a_links_to_footers_if_no_issue_or_pull_reques } [Theory] - [InlineData("some-file.md", "https://example.com/owner/repo/default-branch/some-file.md", "owner", "repo")] - [InlineData("dir/some-file.md", "https://example.com/owner/repo/default-branch/dir/some-file.md", "owner", "repo")] - [InlineData("dir/../some-file.md", "https://example.com/owner/repo/default-branch/some-file.md", "owner", "repo")] - public async Task Run_adds_link_to_repository_file_references(string footerText, string expectedLink, string owner, string repo) + [InlineData("some-file.md", "some-file.md", "some-file.md", "https://example.com/owner/repo/default-branch/some-file.md")] + [InlineData("./some-file.md", "./some-file.md", "./some-file.md", "https://example.com/owner/repo/default-branch/some-file.md")] + [InlineData("dir/some-file.md", "dir/some-file.md", "dir/some-file.md", "https://example.com/owner/repo/default-branch/dir/some-file.md")] + [InlineData("dir/../some-file.md", "dir/../some-file.md", "dir/../some-file.md", "https://example.com/owner/repo/default-branch/some-file.md")] + [InlineData("[Link Text](some-file.md)", "some-file.md", "Link Text", "https://example.com/owner/repo/default-branch/some-file.md")] + [InlineData("[Link Text](./dir/../some-file.md)", "./dir/../some-file.md", "Link Text", "https://example.com/owner/repo/default-branch/some-file.md")] + [InlineData("[](some-file.md)", "some-file.md", "some-file.md", "https://example.com/owner/repo/default-branch/some-file.md")] + [InlineData("[ ](some-file.md)", "some-file.md", "some-file.md", "https://example.com/owner/repo/default-branch/some-file.md")] + public async Task Run_adds_link_to_repository_file_references(string footerText, string filePath, string expectedText, string expectedLink) { // ARRANGE + var owner = "owner"; + var repoName = "repo"; var repoMock = new Mock(MockBehavior.Strict); - repoMock.SetupRemotes("origin", $"http://github.com/{owner}/{repo}.git"); + repoMock.SetupRemotes("origin", $"http://github.com/{owner}/{repoName}.git"); m_GithubClientMock.Repository.Content - .Setup(x => x.GetAllContents(owner, repo, footerText)) + .Setup(x => x.GetAllContents(owner, repoName, filePath)) .ReturnsTestRepositoryContent( new TestRepositoryContent(expectedLink, "File Content") ); @@ -720,14 +727,14 @@ public async Task Run_adds_link_to_repository_file_references(string footerText, Assert.All(entry.Footers.Where(x => x.Name == new CommitMessageFooterName("See-Also")), footer => { var fileReferenceElement = Assert.IsType(footer.Value); - Assert.Equal(footerText, fileReferenceElement.Text); + Assert.Equal(expectedText, fileReferenceElement.Text); Assert.Equal(expectedLink, fileReferenceElement.Uri.ToString()); }); }); m_GithubClientMock.Repository.Content.Verify(x => x.GetAllContents(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); - m_GithubClientMock.Repository.Content.Verify(x => x.GetAllContents(owner, repo, footerText), Times.Once); + m_GithubClientMock.Repository.Content.Verify(x => x.GetAllContents(owner, repoName, filePath), Times.Once); } [Theory] diff --git a/src/ChangeLog/Integrations/GitHub/GitHubLinkTask.cs b/src/ChangeLog/Integrations/GitHub/GitHubLinkTask.cs index 6470cbdb..7d5f11ef 100644 --- a/src/ChangeLog/Integrations/GitHub/GitHubLinkTask.cs +++ b/src/ChangeLog/Integrations/GitHub/GitHubLinkTask.cs @@ -8,6 +8,7 @@ using Grynwald.ChangeLog.Model.Text; using Grynwald.ChangeLog.Pipeline; using Grynwald.ChangeLog.Tasks; +using Grynwald.ChangeLog.Utilities; using Microsoft.Extensions.Logging; using Octokit; @@ -187,16 +188,34 @@ private async Task ProcessEntryAsync(GitHubProjectInfo projectInfo, IGitHubClien } // Detect references to files from the reppository. // Inspired by GiLab's "repository file references", see https://docs.gitlab.com/ee/user/markdown.html#gitlab-specific-references - else if (footer.Value is PlainTextElement && Uri.TryCreate(footer.Value.Text, UriKind.Relative, out var relativeUri)) + // A repository file reference can either be + // - A plain, relative path to a file in the repository - or - + // - A Markdown link with a relative path to a file in the reposiory + else if (footer.Value is PlainTextElement) { - var relativePath = relativeUri.ToString(); - var uri = await TryGetRepositoryFileLink(githubClient, projectInfo, relativePath); - if (uri is not null) + string text; + string path; + + if (MarkdownLinkParser.TryParseMarkdownLink(footer.Value.Text, out var linkText, out var linkDestination)) + { + text = String.IsNullOrWhiteSpace(linkText) ? linkDestination : linkText; + path = linkDestination; + } + else + { + text = footer.Value.Text; + path = footer.Value.Text; + } + + if (Uri.TryCreate(path, UriKind.Relative, out var relativeUri)) { - footer.Value = new GitHubFileReferenceTextElement(footer.Value.Text, uri, relativeUri.ToString()); + var uri = await TryGetRepositoryFileLink(githubClient, projectInfo, relativeUri.ToString()); + if (uri is not null) + { + footer.Value = new GitHubFileReferenceTextElement(text, uri, relativeUri.ToString()); + } } } - // TODO: Also detect file references in Markdown links (like [Description](./docs/some-docs.md) } } diff --git a/src/ChangeLog/Tasks/ParseMarkdownWebLinksTask.cs b/src/ChangeLog/Tasks/ParseMarkdownWebLinksTask.cs index 1054e119..0ea42996 100644 --- a/src/ChangeLog/Tasks/ParseMarkdownWebLinksTask.cs +++ b/src/ChangeLog/Tasks/ParseMarkdownWebLinksTask.cs @@ -4,6 +4,7 @@ using Grynwald.ChangeLog.Model; using Grynwald.ChangeLog.Model.Text; using Grynwald.ChangeLog.Pipeline; +using Grynwald.ChangeLog.Utilities; using Microsoft.Extensions.Logging; namespace Grynwald.ChangeLog.Tasks @@ -11,23 +12,12 @@ namespace Grynwald.ChangeLog.Tasks /// /// Detects footer values that are web links using the Markdown link syntax (e.g. [Some Link](https://example.com)). /// When a valid link is found, the footer's value (see ) is replaced with a . - /// - /// Links (CommonMark Spec, version 0.30) + /// [AfterTask(typeof(ParseCommitsTask))] [AfterTask(typeof(ParseWebLinksTask))] [BeforeTask(typeof(RenderTemplateTask))] internal sealed partial class ParseMarkdownWebLinksTask : SynchronousChangeLogTask { -#if NET7_0_OR_GREATER - [GeneratedRegex("^\\s*\\[(?.*)\\]\\((?.+)\\)\\s*$", RegexOptions.Singleline)] - private static partial Regex MarkdownLinkRegex(); - - // TODO: This field can be removed and usages can be replaced by a call to MarkdownLinkRegex(), once .NET 6 support is no removed - private static readonly Regex s_MarkdownLinkRegex = MarkdownLinkRegex(); -#else - private static readonly Regex s_MarkdownLinkRegex = new("^\\s*\\[(?.*)\\]\\((?.+)\\)\\s*$", RegexOptions.Singleline); -#endif - private readonly ILogger m_Logger; @@ -46,14 +36,12 @@ protected override ChangeLogTaskResult Run(ApplicationChangeLog changelog) foreach (var footer in changeLogEntry.Footers) { if (footer.Value is PlainTextElement plainText && - s_MarkdownLinkRegex.Match(plainText.Text) is { Success: true } match && - Uri.TryCreate(match.Groups["destination"].Value, UriKind.Absolute, out var destination) && + MarkdownLinkParser.TryParseMarkdownLink(plainText.Text, out var linkText, out var linkDestination) && + Uri.TryCreate(linkDestination, UriKind.Absolute, out var destination) && destination.IsWebLink()) { m_Logger.LogDebug($"Detected Markdown link '{plainText.Text}' in footer for commit '{changeLogEntry.Commit.Id}'. Replacing footer value with web link"); - var linkText = match.Groups["text"].Value; - // If link text is empty, e.g. "[](https://example.com", use the destination as text if (String.IsNullOrWhiteSpace(linkText)) { diff --git a/src/ChangeLog/Utilities/MarkdownLinkParser.cs b/src/ChangeLog/Utilities/MarkdownLinkParser.cs new file mode 100644 index 00000000..64bbbc46 --- /dev/null +++ b/src/ChangeLog/Utilities/MarkdownLinkParser.cs @@ -0,0 +1,40 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +namespace Grynwald.ChangeLog.Utilities +{ + /// + /// Simple regex-based parsed for Markdown links (e.g. [Some Link](https://example.com)). + /// + /// Links (CommonMark Spec, version 0.30) + internal static partial class MarkdownLinkParser + { +#if NET7_0_OR_GREATER + [GeneratedRegex("^\\s*\\[(?.*)\\]\\((?.+)\\)\\s*$", RegexOptions.Singleline)] + private static partial Regex MarkdownLinkRegex(); + + // TODO: This field can be removed and usages can be replaced by a call to MarkdownLinkRegex(), once .NET 6 support is no removed + private static readonly Regex s_MarkdownLinkRegex = MarkdownLinkRegex(); +#else + private static readonly Regex s_MarkdownLinkRegex = new("^\\s*\\[(?.*)\\]\\((?.+)\\)\\s*$", RegexOptions.Singleline); +#endif + + public static bool TryParseMarkdownLink(string markdown, out string? text, [NotNullWhen(true)] out string? destination) + { + if (s_MarkdownLinkRegex.Match(markdown) is { Success: true } match) + { + destination = match.Groups["destination"].Value; + text = match.Groups["text"].Value; + if (!String.IsNullOrWhiteSpace(destination)) + { + return true; + } + } + + text = null; + destination = null; + return false; + } + } +}