From 6927bb6a83f57cfec200198b7b276a10f05781b4 Mon Sep 17 00:00:00 2001 From: lcawl Date: Mon, 26 Jan 2026 14:09:15 -0800 Subject: [PATCH] Fix changelog render with blocks --- .../Asciidoc/AsciidocRendererBase.cs | 2 +- .../Rendering/ChangelogRenderContext.cs | 2 + .../Rendering/ChangelogRenderUtilities.cs | 44 +- .../Rendering/ChangelogRenderingService.cs | 8 +- .../BreakingChangesMarkdownRenderer.cs | 2 +- .../Markdown/DeprecationsMarkdownRenderer.cs | 2 +- .../Markdown/IndexMarkdownRenderer.cs | 2 +- .../Markdown/KnownIssuesMarkdownRenderer.cs | 2 +- .../Render/BlockConfigurationTests.cs | 894 ++++++++++++++++++ 9 files changed, 947 insertions(+), 11 deletions(-) create mode 100644 tests/Elastic.Changelog.Tests/Changelogs/Render/BlockConfigurationTests.cs diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs index b298b15dd..db209d6ce 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs @@ -25,7 +25,7 @@ private static (HashSet bundleProductIds, string entryRepo, bool hideLin var bundleProductIds = context.EntryToBundleProducts.GetValueOrDefault(entry, new HashSet(StringComparer.OrdinalIgnoreCase)); var entryRepo = context.EntryToRepo.GetValueOrDefault(entry, context.Repo); var hideLinks = context.EntryToHideLinks.GetValueOrDefault(entry, false); - var shouldHide = ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide); + var shouldHide = ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context); return (bundleProductIds, entryRepo, hideLinks, shouldHide); } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs index eb5a3cc76..a63c1ae41 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Changelog.Configuration; using Elastic.Documentation; namespace Elastic.Changelog.Rendering; @@ -21,4 +22,5 @@ public record ChangelogRenderContext public required Dictionary> EntryToBundleProducts { get; init; } public required Dictionary EntryToRepo { get; init; } public required Dictionary EntryToHideLinks { get; init; } + public ChangelogConfiguration? Configuration { get; init; } } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderUtilities.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderUtilities.cs index a58c26ee4..5c1bbb239 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderUtilities.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderUtilities.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Changelog.Configuration; using Elastic.Documentation; namespace Elastic.Changelog.Rendering; @@ -23,10 +24,47 @@ public static string GetComponent(ChangelogEntry entry) } /// - /// Determines if an entry should be hidden based on feature IDs + /// Determines if an entry should be hidden based on feature IDs or block configuration /// public static bool ShouldHideEntry( ChangelogEntry entry, - HashSet featureIdsToHide) => - !string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId); + HashSet featureIdsToHide, + ChangelogRenderContext? context = null) + { + // Check feature IDs first + if (!string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId)) + return true; + + // Check block configuration if context and configuration are available + if (context?.Configuration?.Block == null) + return false; + + // Get product IDs for this entry + var productIds = context.EntryToBundleProducts.GetValueOrDefault(entry, new HashSet(StringComparer.OrdinalIgnoreCase)); + if (productIds.Count == 0) + return false; + + // Check each product's block configuration + foreach (var productId in productIds) + { + var blocker = GetPublishBlockerForProduct(context.Configuration.Block, productId); + if (blocker != null && blocker.ShouldBlock(entry)) + return true; + } + + return false; + } + + /// + /// Gets the publish blocker configuration for a specific product, checking product-specific overrides first + /// + private static PublishBlocker? GetPublishBlockerForProduct(BlockConfiguration blockConfig, string productId) + { + // Check product-specific override first + if (blockConfig.ByProduct?.TryGetValue(productId, out var productBlockers) == true) + return productBlockers.Publish; + + // Fall back to global publish blocker + return blockConfig.Publish; + } } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 0fcb37ccf..deec9c115 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -127,7 +127,7 @@ Cancel ctx return false; // Build render context - var context = BuildRenderContext(input, outputSetup, resolvedResult, featureHidingResult.FeatureIdsToHide); + var context = BuildRenderContext(input, outputSetup, resolvedResult, featureHidingResult.FeatureIdsToHide, config); // Render output var renderer = new ChangelogRenderer(_fileSystem, _logger); @@ -234,7 +234,8 @@ private static ChangelogRenderContext BuildRenderContext( RenderChangelogsArguments input, OutputSetup outputSetup, ResolvedEntriesResult resolved, - HashSet featureIdsToHide) + HashSet featureIdsToHide, + ChangelogConfiguration? config) { // Group entries by type var entriesByType = resolved.Entries @@ -269,7 +270,8 @@ private static ChangelogRenderContext BuildRenderContext( FeatureIdsToHide = featureIdsToHide, EntryToBundleProducts = entryToBundleProducts, EntryToRepo = entryToRepo, - EntryToHideLinks = entryToHideLinks + EntryToHideLinks = entryToHideLinks, + Configuration = config }; } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs index 69f3f3416..26e2b0f09 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -45,7 +45,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct foreach (var entry in group) { var (bundleProductIds, entryRepo, entryHideLinks) = GetEntryContext(entry, context); - var shouldHide = ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide); + var shouldHide = ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context); _ = sb.AppendLine(); if (shouldHide) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index 102b9e9d3..6a1ee54c3 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -43,7 +43,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct foreach (var entry in areaGroup) { var (bundleProductIds, entryRepo, entryHideLinks) = GetEntryContext(entry, context); - var shouldHide = ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide); + var shouldHide = ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context); _ = sb.AppendLine(); if (shouldHide) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index 26a42a318..f0279a4fc 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -118,7 +118,7 @@ private static void RenderEntriesByArea( foreach (var entry in areaGroup) { var (bundleProductIds, entryRepo, entryHideLinks) = GetEntryContext(entry, context); - var shouldHide = ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide); + var shouldHide = ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context); if (shouldHide) _ = sb.Append("% "); diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index de5d7ec27..c67938e46 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -43,7 +43,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct foreach (var entry in areaGroup) { var (bundleProductIds, entryRepo, entryHideLinks) = GetEntryContext(entry, context); - var shouldHide = ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide); + var shouldHide = ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context); _ = sb.AppendLine(); if (shouldHide) diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/BlockConfigurationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/BlockConfigurationTests.cs new file mode 100644 index 000000000..5b9970196 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/BlockConfigurationTests.cs @@ -0,0 +1,894 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Changelog.Bundling; +using Elastic.Changelog.Rendering; +using FluentAssertions; + +namespace Elastic.Changelog.Tests.Changelogs.Render; + +public class BlockConfigurationTests(ITestOutputHelper output) : RenderChangelogTestBase(output) +{ + [Fact] + public async Task RenderChangelogs_WithBlockedArea_CommentsOutMatchingEntries() + { + // Arrange + var changelogDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create changelog with blocked area + // language=yaml + var changelog1 = + """ + title: Blocked Allocation feature + type: feature + products: + - product: cloud-serverless + target: 2026-01-26 + areas: + - Allocation + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This feature should be blocked + """; + + // Create changelog with non-blocked area + // language=yaml + var changelog2 = + """ + title: Visible Search feature + type: feature + products: + - product: cloud-serverless + target: 2026-01-26 + areas: + - Search + pr: https://github.com/elastic/elasticsearch/pull/101 + description: This feature should be visible + """; + + var changelogFile1 = FileSystem.Path.Combine(changelogDir, "1755268130-blocked.yaml"); + var changelogFile2 = FileSystem.Path.Combine(changelogDir, "1755268140-visible.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile1, changelog1, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(changelogFile2, changelog2, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Combine(bundleDir, "bundle.yaml"); + // language=yaml + var bundleContent = + $""" + products: + - product: cloud-serverless + target: 2026-01-26 + entries: + - file: + name: 1755268130-blocked.yaml + checksum: {ComputeSha1(changelog1)} + - file: + name: 1755268140-visible.yaml + checksum: {ComputeSha1(changelog2)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + // Create config with block configuration + var configDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(configDir); + var configFile = FileSystem.Path.Combine(configDir, "changelog.yml"); + // language=yaml + var configContent = + """ + pivot: + types: + feature: + bug-fix: + breaking-change: + areas: + Allocation: + Search: + lifecycles: + - preview + - beta + - ga + block: + product: + cloud-serverless: + publish: + areas: + - Allocation + """; + await FileSystem.File.WriteAllTextAsync(configFile, configContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "2026-01-26", + Config = configFile + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result || Collector.Errors > 0) + { + foreach (var diagnostic in Collector.Diagnostics) + Output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var indexFile = FileSystem.Path.Combine(outputDir, "2026-01-26", "index.md"); + FileSystem.File.Exists(indexFile).Should().BeTrue(); + + var indexContent = await FileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + // Blocked entry should be commented out with % prefix + indexContent.Should().Contain("% * Blocked Allocation feature"); + // Visible entry should not be commented + indexContent.Should().Contain("* Visible Search feature"); + indexContent.Should().NotContain("% * Visible Search feature"); + } + + [Fact] + public async Task RenderChangelogs_WithBlockedType_CommentsOutMatchingEntries() + { + // Arrange + var changelogDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create changelog with blocked type + // language=yaml + var changelog1 = + """ + title: Blocked deprecation + type: deprecation + products: + - product: cloud-serverless + target: 2026-01-26 + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This deprecation should be blocked + """; + + // Create changelog with non-blocked type + // language=yaml + var changelog2 = + """ + title: Visible feature + type: feature + products: + - product: cloud-serverless + target: 2026-01-26 + pr: https://github.com/elastic/elasticsearch/pull/101 + description: This feature should be visible + """; + + var changelogFile1 = FileSystem.Path.Combine(changelogDir, "1755268130-blocked.yaml"); + var changelogFile2 = FileSystem.Path.Combine(changelogDir, "1755268140-visible.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile1, changelog1, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(changelogFile2, changelog2, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Combine(bundleDir, "bundle.yaml"); + // language=yaml + var bundleContent = + $""" + products: + - product: cloud-serverless + target: 2026-01-26 + entries: + - file: + name: 1755268130-blocked.yaml + checksum: {ComputeSha1(changelog1)} + - file: + name: 1755268140-visible.yaml + checksum: {ComputeSha1(changelog2)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + // Create config with block configuration + var configDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(configDir); + var configFile = FileSystem.Path.Combine(configDir, "changelog.yml"); + // language=yaml + var configContent = + """ + pivot: + types: + feature: + bug-fix: + breaking-change: + deprecation: + lifecycles: + - preview + - beta + - ga + block: + product: + cloud-serverless: + publish: + types: + - deprecation + """; + await FileSystem.File.WriteAllTextAsync(configFile, configContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "2026-01-26", + Config = configFile + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result || Collector.Errors > 0) + { + foreach (var diagnostic in Collector.Diagnostics) + Output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var deprecationsFile = FileSystem.Path.Combine(outputDir, "2026-01-26", "deprecations.md"); + FileSystem.File.Exists(deprecationsFile).Should().BeTrue(); + + var deprecationsContent = await FileSystem.File.ReadAllTextAsync(deprecationsFile, TestContext.Current.CancellationToken); + // Should use block comments + deprecationsContent.Should().Contain(""); + deprecationsContent.Should().Contain("Blocked deprecation"); + // Entry should be between comment markers + var commentStart = deprecationsContent.IndexOf("", StringComparison.Ordinal); + commentStart.Should().BeLessThan(commentEnd); + deprecationsContent.Substring(commentStart, commentEnd - commentStart).Should().Contain("Blocked deprecation"); + + var indexFile = FileSystem.Path.Combine(outputDir, "2026-01-26", "index.md"); + var indexContent = await FileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + // Visible entry should not be commented + indexContent.Should().Contain("* Visible feature"); + indexContent.Should().NotContain("% * Visible feature"); + } + + [Fact] + public async Task RenderChangelogs_WithGlobalBlockedArea_CommentsOutMatchingEntries() + { + // Arrange + var changelogDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create changelog with blocked area + // language=yaml + var changelog1 = + """ + title: Blocked Internal feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + areas: + - Internal + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This feature should be blocked globally + """; + + // Create changelog with non-blocked area + // language=yaml + var changelog2 = + """ + title: Visible Search feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + areas: + - Search + pr: https://github.com/elastic/elasticsearch/pull/101 + description: This feature should be visible + """; + + var changelogFile1 = FileSystem.Path.Combine(changelogDir, "1755268130-blocked.yaml"); + var changelogFile2 = FileSystem.Path.Combine(changelogDir, "1755268140-visible.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile1, changelog1, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(changelogFile2, changelog2, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Combine(bundleDir, "bundle.yaml"); + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: 1755268130-blocked.yaml + checksum: {ComputeSha1(changelog1)} + - file: + name: 1755268140-visible.yaml + checksum: {ComputeSha1(changelog2)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + // Create config with global block configuration + var configDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(configDir); + var configFile = FileSystem.Path.Combine(configDir, "changelog.yml"); + // language=yaml + var configContent = + """ + pivot: + types: + feature: + bug-fix: + breaking-change: + areas: + Internal: + Search: + lifecycles: + - preview + - beta + - ga + block: + publish: + areas: + - Internal + """; + await FileSystem.File.WriteAllTextAsync(configFile, configContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + Config = configFile + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result || Collector.Errors > 0) + { + foreach (var diagnostic in Collector.Diagnostics) + Output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var indexFile = FileSystem.Path.Combine(outputDir, "9.2.0", "index.md"); + FileSystem.File.Exists(indexFile).Should().BeTrue(); + + var indexContent = await FileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + // Blocked entry should be commented out with % prefix + indexContent.Should().Contain("% * Blocked Internal feature"); + // Visible entry should not be commented + indexContent.Should().Contain("* Visible Search feature"); + indexContent.Should().NotContain("% * Visible Search feature"); + } + + [Fact] + public async Task RenderChangelogs_WithProductSpecificOverride_OverridesGlobalBlock() + { + // Arrange + var changelogDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create changelog with area that's blocked globally but not for this product + // language=yaml + var changelog1 = + """ + title: Visible Internal feature for cloud-serverless + type: feature + products: + - product: cloud-serverless + target: 2026-01-26 + areas: + - Internal + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This should be visible for cloud-serverless + """; + + // Create changelog with area that's blocked globally + // language=yaml + var changelog2 = + """ + title: Blocked Internal feature for elasticsearch + type: feature + products: + - product: elasticsearch + target: 9.2.0 + areas: + - Internal + pr: https://github.com/elastic/elasticsearch/pull/101 + description: This should be blocked for elasticsearch + """; + + var changelogFile1 = FileSystem.Path.Combine(changelogDir, "1755268130-cloud.yaml"); + var changelogFile2 = FileSystem.Path.Combine(changelogDir, "1755268140-es.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile1, changelog1, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(changelogFile2, changelog2, TestContext.Current.CancellationToken); + + // Create bundle files + var bundleDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile1 = FileSystem.Path.Combine(bundleDir, "bundle-cloud.yaml"); + // language=yaml + var bundleContent1 = + $""" + products: + - product: cloud-serverless + target: 2026-01-26 + entries: + - file: + name: 1755268130-cloud.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile1, bundleContent1, TestContext.Current.CancellationToken); + + var bundleFile2 = FileSystem.Path.Combine(bundleDir, "bundle-es.yaml"); + // language=yaml + var bundleContent2 = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: 1755268140-es.yaml + checksum: {ComputeSha1(changelog2)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile2, bundleContent2, TestContext.Current.CancellationToken); + + // Create config with global block and product-specific override + var configDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(configDir); + var configFile = FileSystem.Path.Combine(configDir, "changelog.yml"); + // language=yaml + var configContent = + """ + pivot: + types: + feature: + bug-fix: + breaking-change: + areas: + Internal: + lifecycles: + - preview + - beta + - ga + block: + publish: + areas: + - Internal + product: + cloud-serverless: + publish: + areas: [] + """; + await FileSystem.File.WriteAllTextAsync(configFile, configContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [ + new BundleInput { BundleFile = bundleFile1, Directory = changelogDir }, + new BundleInput { BundleFile = bundleFile2, Directory = changelogDir } + ], + Output = outputDir, + Title = "mixed", + Config = configFile + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result || Collector.Errors > 0) + { + foreach (var diagnostic in Collector.Diagnostics) + Output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + // Check cloud-serverless output (should be visible) + var cloudOutputDir = FileSystem.Path.Combine(outputDir, "2026-01-26"); + if (FileSystem.Directory.Exists(cloudOutputDir)) + { + var cloudIndexFile = FileSystem.Path.Combine(cloudOutputDir, "index.md"); + if (FileSystem.File.Exists(cloudIndexFile)) + { + var cloudContent = await FileSystem.File.ReadAllTextAsync(cloudIndexFile, TestContext.Current.CancellationToken); + cloudContent.Should().Contain("* Visible Internal feature for cloud-serverless"); + cloudContent.Should().NotContain("% * Visible Internal feature for cloud-serverless"); + } + } + + // Check elasticsearch output (should be blocked) + var esOutputDir = FileSystem.Path.Combine(outputDir, "9.2.0"); + if (FileSystem.Directory.Exists(esOutputDir)) + { + var esIndexFile = FileSystem.Path.Combine(esOutputDir, "index.md"); + if (FileSystem.File.Exists(esIndexFile)) + { + var esContent = await FileSystem.File.ReadAllTextAsync(esIndexFile, TestContext.Current.CancellationToken); + esContent.Should().Contain("% * Blocked Internal feature for elasticsearch"); + } + } + } + + [Fact] + public async Task RenderChangelogs_WithBlockedArea_BreakingChange_UsesBlockComments() + { + // Arrange + var changelogDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // language=yaml + var changelog = + """ + title: Blocked Allocation breaking change + type: breaking-change + products: + - product: cloud-serverless + target: 2026-01-26 + areas: + - Allocation + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This breaking change should be blocked + impact: Users will be affected + action: Update your code + """; + + var changelogFile = FileSystem.Path.Combine(changelogDir, "1755268130-breaking.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + var bundleDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Combine(bundleDir, "bundle.yaml"); + // language=yaml + var bundleContent = + $""" + products: + - product: cloud-serverless + target: 2026-01-26 + entries: + - file: + name: 1755268130-breaking.yaml + checksum: {ComputeSha1(changelog)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + // Create config with block configuration + var configDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(configDir); + var configFile = FileSystem.Path.Combine(configDir, "changelog.yml"); + // language=yaml + var configContent = + """ + pivot: + types: + feature: + bug-fix: + breaking-change: + areas: + Allocation: + lifecycles: + - preview + - beta + - ga + block: + product: + cloud-serverless: + publish: + areas: + - Allocation + """; + await FileSystem.File.WriteAllTextAsync(configFile, configContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "2026-01-26", + Config = configFile + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result || Collector.Errors > 0) + { + foreach (var diagnostic in Collector.Diagnostics) + Output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var breakingFile = FileSystem.Path.Combine(outputDir, "2026-01-26", "breaking-changes.md"); + FileSystem.File.Exists(breakingFile).Should().BeTrue(); + + var breakingContent = await FileSystem.File.ReadAllTextAsync(breakingFile, TestContext.Current.CancellationToken); + // Should use block comments + breakingContent.Should().Contain(""); + breakingContent.Should().Contain("Blocked Allocation breaking change"); + // Entry should be between comment markers + var commentStart = breakingContent.IndexOf("", StringComparison.Ordinal); + commentStart.Should().BeLessThan(commentEnd); + breakingContent.Substring(commentStart, commentEnd - commentStart).Should().Contain("Blocked Allocation breaking change"); + } + + [Fact] + public async Task RenderChangelogs_WithBlockedArea_KnownIssue_UsesBlockComments() + { + // Arrange + var changelogDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // language=yaml + var changelog = + """ + title: Blocked Allocation known issue + type: known-issue + products: + - product: cloud-serverless + target: 2026-01-26 + areas: + - Allocation + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This known issue should be blocked + impact: Users may experience issues + action: Workaround available + """; + + var changelogFile = FileSystem.Path.Combine(changelogDir, "1755268130-known.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + var bundleDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Combine(bundleDir, "bundle.yaml"); + // language=yaml + var bundleContent = + $""" + products: + - product: cloud-serverless + target: 2026-01-26 + entries: + - file: + name: 1755268130-known.yaml + checksum: {ComputeSha1(changelog)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + // Create config with block configuration + var configDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(configDir); + var configFile = FileSystem.Path.Combine(configDir, "changelog.yml"); + // language=yaml + var configContent = + """ + pivot: + types: + feature: + bug-fix: + breaking-change: + known-issue: + areas: + Allocation: + lifecycles: + - preview + - beta + - ga + block: + product: + cloud-serverless: + publish: + areas: + - Allocation + """; + await FileSystem.File.WriteAllTextAsync(configFile, configContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "2026-01-26", + Config = configFile + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result || Collector.Errors > 0) + { + foreach (var diagnostic in Collector.Diagnostics) + Output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var knownIssuesFile = FileSystem.Path.Combine(outputDir, "2026-01-26", "known-issues.md"); + FileSystem.File.Exists(knownIssuesFile).Should().BeTrue(); + + var knownIssuesContent = await FileSystem.File.ReadAllTextAsync(knownIssuesFile, TestContext.Current.CancellationToken); + // Should use block comments + knownIssuesContent.Should().Contain(""); + knownIssuesContent.Should().Contain("Blocked Allocation known issue"); + // Entry should be between comment markers + var commentStart = knownIssuesContent.IndexOf("", StringComparison.Ordinal); + commentStart.Should().BeLessThan(commentEnd); + knownIssuesContent.Substring(commentStart, commentEnd - commentStart).Should().Contain("Blocked Allocation known issue"); + } + + [Fact] + public async Task RenderChangelogs_WithMultipleBlockedAreas_CommentsOutAllMatchingEntries() + { + // Arrange + var changelogDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // language=yaml + var changelog1 = + """ + title: Blocked Allocation feature + type: feature + products: + - product: cloud-serverless + target: 2026-01-26 + areas: + - Allocation + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + // language=yaml + var changelog2 = + """ + title: Blocked Internal feature + type: feature + products: + - product: cloud-serverless + target: 2026-01-26 + areas: + - Internal + pr: https://github.com/elastic/elasticsearch/pull/101 + """; + + // language=yaml + var changelog3 = + """ + title: Visible Search feature + type: feature + products: + - product: cloud-serverless + target: 2026-01-26 + areas: + - Search + pr: https://github.com/elastic/elasticsearch/pull/102 + """; + + var changelogFile1 = FileSystem.Path.Combine(changelogDir, "1755268130-allocation.yaml"); + var changelogFile2 = FileSystem.Path.Combine(changelogDir, "1755268140-internal.yaml"); + var changelogFile3 = FileSystem.Path.Combine(changelogDir, "1755268150-search.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile1, changelog1, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(changelogFile2, changelog2, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(changelogFile3, changelog3, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Combine(bundleDir, "bundle.yaml"); + // language=yaml + var bundleContent = + $""" + products: + - product: cloud-serverless + target: 2026-01-26 + entries: + - file: + name: 1755268130-allocation.yaml + checksum: {ComputeSha1(changelog1)} + - file: + name: 1755268140-internal.yaml + checksum: {ComputeSha1(changelog2)} + - file: + name: 1755268150-search.yaml + checksum: {ComputeSha1(changelog3)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + // Create config with multiple blocked areas + var configDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(configDir); + var configFile = FileSystem.Path.Combine(configDir, "changelog.yml"); + // language=yaml + var configContent = + """ + pivot: + types: + feature: + bug-fix: + breaking-change: + areas: + Allocation: + Internal: + Search: + lifecycles: + - preview + - beta + - ga + block: + product: + cloud-serverless: + publish: + areas: + - Allocation + - Internal + """; + await FileSystem.File.WriteAllTextAsync(configFile, configContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "2026-01-26", + Config = configFile + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result || Collector.Errors > 0) + { + foreach (var diagnostic in Collector.Diagnostics) + Output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var indexFile = FileSystem.Path.Combine(outputDir, "2026-01-26", "index.md"); + var indexContent = await FileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + // Both blocked entries should be commented out + indexContent.Should().Contain("% * Blocked Allocation feature"); + indexContent.Should().Contain("% * Blocked Internal feature"); + // Visible entry should not be commented + indexContent.Should().Contain("* Visible Search feature"); + indexContent.Should().NotContain("% * Visible Search feature"); + } +}