diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 687ee6a..27335a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,5 +20,8 @@ jobs: - name: Build solution run: dotnet build devops-cli.slnx -c Release + - name: Test + run: dotnet test devops-cli.slnx -c Release --no-build + - name: Pack (validate packaging) run: dotnet pack DevOps/DevOps.csproj -c Release -o ./nupkg diff --git a/DevOps.Tests/ActionHelpersTests.cs b/DevOps.Tests/ActionHelpersTests.cs new file mode 100644 index 0000000..1620f1c --- /dev/null +++ b/DevOps.Tests/ActionHelpersTests.cs @@ -0,0 +1,111 @@ +using DevOps.Actions; +using DevOps.Responses; + +namespace DevOps.Tests; + +public class ActionHelpersTests +{ + [Theory] + [InlineData("approve", 10)] + [InlineData("approve-suggestions", 5)] + [InlineData("reset", 0)] + [InlineData("none", 0)] + [InlineData("wait", -5)] + [InlineData("reject", -10)] + [InlineData("APPROVE", 10)] // case-insensitive + public void VoteValue_MapsKnownVotes(string vote, int expected) + => Assert.Equal(expected, ActionHelpers.VoteValue(vote)); + + [Theory] + [InlineData("bogus")] + [InlineData(null)] + public void VoteValue_UnknownThrows(string vote) + => Assert.Throws(() => ActionHelpers.VoteValue(vote)); + + [Theory] + [InlineData(10, "approved")] + [InlineData(5, "approved w/ suggestions")] + [InlineData(0, "no vote")] + [InlineData(-5, "waiting")] + [InlineData(-10, "rejected")] + [InlineData(99, "99")] // unknown falls back to the number + public void VoteText_MapsScores(int vote, string expected) + => Assert.Equal(expected, ActionHelpers.VoteText(vote)); + + [Theory] + [InlineData("Product Backlog Item", "PBI")] + [InlineData("Bug", "BUG")] + [InlineData("Task", "TASK")] + [InlineData(null, "UNKNOWN")] + public void ParentTypeAbbreviation(string type, string expected) + => Assert.Equal(expected, ActionHelpers.ParentTypeAbbreviation(type)); + + [Theory] + [InlineData("parent", "System.LinkTypes.Hierarchy-Reverse")] + [InlineData("child", "System.LinkTypes.Hierarchy-Forward")] + [InlineData("related", "System.LinkTypes.Related")] + [InlineData("blocks", "System.LinkTypes.Dependency-Forward")] + [InlineData("blocked-by", "System.LinkTypes.Dependency-Reverse")] + public void ResolveRelationType_MapsFriendlyNames(string friendly, string expected) + => Assert.Equal(expected, ActionHelpers.ResolveRelationType(friendly)); + + [Fact] + public void ResolveRelationType_UnknownThrows() + => Assert.Throws(() => ActionHelpers.ResolveRelationType("cousin")); + + [Theory] + [InlineData("short", 20, "short")] + [InlineData("", 10, "")] + [InlineData("exactlyten!", 11, "exactlyten!")] + public void Truncate_KeepsShortValues(string value, int max, string expected) + => Assert.Equal(expected, ActionHelpers.Truncate(value, max)); + + [Fact] + public void Truncate_LongValueGetsEllipsis() + { + var result = ActionHelpers.Truncate("abcdefghijklmnop", 10); + + Assert.Equal(10, result.Length); + Assert.EndsWith("...", result); + Assert.StartsWith("abcdefg", result); + } + + [Theory] + [InlineData("refs/heads/main", "main")] + [InlineData("refs/heads/feature/x", "feature/x")] + [InlineData("", "-")] + [InlineData(null, "-")] + public void ShortBranch(string refName, string expected) + => Assert.Equal(expected, ActionHelpers.ShortBranch(refName)); + + [Fact] + public void DescribeCount_SingularAndPlural() + { + Assert.Equal("Total: 1 item", ActionHelpers.DescribeCount(1, 1, "item")); + Assert.Equal("Total: 3 items", ActionHelpers.DescribeCount(3, 3, "item")); + } + + [Fact] + public void DescribeCount_ReportsTruncation() + => Assert.Equal("Showing 50 of 312 items - use --top to fetch more.", + ActionHelpers.DescribeCount(50, 312, "item")); + + [Fact] + public void ResolvePullRequestUrl_UsesWebLinkWhenPresent() + { + var pr = new PullRequestResponse + { + Links = new PullRequestLinks { Web = new PullRequestWebLink { Href = "https://dev.azure.com/org/proj/_git/repo/pullrequest/7" } } + }; + + Assert.Equal("https://dev.azure.com/org/proj/_git/repo/pullrequest/7", ActionHelpers.ResolvePullRequestUrl(pr)); + } + + [Fact] + public void ResolvePullRequestUrl_NullWhenRepoOrProjectMissing() + { + var pr = new PullRequestResponse { Repository = new PullRequestRepository { Name = null } }; + + Assert.Null(ActionHelpers.ResolvePullRequestUrl(pr)); + } +} diff --git a/DevOps.Tests/AssemblyInfo.cs b/DevOps.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..f6837c0 --- /dev/null +++ b/DevOps.Tests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +// GitHelper reads the process current directory, so tests that change it must not run in +// parallel with each other. The suite is tiny, so serial execution is the simplest safe choice. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/DevOps.Tests/DevOps.Tests.csproj b/DevOps.Tests/DevOps.Tests.csproj new file mode 100644 index 0000000..7458037 --- /dev/null +++ b/DevOps.Tests/DevOps.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + disable + false + true + + + + + + + + + + + + + + + + + diff --git a/DevOps.Tests/GitHelperTests.cs b/DevOps.Tests/GitHelperTests.cs new file mode 100644 index 0000000..8518b08 --- /dev/null +++ b/DevOps.Tests/GitHelperTests.cs @@ -0,0 +1,52 @@ +using DevOps.Utils; + +namespace DevOps.Tests; + +/// +/// Exercises by pointing the process working directory +/// at a throwaway folder with a crafted .git/HEAD. Restores the original directory after. +/// +public sealed class GitHelperTests : IDisposable +{ + private readonly string _originalCwd = Directory.GetCurrentDirectory(); + private readonly string _root = Path.Combine(Path.GetTempPath(), "devops-git-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + Directory.SetCurrentDirectory(_originalCwd); + try { Directory.Delete(_root, recursive: true); } catch { /* best-effort */ } + } + + private void WriteHead(string content) + { + var gitDir = Path.Combine(_root, ".git"); + Directory.CreateDirectory(gitDir); + File.WriteAllText(Path.Combine(gitDir, "HEAD"), content); + Directory.SetCurrentDirectory(_root); + } + + [Fact] + public void CurrentBranch_ReturnsBranchFromHead() + { + WriteHead("ref: refs/heads/feature/x\n"); + + Assert.Equal("feature/x", GitHelper.CurrentBranch()); + } + + [Fact] + public void CurrentBranch_DetachedHead_ReturnsNull() + { + WriteHead("9fceb02d0ae598e95dc970b74767f19372d61af8\n"); + + Assert.Null(GitHelper.CurrentBranch()); + } + + [Fact] + public void CurrentBranch_NoGitDirectory_ReturnsNull() + { + Directory.CreateDirectory(_root); + Directory.SetCurrentDirectory(_root); + + Assert.Null(GitHelper.CurrentBranch()); + } +} diff --git a/DevOps.Tests/TitleNormalizerTests.cs b/DevOps.Tests/TitleNormalizerTests.cs new file mode 100644 index 0000000..e932211 --- /dev/null +++ b/DevOps.Tests/TitleNormalizerTests.cs @@ -0,0 +1,23 @@ +using DevOps.Utils; + +namespace DevOps.Tests; + +public class TitleNormalizerTests +{ + [Theory] + [InlineData("[Dev] Implement the thing", true)] // third-party [Role] Description + [InlineData("[QA] Test the flow", true)] + [InlineData("PBI 1234 - [Dev] Implement", false)] // already normalized + [InlineData("BUG 42 - Something", false)] // already normalized + [InlineData("Plain title", false)] // not the [Role] pattern + [InlineData("[NoDescription]", false)] // bracket but no trailing description + [InlineData("", false)] + [InlineData(null, false)] + public void NeedsNormalization(string title, bool expected) + => Assert.Equal(expected, TitleNormalizer.NeedsNormalization(title)); + + [Fact] + public void BuildTitle_PrefixesTypeAndParentId() + => Assert.Equal("PBI 1234 - [Dev] Implement", + TitleNormalizer.BuildTitle("PBI", 1234, "[Dev] Implement")); +} diff --git a/DevOps/Actions/NormalizeAction.cs b/DevOps/Actions/NormalizeAction.cs index 75239a0..41c3f2a 100644 --- a/DevOps/Actions/NormalizeAction.cs +++ b/DevOps/Actions/NormalizeAction.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using DevOps.Options; using DevOps.Requests; using DevOps.Services; @@ -8,9 +7,6 @@ namespace DevOps.Actions; internal static class NormalizeAction { - private static readonly Regex Unnormalized = new(@"^\[.+\] .+"); - private static readonly Regex AlreadyNormalized = new(@"^[A-Z]+ \d+ - "); - internal static async Task Execute(NormalizeOptions opts, CancellationToken ct) { try @@ -23,7 +19,7 @@ internal static async Task Execute(NormalizeOptions opts, CancellationToken ConsoleHelper.WriteError($"Warning: {totalMatched} tasks matched but only {items.Count} were fetched. Use --top to cover them all."); var toNormalize = items - .Where(i => Unnormalized.IsMatch(i.Fields.Title ?? "") && !AlreadyNormalized.IsMatch(i.Fields.Title ?? "")) + .Where(i => TitleNormalizer.NeedsNormalization(i.Fields.Title)) .ToList(); if (toNormalize.Count == 0) @@ -56,7 +52,7 @@ internal static async Task Execute(NormalizeOptions opts, CancellationToken parentCache[parentId.Value] = abbrev; } - var newTitle = $"{abbrev} {parentId} - {item.Fields.Title}"; + var newTitle = TitleNormalizer.BuildTitle(abbrev, parentId.Value, item.Fields.Title); if (opts.DryRun) { diff --git a/DevOps/DevOps.csproj b/DevOps/DevOps.csproj index 04cd6f7..8510653 100644 --- a/DevOps/DevOps.csproj +++ b/DevOps/DevOps.csproj @@ -25,6 +25,10 @@ + + + + diff --git a/DevOps/Utils/TitleNormalizer.cs b/DevOps/Utils/TitleNormalizer.cs new file mode 100644 index 0000000..244b4ed --- /dev/null +++ b/DevOps/Utils/TitleNormalizer.cs @@ -0,0 +1,26 @@ +using System.Text.RegularExpressions; + +namespace DevOps.Utils; + +/// +/// Decides which task titles need the parent prefix and builds the normalized form. +/// A title needs normalization when it matches the third-party [Role] Description +/// pattern and is not already prefixed with <TYPE> <ID> - . +/// +public static partial class TitleNormalizer +{ + [GeneratedRegex(@"^\[.+\] .+")] + private static partial Regex UnnormalizedPattern(); + + [GeneratedRegex(@"^[A-Z]+ \d+ - ")] + private static partial Regex AlreadyNormalizedPattern(); + + public static bool NeedsNormalization(string title) + { + var value = title ?? ""; + return UnnormalizedPattern().IsMatch(value) && !AlreadyNormalizedPattern().IsMatch(value); + } + + public static string BuildTitle(string abbreviation, int parentId, string originalTitle) => + $"{abbreviation} {parentId} - {originalTitle}"; +} diff --git a/devops-cli.slnx b/devops-cli.slnx index 9457863..1482780 100644 --- a/devops-cli.slnx +++ b/devops-cli.slnx @@ -1,3 +1,4 @@ +