diff --git a/SS14.Changelog.Tests/ChangelogParseTest.cs b/SS14.Changelog.Tests/ChangelogParseTest.cs index 796607b..67275f7 100644 --- a/SS14.Changelog.Tests/ChangelogParseTest.cs +++ b/SS14.Changelog.Tests/ChangelogParseTest.cs @@ -1,97 +1,128 @@ using System; +using System.Diagnostics.CodeAnalysis; using NUnit.Framework; +using SS14.Changelog.Configuration; using SS14.Changelog.Controllers; namespace SS14.Changelog.Tests { + [Parallelizable(ParallelScope.All)] [TestFixture] + // NUnit assertion is bugged. + [SuppressMessage("Assertion", "NUnit2022:Missing property required for constraint")] public class ChangelogParseTest { [Test] public void Test() { - const string text = @" -Did stuff! + const string text = """ + Did stuff! -:cl: Ev1__l P-JB2323 -- add: Did the thing -- remove: Removed the thing -- fix: A -- bugfix: B -- bug: C -"; + :cl: Ev1__l P-JB2323 + - add: Did the thing + - remove: Removed the thing + - fix: A + - bugfix: B + - bug: C + + """; var time = new DateTimeOffset(2021, 1, 1, 1, 1, 1, TimeSpan.Zero); - var pr = new GHPullRequest(true, text, new GHUser("PJB"), time, new GHPullRequestBase("master"), 123, "https://www.example.com"); - var parsed = WebhookController.ParsePRBody(pr); - - Assert.That(parsed, Is.Not.Null); - Assert.That(parsed.Author, Is.EqualTo("Ev1__l P-JB2323")); - Assert.That(parsed.Time, Is.EqualTo(time)); - Assert.That(parsed.HtmlUrl, Is.EqualTo("https://www.example.com")); - Assert.That(parsed.Changes, Is.EquivalentTo(new[] + var pr = new GHPullRequest(true, text, new GHUser("PJB"), time, new GHPullRequestBase("master"), 123, + "https://www.example.com"); + var parsed = WebhookController.ParsePRBody(pr, new ChangelogConfig()); + + Assert.Multiple(() => { - (ChangelogEntryType.Add, "Did the thing"), - (ChangelogEntryType.Remove, "Removed the thing"), - (ChangelogEntryType.Fix, "A"), - (ChangelogEntryType.Fix, "B"), - (ChangelogEntryType.Fix, "C"), - })); + Assert.That(parsed, Is.Not.Null); + Assert.That(parsed.Author, Is.EqualTo("Ev1__l P-JB2323")); + Assert.That(parsed.Time, Is.EqualTo(time)); + Assert.That(parsed.HtmlUrl, Is.EqualTo("https://www.example.com")); + Assert.That(parsed.Categories, Has.Length.EqualTo(1)); + Assert.That(parsed.Categories, Has.One + .Property(nameof(ChangelogData.CategoryData.Category)).EqualTo(ChangelogData.MainCategory) + .And.Property(nameof(ChangelogData.CategoryData.Changes)).EquivalentTo(new ChangelogData.Change[] + { + new(ChangelogData.ChangeType.Add, "Did the thing"), + new(ChangelogData.ChangeType.Remove, "Removed the thing"), + new(ChangelogData.ChangeType.Fix, "A"), + new(ChangelogData.ChangeType.Fix, "B"), + new(ChangelogData.ChangeType.Fix, "C"), + })); + }); } [Test] public void TestWithoutName() { - const string text = @" -Did stuff! + const string text = """ + + Did stuff! -:cl: -- add: Did the thing -- remove: Removed the thing -"; + :cl: + - add: Did the thing + - remove: Removed the thing + + """; var time = new DateTimeOffset(2021, 1, 1, 1, 1, 1, TimeSpan.Zero); - var pr = new GHPullRequest(true, text, new GHUser("Swept"), time, new GHPullRequestBase("master"), 123, "https://www.example.com"); - var parsed = WebhookController.ParsePRBody(pr); - - Assert.That(parsed, Is.Not.Null); - Assert.That(parsed.Author, Is.EqualTo("Swept")); - Assert.That(parsed.Time, Is.EqualTo(time)); - Assert.That(parsed.HtmlUrl, Is.EqualTo("https://www.example.com")); - Assert.That(parsed.Changes, Is.EquivalentTo(new[] + var pr = new GHPullRequest(true, text, new GHUser("Swept"), time, new GHPullRequestBase("master"), 123, + "https://www.example.com"); + var parsed = WebhookController.ParsePRBody(pr, new ChangelogConfig()); + + Assert.Multiple(() => { - (ChangelogEntryType.Add, "Did the thing"), - (ChangelogEntryType.Remove, "Removed the thing"), - })); + Assert.That(parsed, Is.Not.Null); + Assert.That(parsed.Author, Is.EqualTo("Swept")); + Assert.That(parsed.Time, Is.EqualTo(time)); + Assert.That(parsed.HtmlUrl, Is.EqualTo("https://www.example.com")); + Assert.That(parsed.Categories, Has.Length.EqualTo(1)); + Assert.That(parsed.Categories, Has.One + .Property(nameof(ChangelogData.CategoryData.Category)).EqualTo(ChangelogData.MainCategory) + .And.Property(nameof(ChangelogData.CategoryData.Changes)).EquivalentTo(new ChangelogData.Change[] + { + new(ChangelogData.ChangeType.Add, "Did the thing"), + new(ChangelogData.ChangeType.Remove, "Removed the thing"), + })); + }); } [Test] public void TestComment() { - const string text = @" -Did stuff! + const string text = """ - + Did stuff! -:cl: -- add: Did the thing -- remove: Removed the thing -"; + + + :cl: + - add: Did the thing + - remove: Removed the thing + + """; var time = new DateTimeOffset(2021, 1, 1, 1, 1, 1, TimeSpan.Zero); - var pr = new GHPullRequest(true, text, new GHUser("Swept"), time, new GHPullRequestBase("master"), 123, "https://www.example.com"); - var parsed = WebhookController.ParsePRBody(pr); - - Assert.That(parsed, Is.Not.Null); - Assert.That(parsed.Author, Is.EqualTo("Swept")); - Assert.That(parsed.Time, Is.EqualTo(time)); - Assert.That(parsed.HtmlUrl, Is.EqualTo("https://www.example.com")); - Assert.That(parsed.Changes, Is.EquivalentTo(new[] + var pr = new GHPullRequest(true, text, new GHUser("Swept"), time, new GHPullRequestBase("master"), 123, + "https://www.example.com"); + var parsed = WebhookController.ParsePRBody(pr, new ChangelogConfig()); + + Assert.Multiple(() => { - (ChangelogEntryType.Add, "Did the thing"), - (ChangelogEntryType.Remove, "Removed the thing"), - })); + Assert.That(parsed, Is.Not.Null); + Assert.That(parsed.Author, Is.EqualTo("Swept")); + Assert.That(parsed.Time, Is.EqualTo(time)); + Assert.That(parsed.HtmlUrl, Is.EqualTo("https://www.example.com")); + Assert.That(parsed.Categories, Has.Length.EqualTo(1)); + Assert.That(parsed.Categories, Has.One + .Property(nameof(ChangelogData.CategoryData.Category)).EqualTo(ChangelogData.MainCategory) + .And.Property(nameof(ChangelogData.CategoryData.Changes)).EquivalentTo(new ChangelogData.Change[] + { + new(ChangelogData.ChangeType.Add, "Did the thing"), + new(ChangelogData.ChangeType.Remove, "Removed the thing"), + })); + }); } [Test] @@ -101,17 +132,145 @@ public void TestBroke() "Makes it possible to repair things with a welder.\r\n\r\n**Changelog**\r\n:cl: AJCM\r\n- add: Makes gravity generator and windows repairable with a lit welding tool \r\n\r\n"; var time = new DateTimeOffset(2021, 1, 1, 1, 1, 1, TimeSpan.Zero); - var pr = new GHPullRequest(true, text, new GHUser("AJCM-Git"), time, new GHPullRequestBase("master"), 123, "https://www.example.com"); - var parsed = WebhookController.ParsePRBody(pr); - - Assert.That(parsed, Is.Not.Null); - Assert.That(parsed.Author, Is.EqualTo("AJCM")); - Assert.That(parsed.Time, Is.EqualTo(time)); - Assert.That(parsed.HtmlUrl, Is.EqualTo("https://www.example.com")); - Assert.That(parsed.Changes, Is.EquivalentTo(new[] + var pr = new GHPullRequest(true, text, new GHUser("AJCM-Git"), time, new GHPullRequestBase("master"), 123, + "https://www.example.com"); + var parsed = WebhookController.ParsePRBody(pr, new ChangelogConfig()); + + Assert.Multiple(() => + { + Assert.That(parsed, Is.Not.Null); + Assert.That(parsed.Author, Is.EqualTo("AJCM")); + Assert.That(parsed.Time, Is.EqualTo(time)); + Assert.That(parsed.HtmlUrl, Is.EqualTo("https://www.example.com")); + Assert.That(parsed.Categories, Has.Length.EqualTo(1)); + Assert.That(parsed.Categories, Has.One + .Property(nameof(ChangelogData.CategoryData.Category)).EqualTo(ChangelogData.MainCategory) + .And.Property(nameof(ChangelogData.CategoryData.Changes)).EquivalentTo(new ChangelogData.Change[] + { + new(ChangelogData.ChangeType.Add, "Makes gravity generator and windows repairable with a lit welding tool"), + })); + }); + } + + [Test] + public void TestCategory() + { + const string text = """ + + Did stuff! + + :cl: + ADMIN: + - add: Did the thing + - remove: Removed the thing + + """; + + var time = new DateTimeOffset(2021, 1, 1, 1, 1, 1, TimeSpan.Zero); + var pr = new GHPullRequest(true, text, new GHUser("Swept"), time, new GHPullRequestBase("master"), 123, + "https://www.example.com"); + var parsed = WebhookController.ParsePRBody(pr, new ChangelogConfig()); + + Assert.Multiple(() => + { + Assert.That(parsed, Is.Not.Null); + Assert.That(parsed.Author, Is.EqualTo("Swept")); + Assert.That(parsed.Time, Is.EqualTo(time)); + Assert.That(parsed.HtmlUrl, Is.EqualTo("https://www.example.com")); + Assert.That(parsed.Categories, Has.Length.EqualTo(1)); + Assert.That(parsed.Categories, Has.One + .Property(nameof(ChangelogData.CategoryData.Category)).EqualTo(ChangelogData.MainCategory) + .And.Property(nameof(ChangelogData.CategoryData.Changes)).EquivalentTo(new ChangelogData.Change[] + { + new(ChangelogData.ChangeType.Add, "Did the thing"), + new(ChangelogData.ChangeType.Remove, "Removed the thing"), + })); + }); + } + + [Test] + public void TestCategoryMulti() + { + const string text = """ + + Did stuff! + + :cl: + ADMIN: + - add: Did the thing + - remove: Removed the thing + MAIN: + - add: Did more thing + - remove: Removed more thing + ADMIN: + - fix: Fix the thing + """; + + var time = new DateTimeOffset(2021, 1, 1, 1, 1, 1, TimeSpan.Zero); + var pr = new GHPullRequest(true, text, new GHUser("Swept"), time, new GHPullRequestBase("master"), 123, + "https://www.example.com"); + var parsed = WebhookController.ParsePRBody(pr, new ChangelogConfig { ExtraCategories = new []{"Admin"}}); + + Assert.Multiple(() => + { + Assert.That(parsed, Is.Not.Null); + Assert.That(parsed.Author, Is.EqualTo("Swept")); + Assert.That(parsed.Time, Is.EqualTo(time)); + Assert.That(parsed.HtmlUrl, Is.EqualTo("https://www.example.com")); + Assert.That(parsed.Categories, Has.Length.EqualTo(2)); + Assert.That(parsed.Categories, Has.One + .Property(nameof(ChangelogData.CategoryData.Category)).EqualTo("Admin") + .And.Property(nameof(ChangelogData.CategoryData.Changes)).EquivalentTo(new ChangelogData.Change[] + { + new(ChangelogData.ChangeType.Add, "Did the thing"), + new(ChangelogData.ChangeType.Remove, "Removed the thing"), + new(ChangelogData.ChangeType.Fix, "Fix the thing"), + })); + Assert.That(parsed.Categories, Has.One + .Property(nameof(ChangelogData.CategoryData.Category)).EqualTo(ChangelogData.MainCategory) + .And.Property(nameof(ChangelogData.CategoryData.Changes)).EquivalentTo(new ChangelogData.Change[] + { + new(ChangelogData.ChangeType.Add, "Did more thing"), + new(ChangelogData.ChangeType.Remove, "Removed more thing"), + })); + }); + } + + [Test] + public void TestCategoryInvalid() + { + const string text = """ + + Did stuff! + + :cl: + - add: Did the thing + - remove: Removed the thing + NOTACATEGORY: + - add: WOW + """; + + var time = new DateTimeOffset(2021, 1, 1, 1, 1, 1, TimeSpan.Zero); + var pr = new GHPullRequest(true, text, new GHUser("Swept"), time, new GHPullRequestBase("master"), 123, + "https://www.example.com"); + var parsed = WebhookController.ParsePRBody(pr, new ChangelogConfig { ExtraCategories = new []{"Admin"}}); + + Assert.Multiple(() => { - (ChangelogEntryType.Add, "Makes gravity generator and windows repairable with a lit welding tool") - })); + Assert.That(parsed, Is.Not.Null); + Assert.That(parsed.Author, Is.EqualTo("Swept")); + Assert.That(parsed.Time, Is.EqualTo(time)); + Assert.That(parsed.HtmlUrl, Is.EqualTo("https://www.example.com")); + Assert.That(parsed.Categories, Has.Length.EqualTo(1)); + Assert.That(parsed.Categories, Has.One + .Property(nameof(ChangelogData.CategoryData.Category)).EqualTo(ChangelogData.MainCategory) + .And.Property(nameof(ChangelogData.CategoryData.Changes)).EquivalentTo(new ChangelogData.Change[] + { + new(ChangelogData.ChangeType.Add, "Did the thing"), + new(ChangelogData.ChangeType.Remove, "Removed the thing"), + new(ChangelogData.ChangeType.Add, "WOW"), + })); + }); } } } \ No newline at end of file diff --git a/SS14.Changelog/ChangelogData.cs b/SS14.Changelog/ChangelogData.cs index d73a108..2521f17 100644 --- a/SS14.Changelog/ChangelogData.cs +++ b/SS14.Changelog/ChangelogData.cs @@ -5,25 +5,31 @@ namespace SS14.Changelog { public sealed record ChangelogData { - public ChangelogData(string author, ImmutableArray<(ChangelogEntryType, string)> changes, DateTimeOffset time) + public const string MainCategory = "Main"; + + public ChangelogData(string author, ImmutableArray categories, DateTimeOffset time) { Author = author; - Changes = changes; + Categories = categories; Time = time; } public string Author { get; } - public ImmutableArray<(ChangelogEntryType, string)> Changes { get; } + public ImmutableArray Categories { get; } public DateTimeOffset Time { get; } public int Number { get; init; } public required string HtmlUrl { get; init; } - } - - public enum ChangelogEntryType - { - Add, - Remove, - Fix, - Tweak + + public sealed record CategoryData(string Category, ImmutableArray Changes); + + public record struct Change(ChangeType Type, string Message); + + public enum ChangeType + { + Add, + Remove, + Fix, + Tweak + } } } \ No newline at end of file diff --git a/SS14.Changelog/Configuration/ChangelogConfig.cs b/SS14.Changelog/Configuration/ChangelogConfig.cs index f8caf46..1e110aa 100644 --- a/SS14.Changelog/Configuration/ChangelogConfig.cs +++ b/SS14.Changelog/Configuration/ChangelogConfig.cs @@ -1,4 +1,6 @@ -namespace SS14.Changelog.Configuration +using System; + +namespace SS14.Changelog.Configuration { public class ChangelogConfig { @@ -27,5 +29,14 @@ public class ChangelogConfig public string? SshKey { get; set; } public string? GitHubSecret { get; set; } public int DelaySeconds { get; set; } = 60; + + /// + /// Extra changelog categories that should exist. + /// + /// + /// Extra categories will be interpreted by the CATEGORY: directive in PR bodies + /// and are written to a separate Category.yml file in the changelog data. + /// + public string[] ExtraCategories { get; set; } = Array.Empty(); } } \ No newline at end of file diff --git a/SS14.Changelog/Controllers/WebhookController.cs b/SS14.Changelog/Controllers/WebhookController.cs index 99267b9..105c438 100644 --- a/SS14.Changelog/Controllers/WebhookController.cs +++ b/SS14.Changelog/Controllers/WebhookController.cs @@ -26,8 +26,10 @@ public class WebhookController : Controller new Regex(@"^\s*(?::cl:|🆑) *([a-z0-9_\- ]+)?\s+$", RegexOptions.IgnoreCase | RegexOptions.Multiline); private static readonly Regex ChangelogEntryRegex = - new Regex(@"^ *[*-]? *(add|remove|tweak|fix|bug|bugfix): *([^\n\r]+)\r?$", - RegexOptions.Multiline | RegexOptions.IgnoreCase); + new Regex(@"^ *[*-]? *(add|remove|tweak|fix|bug|bugfix): *([^\n\r]+)\r?$", RegexOptions.IgnoreCase); + + private static readonly Regex ChangelogCategoryRegex = + new Regex(@"^\s*([a-z]+):\s*$", RegexOptions.IgnoreCase); private static readonly Regex CommentRegex = new(@"(?]+)(?"); @@ -140,7 +142,7 @@ private void HandlePullRequest(GHPullRequestEvent eventData) return; } - var changelogData = ParsePRBody(eventData.PullRequest); + var changelogData = ParsePRBody(eventData.PullRequest, _cfg.Value); if (changelogData == null) { _log.LogTrace("Did not find changelog in PR"); @@ -148,42 +150,71 @@ private void HandlePullRequest(GHPullRequestEvent eventData) } _log.LogInformation( - "Parsed {EntryCount} entries by {PRAuthor}", - changelogData.Changes.Length, changelogData.Author); + "Parsed {EntryCount} entries in {CategoryCount} categories by {PRAuthor}", + changelogData.Categories.SelectMany(p => p.Changes).Count(), + changelogData.Categories.Length, + changelogData.Author); _changelogService.PushPRChangelog(changelogData); } - internal static ChangelogData? ParsePRBody(GHPullRequest pr) + internal static ChangelogData? ParsePRBody(GHPullRequest pr, ChangelogConfig config) { + var allCategories = config.ExtraCategories + .Append(ChangelogData.MainCategory) + .ToHashSet(StringComparer.InvariantCultureIgnoreCase); + var body = CommentRegex.Replace(pr.Body, ""); var match = ChangelogHeaderRegex.Match(body); if (!match.Success) return null; var author = match.Groups[1].Success ? match.Groups[1].Value.Trim() : pr.User.Login; - var entries = new List<(ChangelogEntryType, string)>(); - var changelogBody = body.Substring(match.Index + match.Length); - - foreach (Match entryMatch in ChangelogEntryRegex.Matches(changelogBody)) + + var currentCategory = ChangelogData.MainCategory; + var entries = new List<(string, ChangelogData.Change)>(); + + var reader = new StringReader(changelogBody); + while (reader.ReadLine() is { } line) { + var categoryMatch = ChangelogCategoryRegex.Match(line); + if (categoryMatch.Success) + { + // Changelog category directive. + // Check if it's actually a defined category, skip it otherwise. + var categoryName = categoryMatch.Groups[1].Value; + if (allCategories.TryGetValue(categoryName, out var matchedCategory)) + currentCategory = matchedCategory; + + continue; + } + + var entryMatch = ChangelogEntryRegex.Match(line); + if (!entryMatch.Success) + continue; + var type = entryMatch.Groups[1].Value.ToLowerInvariant() switch { - "add" => ChangelogEntryType.Add, - "remove" => ChangelogEntryType.Remove, - "fix" or "bugfix" or "bug" => ChangelogEntryType.Fix, - "tweak" => ChangelogEntryType.Tweak, - _ => (ChangelogEntryType?) null + "add" => ChangelogData.ChangeType.Add, + "remove" => ChangelogData.ChangeType.Remove, + "fix" or "bugfix" or "bug" => ChangelogData.ChangeType.Fix, + "tweak" => ChangelogData.ChangeType.Tweak, + _ => (ChangelogData.ChangeType?) null }; var message = entryMatch.Groups[2].Value.Trim(); if (type is { } t) - entries.Add((t, message)); + entries.Add((currentCategory, new ChangelogData.Change(t, message))); } - return new ChangelogData(author, entries.ToImmutableArray(), pr.MergedAt ?? DateTimeOffset.Now) + var finalCategories = entries + .GroupBy(e => e.Item1) + .Select(g => new ChangelogData.CategoryData(g.Key, g.Select(e => e.Item2).ToImmutableArray())) + .ToImmutableArray(); + + return new ChangelogData(author, finalCategories, pr.MergedAt ?? DateTimeOffset.Now) { Number = pr.Number, HtmlUrl = pr.Url diff --git a/SS14.Changelog/Services/ChangelogService.cs b/SS14.Changelog/Services/ChangelogService.cs index 1bb3a82..3eaaed7 100644 --- a/SS14.Changelog/Services/ChangelogService.cs +++ b/SS14.Changelog/Services/ChangelogService.cs @@ -3,7 +3,6 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; -using System.Runtime.Serialization; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; @@ -123,7 +122,7 @@ await WaitForSuccessAsync(new ProcessStartInfo _log.LogTrace("Running changelog script"); - await InvokeChangelogScript(); + await InvokeChangelogScript(cfg); _log.LogTrace("Checking git status..."); var status = await WaitForSuccessAsync(new ProcessStartInfo @@ -263,27 +262,40 @@ private ProcessStartInfo GitNetCommand(ProcessStartInfo info) return info; } - private async Task InvokeChangelogScript() + private async Task InvokeChangelogScript(ChangelogConfig cfg) { var repo = _cfg.Value.ChangelogRepo!; - var filename = _cfg.Value.ChangelogFilename; - var script = Path.Combine(repo, "Tools", "update_changelog.py"); - var parts = Path.Combine(repo, "Resources", "Changelog", "Parts"); - var changelogFile = Path.Combine(repo, "Resources", "Changelog", filename); - var procStart = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? new ProcessStartInfo - { - FileName = "py", - ArgumentList = {script, changelogFile, parts} - } - : new ProcessStartInfo - { - FileName = script, - ArgumentList = {changelogFile, parts} - }; + _log.LogInformation("Running changelog for MAIN changelog:"); + var startMain = MakeBasicStartInfo(_cfg.Value.ChangelogFilename); + await WaitForSuccessAsync(startMain); + + foreach (var category in cfg.ExtraCategories) + { + _log.LogInformation("Running changelog for {Category} changelog:", category); + var startCategory = MakeBasicStartInfo(category + ".yml"); + startCategory.ArgumentList.Add("--category"); + startCategory.ArgumentList.Add(category); + await WaitForSuccessAsync(startCategory); + } + + return; + + ProcessStartInfo MakeBasicStartInfo(string filename) + { + var script = Path.Combine(repo, "Tools", "update_changelog.py"); + var parts = Path.Combine(repo, "Resources", "Changelog", "Parts"); + var changelogFile = Path.Combine(repo, "Resources", "Changelog", filename); - await WaitForSuccessAsync(procStart); + var procStart = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new ProcessStartInfo("py") { ArgumentList = {script} } + : new ProcessStartInfo(script); + + procStart.ArgumentList.Add(changelogFile); + procStart.ArgumentList.Add(parts); + + return procStart; + } } private async Task WaitForSuccessAsync(ProcessStartInfo info, int? timeoutSeconds=null) @@ -340,30 +352,38 @@ private void WriteChangelogPart(ChangelogData data) { try { - var fileName = Path.Combine( - _cfg.Value.ChangelogRepo!, - "Resources", "Changelog", "Parts", $"pr-{data.Number}.yml"); + foreach (var category in data.Categories) + { + var fileName = Path.Combine( + _cfg.Value.ChangelogRepo!, + "Resources", "Changelog", "Parts", $"pr-{data.Number}-{category.Category}.yml"); - _log.LogTrace("Writing changelog part {PartFileName}", fileName); + _log.LogTrace("Writing changelog part {PartFileName}", fileName); - var yamlStream = new YamlStream(new YamlDocument(new YamlMappingNode - { - {"author", Quoted(data.Author)}, - {"time", Quoted(data.Time.ToString("O"))}, - {"url", Quoted(data.HtmlUrl)}, + var yamlMapping = new YamlMappingNode { - "changes", - new YamlSequenceNode( - data.Changes.Select(c => new YamlMappingNode - { - {"type", Quoted(c.Item1.ToString())}, - {"message", Quoted(c.Item2)}, - })) - } - })); - - using var writer = new StreamWriter(fileName); - yamlStream.Save(writer); + {"author", Quoted(data.Author)}, + {"time", Quoted(data.Time.ToString("O"))}, + {"url", Quoted(data.HtmlUrl)}, + { + "changes", + new YamlSequenceNode( + category.Changes.Select(c => new YamlMappingNode + { + {"type", Quoted(c.Type.ToString())}, + {"message", Quoted(c.Message)}, + })) + } + }; + + if (category.Category != ChangelogData.MainCategory) + yamlMapping.Add("category", category.Category); + + var yamlStream = new YamlStream(new YamlDocument(yamlMapping)); + + using var writer = new StreamWriter(fileName); + yamlStream.Save(writer); + } } catch (Exception e) {