diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
new file mode 100644
index 0000000..17a1212
--- /dev/null
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
@@ -0,0 +1,218 @@
+// Copyright (c) DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+using System.Text.RegularExpressions;
+
+namespace DemaConsulting.BuildMark;
+
+///
+/// GitHub repository connector implementation.
+///
+public partial class GitHubRepoConnector : RepoConnectorBase
+{
+ private static readonly Dictionary LabelTypeMap = new()
+ {
+ { "bug", "bug" },
+ { "defect", "bug" },
+ { "feature", "feature" },
+ { "enhancement", "feature" },
+ { "documentation", "documentation" },
+ { "performance", "performance" },
+ { "security", "security" }
+ };
+
+ ///
+ /// Validates and sanitizes a tag name to prevent command injection.
+ ///
+ /// Tag name to validate.
+ /// Sanitized tag name.
+ /// Thrown if tag name is invalid.
+ private static string ValidateTag(string tag)
+ {
+ if (!TagNameRegex().IsMatch(tag))
+ {
+ throw new ArgumentException($"Invalid tag name: {tag}", nameof(tag));
+ }
+
+ return tag;
+ }
+
+ ///
+ /// Validates and sanitizes an issue or PR ID to prevent command injection.
+ ///
+ /// ID to validate.
+ /// Parameter name for exception message.
+ /// Sanitized ID.
+ /// Thrown if ID is invalid.
+ private static string ValidateId(string id, string paramName)
+ {
+ if (!NumericIdRegex().IsMatch(id))
+ {
+ throw new ArgumentException($"Invalid ID: {id}", paramName);
+ }
+
+ return id;
+ }
+
+ ///
+ /// Gets the history of tags leading to the current branch.
+ ///
+ /// List of tags in chronological order.
+ public override async Task> GetTagHistoryAsync()
+ {
+ var output = await RunCommandAsync("git", "tag --sort=creatordate --merged HEAD");
+ return output
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .Select(t => t.Trim())
+ .ToList();
+ }
+
+ ///
+ /// Gets the list of pull request IDs between two tags.
+ ///
+ /// Starting tag (null for start of history).
+ /// Ending tag (null for current state).
+ /// List of pull request IDs.
+ public override async Task> GetPullRequestsBetweenTagsAsync(string? fromTag, string? toTag)
+ {
+ string range;
+ if (string.IsNullOrEmpty(fromTag) && string.IsNullOrEmpty(toTag))
+ {
+ range = "HEAD";
+ }
+ else if (string.IsNullOrEmpty(fromTag))
+ {
+ range = ValidateTag(toTag!);
+ }
+ else if (string.IsNullOrEmpty(toTag))
+ {
+ range = $"{ValidateTag(fromTag)}..HEAD";
+ }
+ else
+ {
+ range = $"{ValidateTag(fromTag)}..{ValidateTag(toTag)}";
+ }
+
+ var output = await RunCommandAsync("git", $"log --oneline --merges {range}");
+ var pullRequests = new List();
+ var regex = NumberReferenceRegex();
+
+ foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var match = regex.Match(line);
+ if (match.Success)
+ {
+ pullRequests.Add(match.Groups[1].Value);
+ }
+ }
+
+ return pullRequests;
+ }
+
+ ///
+ /// Gets the issue IDs associated with a pull request.
+ ///
+ /// Pull request ID.
+ /// List of issue IDs.
+ public override async Task> GetIssuesForPullRequestAsync(string pullRequestId)
+ {
+ var validatedId = ValidateId(pullRequestId, nameof(pullRequestId));
+ var output = await RunCommandAsync("gh", $"pr view {validatedId} --json body --jq .body");
+ var issues = new List();
+ var regex = NumberReferenceRegex();
+
+ foreach (Match match in regex.Matches(output))
+ {
+ issues.Add(match.Groups[1].Value);
+ }
+
+ return issues;
+ }
+
+ ///
+ /// Gets the title of an issue.
+ ///
+ /// Issue ID.
+ /// Issue title.
+ public override async Task GetIssueTitleAsync(string issueId)
+ {
+ var validatedId = ValidateId(issueId, nameof(issueId));
+ return await RunCommandAsync("gh", $"issue view {validatedId} --json title --jq .title");
+ }
+
+ ///
+ /// Gets the type of an issue (bug, feature, etc.).
+ ///
+ /// Issue ID.
+ /// Issue type.
+ public override async Task GetIssueTypeAsync(string issueId)
+ {
+ var validatedId = ValidateId(issueId, nameof(issueId));
+ var output = await RunCommandAsync("gh", $"issue view {validatedId} --json labels --jq '.labels[].name'");
+ var labels = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+
+ // Look for common type labels
+ foreach (var label in labels)
+ {
+ var lowerLabel = label.ToLowerInvariant();
+ foreach (var (key, value) in LabelTypeMap)
+ {
+ if (lowerLabel.Contains(key))
+ {
+ return value;
+ }
+ }
+ }
+
+ return "other";
+ }
+
+ ///
+ /// Gets the git hash for a tag.
+ ///
+ /// Tag name (null for current state).
+ /// Git hash.
+ public override async Task GetHashForTagAsync(string? tag)
+ {
+ var refName = string.IsNullOrEmpty(tag) ? "HEAD" : ValidateTag(tag);
+ return await RunCommandAsync("git", $"rev-parse {refName}");
+ }
+
+ ///
+ /// Regular expression to match valid tag names (alphanumeric, dots, hyphens, underscores, slashes).
+ ///
+ /// Compiled regular expression.
+ [GeneratedRegex(@"^[a-zA-Z0-9._/-]+$", RegexOptions.Compiled)]
+ private static partial Regex TagNameRegex();
+
+ ///
+ /// Regular expression to match numeric IDs.
+ ///
+ /// Compiled regular expression.
+ [GeneratedRegex(@"^\d+$", RegexOptions.Compiled)]
+ private static partial Regex NumericIdRegex();
+
+ ///
+ /// Regular expression to match number references (#123).
+ ///
+ /// Compiled regular expression.
+ [GeneratedRegex(@"#(\d+)", RegexOptions.Compiled)]
+ private static partial Regex NumberReferenceRegex();
+}
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/IRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/IRepoConnector.cs
new file mode 100644
index 0000000..fe63729
--- /dev/null
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/IRepoConnector.cs
@@ -0,0 +1,69 @@
+// Copyright (c) DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+namespace DemaConsulting.BuildMark;
+
+///
+/// Interface for repository connectors that fetch repository information.
+///
+public interface IRepoConnector
+{
+ ///
+ /// Gets the history of tags leading to the current branch.
+ ///
+ /// List of tags in chronological order.
+ Task> GetTagHistoryAsync();
+
+ ///
+ /// Gets the list of pull request IDs between two tags.
+ ///
+ /// Starting tag (null for start of history).
+ /// Ending tag (null for current state).
+ /// List of pull request IDs.
+ Task> GetPullRequestsBetweenTagsAsync(string? fromTag, string? toTag);
+
+ ///
+ /// Gets the issue IDs associated with a pull request.
+ ///
+ /// Pull request ID.
+ /// List of issue IDs.
+ Task> GetIssuesForPullRequestAsync(string pullRequestId);
+
+ ///
+ /// Gets the title of an issue.
+ ///
+ /// Issue ID.
+ /// Issue title.
+ Task GetIssueTitleAsync(string issueId);
+
+ ///
+ /// Gets the type of an issue (bug, feature, etc.).
+ ///
+ /// Issue ID.
+ /// Issue type.
+ Task GetIssueTypeAsync(string issueId);
+
+ ///
+ /// Gets the git hash for a tag.
+ ///
+ /// Tag name (null for current state).
+ /// Git hash.
+ Task GetHashForTagAsync(string? tag);
+}
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/MockRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/MockRepoConnector.cs
new file mode 100644
index 0000000..1f4e26a
--- /dev/null
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/MockRepoConnector.cs
@@ -0,0 +1,150 @@
+// Copyright (c) DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+namespace DemaConsulting.BuildMark;
+
+///
+/// Mock repository connector for deterministic testing.
+///
+public class MockRepoConnector : IRepoConnector
+{
+ private readonly Dictionary _issueTitles = new()
+ {
+ { "1", "Add feature X" },
+ { "2", "Fix bug in Y" },
+ { "3", "Update documentation" }
+ };
+
+ private readonly Dictionary _issueTypes = new()
+ {
+ { "1", "feature" },
+ { "2", "bug" },
+ { "3", "documentation" }
+ };
+
+ private readonly Dictionary> _pullRequestIssues = new()
+ {
+ { "10", new List { "1" } },
+ { "11", new List { "2" } },
+ { "12", new List { "3" } }
+ };
+
+ private readonly Dictionary _tagHashes = new()
+ {
+ { "v1.0.0", "abc123def456" },
+ { "v1.1.0", "def456ghi789" },
+ { "v2.0.0-beta.1", "ghi789jkl012" },
+ { "v2.0.0-rc.1", "jkl012mno345" },
+ { "v2.0.0", "mno345pqr678" }
+ };
+
+ ///
+ /// Gets the history of tags leading to the current branch.
+ ///
+ /// List of tags in chronological order.
+ public Task> GetTagHistoryAsync()
+ {
+ return Task.FromResult(new List { "v1.0.0", "v1.1.0", "v2.0.0-beta.1", "v2.0.0-rc.1", "v2.0.0" });
+ }
+
+ ///
+ /// Gets the list of pull request IDs between two tags.
+ ///
+ /// Starting tag (null for start of history).
+ /// Ending tag (null for current state).
+ /// List of pull request IDs.
+ public Task> GetPullRequestsBetweenTagsAsync(string? fromTag, string? toTag)
+ {
+ // Deterministic mock data based on tag range
+ if (fromTag == "v1.0.0" && toTag == "v1.1.0")
+ {
+ return Task.FromResult(new List { "10" });
+ }
+
+ if (fromTag == "v1.1.0" && toTag == "v2.0.0")
+ {
+ return Task.FromResult(new List { "11", "12" });
+ }
+
+ if (string.IsNullOrEmpty(fromTag) && toTag == "v1.0.0")
+ {
+ return Task.FromResult(new List { "10" });
+ }
+
+ return Task.FromResult(new List { "10", "11", "12" });
+ }
+
+ ///
+ /// Gets the issue IDs associated with a pull request.
+ ///
+ /// Pull request ID.
+ /// List of issue IDs.
+ public Task> GetIssuesForPullRequestAsync(string pullRequestId)
+ {
+ return Task.FromResult(
+ _pullRequestIssues.TryGetValue(pullRequestId, out var issues)
+ ? issues
+ : new List());
+ }
+
+ ///
+ /// Gets the title of an issue.
+ ///
+ /// Issue ID.
+ /// Issue title.
+ public Task GetIssueTitleAsync(string issueId)
+ {
+ return Task.FromResult(
+ _issueTitles.TryGetValue(issueId, out var title)
+ ? title
+ : $"Issue {issueId}");
+ }
+
+ ///
+ /// Gets the type of an issue (bug, feature, etc.).
+ ///
+ /// Issue ID.
+ /// Issue type.
+ public Task GetIssueTypeAsync(string issueId)
+ {
+ return Task.FromResult(
+ _issueTypes.TryGetValue(issueId, out var type)
+ ? type
+ : "other");
+ }
+
+ ///
+ /// Gets the git hash for a tag.
+ ///
+ /// Tag name (null for current state).
+ /// Git hash.
+ public Task GetHashForTagAsync(string? tag)
+ {
+ if (string.IsNullOrEmpty(tag))
+ {
+ return Task.FromResult("current123hash456");
+ }
+
+ return Task.FromResult(
+ _tagHashes.TryGetValue(tag, out var hash)
+ ? hash
+ : "unknown000hash000");
+ }
+}
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/ProcessRunner.cs b/src/DemaConsulting.BuildMark/RepoConnectors/ProcessRunner.cs
new file mode 100644
index 0000000..540f00f
--- /dev/null
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/ProcessRunner.cs
@@ -0,0 +1,116 @@
+// Copyright (c) DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+using System.Diagnostics;
+using System.Text;
+
+namespace DemaConsulting.BuildMark;
+
+///
+/// Helper class for running external processes and capturing output.
+///
+internal static class ProcessRunner
+{
+ ///
+ /// Runs a command and returns its output.
+ ///
+ /// Command to run.
+ /// Command arguments.
+ /// Command output.
+ /// Thrown when command fails.
+ public static async Task RunAsync(string command, string arguments)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = command,
+ Arguments = arguments,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ using var process = new Process { StartInfo = startInfo };
+ var output = new StringBuilder();
+ var error = new StringBuilder();
+
+ process.OutputDataReceived += (_, e) =>
+ {
+ if (e.Data != null)
+ {
+ output.AppendLine(e.Data);
+ }
+ };
+
+ process.ErrorDataReceived += (_, e) =>
+ {
+ if (e.Data != null)
+ {
+ error.AppendLine(e.Data);
+ }
+ };
+
+ process.Start();
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ await process.WaitForExitAsync();
+
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException(
+ $"Command '{command} {arguments}' failed with exit code {process.ExitCode}: {error}");
+ }
+
+ return output.ToString().TrimEnd();
+ }
+
+ ///
+ /// Tries to run a command and returns its output.
+ ///
+ /// Command to run.
+ /// Command arguments.
+ /// Command output, or null if the command fails.
+ public static async Task TryRunAsync(string command, string arguments)
+ {
+ try
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = command,
+ Arguments = arguments,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ using var process = new Process { StartInfo = startInfo };
+ process.Start();
+ var output = await process.StandardOutput.ReadToEndAsync();
+ await process.WaitForExitAsync();
+
+ return process.ExitCode == 0 ? output : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/RepoConnectorBase.cs b/src/DemaConsulting.BuildMark/RepoConnectors/RepoConnectorBase.cs
new file mode 100644
index 0000000..7d5277f
--- /dev/null
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/RepoConnectorBase.cs
@@ -0,0 +1,80 @@
+// Copyright (c) DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+namespace DemaConsulting.BuildMark;
+
+///
+/// Base class for repository connectors with common functionality.
+///
+public abstract class RepoConnectorBase : IRepoConnector
+{
+ ///
+ /// Runs a command and returns its output.
+ ///
+ /// Command to run.
+ /// Command arguments.
+ /// Command output.
+ protected virtual Task RunCommandAsync(string command, string arguments)
+ {
+ return ProcessRunner.RunAsync(command, arguments);
+ }
+
+ ///
+ /// Gets the history of tags leading to the current branch.
+ ///
+ /// List of tags in chronological order.
+ public abstract Task> GetTagHistoryAsync();
+
+ ///
+ /// Gets the list of pull request IDs between two tags.
+ ///
+ /// Starting tag (null for start of history).
+ /// Ending tag (null for current state).
+ /// List of pull request IDs.
+ public abstract Task> GetPullRequestsBetweenTagsAsync(string? fromTag, string? toTag);
+
+ ///
+ /// Gets the issue IDs associated with a pull request.
+ ///
+ /// Pull request ID.
+ /// List of issue IDs.
+ public abstract Task> GetIssuesForPullRequestAsync(string pullRequestId);
+
+ ///
+ /// Gets the title of an issue.
+ ///
+ /// Issue ID.
+ /// Issue title.
+ public abstract Task GetIssueTitleAsync(string issueId);
+
+ ///
+ /// Gets the type of an issue (bug, feature, etc.).
+ ///
+ /// Issue ID.
+ /// Issue type.
+ public abstract Task GetIssueTypeAsync(string issueId);
+
+ ///
+ /// Gets the git hash for a tag.
+ ///
+ /// Tag name (null for current state).
+ /// Git hash.
+ public abstract Task GetHashForTagAsync(string? tag);
+}
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/RepoConnectorFactory.cs b/src/DemaConsulting.BuildMark/RepoConnectors/RepoConnectorFactory.cs
new file mode 100644
index 0000000..4245cb7
--- /dev/null
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/RepoConnectorFactory.cs
@@ -0,0 +1,60 @@
+// Copyright (c) DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+namespace DemaConsulting.BuildMark;
+
+///
+/// Factory for creating repository connector instances.
+///
+public static class RepoConnectorFactory
+{
+ ///
+ /// Creates a repository connector based on the current environment.
+ ///
+ /// Repository connector instance.
+ public static IRepoConnector Create()
+ {
+ // Check for GitHub environment variables
+ if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")) ||
+ !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_WORKSPACE")))
+ {
+ return new GitHubRepoConnector();
+ }
+
+ // Check git remote for GitHub
+ if (IsGitHubRepository())
+ {
+ return new GitHubRepoConnector();
+ }
+
+ // Default to GitHub
+ return new GitHubRepoConnector();
+ }
+
+ ///
+ /// Checks if the current repository is a GitHub repository.
+ ///
+ /// True if GitHub repository.
+ private static bool IsGitHubRepository()
+ {
+ var output = ProcessRunner.TryRunAsync("git", "remote get-url origin").Result;
+ return output != null && output.Contains("github.com", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/test/DemaConsulting.BuildMark.Tests/GitHubRepoConnectorTests.cs b/test/DemaConsulting.BuildMark.Tests/GitHubRepoConnectorTests.cs
new file mode 100644
index 0000000..a5743e0
--- /dev/null
+++ b/test/DemaConsulting.BuildMark.Tests/GitHubRepoConnectorTests.cs
@@ -0,0 +1,405 @@
+// Copyright (c) DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+namespace DemaConsulting.BuildMark.Tests;
+
+///
+/// Testable GitHub repository connector that allows injecting command results.
+///
+internal class TestableGitHubRepoConnector : GitHubRepoConnector
+{
+ private readonly Dictionary _commandResults = new();
+
+ ///
+ /// Adds a command result for testing.
+ ///
+ /// Command name.
+ /// Command arguments.
+ /// Expected result.
+ public void AddCommandResult(string command, string arguments, string result)
+ {
+ _commandResults[$"{command} {arguments}"] = result;
+ }
+
+ ///
+ /// Runs a command and returns its output.
+ ///
+ /// Command to run.
+ /// Command arguments.
+ /// Command output.
+ protected override Task RunCommandAsync(string command, string arguments)
+ {
+ var key = $"{command} {arguments}";
+ if (_commandResults.TryGetValue(key, out var result))
+ {
+ return Task.FromResult(result);
+ }
+
+ throw new InvalidOperationException($"No result configured for command: {key}");
+ }
+}
+
+///
+/// Tests for the GitHubRepoConnector class.
+///
+[TestClass]
+public class GitHubRepoConnectorTests
+{
+ ///
+ /// Test that GetTagHistoryAsync returns expected tags.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetTagHistoryAsync_ReturnsExpectedTags()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult("git", "tag --sort=creatordate --merged HEAD", "v1.0.0\nv1.1.0\nv2.0.0");
+
+ // Act
+ var tags = await connector.GetTagHistoryAsync();
+
+ // Assert
+ Assert.HasCount(3, tags);
+ Assert.AreEqual("v1.0.0", tags[0]);
+ Assert.AreEqual("v1.1.0", tags[1]);
+ Assert.AreEqual("v2.0.0", tags[2]);
+ }
+
+ ///
+ /// Test that GetTagHistoryAsync returns empty list when no tags.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetTagHistoryAsync_ReturnsEmptyListWhenNoTags()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult("git", "tag --sort=creatordate --merged HEAD", "");
+
+ // Act
+ var tags = await connector.GetTagHistoryAsync();
+
+ // Assert
+ Assert.IsEmpty(tags);
+ }
+
+ ///
+ /// Test that GetPullRequestsBetweenTagsAsync returns expected PRs.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetPullRequestsBetweenTagsAsync_ReturnsExpectedPRs()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult(
+ "git",
+ "log --oneline --merges v1.0.0..v2.0.0",
+ "abc123 Merge pull request #10 from feature/x\ndef456 Merge pull request #11 from bugfix/y");
+
+ // Act
+ var prs = await connector.GetPullRequestsBetweenTagsAsync("v1.0.0", "v2.0.0");
+
+ // Assert
+ Assert.HasCount(2, prs);
+ Assert.AreEqual("10", prs[0]);
+ Assert.AreEqual("11", prs[1]);
+ }
+
+ ///
+ /// Test that GetPullRequestsBetweenTagsAsync handles null fromTag.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetPullRequestsBetweenTagsAsync_HandlesNullFromTag()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult(
+ "git",
+ "log --oneline --merges v1.0.0",
+ "abc123 Merge pull request #10 from feature/x");
+
+ // Act
+ var prs = await connector.GetPullRequestsBetweenTagsAsync(null, "v1.0.0");
+
+ // Assert
+ Assert.HasCount(1, prs);
+ Assert.AreEqual("10", prs[0]);
+ }
+
+ ///
+ /// Test that GetPullRequestsBetweenTagsAsync handles null toTag.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetPullRequestsBetweenTagsAsync_HandlesNullToTag()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult(
+ "git",
+ "log --oneline --merges v1.0.0..HEAD",
+ "abc123 Merge pull request #11 from feature/y");
+
+ // Act
+ var prs = await connector.GetPullRequestsBetweenTagsAsync("v1.0.0", null);
+
+ // Assert
+ Assert.HasCount(1, prs);
+ Assert.AreEqual("11", prs[0]);
+ }
+
+ ///
+ /// Test that GetPullRequestsBetweenTagsAsync handles both null tags.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetPullRequestsBetweenTagsAsync_HandlesBothNullTags()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult(
+ "git",
+ "log --oneline --merges HEAD",
+ "abc123 Merge pull request #12 from feature/z");
+
+ // Act
+ var prs = await connector.GetPullRequestsBetweenTagsAsync(null, null);
+
+ // Assert
+ Assert.HasCount(1, prs);
+ Assert.AreEqual("12", prs[0]);
+ }
+
+ ///
+ /// Test that GetIssuesForPullRequestAsync returns expected issues.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetIssuesForPullRequestAsync_ReturnsExpectedIssues()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult(
+ "gh",
+ "pr view 10 --json body --jq .body",
+ "This PR fixes #123 and resolves #456");
+
+ // Act
+ var issues = await connector.GetIssuesForPullRequestAsync("10");
+
+ // Assert
+ Assert.HasCount(2, issues);
+ Assert.AreEqual("123", issues[0]);
+ Assert.AreEqual("456", issues[1]);
+ }
+
+ ///
+ /// Test that GetIssuesForPullRequestAsync returns empty when no issues.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetIssuesForPullRequestAsync_ReturnsEmptyWhenNoIssues()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult(
+ "gh",
+ "pr view 10 --json body --jq .body",
+ "This PR has no issue references");
+
+ // Act
+ var issues = await connector.GetIssuesForPullRequestAsync("10");
+
+ // Assert
+ Assert.IsEmpty(issues);
+ }
+
+ ///
+ /// Test that GetIssueTitleAsync returns expected title.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetIssueTitleAsync_ReturnsExpectedTitle()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult("gh", "issue view 123 --json title --jq .title", "Add new feature");
+
+ // Act
+ var title = await connector.GetIssueTitleAsync("123");
+
+ // Assert
+ Assert.AreEqual("Add new feature", title);
+ }
+
+ ///
+ /// Test that GetIssueTypeAsync returns bug for bug label.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetIssueTypeAsync_ReturnsBugForBugLabel()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult("gh", "issue view 123 --json labels --jq '.labels[].name'", "bug\npriority:high");
+
+ // Act
+ var type = await connector.GetIssueTypeAsync("123");
+
+ // Assert
+ Assert.AreEqual("bug", type);
+ }
+
+ ///
+ /// Test that GetIssueTypeAsync returns feature for enhancement label.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetIssueTypeAsync_ReturnsFeatureForEnhancementLabel()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult("gh", "issue view 123 --json labels --jq '.labels[].name'", "enhancement");
+
+ // Act
+ var type = await connector.GetIssueTypeAsync("123");
+
+ // Assert
+ Assert.AreEqual("feature", type);
+ }
+
+ ///
+ /// Test that GetIssueTypeAsync returns other for unknown label.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetIssueTypeAsync_ReturnsOtherForUnknownLabel()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult("gh", "issue view 123 --json labels --jq '.labels[].name'", "question");
+
+ // Act
+ var type = await connector.GetIssueTypeAsync("123");
+
+ // Assert
+ Assert.AreEqual("other", type);
+ }
+
+ ///
+ /// Test that GetHashForTagAsync returns expected hash.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetHashForTagAsync_ReturnsExpectedHash()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult("git", "rev-parse v1.0.0", "abc123def456789");
+
+ // Act
+ var hash = await connector.GetHashForTagAsync("v1.0.0");
+
+ // Assert
+ Assert.AreEqual("abc123def456789", hash);
+ }
+
+ ///
+ /// Test that GetHashForTagAsync returns current hash for null tag.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetHashForTagAsync_ReturnsCurrentHashForNullTag()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+ connector.AddCommandResult("git", "rev-parse HEAD", "current123hash456");
+
+ // Act
+ var hash = await connector.GetHashForTagAsync(null);
+
+ // Assert
+ Assert.AreEqual("current123hash456", hash);
+ }
+
+ ///
+ /// Test that GetPullRequestsBetweenTagsAsync throws for invalid tag names.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetPullRequestsBetweenTagsAsync_ThrowsForInvalidTagName()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ async () => await connector.GetPullRequestsBetweenTagsAsync("v1.0.0; rm -rf /", null));
+ Assert.Contains("Invalid tag name", ex.Message);
+ }
+
+ ///
+ /// Test that GetIssuesForPullRequestAsync throws for invalid PR ID.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetIssuesForPullRequestAsync_ThrowsForInvalidPRId()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ async () => await connector.GetIssuesForPullRequestAsync("10; cat /etc/passwd"));
+ Assert.Contains("Invalid ID", ex.Message);
+ }
+
+ ///
+ /// Test that GetIssueTitleAsync throws for invalid issue ID.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetIssueTitleAsync_ThrowsForInvalidIssueId()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ async () => await connector.GetIssueTitleAsync("123; whoami"));
+ Assert.Contains("Invalid ID", ex.Message);
+ }
+
+ ///
+ /// Test that GetIssueTypeAsync throws for invalid issue ID.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetIssueTypeAsync_ThrowsForInvalidIssueId()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ async () => await connector.GetIssueTypeAsync("456 && echo hacked"));
+ Assert.Contains("Invalid ID", ex.Message);
+ }
+
+ ///
+ /// Test that GetHashForTagAsync throws for invalid tag name.
+ ///
+ [TestMethod]
+ public async Task GitHubRepoConnector_GetHashForTagAsync_ThrowsForInvalidTagName()
+ {
+ // Arrange
+ var connector = new TestableGitHubRepoConnector();
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ async () => await connector.GetHashForTagAsync("v1.0.0 | echo pwned"));
+ Assert.Contains("Invalid tag name", ex.Message);
+ }
+}
diff --git a/test/DemaConsulting.BuildMark.Tests/MockRepoConnectorTests.cs b/test/DemaConsulting.BuildMark.Tests/MockRepoConnectorTests.cs
new file mode 100644
index 0000000..843be7f
--- /dev/null
+++ b/test/DemaConsulting.BuildMark.Tests/MockRepoConnectorTests.cs
@@ -0,0 +1,245 @@
+// Copyright (c) DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+namespace DemaConsulting.BuildMark.Tests;
+
+///
+/// Tests for the MockRepoConnector class.
+///
+[TestClass]
+public class MockRepoConnectorTests
+{
+ ///
+ /// Test that GetTagHistoryAsync returns expected tags.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetTagHistoryAsync_ReturnsExpectedTags()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var tags = await connector.GetTagHistoryAsync();
+
+ // Assert
+ Assert.HasCount(5, tags);
+ Assert.AreEqual("v1.0.0", tags[0]);
+ Assert.AreEqual("v1.1.0", tags[1]);
+ Assert.AreEqual("v2.0.0-beta.1", tags[2]);
+ Assert.AreEqual("v2.0.0-rc.1", tags[3]);
+ Assert.AreEqual("v2.0.0", tags[4]);
+ }
+
+ ///
+ /// Test that GetPullRequestsBetweenTagsAsync returns expected PRs for specific range.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetPullRequestsBetweenTagsAsync_ReturnsExpectedPRsForRange()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var prs = await connector.GetPullRequestsBetweenTagsAsync("v1.0.0", "v1.1.0");
+
+ // Assert
+ Assert.HasCount(1, prs);
+ Assert.AreEqual("10", prs[0]);
+ }
+
+ ///
+ /// Test that GetPullRequestsBetweenTagsAsync returns expected PRs for different range.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetPullRequestsBetweenTagsAsync_ReturnsExpectedPRsForDifferentRange()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var prs = await connector.GetPullRequestsBetweenTagsAsync("v1.1.0", "v2.0.0");
+
+ // Assert
+ Assert.HasCount(2, prs);
+ Assert.AreEqual("11", prs[0]);
+ Assert.AreEqual("12", prs[1]);
+ }
+
+ ///
+ /// Test that GetPullRequestsBetweenTagsAsync returns all PRs when no tags specified.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetPullRequestsBetweenTagsAsync_ReturnsAllPRsWhenNoTags()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var prs = await connector.GetPullRequestsBetweenTagsAsync(null, null);
+
+ // Assert
+ Assert.HasCount(3, prs);
+ }
+
+ ///
+ /// Test that GetIssuesForPullRequestAsync returns expected issues.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetIssuesForPullRequestAsync_ReturnsExpectedIssues()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var issues = await connector.GetIssuesForPullRequestAsync("10");
+
+ // Assert
+ Assert.HasCount(1, issues);
+ Assert.AreEqual("1", issues[0]);
+ }
+
+ ///
+ /// Test that GetIssuesForPullRequestAsync returns empty for unknown PR.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetIssuesForPullRequestAsync_ReturnsEmptyForUnknownPR()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var issues = await connector.GetIssuesForPullRequestAsync("999");
+
+ // Assert
+ Assert.IsEmpty(issues);
+ }
+
+ ///
+ /// Test that GetIssueTitleAsync returns expected title.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetIssueTitleAsync_ReturnsExpectedTitle()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var title = await connector.GetIssueTitleAsync("1");
+
+ // Assert
+ Assert.AreEqual("Add feature X", title);
+ }
+
+ ///
+ /// Test that GetIssueTitleAsync returns default for unknown issue.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetIssueTitleAsync_ReturnsDefaultForUnknownIssue()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var title = await connector.GetIssueTitleAsync("999");
+
+ // Assert
+ Assert.AreEqual("Issue 999", title);
+ }
+
+ ///
+ /// Test that GetIssueTypeAsync returns expected type.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetIssueTypeAsync_ReturnsExpectedType()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var type = await connector.GetIssueTypeAsync("2");
+
+ // Assert
+ Assert.AreEqual("bug", type);
+ }
+
+ ///
+ /// Test that GetIssueTypeAsync returns other for unknown issue.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetIssueTypeAsync_ReturnsOtherForUnknownIssue()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var type = await connector.GetIssueTypeAsync("999");
+
+ // Assert
+ Assert.AreEqual("other", type);
+ }
+
+ ///
+ /// Test that GetHashForTagAsync returns expected hash.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetHashForTagAsync_ReturnsExpectedHash()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var hash = await connector.GetHashForTagAsync("v1.0.0");
+
+ // Assert
+ Assert.AreEqual("abc123def456", hash);
+ }
+
+ ///
+ /// Test that GetHashForTagAsync returns current hash for null tag.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetHashForTagAsync_ReturnsCurrentHashForNullTag()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var hash = await connector.GetHashForTagAsync(null);
+
+ // Assert
+ Assert.AreEqual("current123hash456", hash);
+ }
+
+ ///
+ /// Test that GetHashForTagAsync returns unknown hash for unknown tag.
+ ///
+ [TestMethod]
+ public async Task MockRepoConnector_GetHashForTagAsync_ReturnsUnknownHashForUnknownTag()
+ {
+ // Arrange
+ var connector = new MockRepoConnector();
+
+ // Act
+ var hash = await connector.GetHashForTagAsync("v999.0.0");
+
+ // Assert
+ Assert.AreEqual("unknown000hash000", hash);
+ }
+}
diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectorFactoryTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectorFactoryTests.cs
new file mode 100644
index 0000000..cb4fe86
--- /dev/null
+++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectorFactoryTests.cs
@@ -0,0 +1,55 @@
+// Copyright (c) DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+namespace DemaConsulting.BuildMark.Tests;
+
+///
+/// Tests for the RepoConnectorFactory class.
+///
+[TestClass]
+public class RepoConnectorFactoryTests
+{
+ ///
+ /// Test that Create returns a connector instance.
+ ///
+ [TestMethod]
+ public void RepoConnectorFactory_Create_ReturnsConnector()
+ {
+ // Act
+ var connector = RepoConnectorFactory.Create();
+
+ // Assert
+ Assert.IsNotNull(connector);
+ Assert.IsInstanceOfType(connector);
+ }
+
+ ///
+ /// Test that Create returns GitHubRepoConnector for this repository.
+ ///
+ [TestMethod]
+ public void RepoConnectorFactory_Create_ReturnsGitHubConnectorForThisRepo()
+ {
+ // Act
+ var connector = RepoConnectorFactory.Create();
+
+ // Assert
+ Assert.IsInstanceOfType(connector);
+ }
+}