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