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..b9c3690b 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,209 @@ 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", "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}/{repoName}.git");
+
+ m_GithubClientMock.Repository.Content
+ .Setup(x => x.GetAllContents(owner, repoName, filePath))
+ .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(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, repoName, filePath), 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..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;
@@ -18,7 +19,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 +186,36 @@ 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
+ // 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)
+ {
+ 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))
+ {
+ var uri = await TryGetRepositoryFileLink(githubClient, projectInfo, relativeUri.ToString());
+ if (uri is not null)
+ {
+ footer.Value = new GitHubFileReferenceTextElement(text, uri, relativeUri.ToString());
+ }
+ }
+ }
}
}
@@ -245,5 +276,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;
+ }
}
}
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;
+ }
+ }
+}