Skip to content

Commit f895d04

Browse files
Implement repository topics management and add related tests
1 parent eefa123 commit f895d04

8 files changed

Lines changed: 353 additions & 62 deletions

File tree

‎KtsuBuild.CLI/Commands/CiCommand.cs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ private static async Task<int> ExecutePipelineAsync(
122122
buildConfig.Version = metadataResult.Version;
123123
buildConfig.ReleaseHash = metadataResult.ReleaseHash;
124124

125+
// Update GitHub repository topics from TAGS.md
126+
if (shouldCommitMetadata)
127+
{
128+
await UpdateRepositoryTopicsAsync(gitHubService, logger, workspace, cancellationToken).ConfigureAwait(false);
129+
}
130+
125131
// Parse version bump option
126132
VersionType? forcedVersionType = ParseVersionBump(versionBump);
127133

@@ -163,6 +169,35 @@ await processRunner.RunWithCallbackAsync(
163169
return 0;
164170
}
165171

172+
private static async Task UpdateRepositoryTopicsAsync(
173+
GitHubService gitHubService,
174+
IBuildLogger logger,
175+
string workspace,
176+
CancellationToken cancellationToken)
177+
{
178+
string tagsFile = Path.Combine(workspace, "TAGS.md");
179+
if (!File.Exists(tagsFile))
180+
{
181+
logger.WriteVerbose("No TAGS.md found, skipping repository topic update.");
182+
return;
183+
}
184+
185+
#pragma warning disable CA1031 // Topic update is non-fatal
186+
try
187+
{
188+
IReadOnlyList<string> topics = await TagsParser.ParseAsync(tagsFile, cancellationToken).ConfigureAwait(false);
189+
if (topics.Count > 0)
190+
{
191+
await gitHubService.SetRepositoryTopicsAsync(workspace, topics, cancellationToken).ConfigureAwait(false);
192+
}
193+
}
194+
catch (Exception ex)
195+
{
196+
logger.WriteWarning($"Failed to update repository topics: {ex.Message}");
197+
}
198+
#pragma warning restore CA1031
199+
}
200+
166201
private static VersionType? ParseVersionBump(string versionBump) => versionBump.ToLowerInvariant() switch
167202
{
168203
"major" => VersionType.Major,
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace KtsuBuild.Tests.Metadata;
6+
7+
using KtsuBuild.Metadata;
8+
using KtsuBuild.Tests.Helpers;
9+
10+
[TestClass]
11+
public class TagsParserTests
12+
{
13+
private static readonly string[] ThreeTopics = ["extensions", "collection", "utility"];
14+
private static readonly string[] HyphenatedTopics = ["my-library", "code-helper"];
15+
private static readonly string[] SanitizedTopics = ["c", "dotnet", "mytag", "valid-tag"];
16+
private static readonly string[] TwoTopics = ["dotnet", "csharp"];
17+
18+
private string _tempDir = null!;
19+
20+
[TestInitialize]
21+
public void Setup() => _tempDir = TestHelpers.CreateTempDir("TagsParser");
22+
23+
[TestCleanup]
24+
public void Cleanup()
25+
{
26+
if (Directory.Exists(_tempDir))
27+
{
28+
Directory.Delete(_tempDir, recursive: true);
29+
}
30+
}
31+
32+
[TestMethod]
33+
public async Task ParseAsync_SemicolonSeparated_ReturnsParsedTopics()
34+
{
35+
string tagsFile = Path.Combine(_tempDir, "TAGS.md");
36+
await File.WriteAllTextAsync(tagsFile, "extensions;collection;utility").ConfigureAwait(false);
37+
38+
IReadOnlyList<string> topics = await TagsParser.ParseAsync(tagsFile).ConfigureAwait(false);
39+
40+
CollectionAssert.AreEqual(ThreeTopics, topics.ToList());
41+
}
42+
43+
[TestMethod]
44+
public async Task ParseAsync_TrimsWhitespace()
45+
{
46+
string tagsFile = Path.Combine(_tempDir, "TAGS.md");
47+
await File.WriteAllTextAsync(tagsFile, " extensions ; collection ; utility ").ConfigureAwait(false);
48+
49+
IReadOnlyList<string> topics = await TagsParser.ParseAsync(tagsFile).ConfigureAwait(false);
50+
51+
CollectionAssert.AreEqual(ThreeTopics, topics.ToList());
52+
}
53+
54+
[TestMethod]
55+
public async Task ParseAsync_ConvertsToLowercase()
56+
{
57+
string tagsFile = Path.Combine(_tempDir, "TAGS.md");
58+
await File.WriteAllTextAsync(tagsFile, "Extensions;COLLECTION;Utility").ConfigureAwait(false);
59+
60+
IReadOnlyList<string> topics = await TagsParser.ParseAsync(tagsFile).ConfigureAwait(false);
61+
62+
CollectionAssert.AreEqual(ThreeTopics, topics.ToList());
63+
}
64+
65+
[TestMethod]
66+
public async Task ParseAsync_ReplacesSpacesWithHyphens()
67+
{
68+
string tagsFile = Path.Combine(_tempDir, "TAGS.md");
69+
await File.WriteAllTextAsync(tagsFile, "my library;code helper").ConfigureAwait(false);
70+
71+
IReadOnlyList<string> topics = await TagsParser.ParseAsync(tagsFile).ConfigureAwait(false);
72+
73+
CollectionAssert.AreEqual(HyphenatedTopics, topics.ToList());
74+
}
75+
76+
[TestMethod]
77+
public async Task ParseAsync_RemovesInvalidCharacters()
78+
{
79+
string tagsFile = Path.Combine(_tempDir, "TAGS.md");
80+
await File.WriteAllTextAsync(tagsFile, "c#;dotnet!;my_tag;valid-tag").ConfigureAwait(false);
81+
82+
IReadOnlyList<string> topics = await TagsParser.ParseAsync(tagsFile).ConfigureAwait(false);
83+
84+
CollectionAssert.AreEqual(SanitizedTopics, topics.ToList());
85+
}
86+
87+
[TestMethod]
88+
public async Task ParseAsync_FileNotFound_ReturnsEmptyList()
89+
{
90+
IReadOnlyList<string> topics = await TagsParser.ParseAsync(Path.Combine(_tempDir, "nonexistent.md")).ConfigureAwait(false);
91+
92+
Assert.AreEqual(0, topics.Count);
93+
}
94+
95+
[TestMethod]
96+
public async Task ParseAsync_EmptyFile_ReturnsEmptyList()
97+
{
98+
string tagsFile = Path.Combine(_tempDir, "TAGS.md");
99+
await File.WriteAllTextAsync(tagsFile, "").ConfigureAwait(false);
100+
101+
IReadOnlyList<string> topics = await TagsParser.ParseAsync(tagsFile).ConfigureAwait(false);
102+
103+
Assert.AreEqual(0, topics.Count);
104+
}
105+
106+
[TestMethod]
107+
public async Task ParseAsync_DeduplicatesTopics()
108+
{
109+
string tagsFile = Path.Combine(_tempDir, "TAGS.md");
110+
await File.WriteAllTextAsync(tagsFile, "dotnet;csharp;dotnet;csharp").ConfigureAwait(false);
111+
112+
IReadOnlyList<string> topics = await TagsParser.ParseAsync(tagsFile).ConfigureAwait(false);
113+
114+
CollectionAssert.AreEqual(TwoTopics, topics.ToList());
115+
}
116+
117+
[TestMethod]
118+
public void Parse_WhitespaceOnly_ReturnsEmptyList()
119+
{
120+
IReadOnlyList<string> topics = TagsParser.Parse(" ");
121+
122+
Assert.AreEqual(0, topics.Count);
123+
}
124+
125+
[TestMethod]
126+
public void Parse_EmptySemicolonSegments_FilteredOut()
127+
{
128+
IReadOnlyList<string> topics = TagsParser.Parse(";;dotnet;;;csharp;;");
129+
130+
CollectionAssert.AreEqual(TwoTopics, topics.ToList());
131+
}
132+
}

‎KtsuBuild.Tests/Publishing/GitHubServiceTests.cs‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,61 @@ public async Task IsOfficialRepositoryAsync_CaseInsensitiveOwnerComparison()
312312
Assert.IsTrue(result);
313313
}
314314

315+
// SetRepositoryTopicsAsync
316+
317+
[TestMethod]
318+
public async Task SetRepositoryTopicsAsync_Success_CallsGhApi()
319+
{
320+
string json = """{"owner":{"login":"ktsu-dev"},"nameWithOwner":"ktsu-dev/KtsuBuild","isFork":false}""";
321+
_processRunner.RunAsync("gh", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
322+
.Returns(TestHelpers.SuccessResult(json));
323+
_processRunner.RunWithCallbackAsync("gh", Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<Action<string>?>(), Arg.Any<Action<string>?>(), Arg.Any<CancellationToken>())
324+
.Returns(0);
325+
326+
await _service.SetRepositoryTopicsAsync("/repo", ["dotnet", "csharp"]).ConfigureAwait(false);
327+
328+
await _processRunner.Received(1).RunWithCallbackAsync("gh",
329+
Arg.Is<string>(a => a.Contains("repos/ktsu-dev/KtsuBuild/topics") && a.Contains("names[]=dotnet") && a.Contains("names[]=csharp")),
330+
Arg.Any<string?>(), Arg.Any<Action<string>?>(), Arg.Any<Action<string>?>(), Arg.Any<CancellationToken>()).ConfigureAwait(false);
331+
}
332+
333+
[TestMethod]
334+
public async Task SetRepositoryTopicsAsync_EmptyTopics_SkipsApiCall()
335+
{
336+
await _service.SetRepositoryTopicsAsync("/repo", []).ConfigureAwait(false);
337+
338+
await _processRunner.DidNotReceive().RunWithCallbackAsync("gh",
339+
Arg.Any<string>(),
340+
Arg.Any<string?>(), Arg.Any<Action<string>?>(), Arg.Any<Action<string>?>(), Arg.Any<CancellationToken>()).ConfigureAwait(false);
341+
}
342+
343+
[TestMethod]
344+
public async Task SetRepositoryTopicsAsync_FailedRepoInfo_LogsWarning()
345+
{
346+
_processRunner.RunAsync("gh", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
347+
.Returns(TestHelpers.FailureResult());
348+
349+
// Should not throw
350+
await _service.SetRepositoryTopicsAsync("/repo", ["dotnet"]).ConfigureAwait(false);
351+
352+
await _processRunner.DidNotReceive().RunWithCallbackAsync("gh",
353+
Arg.Any<string>(),
354+
Arg.Any<string?>(), Arg.Any<Action<string>?>(), Arg.Any<Action<string>?>(), Arg.Any<CancellationToken>()).ConfigureAwait(false);
355+
}
356+
357+
[TestMethod]
358+
public async Task SetRepositoryTopicsAsync_ApiFailure_DoesNotThrow()
359+
{
360+
string json = """{"owner":{"login":"ktsu-dev"},"nameWithOwner":"ktsu-dev/KtsuBuild","isFork":false}""";
361+
_processRunner.RunAsync("gh", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
362+
.Returns(TestHelpers.SuccessResult(json));
363+
_processRunner.RunWithCallbackAsync("gh", Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<Action<string>?>(), Arg.Any<Action<string>?>(), Arg.Any<CancellationToken>())
364+
.Returns(1);
365+
366+
// Should not throw even on API failure
367+
await _service.SetRepositoryTopicsAsync("/repo", ["dotnet"]).ConfigureAwait(false);
368+
}
369+
315370
private ReleaseOptions CreateReleaseOptions() => new()
316371
{
317372
Version = "1.0.0",

‎KtsuBuild.sln‎

Lines changed: 0 additions & 62 deletions
This file was deleted.

‎KtsuBuild.slnx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<Solution>
2+
<Configurations>
3+
<Platform Name="Any CPU" />
4+
<Platform Name="x64" />
5+
<Platform Name="x86" />
6+
</Configurations>
7+
<Project Path="KtsuBuild.CLI/KtsuBuild.CLI.csproj" />
8+
<Project Path="KtsuBuild.Tests/KtsuBuild.Tests.csproj" />
9+
<Project Path="KtsuBuild/KtsuBuild.csproj" />
10+
</Solution>

‎KtsuBuild/Abstractions/IGitHubService.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ public interface IGitHubService
3434
/// <returns>Repository info including owner, name, and fork status.</returns>
3535
public Task<RepositoryInfo?> GetRepositoryInfoAsync(string workingDirectory, CancellationToken cancellationToken = default);
3636

37+
/// <summary>
38+
/// Sets the repository topics on GitHub.
39+
/// </summary>
40+
/// <param name="workingDirectory">The repository directory.</param>
41+
/// <param name="topics">The topics to set.</param>
42+
/// <param name="cancellationToken">A cancellation token.</param>
43+
public Task SetRepositoryTopicsAsync(string workingDirectory, IReadOnlyList<string> topics, CancellationToken cancellationToken = default);
44+
3745
/// <summary>
3846
/// Checks if the repository is official (not a fork and owned by expected owner).
3947
/// </summary>

‎KtsuBuild/Metadata/TagsParser.cs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace KtsuBuild.Metadata;
6+
7+
using System.Text.RegularExpressions;
8+
#if !NET10_0_OR_GREATER
9+
using static Polyfill;
10+
#endif
11+
12+
/// <summary>
13+
/// Parses TAGS.md files into GitHub-compatible repository topics.
14+
/// </summary>
15+
public static partial class TagsParser
16+
{
17+
/// <summary>
18+
/// Parses a TAGS.md file into a list of GitHub-compatible topics.
19+
/// </summary>
20+
/// <param name="filePath">The path to the TAGS.md file.</param>
21+
/// <param name="cancellationToken">A cancellation token.</param>
22+
/// <returns>A deduplicated list of valid GitHub topics.</returns>
23+
public static async Task<IReadOnlyList<string>> ParseAsync(string filePath, CancellationToken cancellationToken = default)
24+
{
25+
if (!File.Exists(filePath))
26+
{
27+
return [];
28+
}
29+
30+
string content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
31+
return Parse(content);
32+
}
33+
34+
/// <summary>
35+
/// Parses a semicolon-separated tags string into a list of GitHub-compatible topics.
36+
/// </summary>
37+
/// <param name="content">The raw tags content.</param>
38+
/// <returns>A deduplicated list of valid GitHub topics.</returns>
39+
public static IReadOnlyList<string> Parse(string content)
40+
{
41+
if (string.IsNullOrWhiteSpace(content))
42+
{
43+
return [];
44+
}
45+
46+
return [.. content
47+
.Split(';')
48+
.Select(static tag => tag.Trim())
49+
.Select(static tag => tag.ToLowerInvariant())
50+
.Select(static tag => tag.Replace(' ', '-'))
51+
.Select(static tag => InvalidTopicChars().Replace(tag, string.Empty))
52+
.Where(static tag => tag.Length > 0)
53+
.Where(static tag => tag.Length <= 50)
54+
.Distinct()];
55+
}
56+
57+
#if NET7_0_OR_GREATER
58+
[GeneratedRegex("[^a-z0-9-]")]
59+
private static partial Regex InvalidTopicChars();
60+
#else
61+
private static readonly Regex s_invalidTopicChars = new("[^a-z0-9-]", RegexOptions.Compiled);
62+
private static Regex InvalidTopicChars() => s_invalidTopicChars;
63+
#endif
64+
}

0 commit comments

Comments
 (0)