Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
111 changes: 111 additions & 0 deletions DevOps.Tests/ActionHelpersTests.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() => 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<ArgumentException>(() => 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));
}
}
3 changes: 3 additions & 0 deletions DevOps.Tests/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -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)]
25 changes: 25 additions & 0 deletions DevOps.Tests/DevOps.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../DevOps/DevOps.csproj" />
</ItemGroup>

</Project>
52 changes: 52 additions & 0 deletions DevOps.Tests/GitHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using DevOps.Utils;

namespace DevOps.Tests;

/// <summary>
/// Exercises <see cref="GitHelper.CurrentBranch"/> by pointing the process working directory
/// at a throwaway folder with a crafted <c>.git/HEAD</c>. Restores the original directory after.
/// </summary>
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());
}
}
23 changes: 23 additions & 0 deletions DevOps.Tests/TitleNormalizerTests.cs
Original file line number Diff line number Diff line change
@@ -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"));
}
8 changes: 2 additions & 6 deletions DevOps/Actions/NormalizeAction.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Text.RegularExpressions;
using DevOps.Options;
using DevOps.Requests;
using DevOps.Services;
Expand All @@ -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<int> Execute(NormalizeOptions opts, CancellationToken ct)
{
try
Expand All @@ -23,7 +19,7 @@ internal static async Task<int> 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)
Expand Down Expand Up @@ -56,7 +52,7 @@ internal static async Task<int> 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)
{
Expand Down
4 changes: 4 additions & 0 deletions DevOps/DevOps.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
<None Include="..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="DevOps.Tests" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
Expand Down
26 changes: 26 additions & 0 deletions DevOps/Utils/TitleNormalizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Text.RegularExpressions;

namespace DevOps.Utils;

/// <summary>
/// Decides which task titles need the parent prefix and builds the normalized form.
/// A title needs normalization when it matches the third-party <c>[Role] Description</c>
/// pattern and is not already prefixed with <c>&lt;TYPE&gt; &lt;ID&gt; - </c>.
/// </summary>
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}";
}
1 change: 1 addition & 0 deletions devops-cli.slnx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<Solution>
<Project Path="DevOps/DevOps.csproj" />
<Project Path="DevOps.Tests/DevOps.Tests.csproj" />
</Solution>
Loading